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", () => {