Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Unit tests run on `bun test` (repo root, `bunfig.toml`); component and visual te
- tears down the whole test process group on exit, Ctrl+C, SIGTERM or SIGHUP;
- at the start of every run, kills browsers left by runs whose runner died (SIGKILL, closed terminal, killed agent job).

Ownership is explicit: only processes carrying the tag of a dead run of this repo are touched. Other projects on the machine (`../ac`, …) share `~/Library/Caches/ms-playwright`; never kill Playwright browsers by name or path. To pass extra args: `bun run test:browser -- src/lib/components/Button`. A browser test that must hold on a loaded machine gets its own `*.throttled.svelte.test.ts` (CDP `Emulation.setCPUThrottlingRate`, see `CommandPalette.throttled`); a full run under parallel CPU load (#544) must stay green. Multi-step keyboard flows take `SLOW_FLOW` from `tests/settled.ts` as their timeout; a condition is awaited with `expect.poll`/`expect.element`, never a fixed `setTimeout` deadline. Local runs use a quarter of the cores (`maxWorkers`, Playwright `workers: "25%"`, also when a shell exports `CI=true`: only `GITHUB_ACTIONS` gets every core, #539); behavior tests use the headless shell. **Visual baselines are rendered natively and never in Docker.** The platform is part of every baseline's file name (`Name-dark-glass-chromium-darwin.png` for Vitest, `home-dark-darwin.webp` for Playwright), so a platform is only ever compared with its own baselines and the linux and darwin sets sit side by side. The `vrt` project runs on darwin and linux and compares nothing elsewhere. The Vitest screenshot setup loads the same self-hosted typefaces as the site (`@fontsource-variable`), so text does not depend on the machine's fonts. On a platform with no baselines, run `bun run test:vrt:update` and review the images before committing. The linux set is what a future CI would use, or regenerate; **CI stays disabled** (`ci.yml` is untouched), so nothing but the local commands above renders or compares pixels. The site visual project captures the visible viewport of five pages (landing, templates, theme): the docs pages are a stack of component demos that the component screenshots already compare one by one, and the layout audit checks their geometry, so a tall full-page docs screenshot duplicated both and was the flaky one.
Ownership is explicit: only processes carrying the tag of a dead run of this repo are touched. Other projects on the machine (`../ac`, …) share `~/Library/Caches/ms-playwright`; never kill Playwright browsers by name or path. To pass extra args: `bun run test:browser -- src/lib/components/Button`. A browser test that must hold on a loaded machine gets its own `*.throttled.svelte.test.ts` (CDP `Emulation.setCPUThrottlingRate`, see `CommandPalette.throttled`); a full run under parallel CPU load (#544) must stay green. Multi-step keyboard flows take `SLOW_FLOW` from `tests/settled.ts` as their timeout; a condition is awaited with `expect.poll`/`expect.element`, never a fixed `setTimeout` deadline. A "does not happen" assertion on a component timer installs a fake clock BEFORE `render` (`vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })`), advances it with `vi.advanceTimersByTimeAsync`, restores real timers before Playwright calls and axe, and awaits `settled()` before asserting anything a frame produces (`announce`, observers), or the negative passes whatever the component does; prove each one fails by mutating the component (#568). `devWarn` prints once per key per page: run the silent cases before the one that warns. `tests/unit-suite.test.ts` fails a `new Promise(r => setTimeout(r, n))` in a `*.svelte.test.ts` unless the line above it says `real-time:` and why. Local runs use a quarter of the cores (`maxWorkers`, Playwright `workers: "25%"`, also when a shell exports `CI=true`: only `GITHUB_ACTIONS` gets every core, #539); behavior tests use the headless shell. **Visual baselines are rendered natively and never in Docker.** The platform is part of every baseline's file name (`Name-dark-glass-chromium-darwin.png` for Vitest, `home-dark-darwin.webp` for Playwright), so a platform is only ever compared with its own baselines and the linux and darwin sets sit side by side. The `vrt` project runs on darwin and linux and compares nothing elsewhere. The Vitest screenshot setup loads the same self-hosted typefaces as the site (`@fontsource-variable`), so text does not depend on the machine's fonts. On a platform with no baselines, run `bun run test:vrt:update` and review the images before committing. The linux set is what a future CI would use, or regenerate; **CI stays disabled** (`ci.yml` is untouched), so nothing but the local commands above renders or compares pixels. The site visual project captures the visible viewport of five pages (landing, templates, theme): the docs pages are a stack of component demos that the component screenshots already compare one by one, and the layout audit checks their geometry, so a tall full-page docs screenshot duplicated both and was the flaky one.

Naming conventions:
- `*.test.ts` in `packages/ui/tests/`: unit test on `bun:test` (`mock`/`spyOn`/`jest.useFakeTimers`; DOM only through happy-dom's `GlobalRegistrator` inside that file; fresh module via `import("…?fresh=n")`). Types checked by `bun --check`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@ const key = (el: HTMLElement, k: string) => {
el.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }));
return frame();
};
async function waitFor(fn: () => boolean, ms = 3000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 15));
}
}
// Awaits the condition with Vitest's poll (its timeout is the test's, not a fixed deadline that a starved
// machine outruns).
const waitFor = (fn: () => boolean) => expect.poll(fn, { timeout: 30_000 }).toBe(true);

