diff --git a/AGENTS.md b/AGENTS.md
index 273e431d..af4281be 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`.
diff --git a/packages/ui/src/lib/components/AppShell/NavItemExtras.svelte.test.ts b/packages/ui/src/lib/components/AppShell/NavItemExtras.svelte.test.ts
index b199f295..68fd42fd 100644
--- a/packages/ui/src/lib/components/AppShell/NavItemExtras.svelte.test.ts
+++ b/packages/ui/src/lib/components/AppShell/NavItemExtras.svelte.test.ts
@@ -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 () => {
diff --git a/packages/ui/src/lib/components/Button/Button.svelte.test.ts b/packages/ui/src/lib/components/Button/Button.svelte.test.ts
index 10367e00..6573e987 100644
--- a/packages/ui/src/lib/components/Button/Button.svelte.test.ts
+++ b/packages/ui/src/lib/components/Button/Button.svelte.test.ts
@@ -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: () => `${text}` }));
@@ -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");
diff --git a/packages/ui/src/lib/components/Card/Card.svelte.test.ts b/packages/ui/src/lib/components/Card/Card.svelte.test.ts
index 88032bc2..2f83056f 100644
--- a/packages/ui/src/lib/components/Card/Card.svelte.test.ts
+++ b/packages/ui/src/lib/components/Card/Card.svelte.test.ts
@@ -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";
@@ -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(" 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(" 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();
diff --git a/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts b/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts
index 79aa14e3..9e115373 100644
--- a/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts
+++ b/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts
@@ -13,7 +13,6 @@ const btn = (c: HTMLElement, name: RegExp) =>
[...c.querySelectorAll("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).
@@ -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 () => {
diff --git a/packages/ui/src/lib/components/CommandPalette/CommandPalette.async.svelte.test.ts b/packages/ui/src/lib/components/CommandPalette/CommandPalette.async.svelte.test.ts
index 6839860b..a311c3c4 100644
--- a/packages/ui/src/lib/components/CommandPalette/CommandPalette.async.svelte.test.ts
+++ b/packages/ui/src/lib/components/CommandPalette/CommandPalette.async.svelte.test.ts
@@ -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();
diff --git a/packages/ui/src/lib/components/ContextMenu/ContextMenu.svelte.test.ts b/packages/ui/src/lib/components/ContextMenu/ContextMenu.svelte.test.ts
index 162b273b..217ea526 100644
--- a/packages/ui/src/lib/components/ContextMenu/ContextMenu.svelte.test.ts
+++ b/packages/ui/src/lib/components/ContextMenu/ContextMenu.svelte.test.ts
@@ -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;
@@ -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);
@@ -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();
});
diff --git a/packages/ui/src/lib/components/DropdownMenu/DropdownMenu.svelte.test.ts b/packages/ui/src/lib/components/DropdownMenu/DropdownMenu.svelte.test.ts
index b32dda82..4406ce1e 100644
--- a/packages/ui/src/lib/components/DropdownMenu/DropdownMenu.svelte.test.ts
+++ b/packages/ui/src/lib/components/DropdownMenu/DropdownMenu.svelte.test.ts
@@ -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");
});
@@ -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();
});
diff --git a/packages/ui/src/lib/components/HoverCard/HoverCard.svelte.test.ts b/packages/ui/src/lib/components/HoverCard/HoverCard.svelte.test.ts
index 027f202c..2263ee39 100644
--- a/packages/ui/src/lib/components/HoverCard/HoverCard.svelte.test.ts
+++ b/packages/ui/src/lib/components/HoverCard/HoverCard.svelte.test.ts
@@ -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";
@@ -9,6 +9,10 @@ const card = () => document.querySelector(".ca-hovercard-content");
const trigger = (screen: Awaited>) =>
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);
@@ -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();
});
@@ -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();
});
diff --git a/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts b/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts
index 7ca6f5c9..d28d93cc 100644
--- a/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts
+++ b/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts
@@ -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 () => {
diff --git a/packages/ui/src/lib/components/LiveList/LiveList.svelte.test.ts b/packages/ui/src/lib/components/LiveList/LiveList.svelte.test.ts
index ab9f22df..d0a6e259 100644
--- a/packages/ui/src/lib/components/LiveList/LiveList.svelte.test.ts
+++ b/packages/ui/src/lib/components/LiveList/LiveList.svelte.test.ts
@@ -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 () => {
diff --git a/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts b/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts
index b1d1aa3e..d650bade 100644
--- a/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts
+++ b/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts
@@ -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();
diff --git a/packages/ui/src/lib/components/MotionStage/MotionStage.svelte.test.ts b/packages/ui/src/lib/components/MotionStage/MotionStage.svelte.test.ts
index 6e9ef513..25f42d0d 100644
--- a/packages/ui/src/lib/components/MotionStage/MotionStage.svelte.test.ts
+++ b/packages/ui/src/lib/components/MotionStage/MotionStage.svelte.test.ts
@@ -1,5 +1,5 @@
///
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { cdp, userEvent } from "vitest/browser";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
@@ -11,11 +11,31 @@ const emulate = (value: "reduce" | "no-preference") =>
});
afterEach(async () => {
+ vi.useRealTimers();
await cdp().send("Emulation.setEmulatedMedia", { features: [] });
});
const stageOf = (c: Element) => c.querySelector(".ca-motion-stage") as HTMLElement;
const cycle = (c: Element) => Number(stageOf(c).dataset.cycle);
+// The loop is a plain setInterval that lives only while playing. "Nothing ticks" is proven on a fake
+// clock installed BEFORE render (a clock faked later would not own the component's interval),
+// advanced past several loops: no wall time, and a leaked interval would tick on it. The
+// IntersectionObserver and the effects run on frames, which stay real; `until` waits for them
+// while the clock is advanced. Real timers are restored before polls, clicks and axe.
+const fakeClock = () => vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
+const LOOP = 150;
+const until = async (fn: () => boolean) => {
+ for (let i = 0; i < 200 && !fn(); i++) {
+ await vi.advanceTimersByTimeAsync(0);
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
+ }
+ expect(fn()).toBe(true);
+};
+// An IntersectionObserver reports on a later frame than render: a negative asserted before it has
+// reported would pass whatever the component does. Frames are real (only timers are faked).
+const observed = async () => {
+ for (let i = 0; i < 6; i++) await new Promise((r) => requestAnimationFrame(() => r(null)));
+};
const buttons = (c: Element) => [...c.querySelectorAll("button")].map((b) => b.textContent?.trim());
describe("MotionStage", () => {
@@ -33,9 +53,6 @@ describe("MotionStage", () => {
pause.click();
await expect.poll(() => stageOf(container).dataset.playing).toBe("false");
expect(pause.getAttribute("aria-pressed")).toBe("true");
- const stopped = cycle(container);
- await new Promise((r) => setTimeout(r, 500));
- expect(cycle(container)).toBe(stopped);
expect(await checkA11y(container)).toHaveNoViolations();
pause.click();
@@ -43,12 +60,30 @@ describe("MotionStage", () => {
expect(pause.getAttribute("aria-pressed")).toBe("false");
});
+ it("a paused stage does not loop", async () => {
+ await emulate("no-preference");
+ fakeClock();
+ const { container } = await render(Harness, { loop: LOOP });
+ await until(() => stageOf(container).dataset.playing === "true");
+ await vi.advanceTimersByTimeAsync(LOOP * 2);
+ expect(cycle(container)).toBeGreaterThan(1);
+ (container.querySelector("button") as HTMLButtonElement).click();
+ await until(() => stageOf(container).dataset.playing === "false");
+ const stopped = cycle(container);
+ await vi.advanceTimersByTimeAsync(LOOP * 4);
+ expect(cycle(container)).toBe(stopped);
+ });
+
it("does not play while out of view", async () => {
await emulate("no-preference");
- const { container } = await render(Harness, { spacer: 4000 });
- await new Promise((r) => setTimeout(r, 400));
+ fakeClock();
+ const { container } = await render(Harness, { spacer: 4000, loop: LOOP });
+ // The observer has reported by the time a frame has passed; the clock then runs several loops.
+ await observed();
+ await vi.advanceTimersByTimeAsync(LOOP * 4);
expect(stageOf(container).dataset.playing).toBe("false");
expect(cycle(container)).toBe(0);
+ vi.useRealTimers();
stageOf(container).scrollIntoView({ block: "center" });
await expect.poll(() => stageOf(container).dataset.playing).toBe("true");
});
@@ -64,8 +99,6 @@ describe("MotionStage", () => {
expect(stageOf(container).dataset.playing).toBe("false");
expect(buttons(container)).toEqual(["Pause", "Replay"]);
const taken = cycle(container);
- await new Promise((r) => setTimeout(r, 500));
- expect(cycle(container)).toBe(taken);
expect(await checkA11y(container)).toHaveNoViolations();
(container.querySelectorAll("button")[1] as HTMLButtonElement).click();
@@ -75,6 +108,18 @@ describe("MotionStage", () => {
expect(buttons(container)).toEqual(["Pause"]);
});
+ it("a takeover stops the loop", async () => {
+ await emulate("no-preference");
+ fakeClock();
+ const { container } = await render(Harness, { loop: LOOP });
+ await until(() => stageOf(container).dataset.playing === "true");
+ (container.querySelector("input") as HTMLInputElement).focus();
+ await until(() => stageOf(container).dataset.taken === "true");
+ const taken = cycle(container);
+ await vi.advanceTimersByTimeAsync(LOOP * 4);
+ expect(cycle(container)).toBe(taken);
+ });
+
it("pressing a key inside the demo is a takeover", async () => {
await emulate("no-preference");
const { container } = await render(Harness);
@@ -86,14 +131,17 @@ describe("MotionStage", () => {
it("under reduced motion it renders the final state and never plays", async () => {
await emulate("reduce");
- const { container } = await render(Harness);
+ fakeClock();
+ const { container } = await render(Harness, { loop: LOOP });
+ await observed();
const stage = stageOf(container);
expect(stage.dataset.reduced).toBe("true");
expect(stage.dataset.playing).toBe("false");
expect((container.querySelector(".probe") as HTMLElement).dataset.final).toBe("true");
const before = cycle(container);
- await new Promise((r) => setTimeout(r, 500));
+ await vi.advanceTimersByTimeAsync(LOOP * 4);
expect(cycle(container)).toBe(before);
+ vi.useRealTimers();
expect(buttons(container)).toEqual([]);
expect(await checkA11y(container)).toHaveNoViolations();
});
@@ -102,14 +150,17 @@ describe("MotionStage", () => {
await emulate("no-preference");
document.documentElement.style.setProperty("--ca-enter-scale", "0");
try {
- const { container } = await render(Harness);
+ fakeClock();
+ const { container } = await render(Harness, { loop: LOOP });
const stage = stageOf(container);
- await expect.poll(() => stage.dataset.reduced).toBe("true");
+ await until(() => stage.dataset.reduced === "true");
+ await observed();
expect(stage.dataset.playing).toBe("false");
expect((container.querySelector(".probe") as HTMLElement).dataset.final).toBe("true");
const before = cycle(container);
- await new Promise((r) => setTimeout(r, 500));
+ await vi.advanceTimersByTimeAsync(LOOP * 4);
expect(cycle(container)).toBe(before);
+ vi.useRealTimers();
// the knob is live: switching entrances back on resumes autoplay
document.documentElement.style.removeProperty("--ca-enter-scale");
await expect.poll(() => stage.dataset.reduced).toBe("false");
diff --git a/packages/ui/src/lib/components/NavigationMenu/NavigationMenu.svelte.test.ts b/packages/ui/src/lib/components/NavigationMenu/NavigationMenu.svelte.test.ts
index 1b3bcc96..7091f3ca 100644
--- a/packages/ui/src/lib/components/NavigationMenu/NavigationMenu.svelte.test.ts
+++ b/packages/ui/src/lib/components/NavigationMenu/NavigationMenu.svelte.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } 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";
@@ -12,6 +12,10 @@ const gotoNav = async () => {
await userEvent.tab(); // Products
};
+afterEach(() => {
+ vi.useRealTimers();
+});
+
describe("NavigationMenu", () => {
it("is a labelled nav landmark with a list, disclosure buttons and no menu roles", async () => {
const screen = await render(Harness);
@@ -118,18 +122,51 @@ describe("NavigationMenu", () => {
await expect.element(link("Analytics")).not.toBeInTheDocument();
});
- it("hover delays, switches once open, closes after closeDelay", async () => {
+ it("hover opens only after openDelay", async () => {
+ // The open timer is a plain setTimeout: a fake clock installed BEFORE render owns it.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await render(Harness, { openDelay: 150, closeDelay: 150 });
await trigger("Products").hover();
- await new Promise((r) => setTimeout(r, 60));
+ await vi.advanceTimersByTimeAsync(149);
expect(document.querySelector(".ca-navmenu-panel")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ vi.useRealTimers();
+ await expect.element(link("Analytics")).toBeVisible();
+ });
+
+ it("leaving the trigger closes the panel only after closeDelay", async () => {
+ await render(Harness, { openDelay: 0, closeDelay: 150 });
+ await trigger("Products").hover();
+ await expect.element(link("Analytics")).toBeVisible();
+ // The close timer is armed by the leave, inside the faked window, so the clock owns it.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ // A synthetic leave: a real unhover parks the pointer where it may cross into the panel, which
+ // (rightly) cancels the close and is not what this test is about.
+ trigger("Products")
+ .element()
+ .dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
+ await vi.advanceTimersByTimeAsync(149);
+ // Aria-expanded is the source of truth: the panel element can outlive its close for the exit.
+ expect(trigger("Products").element().getAttribute("aria-expanded")).toBe("true");
+ await vi.advanceTimersByTimeAsync(1);
+ expect(trigger("Products").element().getAttribute("aria-expanded")).toBe("false");
+ vi.useRealTimers();
+ await expect.element(link("Analytics")).not.toBeInTheDocument();
+ });
+
+ it("switches once open and closes after closeDelay", async () => {
+ await render(Harness, { openDelay: 0, closeDelay: 150 });
+ await trigger("Products").hover();
await expect.element(link("Analytics")).toBeVisible();
await trigger("Resources").hover();
await expect.element(link("Docs")).toBeVisible();
await expect.element(link("Analytics")).not.toBeInTheDocument();
+ // Pointer inside the panel cancels the close timer; faked from here so the wait costs nothing.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await userEvent.hover(link("Docs"));
- await new Promise((r) => setTimeout(r, 300));
- await expect.element(link("Docs")).toBeVisible();
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(link("Docs").element()).toBeVisible();
+ vi.useRealTimers();
await userEvent.unhover(link("Docs"));
await expect.element(link("Docs")).not.toBeInTheDocument();
});
diff --git a/packages/ui/src/lib/components/Overlays/ModalGate.svelte.test.ts b/packages/ui/src/lib/components/Overlays/ModalGate.svelte.test.ts
index aa8d3696..2f184075 100644
--- a/packages/ui/src/lib/components/Overlays/ModalGate.svelte.test.ts
+++ b/packages/ui/src/lib/components/Overlays/ModalGate.svelte.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-svelte";
+import { settled } from "../../../../tests/settled";
import { layers } from "../../stores/layers.svelte.js";
import Harness from "./ModalGate.test.svelte";
@@ -40,7 +41,9 @@ describe("hover UI stays shut under a modal", () => {
it("Tooltip behind the modal does not open on pointer movement", async () => {
await openModal();
moveOver(page.getByText("Behind", { exact: true }).element());
- await new Promise((r) => setTimeout(r, 150));
+ // Every delay in the harness is 0: an open would run in the handler or the effect flush after it, so
+ // one settled frame is all "stays shut" needs.
+ await settled();
expect(
document.querySelector(".ca-tooltip-content[data-state=open], [role=tooltip]")
).toBeNull();
@@ -49,14 +52,18 @@ describe("hover UI stays shut under a modal", () => {
it("HoverCard behind the modal does not open", async () => {
await openModal();
moveOver(page.getByRole("link", { name: "@behind" }).element());
- await new Promise((r) => setTimeout(r, 150));
+ // Every delay in the harness is 0: an open would run in the handler or the effect flush after it, so
+ // one settled frame is all "stays shut" needs.
+ await settled();
expect(document.querySelector(".ca-hovercard-content")).toBeNull();
});
it("NavigationMenu behind the modal does not open its panel", async () => {
await openModal();
moveOver(page.getByRole("button", { name: "Behind products" }).element());
- await new Promise((r) => setTimeout(r, 150));
+ // Every delay in the harness is 0: an open would run in the handler or the effect flush after it, so
+ // one settled frame is all "stays shut" needs.
+ await settled();
expect(document.querySelector(".ca-navmenu-panel")).toBeNull();
});
});
diff --git a/packages/ui/src/lib/components/PageShell/PageShell.nomain.svelte.test.ts b/packages/ui/src/lib/components/PageShell/PageShell.nomain.svelte.test.ts
index bfd7a251..2938de86 100644
--- a/packages/ui/src/lib/components/PageShell/PageShell.nomain.svelte.test.ts
+++ b/packages/ui/src/lib/components/PageShell/PageShell.nomain.svelte.test.ts
@@ -1,5 +1,6 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
+import { settled } from "../../../../tests/settled";
import Harness from "./NoMainHarness.test.svelte";
// devWarn prints once per key for the page's lifetime: every case that expects the warning
@@ -20,7 +21,8 @@ describe("page layouts without a landmark", () => {
for (const frame of [false, true])
it(`${frame ? "PageFrame" : "PageShell"} stays silent: ${where}`, async () => {
await render(Harness, { where, frame });
- await new Promise((r) => setTimeout(r, 50));
+ // The check runs in the mount effect; a frame is enough for "stays silent".
+ await settled();
expect(landmark()).toEqual([]);
});
@@ -30,7 +32,7 @@ describe("page layouts without a landmark", () => {
expect(landmark()[0]).toContain("");
expect(landmark()[0]).toContain("#standalone-page");
await render(Harness, { where: "alone", frame: true });
- await new Promise((r) => setTimeout(r, 50));
+ await settled();
expect(landmark()).toHaveLength(1);
});
});
diff --git a/packages/ui/src/lib/components/SplashScreen/SplashScreen.svelte.test.ts b/packages/ui/src/lib/components/SplashScreen/SplashScreen.svelte.test.ts
index afe49c4d..356b96b8 100644
--- a/packages/ui/src/lib/components/SplashScreen/SplashScreen.svelte.test.ts
+++ b/packages/ui/src/lib/components/SplashScreen/SplashScreen.svelte.test.ts
@@ -1,5 +1,5 @@
import { createRawSnippet } from "svelte";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
import SplashScreen from "./SplashScreen.svelte";
@@ -9,6 +9,10 @@ const logo = createRawSnippet(() => ({
}));
const splash = (c: HTMLElement) => c.querySelector(".ca-splash");
+afterEach(() => {
+ vi.useRealTimers();
+});
+
describe("SplashScreen", () => {
it("is a polite status region with a localised accessible name", async () => {
const screen = await render(SplashScreen, { logo, ready: false });
@@ -35,13 +39,18 @@ describe("SplashScreen", () => {
it("waits for ready after minDuration has passed, then leaves", async () => {
let done = 0;
+ // minDuration is a plain setTimeout: a fake clock installed BEFORE render owns it, so "still here
+ // well past minDuration while not ready" costs no wall time.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const screen = await render(SplashScreen, {
minDuration: 50,
ready: false,
ondone: () => done++,
});
- await new Promise((r) => setTimeout(r, 200));
+ await vi.advanceTimersByTimeAsync(5000);
expect(splash(screen.container)).not.toBeNull();
+ expect(done).toBe(0);
+ vi.useRealTimers();
await screen.rerender({ ready: true });
await expect.poll(() => splash(screen.container)).toBeNull();
expect(done).toBe(1);
diff --git a/packages/ui/src/lib/components/Tooltip/Tooltip.svelte.test.ts b/packages/ui/src/lib/components/Tooltip/Tooltip.svelte.test.ts
index 25b222ce..8da59b12 100644
--- a/packages/ui/src/lib/components/Tooltip/Tooltip.svelte.test.ts
+++ b/packages/ui/src/lib/components/Tooltip/Tooltip.svelte.test.ts
@@ -1,10 +1,14 @@
-import { describe, expect, it } 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 "./Tooltip.test.svelte";
+afterEach(() => {
+ vi.useRealTimers();
+});
+
describe("Tooltip", () => {
it("is hidden until the trigger receives keyboard focus, then role=tooltip describes it", async () => {
const screen = await render(Harness);
@@ -51,13 +55,17 @@ describe("Tooltip", () => {
});
it("does not open from pointerenter alone or from touch, only from real mouse movement", async () => {
+ // The open delay is a plain setTimeout: a fake clock installed BEFORE render owns it, so "never
+ // opens" is proven by advancing past the delay, not by sitting through it.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const screen = await render(Harness);
const trigger = screen.getByRole("button", { name: "Save" }).element();
// A cursor resting where the trigger mounts (or layout moving it under the cursor) fires enter only.
trigger.dispatchEvent(new PointerEvent("pointerenter", { pointerType: "mouse" }));
trigger.dispatchEvent(new PointerEvent("pointermove", { pointerType: "touch", bubbles: true }));
- await new Promise((r) => setTimeout(r, 50));
+ await vi.advanceTimersByTimeAsync(5000);
expect(document.querySelector('[role="tooltip"]')).toBeNull();
+ vi.useRealTimers();
trigger.dispatchEvent(new PointerEvent("pointermove", { pointerType: "mouse", bubbles: true }));
await expect.element(page.getByRole("tooltip")).toBeVisible();
});
diff --git a/packages/ui/src/lib/components/VirtualList/VirtualList.svelte.test.ts b/packages/ui/src/lib/components/VirtualList/VirtualList.svelte.test.ts
index fa104a86..ee8a0fa4 100644
--- a/packages/ui/src/lib/components/VirtualList/VirtualList.svelte.test.ts
+++ b/packages/ui/src/lib/components/VirtualList/VirtualList.svelte.test.ts
@@ -1,5 +1,5 @@
import { tick } from "svelte";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
import { SLOW_FLOW } from "../../../../tests/settled";
@@ -20,6 +20,10 @@ const keys = (c: Element) =>
const waitFor = (fn: () => boolean) => expect.poll(fn, { timeout: 30_000 }).toBe(true);
const top = (el: HTMLElement) => el.getBoundingClientRect().top;
+afterEach(() => {
+ vi.useRealTimers();
+});
+
describe("VirtualList", () => {
it("renders a named, focusable region with a list inside", async () => {
const screen = await render(Harness, { props: { initial: 5 } });
@@ -191,7 +195,6 @@ describe("VirtualList", () => {
await settle();
scroller(a.container).scrollTop = 0;
await settle();
- await new Promise((r) => setTimeout(r, 100));
expect(loading).not.toHaveBeenCalled();
const none = vi.fn();
@@ -199,7 +202,6 @@ describe("VirtualList", () => {
await settle();
scroller(b.container).scrollTop = 0;
await settle();
- await new Promise((r) => setTimeout(r, 100));
expect(none).not.toHaveBeenCalled();
});
@@ -282,13 +284,21 @@ describe("VirtualList", () => {
async () => {
const announcer = () =>
document.querySelector('[data-ca-announcer="polite"]')?.textContent ?? "";
+ // The announcement is a 600 ms debounce on a plain setTimeout: a fake clock installed BEFORE
+ // render proves "a prepend is never announced" with no wall time. `announce` writes the text on
+ // the next real frame (and clears it 5 s later on the same clock), so each advance is followed by
+ // a settled frame BEFORE the assertion; without it "not announced" would pass for any input.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const screen = await render(Harness, { props: { initial: 20 } });
await settle();
screen.component.prepend(7);
- await new Promise((r) => setTimeout(r, 900));
+ await vi.advanceTimersByTimeAsync(900);
+ await settle();
expect(announcer()).not.toContain("7 items");
screen.component.append(2);
screen.component.append(3);
+ await vi.advanceTimersByTimeAsync(900);
+ vi.useRealTimers();
await waitFor(() => announcer().includes("5 items"));
},
SLOW_FLOW
diff --git a/packages/ui/src/lib/forms/Turnstile.svelte.test.ts b/packages/ui/src/lib/forms/Turnstile.svelte.test.ts
index 4cf6b599..c0b772c2 100644
--- a/packages/ui/src/lib/forms/Turnstile.svelte.test.ts
+++ b/packages/ui/src/lib/forms/Turnstile.svelte.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../tests/a11y";
+import { settled } from "../../../tests/settled";
import { setLocale } from "../i18n/index.js";
import { colorScheme } from "../stores/color-scheme.svelte.js";
import Harness from "./Turnstile.test.svelte";
@@ -45,7 +46,9 @@ function installFake() {
};
}
-const settle = () => new Promise((r) => setTimeout(r, 30));
+// The loader resolves in microtasks when `window.turnstile` is already there, and the widget renders in
+// the effect after it: a settled frame covers both. Waits for a widget that must APPEAR poll on `calls`.
+const rendered = (n: number) => expect.poll(() => calls.length).toBe(n);
const last = () => calls[calls.length - 1];
beforeEach(() => {
@@ -64,7 +67,7 @@ describe("Turnstile", () => {
it("without a site key renders nothing, loads nothing and returns no token", async () => {
delete (window as { turnstile?: unknown }).turnstile;
const screen = await render(Harness, {});
- await settle();
+ await settled();
expect(screen.container.querySelector(".ca-turnstile")).toBeNull();
expect(document.querySelector("script[data-ca-turnstile]")).toBeNull();
await expect.element(screen.getByTestId("token")).toHaveTextContent("none");
@@ -72,7 +75,7 @@ describe("Turnstile", () => {
it("renders explicitly with sitekey, action, scheme theme and the locale", async () => {
await render(Harness, { siteKey: "0xKEY", action: "sign-in" });
- await settle();
+ await rendered(1);
expect(calls).toHaveLength(1);
expect(last().opts.sitekey).toBe("0xKEY");
expect(last().opts.action).toBe("sign-in");
@@ -83,7 +86,7 @@ describe("Turnstile", () => {
it("passes the token to onverify, the bound token and handle.getToken()", async () => {
const verified: string[] = [];
const screen = await render(Harness, { siteKey: "k", verified });
- await settle();
+ await rendered(1);
last().opts.callback("TOKEN-1");
await expect.element(screen.getByTestId("bound")).toHaveTextContent("TOKEN-1");
await expect.element(screen.getByTestId("token")).toHaveTextContent("TOKEN-1");
@@ -92,7 +95,7 @@ describe("Turnstile", () => {
it("reset() resets the widget and clears the token", async () => {
const screen = await render(Harness, { siteKey: "k" });
- await settle();
+ await rendered(1);
last().opts.callback("T");
await screen.getByRole("button", { name: "reset" }).click();
expect(resets).toEqual([last().id]);
@@ -103,7 +106,7 @@ describe("Turnstile", () => {
const errors: string[] = [];
const expired = { n: 0 };
const screen = await render(Harness, { siteKey: "k", errors, expired });
- await settle();
+ await rendered(1);
last().opts.callback("T");
last().opts["expired-callback"]();
await expect.element(screen.getByTestId("bound")).toHaveTextContent("none");
@@ -116,10 +119,10 @@ describe("Turnstile", () => {
it("re-renders in the new language when the locale changes, removing the old widget", async () => {
await render(Harness, { siteKey: "k" });
- await settle();
+ await rendered(1);
expect(calls).toHaveLength(1);
setLocale("pt-BR");
- await settle();
+ await rendered(2);
expect(removed).toEqual(["w1"]);
expect(calls).toHaveLength(2);
expect(last().opts.language).toBe("pt-br");
@@ -127,18 +130,17 @@ describe("Turnstile", () => {
it("follows the colour scheme when theme is auto, an explicit theme wins", async () => {
await render(Harness, { siteKey: "k" });
- await settle();
+ await rendered(1);
colorScheme.setPreference("dark");
- await settle();
- expect(last().opts.theme).toBe("dark");
+ await expect.poll(() => last().opts.theme).toBe("dark");
await render(Harness, { siteKey: "k2", theme: "light" });
- await settle();
+ await expect.poll(() => last().opts.sitekey).toBe("k2");
expect(last().opts.theme).toBe("light");
});
it("removes the widget on unmount (no leak)", async () => {
const screen = await render(Harness, { siteKey: "k" });
- await settle();
+ await rendered(1);
const id = last().id;
screen.unmount();
expect(removed).toContain(id);
@@ -150,13 +152,13 @@ describe("Turnstile", () => {
screen.unmount();
installFake();
document.querySelector("script[data-ca-turnstile]")?.dispatchEvent(new Event("load"));
- await settle();
+ await settled();
expect(calls).toHaveLength(0);
});
it("has no axe violations with and without a key", async () => {
const a = await render(Harness, { siteKey: "k" });
- await settle();
+ await rendered(1);
expect(await checkA11y(a.container)).toHaveNoViolations();
a.unmount();
const b = await render(Harness, {});
diff --git a/packages/ui/src/lib/kit/navigation-progress.svelte.test.ts b/packages/ui/src/lib/kit/navigation-progress.svelte.test.ts
index 016a778b..20880c38 100644
--- a/packages/ui/src/lib/kit/navigation-progress.svelte.test.ts
+++ b/packages/ui/src/lib/kit/navigation-progress.svelte.test.ts
@@ -25,17 +25,22 @@ const end = () => {
};
const bar = () => document.querySelector("[data-ca-nav-progress]");
-afterEach(end);
+afterEach(() => {
+ vi.useRealTimers();
+ end();
+});
describe("NavigationProgress", () => {
it("stays hidden for a navigation that ends before `delay`", async () => {
- // A long delay keeps this deterministic under CPU load: `end()` always wins the race.
- await render(NavigationProgress, { props: { delay: 5000 } });
+ // The bar appears from a plain setTimeout(delay): a fake clock installed BEFORE render owns it, so
+ // the navigation ends at exactly 119 of 120 ms whatever the load, and the bar must never show.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ await render(NavigationProgress, { props: { delay: 120 } });
start("/slow");
- await new Promise((r) => setTimeout(r, 50));
+ await vi.advanceTimersByTimeAsync(119);
expect(bar()?.hidden).toBe(true);
end();
- await new Promise((r) => setTimeout(r, 50));
+ await vi.advanceTimersByTimeAsync(1000);
expect(bar()?.hidden).toBe(true);
expect(document.documentElement.hasAttribute("data-ca-navigating")).toBe(false);
});
diff --git a/packages/ui/src/lib/kit/route-announcer.svelte.test.ts b/packages/ui/src/lib/kit/route-announcer.svelte.test.ts
index 0cd92ed8..64c87b68 100644
--- a/packages/ui/src/lib/kit/route-announcer.svelte.test.ts
+++ b/packages/ui/src/lib/kit/route-announcer.svelte.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
+import { settled } from "../../../tests/settled";
type Nav = { type: string };
const callbacks: Array<(nav: Nav) => void> = [];
@@ -36,7 +37,8 @@ describe("RouteAnnouncer", () => {
await render(RouteAnnouncer, {});
document.title = "Home";
navigate("enter");
- await new Promise((r) => setTimeout(r, 150));
+ // A navigation announces from one requestAnimationFrame; once a frame has passed it would have.
+ await settled();
expect(polite()).toBeUndefined();
});
diff --git a/packages/ui/src/lib/map/MapStageDeck.svelte.test.ts b/packages/ui/src/lib/map/MapStageDeck.svelte.test.ts
index a652c876..2b87cf9b 100644
--- a/packages/ui/src/lib/map/MapStageDeck.svelte.test.ts
+++ b/packages/ui/src/lib/map/MapStageDeck.svelte.test.ts
@@ -40,12 +40,12 @@ describe("MapStage with a deck.gl layer", () => {
],
});
await vi.waitFor(() => expect(map?.isStyleLoaded()).toBe(true), { timeout: 10_000 });
- // Let several frames render: the error fires per frame.
- for (let i = 0; i < 5; i++) {
+ // The error fires per rendered frame: render a run of them, repainting each time, so no fixed
+ // wait is needed to give it a chance to appear.
+ for (let i = 0; i < 12; i++) {
map?.triggerRepaint();
await new Promise((r) => requestAnimationFrame(() => r(null)));
}
- await new Promise((r) => setTimeout(r, 500));
expect(errors).toEqual([]);
} finally {
window.removeEventListener("error", onError);
diff --git a/packages/ui/src/lib/primitives/dnd.svelte.test.ts b/packages/ui/src/lib/primitives/dnd.svelte.test.ts
index be63f856..b0a64434 100644
--- a/packages/ui/src/lib/primitives/dnd.svelte.test.ts
+++ b/packages/ui/src/lib/primitives/dnd.svelte.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../tests/a11y";
import type { DndDropEvent } from "./dnd.js";
@@ -10,13 +10,9 @@ const grip = (c: Element, id: string) =>
c.querySelector(`[data-dnd-item="${id}"] .grip`) as HTMLElement;
const row = (c: Element, id: string) => c.querySelector(`[data-dnd-item="${id}"]`) as HTMLElement;
const frame = () => new Promise((r) => requestAnimationFrame(() => r(null)));
-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);
const live = () =>
Array.from(document.querySelectorAll("[data-ca-announcer]"))
.map((el) => el.textContent)
@@ -148,16 +144,24 @@ describe("dnd sortable (pointer)", () => {
});
});
+afterEach(() => {
+ vi.useRealTimers();
+});
+
describe("dnd touch", () => {
it("press and hold starts the drag; an early move is a scroll and gives up", async () => {
const drops: DndDropEvent[] = [];
const screen = await render(Harness, { props: { drops } });
const c = screen.container;
const [x, y] = center(grip(c, "a1"));
+ // The 350 ms hold is a plain setTimeout armed at pointerdown: a fake clock installed before it
+ // owns it, so "an early move gives up for good" is proven by advancing far past the hold.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
pointer("pointerdown", x, y, "touch", grip(c, "a1"));
pointer("pointermove", x, y + 30, "touch");
- await new Promise((r) => setTimeout(r, 200));
+ await vi.advanceTimersByTimeAsync(2000);
expect(document.querySelector(".ca-dnd-overlay")).toBeNull();
+ vi.useRealTimers();
pointer("pointerdown", x, y, "touch", grip(c, "a1"));
expect(row(c, "a1").hasAttribute("data-dnd-pressing")).toBe(true);
diff --git a/packages/ui/src/lib/primitives/presence.svelte.test.ts b/packages/ui/src/lib/primitives/presence.svelte.test.ts
index e67f6ddc..86911a4e 100644
--- a/packages/ui/src/lib/primitives/presence.svelte.test.ts
+++ b/packages/ui/src/lib/primitives/presence.svelte.test.ts
@@ -2,9 +2,28 @@ import { flushSync } from "svelte";
import { afterEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { render } from "vitest-browser-svelte";
+import { settled } from "../../../tests/settled";
import PresenceBlock from "./PresenceBlock.test.svelte";
import { createPresence } from "./presence.svelte.js";
+const frame = () => new Promise((r) => requestAnimationFrame(() => r(null)));
+const exiting = (el: Element) => el.getAnimations().filter((a) => a.playState === "running");
+// Holds the exit animation still, so "stays present while it plays" is a property of the animation
+// being unfinished, not of how long the machine takes to reach a timer. The primitive looks at the
+// running animations in ONE requestAnimationFrame after the close and then awaits `finished`; ours is
+// registered after it (frame callbacks run in registration order), so the animation is paused only
+// once the primitive has attached to it. Call right after the close.
+async function holdExit(el: Element) {
+ await frame();
+ const running = exiting(el);
+ expect(running.length).toBeGreaterThan(0);
+ for (const a of running) a.pause();
+ for (let i = 0; i < 3; i++) await frame();
+}
+const release = (el: Element) => {
+ for (const a of el.getAnimations()) a.play();
+};
+
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
@@ -33,6 +52,7 @@ function harness(animation: boolean) {
});
cleanups.push(stop, () => release());
return {
+ node,
presence,
setOpen(v: boolean) {
open = v;
@@ -44,12 +64,13 @@ function harness(animation: boolean) {
describe("createPresence", () => {
it("stays present until the exit animation ends", async () => {
- const { presence, setOpen } = harness(true);
+ const { node, presence, setOpen } = harness(true);
expect(presence.present).toBe(true);
setOpen(false);
expect(presence.present).toBe(true);
- await new Promise((r) => setTimeout(r, 60));
+ await holdExit(node);
expect(presence.present).toBe(true);
+ release(node);
await expect.poll(() => presence.present, { timeout: 2000 }).toBe(false);
});
@@ -63,7 +84,10 @@ describe("createPresence", () => {
const { presence, setOpen } = harness(true);
setOpen(false);
setOpen(true);
- await new Promise((r) => setTimeout(r, 250));
+ // What could flip it is the exit's frame callback; it was cancelled by the reopen. Let the frames
+ // that would have run it, and the 150 ms exit animation if one had started, play out.
+ for (let i = 0; i < 4; i++) await frame();
+ await settled();
expect(presence.present).toBe(true);
});
});
@@ -76,8 +100,9 @@ describe("createPresence in a template", () => {
// Not removed in the flush that flips `open`: the exit animation has to play.
expect(node.isConnected).toBe(true);
expect(node.dataset.state).toBe("closed");
- await new Promise((r) => setTimeout(r, 150));
+ await holdExit(node);
expect(node.isConnected).toBe(true);
+ release(node);
await expect.poll(() => node.isConnected, { timeout: 2000 }).toBe(false);
});
diff --git a/packages/ui/tests/unit-suite.test.ts b/packages/ui/tests/unit-suite.test.ts
index dfb43ddc..30a42e9b 100644
--- a/packages/ui/tests/unit-suite.test.ts
+++ b/packages/ui/tests/unit-suite.test.ts
@@ -18,6 +18,25 @@ describe("unit suite", () => {
expect(offenders).toEqual([]);
});
+ // #568: a fixed `setTimeout` sleep in a component test costs wall time on every run and flakes under
+ // load. Await a condition (`expect.poll`, `settled()`), or install a fake clock BEFORE render. A
+ // sleep that is truly needed carries `real-time:` and the reason on the line above it.
+ it("no component test sleeps on a real timer", () => {
+ const root = join(import.meta.dir, "../src");
+ const offenders: string[] = [];
+ for (const file of new Bun.Glob("**/*.svelte.test.ts").scanSync(root)) {
+ const lines = readFileSync(join(root, file), "utf8").split("\n");
+ lines.forEach((line, i) => {
+ if (
+ /new Promise\(\(?\w*\)? => setTimeout\(/.test(line) &&
+ !/real-time:/.test(lines[i - 1] ?? "")
+ )
+ offenders.push(`${file}:${i + 1}`);
+ });
+ }
+ expect(offenders).toEqual([]);
+ });
+
// #529: raw `mkdtempSync(join(tmpdir(), …))` left 51k directories in $TMPDIR. Use `tmpDir()` and
// `afterAll(cleanTmp)` from ./tmp, or remove the directory yourself with `rmSync`.
it("every test file that makes a temp directory removes it", () => {