describe("NavItem: color and leading (#275)", () => {
it("color renders a decorative dot that is not part of the link name", async () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/lib/components/Button/Button.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRawSnippet } from "svelte";
import { describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
import { settled } from "../../../../tests/settled";
import Button from "./Button.svelte";

const label = (text: string) => createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
Expand Down Expand Up @@ -29,7 +30,8 @@ describe("Button", () => {
it("announces when loading finishes, but not on mount or while loading", async () => {
const polite = () => document.querySelector('[data-ca-announcer="polite"]')?.textContent ?? "";
const screen = await render(Button, { children: label("Save"), loading: true });
await new Promise((r) => setTimeout(r, 100));
// The announcement is an effect on the loading->done edge: a frame covers the mount effects.
await settled();
expect(polite()).toBe("");
await screen.rerender({ loading: false });
await expect.poll(polite).toBe("Finished loading");
Expand Down
23 changes: 15 additions & 8 deletions packages/ui/src/lib/components/Card/Card.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRawSnippet } from "svelte";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
import { settled } from "../../../../tests/settled";
import SurfacePanel from "../SurfacePanel/SurfacePanel.svelte";
import Card from "./Card.svelte";
import Harness from "./Card.test.svelte";
Expand Down Expand Up @@ -96,22 +97,28 @@ describe("Card", () => {
expect(getComputedStyle(card(screen.container)).paddingTop).toBe("0px");
});

it("warns once when a padded child doubles the card padding", async () => {
// devWarn prints once per key for the page's lifetime, so the silent cases must run BEFORE the case
// that warns: after it the key is spent and "no warning" would pass whatever the component does.
it("stays silent for a flush or unpadded card around a padded child", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const screen = await render(PaddedChild, {});
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(expect.stringContaining("<Card> content is already padded"))
);
screen.unmount();
warn.mockClear();
for (const props of [{ flush: true }, { padding: "none" }]) {
const ok = await render(PaddedChild, props);
await new Promise((r) => setTimeout(r, 50));
// The check runs in the mount effect, which has flushed once render() resolved; a frame covers
// a layout-dependent re-check.
await settled();
ok.unmount();
}
expect(warn).not.toHaveBeenCalled();
});

it("warns once when a padded child doubles the card padding", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await render(PaddedChild, {});
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(expect.stringContaining("<Card> content is already padded"))
);
});

it("headerActions render beside the title", async () => {
const screen = await render(Harness, { title: "Summary", actionLabel: "Edit" });
await expect.element(screen.getByText("Summary")).toBeVisible();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const btn = (c: HTMLElement, name: RegExp) =>
[...c.querySelectorAll<HTMLButtonElement>("button")].find((b) =>
name.test(b.getAttribute("aria-label") ?? b.textContent ?? "")
) as HTMLButtonElement;
const settle = () => new Promise((r) => setTimeout(r, 450));
// The autoplay chain is plain setTimeout: a fake clock installed BEFORE render, advanced past several
// of its intervals, proves "nothing rotates" with no wall time (a clock faked after render would not
// own the component's timer and prove nothing).
Expand Down Expand Up @@ -123,10 +122,12 @@ describe("Carousel controls", () => {
it("bind:index follows the controls and drives the slide", async () => {
const screen = await render(Harness, { index: 2 });
await expect.poll(() => dots(screen.container)[2].getAttribute("aria-current")).toBe("true");
await settle();
const vp = viewport(screen.container);
const target = slides(screen.container)[2];
expect(Math.abs(target.offsetLeft - vp.scrollLeft - vp.offsetLeft)).toBeLessThan(4);
// The strip scrolls to the slide: wait for it to arrive, not for a guessed duration.
await expect
.poll(() => Math.abs(target.offsetLeft - vp.scrollLeft - vp.offsetLeft))
.toBeLessThan(4);
});

it("a scroll by touch updates the current dot", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ describe("CommandPalette async search (#293)", () => {
const screen = await open({ search, minQueryLength: 3 });
await input().fill("in");
await expect.element(screen.getByTestId("query")).toHaveTextContent("in");
await new Promise((r) => setTimeout(r, 120));
// The harness debounce is 0 ms: a search that was going to fire would have by the next frame.
await settled();
expect(search).not.toHaveBeenCalled();
await input().fill("inv");
await expect.element(page.getByRole("option", { name: /Invoice 1042/ })).toBeVisible();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
import { settled } from "../../../../tests/settled";
import Harness from "./ContextMenu.test.svelte";

// The long press is a plain setTimeout in the component: a fake clock installed BEFORE render owns it,
// so "not open yet" / "never opens" are proven by advancing it, with no wall time.
const fakeClock = () => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
afterEach(() => {
vi.useRealTimers();
});

const area = () => page.getByTestId("area");
const menu = () => document.querySelector('[role="menu"]') as HTMLElement;

Expand Down Expand Up @@ -113,10 +120,13 @@ describe("ContextMenu", () => {
});

it("a long touch press opens it at the finger and swallows the native contextmenu", async () => {
fakeClock();
await render(Harness, { longPressDelay: 80 });
touch("pointerdown", 50, 60);
await new Promise((r) => setTimeout(r, 30));
await expect.element(page.getByRole("menu")).not.toBeInTheDocument();
await vi.advanceTimersByTimeAsync(79);
expect(menu()).toBeNull();
await vi.advanceTimersByTimeAsync(1);
vi.useRealTimers();
await expect.element(page.getByRole("menu")).toBeVisible();
const e = rightClick(50, 60);
expect(e.e.defaultPrevented).toBe(true);
Expand All @@ -126,12 +136,13 @@ describe("ContextMenu", () => {
});

it("a touch that lifts early or drags away never opens it", async () => {
fakeClock();
await render(Harness, { longPressDelay: 120 });
touch("pointerdown", 50, 60);
touch("pointerup", 50, 60);
touch("pointerdown", 50, 60);
touch("pointermove", 120, 140);
await new Promise((r) => setTimeout(r, 250));
await vi.advanceTimersByTimeAsync(500);
expect(document.querySelector('[role="menu"]')).toBeNull();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,15 @@ describe("DropdownMenu submenu", () => {
await userEvent.hover(color());
const red = page.getByRole("menuitem", { name: "Red" });
await expect.element(red).toBeVisible();
await userEvent.hover(red);
await new Promise((r) => setTimeout(r, 400));
// The sub-trigger's leave arms a 200 ms close timer: faked from here (Playwright's pointer calls
// are CDP round trips, not page timers), advanced past it, so "still open" costs no wall time.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
await userEvent.hover(red);
await vi.advanceTimersByTimeAsync(1000);
} finally {
vi.useRealTimers();
}
await expect.element(red).toBeVisible();
await expect.element(color()).toHaveAttribute("aria-expanded", "true");
});
Expand All @@ -425,9 +432,14 @@ describe("DropdownMenu submenu", () => {
// Pointer events are off on a disabled item; the keyboard path must refuse too.
const el = move.element() as HTMLElement;
el.focus();
el.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
await userEvent.keyboard("{ArrowRight}");
await new Promise((r) => setTimeout(r, 400));
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
el.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true }));
await userEvent.keyboard("{ArrowRight}");
await vi.advanceTimersByTimeAsync(1000);
} finally {
vi.useRealTimers();
}
await expect.element(move).toHaveAttribute("aria-expanded", "false");
await expect.element(page.getByRole("menuitem", { name: "Inbox" })).not.toBeInTheDocument();
});
Expand Down
17 changes: 13 additions & 4 deletions packages/ui/src/lib/components/HoverCard/HoverCard.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
Expand All @@ -9,6 +9,10 @@ const card = () => document.querySelector<HTMLElement>(".ca-hovercard-content");
const trigger = (screen: Awaited<ReturnType<typeof render>>) =>
screen.getByRole("link", { name: "@ada" });

afterEach(() => {
vi.useRealTimers();
});

describe("HoverCard", () => {
it("is closed until hovered, then shows the card and describes the trigger", async () => {
const screen = await render(Harness);
Expand All @@ -35,14 +39,18 @@ describe("HoverCard", () => {
});

it("waits openDelay for the pointer, and a pointer that leaves first cancels it", async () => {
// The open timer is a plain setTimeout: a fake clock installed BEFORE render owns it, so the
// pointer round trips take any time under load and "still closed" is not a race.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const screen = await render(Harness, { openDelay: 250 });
const t = trigger(screen);
await t.hover();
await new Promise((r) => setTimeout(r, 100));
await vi.advanceTimersByTimeAsync(249);
expect(card()).toBeNull();
await userEvent.unhover(t);
await new Promise((r) => setTimeout(r, 300));
await vi.advanceTimersByTimeAsync(1000);
expect(card()).toBeNull();
vi.useRealTimers();
await t.hover();
await expect.element(page.getByText("Ada Lovelace")).toBeVisible();
});
Expand Down Expand Up @@ -94,11 +102,12 @@ describe("HoverCard", () => {
});

it("touch never opens it, so a tap only does the trigger's own action", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const screen = await render(Harness);
const el = trigger(screen).element();
for (const type of ["pointerenter", "pointermove", "pointerdown"])
el.dispatchEvent(new PointerEvent(type, { pointerType: "touch", bubbles: true }));
await new Promise((r) => setTimeout(r, 80));
await vi.advanceTimersByTimeAsync(2000);
expect(card()).toBeNull();
});

Expand Down
10 changes: 3 additions & 7 deletions packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,9 @@ const key = (el: HTMLElement, k: string) => {
el.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }));
return frame();
};
async function waitFor(fn: () => boolean, ms = 3000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 15));
}
}
// Awaits the condition with Vitest's poll (its timeout is the test's, not a fixed deadline that a starved
// machine outruns).
const waitFor = (fn: () => boolean) => expect.poll(fn, { timeout: 30_000 }).toBe(true);

describe("Kanban board (desktop)", () => {
it("renders a fixed header per column and independently scrolling lists", async () => {
Expand Down
10 changes: 3 additions & 7 deletions packages/ui/src/lib/components/LiveList/LiveList.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,9 @@ import Harness from "./LiveList.test.svelte";
const scroller = (c: Element) => c.querySelector(".ca-live-list__scroller") as HTMLElement;
const frame = () => new Promise((r) => requestAnimationFrame(() => r(null)));
const announcer = () => document.querySelector('[data-ca-announcer="polite"]')?.textContent ?? "";
async function waitFor(fn: () => boolean, ms = 2000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 25));
}
}
// Awaits the condition with Vitest's poll (its timeout is the test's, not a fixed deadline that a starved
// machine outruns).
const waitFor = (fn: () => boolean) => expect.poll(fn, { timeout: 30_000 }).toBe(true);

describe("LiveList", () => {
it("renders rows through the item snippet inside a named log region", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ describe("Menubar", () => {
it("once one is open, hovering another top-level item switches to it; hover alone opens nothing", async () => {
await render(Harness);
await top("Edit").hover();
await new Promise((r) => setTimeout(r, 120));
// No timer is involved (the pointer handler is synchronous): a settled frame is enough.
await settled();
await expect.element(menu()).not.toBeInTheDocument();
await top("File").click();
await expect.element(page.getByRole("menuitem", { name: "New" })).toBeVisible();
Expand Down
Loading