From 71aee5c2d1737e11e5e0f32d0e6db82e98c90e76 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 16:07:13 +0000 Subject: [PATCH] test(webviews): B4 waits for conditions, not fixed times Removes all 101 fixed waits in the 17 B4 browser tests and drops their baseline rows. - Settling after hover, focus, press and Escape waits for painted frames (installPaintedFrames) plus the element's own CSS animations and transitions finishing, or for the exact state the test then asserts (:hover, aria-expanded, open chips, focus). - Wheel and scroll waits wait until the wheel events arrived and the scroll offsets hold steady across painted frames. - "Nothing was sent" checks use a later action as the signal the page has processed the input (a later Delete or Approve, a streamed chunk drawing), then assert the absence. - The settings dev servers start in-process with vite listen() instead of a spawned server and a fetch poll. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15c78-27ca-7140-a6c7-95f0db83b18c&host=huge-blue --- webviews/scripts/fixed-waits-baseline.tsv | 17 --- ...gent-pane-permission-deny-feedback.test.ts | 49 ++++++-- .../test/agent-pane-permission-queue.test.ts | 56 ++++----- webviews/test/agent-pane-queue-steer.test.ts | 22 +++- webviews/test/agent-pane-queue.test.ts | 10 +- .../agent-pane-transcript-spacing.test.ts | 20 +++- webviews/test/composer-caret-focus.test.ts | 43 ++++--- webviews/test/composer-draft-wrap.test.ts | 12 +- webviews/test/composer-location-strip.test.ts | 5 +- webviews/test/composer-menu-dismiss.test.ts | 69 +++++++++-- webviews/test/composer-quiet-chips.test.ts | 42 +++++-- webviews/test/hover-row-sweep.test.ts | 28 ++++- .../test/markdown-wide-table-scroll.test.ts | 35 +++++- webviews/test/newtab-quiet-focus.test.ts | 13 ++- webviews/test/popup-reduce-motion.test.ts | 10 +- webviews/test/settings-scroll.test.ts | 107 +++++++++++------- webviews/test/settings-search-results.test.ts | 43 +++---- webviews/test/transcript-follow.test.ts | 49 ++++---- 18 files changed, 421 insertions(+), 209 deletions(-) diff --git a/webviews/scripts/fixed-waits-baseline.tsv b/webviews/scripts/fixed-waits-baseline.tsv index ad2331d78e7b..4cc1794a5334 100644 --- a/webviews/scripts/fixed-waits-baseline.tsv +++ b/webviews/scripts/fixed-waits-baseline.tsv @@ -25,11 +25,7 @@ test/agent-pane-mermaid.test.tsx 8 test/agent-pane-mode-keys.test.ts 1 test/agent-pane-model-sticks.test.ts 14 test/agent-pane-older-rows-fade.test.ts 14 -test/agent-pane-permission-deny-feedback.test.ts 6 -test/agent-pane-permission-queue.test.ts 5 test/agent-pane-queue-collapse.test.ts 4 -test/agent-pane-queue-steer.test.ts 4 -test/agent-pane-queue.test.ts 2 test/agent-pane-reading-place.test.ts 2 test/agent-pane-recap-card.test.ts 9 test/agent-pane-scrollbar-full-height.test.ts 19 @@ -44,7 +40,6 @@ test/agent-pane-stream-jitter.test.ts 22 test/agent-pane-subagent-groups-open.test.ts 12 test/agent-pane-suggested-tasks.test.ts 13 test/agent-pane-tab-row.test.ts 1 -test/agent-pane-transcript-spacing.test.ts 2 test/agent-pane-turn-footer.test.ts 5 test/agent-pane-turn-tokens.test.tsx 23 test/agent-pane-unknown-harness.test.ts 3 @@ -57,18 +52,13 @@ test/changelog-ledger.test.tsx 1 test/changelog-quality.test.tsx 1 test/chat-find.test.ts 1 test/chief-context-copy.test.ts 1 -test/composer-caret-focus.test.ts 21 test/composer-chip-plain-rest.test.ts 4 test/composer-chip-press.test.ts 8 test/composer-claude-split.test.ts 8 -test/composer-draft-wrap.test.ts 4 test/composer-fast-toggle.test.tsx 15 test/composer-inbound-keeps-caret.test.tsx 6 test/composer-kept-attachments.test.tsx 37 -test/composer-location-strip.test.ts 1 -test/composer-menu-dismiss.test.ts 16 test/composer-paste-images.test.tsx 7 -test/composer-quiet-chips.test.ts 14 test/desktop-feel.test.ts 10 test/dev-server-stop.test.ts 1 test/diff-virtualization.test.ts 56 @@ -77,7 +67,6 @@ test/editor-page.test.ts 15 test/effort-picker-slider.test.tsx 21 test/harnesses-page.test.tsx 8 test/hover-intent-cards.test.tsx 5 -test/hover-row-sweep.test.ts 6 test/image-viewer-actions.test.tsx 5 test/ime-composition-keys.test.ts 2 test/latency-harness.test.ts 2 @@ -87,7 +76,6 @@ test/latency/picker.harness.tsx 7 test/markdown-link-bubble.test.ts 1 test/markdown-obsidian-syntax.test.ts 1 test/markdown-table-obsidian.test.ts 1 -test/markdown-wide-table-scroll.test.ts 4 test/markdown-wide-width-layout.test.ts 1 test/menu-pointer-close-focus.test.ts 10 test/model-menu-compact.test.tsx 53 @@ -102,8 +90,6 @@ test/new-tab-omnibar-pointer.test.ts 1 test/new-tab-style-switch.test.tsx 1 test/new-tab-warm-first-frame.test.ts 21 test/new-tab-warm-snapshot.test.ts 2 -test/newtab-quiet-focus.test.ts 1 -test/popup-reduce-motion.test.ts 2 test/quick-composer-chief-dock.test.ts 1 test/quiet-access-chip.test.ts 4 test/reasoning-chip-steps.test.ts 2 @@ -114,15 +100,12 @@ test/retry-model-menu.test.ts 2 test/session-hover-card.test.tsx 7 test/session-sidebar-preview.test.tsx 6 test/settings-open-frames.test.ts 2 -test/settings-scroll.test.ts 9 -test/settings-search-results.test.ts 1 test/summary-background-work.test.tsx 1 test/support/papercutChat.ts 2 test/thin-scrollers.test.ts 1 test/thread-minimap-feel.test.ts 1 test/thread-minimap-motion.test.ts 6 test/transcript-fold-anchor-browser.test.ts 5 -test/transcript-follow.test.ts 3 test/transcript-hidden-follow.test.tsx 2 test/transcript-scroll-restore.test.tsx 3 test/transcript-switch-back.test.tsx 5 diff --git a/webviews/test/agent-pane-permission-deny-feedback.test.ts b/webviews/test/agent-pane-permission-deny-feedback.test.ts index 6912653ba9bd..66351332e3a1 100644 --- a/webviews/test/agent-pane-permission-deny-feedback.test.ts +++ b/webviews/test/agent-pane-permission-deny-feedback.test.ts @@ -136,10 +136,24 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async () ] : [], ); - const settle = async (page: Page, count: number) => { - for (let tries = 0; tries < 40 && answers().length < count; tries++) await page.waitForTimeout(50); - await page.waitForTimeout(200); - }; + /// Called each time the pane asks acpmux something. + let onAsk: (() => void) | undefined; + /// Resolves once acpmux has heard `count` answers. + const heard = (count: number) => + new Promise((resolve) => { + const check = () => { + if (answers().length < count) return; + onAsk = undefined; + resolve(); + }; + onAsk = check; + check(); + }); + /// Whether the keyboard is in the composer: a deny acpmux took has finished (sendFeedback). + const inComposer = (page: Page) => + page.waitForFunction(() => Boolean(document.activeElement?.closest(".acpmux-composer")), undefined, { + timeout: 10_000, + }); beforeAll(async () => { daemon = Bun.serve({ port: 0, @@ -151,6 +165,7 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async () message(ws, raw) { const message = JSON.parse(String(raw)) as { id?: number; method: string; params?: Record }; asked.push({ method: message.method, params: message.params ?? {} }); + onAsk?.(); if (message.id !== undefined && refuseDeny && message.method === "_acpmux/permission_respond") ws.send( JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "already answered" } }), @@ -194,10 +209,9 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async () await page.getByRole("button", { name: "No, tell Claude what to do differently" }).click(); expect(await field.inputValue()).toBe("Run only the unit tests"); await field.press("Enter"); - await settle(page, 2); + // The keyboard goes back to the composer, as a plain deny's does, once the text has gone too. + await inComposer(page); expect(answers()).toEqual(["respond reject_once", "prompt (steer) Run only the unit tests"]); - // The keyboard goes back to the composer, as a plain deny's does. - expect(await page.evaluate(() => Boolean(document.activeElement?.closest(".acpmux-composer")))).toBe(true); await page.close(); }); @@ -209,9 +223,18 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async () const field = page.getByRole("textbox", { name: "Tell the agent what to do instead" }); await field.fill("Use pnpm"); await field.press("Enter"); - await settle(page, 1); + // The refusal is back: the field can be typed in again and has the keyboard. + await page.waitForFunction( + () => { + const active = document.activeElement; + return ( + active instanceof HTMLTextAreaElement && active.closest(".acpmux-permission-feedback") && !active.readOnly + ); + }, + undefined, + { timeout: 10_000 }, + ); expect(answers()).toEqual(["respond reject_once"]); - await field.waitFor({ timeout: 5_000 }); expect(await field.inputValue()).toBe("Use pnpm"); } finally { refuseDeny = false; @@ -225,7 +248,7 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async () await page.getByRole("button", { name: "Decline" }).waitFor({ timeout: 5_000 }); await page.getByRole("button", { name: "Deny and tell the agent what to do instead" }).click(); await page.getByRole("button", { name: "Deny and send" }).click(); - await settle(page, 1); + await inComposer(page); expect(answers()).toEqual(["respond denied"]); await page.close(); }); @@ -241,8 +264,10 @@ await requireBrowserLane("agent-pane-permission-deny-feedback.test.ts", async () expect(await page.evaluate(() => document.activeElement?.textContent)).toBe( "Deny and tell the agent what to do instead", ); - await page.waitForTimeout(200); - expect(answers()).toEqual([]); + // Cancel sent nothing: the next answer the card gives is the first acpmux hears. + await page.getByRole("button", { name: "Approve" }).click(); + await heard(1); + expect(answers()).toEqual(["respond approved"]); await page.close(); }); }); diff --git a/webviews/test/agent-pane-permission-queue.test.ts b/webviews/test/agent-pane-permission-queue.test.ts index c4a5e5a2edbc..6f03ffbda9dc 100644 --- a/webviews/test/agent-pane-permission-queue.test.ts +++ b/webviews/test/agent-pane-permission-queue.test.ts @@ -74,6 +74,17 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => { /// The answers each page sent, as `_acpmux/permission_respond` params. const responses: Record[] = []; + /// Called each time the pane answers an ask. + let onResponse: (() => void) | undefined; + /// Resolves once acpmux has heard an answer. + const answered = () => + new Promise((resolve) => { + if (responses.length > 0) return resolve(); + onResponse = () => { + onResponse = undefined; + resolve(); + }; + }); function answer(method: string, params: Record = {}): unknown { const id = params.sessionId === REOPENED ? REOPENED : LIVE; switch (method) { @@ -92,6 +103,7 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => { return { events: [], hasMore: false, lastSeq: steps.length }; case "_acpmux/permission_respond": responses.push(params); + onResponse?.(); return {}; default: return null; @@ -122,20 +134,12 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => { return page; }; const card = (page: Page) => page.locator(".acpmux-permission-ask"); - /// Waits until `read` returns a value `ok` accepts, and returns it (fails after 10 s). - const until = async (read: () => T | Promise, ok: (value: T) => boolean): Promise => { - const deadline = Date.now() + 10_000; - for (;;) { - const value = await read(); - if (ok(value) || Date.now() > deadline) return value; - await new Promise((resolve) => setTimeout(resolve, 50)); - } + /// The card's text once it shows `text` (fails after 10 s). + const cardShowing = async (page: Page, text: string) => { + const shown = card(page).filter({ hasText: text }).first(); + await shown.waitFor({ timeout: 10_000 }); + return shown.textContent(); }; - const cardText = (page: Page) => - card(page) - .first() - .textContent({ timeout: 1000 }) - .catch(() => ""); beforeAll(async () => { daemon = Bun.serve({ @@ -189,25 +193,13 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => { expect(await card(page).textContent()).toContain("1 more waiting"); responses.length = 0; await card(page).getByRole("button", { name: "Yes" }).click(); - const sent = await until( - () => responses.map((params) => params.permissionId), - (ids) => ids.length > 0, - ); - expect(sent).toEqual(["p1"]); + await answered(); + expect(responses.map((params) => params.permissionId)).toEqual(["p1"]); push(LIVE, ["mux", "permission_decision", { permissionId: "p1", optionId: "allow" }]); - const next = await until( - () => cardText(page), - (text) => (text ?? "").includes("Run git push"), - ); - expect(next).toContain("Run git push"); + const next = await cardShowing(page, "Run git push"); expect(next).not.toContain("more waiting"); push(LIVE, ["mux", "permission_decision", { permissionId: "p2", optionId: "allow" }]); - expect( - await until( - () => card(page).count(), - (n) => n === 0, - ), - ).toBe(0); + await card(page).first().waitFor({ state: "detached", timeout: 10_000 }); await page.close(); }); @@ -217,11 +209,7 @@ await requireBrowserLane("agent-pane-permission-queue.test.ts", async () => { expect(await card(page).textContent()).toContain("Run npm test"); expect(await card(page).textContent()).toContain("1 more waiting"); push(REOPENED, ["mux", "permission_decision", { permissionId: "p1", optionId: "allow" }]); - const next = await until( - () => cardText(page), - (text) => (text ?? "").includes("Run git push"), - ); - expect(next).toContain("Run git push"); + expect(await cardShowing(page, "Run git push")).toContain("Run git push"); await page.close(); }); }); diff --git a/webviews/test/agent-pane-queue-steer.test.ts b/webviews/test/agent-pane-queue-steer.test.ts index 1b0b70be270c..c568abe7d336 100644 --- a/webviews/test/agent-pane-queue-steer.test.ts +++ b/webviews/test/agent-pane-queue-steer.test.ts @@ -28,6 +28,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import path from "node:path"; import { chromium, webkit, type Browser, type Page } from "playwright"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; import { warmViteServer } from "./support/warmDevServer"; @@ -260,8 +261,15 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => { await page.goto(`http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${SESSION}`); // A cold dev server's first load can stall in CI: the first page waits longer. await page.locator(".acpmux-queued", { hasText: "space thing" }).waitFor({ timeout: 60_000 }); + await installPaintedFrames(page); return page; } + /// Streams `text` onto the reply (message m1) and waits for it to draw: the page has handled + /// every record sent before it. + const caughtUp = async (page: Page, text: string) => { + send(["in", "agent_message_chunk", say(text, "m1")]); + await page.locator(".acpmux-scroll", { hasText: text }).waitFor({ timeout: 10_000 }); + }; /// The bubble's read receipt once it is `read` (two checks) or not yet (one). const receipt = (page: Page, text: string, read: boolean) => page.locator(".cv-user", { hasText: text }).locator(`.cv-user__read[data-read="${read}"]`); @@ -273,7 +281,7 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => { await dock.waitFor(); // acpmux's later listing carries only the preview. changed(); - await page.waitForTimeout(200); + await caughtUp(page, "queued prompts."); const list = await page.locator(".acpmux-composer-queue").boundingBox(); const bar = await dock.boundingBox(); // A chat that has sent shows no location strip (#20869): the queue stands on the card. @@ -343,7 +351,9 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => { await page.locator(".acpmux-pr-dock").waitFor(); await page.locator(".acpmux-permission-card").waitFor(); await page.locator(".acpmux-queued-text", { hasText: "the pr thing" }).click(); - await page.waitForTimeout(300); + // The prompt has opened, and a frame has painted with the list fitted to it. + await page.locator('.acpmux-queued-text[aria-expanded="true"]', { hasText: "the pr thing" }).waitFor(); + await page.evaluate(() => window.cmuxPaintFrame()); const box = async (selector: string) => (await page.locator(selector).first().boundingBox())!; const list = await box(".acpmux-composer-queue"); const scroller = await box(".acpmux-scroll"); @@ -407,8 +417,9 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => { // A queued row shows Edit, Remove and Send now (#22805): click Send now by its name. await row.getByRole("button", { name: "Send now", exact: true }).click(); await row.locator(".acpmux-queued-kind", { hasText: "Steering" }).waitFor({ timeout: 10_000 }); - // The stopped turn has not ended yet: the row still shows. - await page.waitForTimeout(500); + // The stopped turn has not ended yet: once the page has handled what came after Send now, the + // row still shows. + await caughtUp(page, "queue order."); expect(await row.isVisible()).toBe(true); send(["mux", "turn_result", { status: "cancelled", turnId: "turn-1" }]); send(["mux", "dequeued", { promptId: "q-2" }]); @@ -427,8 +438,7 @@ await requireBrowserLane("agent-pane-queue-steer.test.ts", async () => { send(["mux", "user_message", { text: "use the cache dir", promptId: "s-1", turnId: "turn-1", steer: true }]); await receipt(page, "use the cache dir", false).waitFor({ state: "attached", timeout: 10_000 }); // The rest of the reply it went into still streams: not read yet. - send(["in", "agent_message_chunk", say("remapped paths.", "m1")]); - await page.waitForTimeout(200); + await caughtUp(page, "remapped paths."); expect(await receipt(page, "use the cache dir", false).count()).toBe(1); // The agent's next message answers the steer. send(["in", "agent_message_chunk", say("Using the cache dir now.", "m2")]); diff --git a/webviews/test/agent-pane-queue.test.ts b/webviews/test/agent-pane-queue.test.ts index 2038d22f80c9..bc6a29cb181e 100644 --- a/webviews/test/agent-pane-queue.test.ts +++ b/webviews/test/agent-pane-queue.test.ts @@ -255,8 +255,10 @@ await requireBrowserLane("agent-pane-queue.test.ts", async () => { expect(await input.inputValue()).toBe("line one\nline two"); await page.keyboard.press("Enter"); await input.waitFor({ state: "detached" }); - await page.waitForTimeout(300); - expect(edits).toEqual([]); + // The save sent nothing: the next change, a Delete, is the first acpmux hears. + await page.keyboard.press("Delete"); + await page.waitForFunction(() => document.querySelectorAll(".acpmux-queued").length === 2); + expect(edits).toEqual([{ op: "remove", promptId: "q-1" }]); await page.close(); }); @@ -267,8 +269,8 @@ await requireBrowserLane("agent-pane-queue.test.ts", async () => { await page.keyboard.press("ArrowUp"); expect(await focused(page)).toBe("q-2"); await page.keyboard.press("Meta+Enter"); - await page.waitForTimeout(300); - expect({ edits, focused: await focused(page) }).toEqual({ edits: [], focused: "q-2" }); + expect(await focused(page)).toBe("q-2"); + // ⌘Return sent nothing: the Delete after it is the first change acpmux hears. await page.keyboard.press("Delete"); await page.waitForFunction(() => document.querySelectorAll(".acpmux-queued").length === 2); expect({ edits, rows: await rows(page) }).toEqual({ diff --git a/webviews/test/agent-pane-transcript-spacing.test.ts b/webviews/test/agent-pane-transcript-spacing.test.ts index 53802f325b87..3c4674a60684 100644 --- a/webviews/test/agent-pane-transcript-spacing.test.ts +++ b/webviews/test/agent-pane-transcript-spacing.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; import { warmViteServer } from "./support/warmDevServer"; @@ -102,10 +103,27 @@ await requireBrowserLane("agent-pane-transcript-spacing.test.ts", async () => { }; const open = async (): Promise => { const page = await browser!.newPage({ viewport: { width: 900, height: 900 } }); + await installPaintedFrames(page); await page.goto(url()); await page.getByText("Meanwhile the PR checks").waitFor(); await page.mouse.move(0, 0); - await page.waitForTimeout(300); + // At rest: a frame has painted with no hover fade or other finite animation still running. + await page.evaluate( + () => + new Promise((resolve) => + window.cmuxAfterPaint(() => { + const moving = document + .getAnimations() + .some( + (animation) => + animation.playState === "running" && animation.effect?.getComputedTiming().endTime !== Infinity, + ); + if (moving) return true; + resolve(); + return false; + }), + ), + ); return page; }; beforeAll(async () => { diff --git a/webviews/test/composer-caret-focus.test.ts b/webviews/test/composer-caret-focus.test.ts index fe6318edf23f..122e92541dfe 100644 --- a/webviews/test/composer-caret-focus.test.ts +++ b/webviews/test/composer-caret-focus.test.ts @@ -19,6 +19,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; await requireBrowserLane("composer-caret-focus.test.ts", async () => { @@ -117,14 +118,28 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { timeout: 120_000, }); await page.evaluate(() => document.fonts.ready); + await installPaintedFrames(page); if (rest) { await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await page.mouse.move(1, 1); - await page.waitForTimeout(250); + await settled(page); } return page; }; + /** Resolves once a frame has painted since the last input and every transition or animation that + * input started (a pill's fade, a ring's) has run out, so a read sees the look it comes to rest + * in. Endless animations (a spinner) never settle, so they are left out. */ + const settled = (page: Page) => + page.evaluate(async () => { + await window.cmuxPaintFrame(); + const finite = document + .getAnimations() + .filter((animation) => Number.isFinite(animation.effect?.getComputedTiming().endTime ?? Infinity)); + await Promise.all(finite.map((animation) => animation.finished.catch(() => undefined))); + await window.cmuxPaintFrame(); + }); + /** The page as the key window, or not: the field keeps DOM focus either way. */ const setKey = (page: Page, key: boolean) => page.evaluate((key) => { @@ -241,7 +256,7 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { if (rest.background !== TRANSPARENT || rest.edge !== "none") wrong.push(`${button.label}: a pill at rest`); const box = (await page.locator(`${stage.row} ${CONTROLS} >> visible=true`).nth(button.index).boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.waitForTimeout(300); + await settled(page); const hovered = await look(page, stage, button.index); if (hovered.background !== tone) wrong.push(`${button.label}: hover ${hovered.background}, not ${tone}`); if (hovered.edge !== "none") wrong.push(`${button.label}: hover draws an edge`); @@ -250,7 +265,7 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { wrong.push(`${button.label}: hover also pills ${other.label}`); if ((await state(page, stage)).ring !== restBox) wrong.push(`${button.label}: hover changes the box`); await page.mouse.move(1, 1); - await page.waitForTimeout(300); + await settled(page); } await page.close(); expect(wrong).toEqual([]); @@ -266,7 +281,7 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { // A key first, so a script focus counts as keyboard focus (:focus-visible) in both engines. await page.keyboard.press("Shift"); await page.locator(`${stage.row} ${CONTROLS} >> visible=true`).nth(button.index).focus(); - await page.waitForTimeout(250); + await settled(page); const focused = await look(page, stage, button.index); if (focused.background !== tone) wrong.push(`${button.label}: focus ${focused.background}, not ${tone}`); if (focused.edge !== "none") wrong.push(`${button.label}: focus draws an edge`); @@ -287,21 +302,21 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { expect(rest.focused, "at rest the field has no focus").toBe(false); await page.locator(stage.field).first().click(); - await page.waitForTimeout(200); + await settled(page); const engaged = await state(page, stage); expect(engaged.focused, "a click focuses the field").toBe(true); expect(engaged.caret, "the focused field shows its caret").toBe(true); expect(engaged.ring, "the focused field draws the resting ring").toBe(rest.ring); await setKey(page, false); - await page.waitForTimeout(200); + await settled(page); const background = await state(page, stage); expect(background.focused, "the field keeps DOM focus in the background").toBe(true); expect(background.caret, "in the background the caret hides").toBe(false); expect(background.ring, "in the background the box draws the resting ring").toBe(rest.ring); await setKey(page, true); - await page.waitForTimeout(200); + await settled(page); const back = await state(page, stage); expect(back.caret, "key again, the caret is back").toBe(true); expect(back.ring, "key again, still the resting ring").toBe(rest.ring); @@ -317,18 +332,18 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { const rest = (await state(page, stage)).ring; await setKey(page, false); await page.locator(stage.field).first().focus(); - await page.waitForTimeout(200); + await settled(page); const background = await state(page, stage); expect(background).toEqual({ focused: true, caret: false, ring: rest }); // A key typed on the page means it is the key one, whatever hasFocus() said. await page.keyboard.press("Shift"); - await page.waitForTimeout(200); + await settled(page); const typed = await state(page, stage); expect(typed.caret, "a key brings the caret back").toBe(true); expect(typed.ring, "a key brings no ring").toBe(rest); await setKey(page, false); await setKey(page, true); - await page.waitForTimeout(200); + await settled(page); const key = await state(page, stage); expect(key.caret, "key: the caret").toBe(true); expect(key.ring, "key: still the resting ring").toBe(rest); @@ -342,7 +357,7 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { const page = await open(engine, stage, { rest: false }); const opened = await state(page, stage); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - await page.waitForTimeout(200); + await settled(page); const rest = (await state(page, stage)).ring; await page.close(); expect({ caret: opened.focused && opened.caret, ring: opened.ring }).toEqual({ @@ -357,14 +372,14 @@ await requireBrowserLane("composer-caret-focus.test.ts", async () => { test(`${engine}: ${stage.name}: over art focus leaves the glass rim at rest`, async () => { const page = await open(engine, stage); await page.evaluate(() => document.documentElement.setAttribute("data-cmux-see-through", "")); - await page.waitForTimeout(100); + await settled(page); const rim = () => page.evaluate((box) => getComputedStyle(document.querySelector(box)!).boxShadow, stage.box); const rest = await rim(); await page.locator(stage.field).first().click(); - await page.waitForTimeout(200); + await settled(page); const focused = await rim(); await setKey(page, false); - await page.waitForTimeout(200); + await settled(page); const background = await rim(); await page.close(); expect(focused, "focus leaves the glass card's rim and ring at rest").toBe(rest); diff --git a/webviews/test/composer-draft-wrap.test.ts b/webviews/test/composer-draft-wrap.test.ts index 6ac6cb49fb8e..2e025c687054 100644 --- a/webviews/test/composer-draft-wrap.test.ts +++ b/webviews/test/composer-draft-wrap.test.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; type Field = { @@ -168,6 +169,7 @@ await requireBrowserLane("composer-draft-wrap.test.ts", async () => { await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1"); await page.locator(surface.field).first().waitFor(); await page.evaluate(() => document.fonts.ready); + await installPaintedFrames(page); await page.click(surface.field); // Some running-turn fixtures intentionally arrive with a kept draft. This measurement starts // from an empty prompt so it tests wrapping and growth rather than inserting into the clicked @@ -178,9 +180,15 @@ await requireBrowserLane("composer-draft-wrap.test.ts", async () => { }; const read = (page: Page, surface: (typeof surfaces)[number]) => page.evaluate(measure, [surface.field, surface.scroller] as [string, string]); + /** Two painted frames: the field's React render, then any resize it sets off, are on screen. */ + const painted = (page: Page) => + page.evaluate(async () => { + await window.cmuxPaintFrame(); + await window.cmuxPaintFrame(); + }); const blur = async (page: Page) => { await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - await page.waitForTimeout(50); + await painted(page); }; for (const [engine] of engines) @@ -189,7 +197,7 @@ await requireBrowserLane("composer-draft-wrap.test.ts", async () => { test(`${engine}: ${surface.name} at ${width} px wraps a long one-line draft, whole, focused and blurred`, async () => { const page = await open(engine, surface, width); await page.keyboard.insertText(LONG); - await page.waitForTimeout(50); + await painted(page); const focused = await read(page, surface); await blur(page); const blurred = await read(page, surface); diff --git a/webviews/test/composer-location-strip.test.ts b/webviews/test/composer-location-strip.test.ts index 0e0a55068d90..0e29909e30f7 100644 --- a/webviews/test/composer-location-strip.test.ts +++ b/webviews/test/composer-location-strip.test.ts @@ -26,6 +26,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { PNG } from "pngjs"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; import { warmViteServer } from "./support/warmDevServer"; @@ -563,8 +564,9 @@ await requireBrowserLane("composer-location-strip.test.ts", async () => { // Focus draws no ring (Leo 2026-10-19): a focused footer control leaves the strip's rim at rest. test("focusing a footer control changes neither the strip rim nor its geometry", async () => { const page = await open("agent-pane.composer-strip", "project", 420); + await installPaintedFrames(page); await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - await page.waitForTimeout(20); + await page.evaluate(() => window.cmuxPaintFrame()); const before = await page.evaluate(() => { const strip = document.querySelector(".acpmux-composer-strip")!; const rect = strip.getBoundingClientRect(); @@ -574,6 +576,7 @@ await requireBrowserLane("composer-location-strip.test.ts", async () => { }; }); await page.evaluate(() => document.querySelector(".acpmux-composer-strip button")!.focus()); + await page.evaluate(() => window.cmuxPaintFrame()); const after = await page.evaluate(() => { const strip = document.querySelector(".acpmux-composer-strip")!; const rect = strip.getBoundingClientRect(); diff --git a/webviews/test/composer-menu-dismiss.test.ts b/webviews/test/composer-menu-dismiss.test.ts index 64e92de8c5af..6dedf0f04cae 100644 --- a/webviews/test/composer-menu-dismiss.test.ts +++ b/webviews/test/composer-menu-dismiss.test.ts @@ -113,7 +113,6 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.up(); - await page.waitForTimeout(300); }; /** A point on `selector` that no open menu covers, from its trailing end, or null. */ const clearPoint = (page: Page, selector: string) => @@ -138,6 +137,42 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { .map(([name]) => name), names.map((name) => [name, chip(name)] as const), ); + /** Waits until the chips whose menu is open are `want` and every menu has stopped moving (a + * closed one has faded out and left), then returns the chips whose menu is open. If that never + * comes, it returns the state it stopped at, for the expect to show. */ + const settle = async (page: Page, names: string[], want: string[]) => { + await page + .waitForFunction( + ([pairs, want]) => { + const open = pairs + .filter(([, selector]) => { + const button = document.querySelector(selector); + return button?.getAttribute("aria-expanded") === "true" || button?.hasAttribute("data-popup-open"); + }) + .map(([name]) => name); + if (open.join("\n") !== want.join("\n")) return false; + const popups = [...document.querySelectorAll(".ui-popup")]; + if (want.length === 0 ? popups.length > 0 : popups.length === 0) return false; + return popups.every((popup) => + popup.getAnimations({ subtree: true }).every((animation) => animation.playState !== "running"), + ); + }, + [names.map((name) => [name, chip(name)] as const), want] as const, + { timeout: 5_000 }, + ) + .catch(() => {}); + return expanded(page, names); + }; + /** Waits until the open menus have stopped moving, so a press lands where their rows rest. */ + const menusAtRest = (page: Page) => + page.waitForFunction(() => + [...document.querySelectorAll(".ui-popup")].every((popup) => + popup.getAnimations({ subtree: true }).every((animation) => animation.playState !== "running"), + ), + ); + /** Waits until `holds` is true in the page or 5 s pass; the expect after it reads the state. */ + const until = (page: Page, holds: (arg: string) => boolean, arg: string) => + page.waitForFunction(holds, arg, { timeout: 5_000 }).catch(() => {}); /** Whether `name`'s chip is what the pointer would press at its centre (no open menu over it). */ const onTop = (page: Page, name: string) => page.evaluate((selector) => { @@ -154,9 +189,9 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { const page = await open(engine, stage, width); for (const name of stage.chips) { await pressAt(page, chip(name)); - expect({ name, open: await expanded(page, stage.chips) }).toEqual({ name, open: [name] }); + expect({ name, open: await settle(page, stage.chips, [name]) }).toEqual({ name, open: [name] }); await pressAt(page, chip(name)); - expect({ name, open: await expanded(page, stage.chips) }).toEqual({ name, open: [] }); + expect({ name, open: await settle(page, stage.chips, []) }).toEqual({ name, open: [] }); } await page.close(); }); @@ -167,13 +202,14 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { for (const to of stage.chips) { if (from === to) continue; await pressAt(page, chip(from)); + expect({ from, open: await settle(page, stage.chips, [from]) }).toEqual({ from, open: [from] }); // A menu that covers the other chip takes the press itself. if (await onTop(page, to)) { await pressAt(page, chip(to)); - expect({ from, to, open: await expanded(page, stage.chips) }).toEqual({ from, to, open: [to] }); + expect({ from, to, open: await settle(page, stage.chips, [to]) }).toEqual({ from, to, open: [to] }); } await page.keyboard.press("Escape"); - await page.waitForTimeout(150); + expect({ from, to, open: await settle(page, stage.chips, []) }).toEqual({ from, to, open: [] }); } await page.close(); }); @@ -197,15 +233,17 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { for (const name of stage.chips) for (const target of [".acpmux-mic", stage.field]) { await pressAt(page, chip(name)); - expect({ name, open: await expanded(page, stage.chips) }).toEqual({ name, open: [name] }); + expect({ name, open: await settle(page, stage.chips, [name]) }).toEqual({ name, open: [name] }); const point = await clearPoint(page, target); if (!point) { await page.keyboard.press("Escape"); + expect({ name, open: await settle(page, stage.chips, []) }).toEqual({ name, open: [] }); continue; } const before = await pressed(); + // The press's events are dispatched by the time it returns, so the counts below are final. await pressPoint(page, point.x, point.y); - expect({ name, target, open: await expanded(page, stage.chips) }).toEqual({ name, target, open: [] }); + expect({ name, target, open: await settle(page, stage.chips, []) }).toEqual({ name, target, open: [] }); expect({ name, target, pressed: await pressed() }).toEqual({ name, target, pressed: before }); await page.keyboard.press("Escape"); } @@ -219,8 +257,15 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { for (const name of stage.chips) { const page = await open(engine, stage, width); await pressAt(page, chip(name)); + await settle(page, stage.chips, [name]); await page.keyboard.press("Escape"); - await page.waitForTimeout(250); + await until( + page, + (selector) => + document.querySelectorAll(".ui-popup").length === 0 && + document.activeElement === document.querySelector(selector), + chip(name), + ); const focus = await page.evaluate( (selector) => ({ open: document.querySelectorAll(".ui-popup").length, @@ -238,15 +283,23 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { for (const name of stage.picks) { const page = await open(engine, stage, width); await pressAt(page, chip(name)); + await settle(page, stage.chips, [name]); if (name === "Model") { // The level rows sit in the model menu's Effort › submenu, the popup with no submenu row. await pressAt(page, '.acpmux-quick-sub:has-text("Effort")'); await page.locator('[role="menu"]:not(:has(.acpmux-quick-sub)) [role="menuitemradio"]').first().waitFor(); + await menusAtRest(page); await pressAt( page, '[role="menu"]:not(:has(.acpmux-quick-sub)) [role="menuitemradio"][aria-checked="false"]', ); } else await pressAt(page, '[role="menuitemradio"][aria-checked="false"]'); + await until( + page, + (field) => + document.querySelectorAll(".ui-popup").length === 0 && !!document.activeElement?.closest(field), + stage.field, + ); const focus = await page.evaluate( (field) => ({ open: document.querySelectorAll(".ui-popup").length, diff --git a/webviews/test/composer-quiet-chips.test.ts b/webviews/test/composer-quiet-chips.test.ts index 118056b7a199..8d6e27f5e32d 100644 --- a/webviews/test/composer-quiet-chips.test.ts +++ b/webviews/test/composer-quiet-chips.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; await requireBrowserLane("composer-quiet-chips.test.ts", async () => { @@ -83,6 +84,7 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { const open = async (engine: string, entry: string, variant: string) => { const page = await browsers.get(engine)!.newPage({ viewport: { width: 760, height: 600 } }); + await installPaintedFrames(page); await page.goto(stageURL(entry, variant)); await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1", undefined, { timeout: 120_000, @@ -91,9 +93,24 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { // At rest: nothing focused, the pointer off the composer. await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await page.mouse.move(1, 1); - await page.waitForTimeout(250); + await page.waitForFunction((controls) => !document.querySelector(`${controls}:hover`), CONTROLS); + await settle(page); return page; }; + /** Waits for a painted frame with the pointer's or focus's change and for every transition it + * started to end, so a read sees what the page holds, not a fade on its way there. */ + const settle = (page: Page) => + page.evaluate(async () => { + await window.cmuxPaintFrame(); + const transitions = document.getAnimations().filter((animation) => animation instanceof CSSTransition); + await Promise.all(transitions.map((transition) => transition.finished.catch(() => {}))); + await window.cmuxPaintFrame(); + }); + /** Waits for the pointer to be over `selector`, then for the page to settle. */ + const hovered = async (page: Page, selector: string) => { + await page.waitForFunction((selector) => document.querySelector(selector)?.matches(":hover"), selector); + await settle(page); + }; const look = (page: Page, selector: string) => page .locator(selector) @@ -134,7 +151,7 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { const box = (await page.locator(".nt-box").boundingBox())!; // Over the box's own empty area, clear of the field's text and every control. await page.mouse.move(box.x + box.width - 24, box.y + 10); - await page.waitForTimeout(300); + await hovered(page, ".nt-box"); expect(await look(page, ".nt-box")).toEqual(rest); await page.close(); }); @@ -146,7 +163,7 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { const rest = await look(page, chip(name)); const box = (await page.locator(chip(name)).first().boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.waitForTimeout(300); + await hovered(page, chip(name)); const hover = await look(page, chip(name)); // Hover lives under a fine pointer only; a headless engine may report a coarse one. const fine = await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches); @@ -154,7 +171,8 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { await page.mouse.down(); const pressed = await look(page, chip(name)); await page.mouse.up(); - await page.waitForTimeout(300); + await page.waitForSelector(`${chip(name)}[aria-expanded="true"]`); + await settle(page); const opened = await look(page, chip(name)); expect( await page.locator(chip(name)).first().getAttribute("aria-expanded"), @@ -184,12 +202,12 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { const name = stage.chips[0]!; const box = (await page.locator(chip(name)).first().boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.waitForTimeout(300); - const hovered = await pills(); - const under = hovered.filter((each) => each.hovered).map((each) => each.label); + await hovered(page, chip(name)); + const pilled = await pills(); + const under = pilled.filter((each) => each.hovered).map((each) => each.label); expect(under.length, "the pointer is over one button").toBe(1); expect( - hovered.filter((each) => each.pill).map((each) => each.label), + pilled.filter((each) => each.pill).map((each) => each.label), "only the hovered button pills", ).toEqual(under); } @@ -204,7 +222,7 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { // A key first, so a script focus counts as keyboard focus (:focus-visible) in both engines. await page.keyboard.press("Shift"); await page.locator(selector).first().focus(); - await page.waitForTimeout(250); + await settle(page); const controls = await pillsOf(page); const focused = controls.filter((each) => each.focused); expect(focused.length, `${name} has keyboard focus`).toBe(1); @@ -246,16 +264,16 @@ await requireBrowserLane("composer-quiet-chips.test.ts", async () => { const eased = await button.evaluate((element) => getComputedStyle(element).transitionProperty); const box = (await button.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.waitForTimeout(200); + await hovered(page, target.selector); const hover = await read(); await page.mouse.down(); const pressed = await read(); // Held: a chip whose menu opened on pointerdown has lost :active by now, but keeps // data-press-active (popoverTrigger.ts) until the release. - await page.waitForTimeout(300); + await settle(page); const held = await read(); await page.mouse.up(); - await page.waitForTimeout(400); + await settle(page); const after = await read(); await page.close(); expect(eased, "no transform to ease").not.toContain("transform"); diff --git a/webviews/test/hover-row-sweep.test.ts b/webviews/test/hover-row-sweep.test.ts index b820353eae52..fe81a7ba80cd 100644 --- a/webviews/test/hover-row-sweep.test.ts +++ b/webviews/test/hover-row-sweep.test.ts @@ -5,7 +5,7 @@ // may still fade out, as the native sidebar's rows cross-fade (#20315). // // The page is the real stylesheets in a real engine: New Tab rows and agent-pane menu rows. The -// mouse sweeps them at 1 px/ms; every frame, any hovered row must already show its full fill. +// mouse sweeps them at 4 px a frame; every frame, any hovered row must already show its full fill. import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; import fs from "node:fs"; import path from "node:path"; @@ -64,23 +64,39 @@ await requireBrowserLane("hover-row-sweep.test.ts", async () => { for (const id of ids) { const box = (await page.locator(`#${id}`).boundingBox())!; await page.mouse.move(box.x + 150, box.y + 14); - await page.waitForTimeout(250); + // Settled: the row is hovered and its fill transition has ended. + await page.waitForFunction( + (rowId) => { + const row = document.getElementById(rowId)!; + return row.matches(":hover") && row.getAnimations().length === 0; + }, + id, + { polling: "raf" }, + ); await page.$eval(`#${id}`, (row) => { (row as HTMLElement).dataset.fill = getComputedStyle(row).backgroundColor; }); } await page.mouse.move(150, 2); - await page.waitForTimeout(250); + // Back at the top: only the first row is hovered, and no row is still fading. + await page.waitForFunction( + (rowSelector) => + [...document.querySelectorAll(rowSelector)].every( + (row, index) => row.matches(":hover") === (index === 0) && row.getAnimations().length === 0, + ), + selector, + { polling: "raf" }, + ); await page.evaluate(() => { (window as unknown as { partial: string[] }).partial = []; }); - // The sweep: 1 px every ms, top to bottom. + // The sweep: 4 px a painted frame, top to bottom, so each row is under the pointer for 7 frames + // and every frame of the sweep is read. const end = ids.length * 28 + 10; for (let y = 2; y < end; y += 4) { await page.mouse.move(150, y); - await page.waitForTimeout(4); + await page.evaluate(() => window.cmuxPaintFrame()); } - await page.waitForTimeout(50); return page.evaluate(() => { const state = window as unknown as { partial: string[]; stop: boolean }; state.stop = true; diff --git a/webviews/test/markdown-wide-table-scroll.test.ts b/webviews/test/markdown-wide-table-scroll.test.ts index a374bb2bfefc..07658bbc6081 100644 --- a/webviews/test/markdown-wide-table-scroll.test.ts +++ b/webviews/test/markdown-wide-table-scroll.test.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from " import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; await requireBrowserLane("markdown-wide-table-scroll.test.ts", async () => { @@ -61,6 +62,7 @@ await requireBrowserLane("markdown-wide-table-scroll.test.ts", async () => { const open = async (engine: string): Promise => { const page = await browsers.get(engine)!.newPage({ viewport: { width: 640, height: 520 } }); const port = (vite!.httpServer!.address() as { port: number }).port; + await installPaintedFrames(page); for (let attempt = 1; ; attempt++) { await page.goto(`http://127.0.0.1:${port}${galleryFrame("pages.markdown", { variant: "wide-table" })}`); try { @@ -90,12 +92,41 @@ await requireBrowserLane("markdown-wide-table-scroll.test.ts", async () => { return { page: box(".md-scroll"), table: box(".md-prose .tableWrapper"), code: box(".md-prose .md-code pre") }; }); - /** Wheels over the middle of `selector` and waits for the scroll to land. */ + /** Wheels over the middle of `selector` and waits for the scroll to land: something has moved, + * and a painted frame shows it where the one before did. A wheel that moves nothing stops + * waiting after 300 frames, and the test's own checks fail. */ const wheel = async (page: Page, selector: string, dx: number, dy: number) => { const rect = (await page.locator(selector).first().boundingBox())!; await page.mouse.move(rect.x + rect.width / 2, rect.y + Math.min(rect.height / 2, 40)); + // The scroll offsets of the page scroller, the table's box and the code block, as one key. + await page.evaluate(() => { + const state = window as unknown as { offsets(): string; from: string }; + state.offsets = () => + [".md-scroll", ".md-prose .tableWrapper", ".md-prose .md-code pre"] + .map((at) => { + const element = document.querySelector(at)!; + return `${element.scrollLeft},${element.scrollTop}`; + }) + .join(" "); + state.from = state.offsets(); + }); await page.mouse.wheel(dx, dy); - await page.waitForTimeout(250); + await page.evaluate( + () => + new Promise((resolve) => { + const state = window as unknown as { offsets(): string; from: string }; + let last = state.from; + let left = 300; + window.cmuxAfterPaint(() => { + const at = state.offsets(); + const landed = at !== state.from && at === last; + last = at; + if (!landed && --left > 0) return true; + resolve(); + return false; + }); + }), + ); }; for (const [engine] of engines) diff --git a/webviews/test/newtab-quiet-focus.test.ts b/webviews/test/newtab-quiet-focus.test.ts index 7354c74a69d0..5c3daced42d2 100644 --- a/webviews/test/newtab-quiet-focus.test.ts +++ b/webviews/test/newtab-quiet-focus.test.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; import { warmViteServer } from "./support/warmDevServer"; @@ -229,9 +230,19 @@ await requireBrowserLane("newtab-quiet-focus.test.ts", async () => { // Increase Contrast keeps the shared ring at its offset. test("a footer chip under keyboard focus: hover wash, ring on its edge", async () => { const page = await open(2, "light"); + await installPaintedFrames(page); await page.focus("#composer-field"); await page.keyboard.press("Tab"); - await page.waitForTimeout(400); // the wash's hover fade settles + // The wash's hover fade settles: a frame paints the focus, then its transitions run out. + await page.evaluate(async () => { + await window.cmuxPaintFrame(); + await Promise.all( + document + .getElementById("composer-chip")! + .getAnimations() + .map((animation) => animation.finished.catch(() => undefined)), + ); + }); const chip = () => page.evaluate(() => { const element = document.getElementById("composer-chip")!; diff --git a/webviews/test/popup-reduce-motion.test.ts b/webviews/test/popup-reduce-motion.test.ts index 0acd7e8ad874..7c33963dee67 100644 --- a/webviews/test/popup-reduce-motion.test.ts +++ b/webviews/test/popup-reduce-motion.test.ts @@ -94,7 +94,8 @@ await requireBrowserLane("popup-reduce-motion.test.ts", async () => { await page.getByRole("button", { name: "Open demo menu" }).click(); const popup = page.locator(".ui-popup").first(); await popup.waitFor(); - await page.waitForTimeout(300); // the open settles + // The open settles: its fade-in has run to the end. + await popup.evaluate((element) => Promise.all(element.getAnimations({ subtree: true }).map((a) => a.finished))); await page.evaluate(() => { const faded: string[] = []; (window as unknown as { faded: string[] }).faded = faded; @@ -147,7 +148,12 @@ await requireBrowserLane("popup-reduce-motion.test.ts", async () => { const forget = () => page.evaluate(() => ((window as unknown as { faded: string[] }).faded.length = 0)); await page.getByRole("button", { name: "Open demo dialog" }).click(); await page.locator(".ui-dialog").waitFor(); - await page.waitForTimeout(300); + // Both fades start as the dialog opens; a transition that never runs leaves the wait to time out. + await page + .waitForFunction(() => new Set((window as unknown as { faded: string[] }).faded).size === 2, undefined, { + timeout: 5_000, + }) + .catch(() => {}); expect(await faded()).toEqual(["backdrop", "dialog"]); await forget(); await page.keyboard.press("Escape"); diff --git a/webviews/test/settings-scroll.test.ts b/webviews/test/settings-scroll.test.ts index 8b53418a3f32..5d7f91c6a4c1 100644 --- a/webviews/test/settings-scroll.test.ts +++ b/webviews/test/settings-scroll.test.ts @@ -11,13 +11,11 @@ // The engines come from `playwright install chromium webkit`. Where one is not installed, its // cases are skipped and say so. import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { spawn, type ChildProcess } from "node:child_process"; -import net from "node:net"; import path from "node:path"; import { chromium, webkit, type Browser, type BrowserType, type Page } from "playwright"; -import { stopDevServer } from "./support/devServer"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; -import { warmDevServer } from "./support/warmDevServer"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("settings-scroll.test.ts", async () => { setDefaultTimeout(120_000); @@ -35,39 +33,25 @@ await requireBrowserLane("settings-scroll.test.ts", async () => { } } - let server: ChildProcess | null = null; + const { createServer } = await import("vite"); + let vite: Awaited> | undefined; let base = ""; beforeAll(async () => { if (engines.length === 0) return; - const port = await new Promise((resolve) => { - const probe = net.createServer().listen(0, "127.0.0.1", () => { - const { port } = probe.address() as net.AddressInfo; - probe.close(() => resolve(port)); - }); + // The settings dev server, in this process: `listen()` resolves once it takes requests. + vite = await createServer({ + configFile: path.join(webviews, "vite.config.settings-page.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", }); - server = spawn( - path.join(webviews, "node_modules/.bin/vp"), - ["dev", "--config", "vite.config.settings-page.mjs", "--port", String(port), "--strictPort"], - { cwd: webviews, stdio: ["ignore", "ignore", "ignore"] }, - ); - base = `http://127.0.0.1:${port}`; - const deadline = Date.now() + 60_000; - let listening = false; - while (!listening && Date.now() < deadline) { - try { - listening = (await fetch(`${base}/`)).ok; - } catch { - // Not listening yet. - } - if (!listening) await new Promise((resolve) => setTimeout(resolve, 250)); - } - if (!listening) throw new Error("settings dev server did not start"); - await warmDevServer(base, "/"); + await vite.listen(); + base = `http://127.0.0.1:${(vite.httpServer!.address() as { port: number }).port}`; + await warmViteServer(vite, "/"); }, 180_000); - afterAll(() => { - stopDevServer(server); + afterAll(async () => { + await vite?.close(); }); /** The page's scroll state: the document's, and every element that scrolls a row. */ @@ -124,11 +108,60 @@ await requireBrowserLane("settings-scroll.test.ts", async () => { const open = async (page: Page, section: string) => { await page.goto(`${base}/?mock#/settings/${section}`); await page.waitForSelector(".content [data-row-key]"); - // The mock answers at once; a frame more for the cards that render from its answers. - await page.waitForTimeout(500); + // The mock answers at once and the cards render from its answers: open once three painted + // frames in a row show the same rows at the same places. + await page.evaluate(async () => { + await document.fonts.ready; + const state = window as unknown as { wheels: number }; + state.wheels = 0; + addEventListener("wheel", () => (state.wheels += 1), { capture: true, passive: true }); + const layout = () => + [...document.querySelectorAll("[data-row-key]")].map((row) => row.offsetTop).join() + + ` ${document.querySelector(".content")!.scrollHeight}`; + await new Promise((resolve) => { + let last = ""; + let same = 0; + window.cmuxAfterPaint(() => { + const now = layout(); + same = now === last ? same + 1 : 0; + last = now; + if (same < 2) return true; + resolve(); + return false; + }); + }); + }); await page.mouse.move(640, 350); }; + /** Turns the wheel `times` times by `delta` and waits until the page has taken every turn and its + * scroll, and any pop-up, stay put through three painted frames: a wheel's scroll can glide over + * several frames. */ + const wheel = async (page: Page, delta: number, times = 1) => { + const before = await page.evaluate(() => (window as unknown as { wheels: number }).wheels); + for (let turn = 0; turn < times; turn += 1) await page.mouse.wheel(0, delta); + await page.waitForFunction((count) => (window as unknown as { wheels: number }).wheels >= count, before + times); + await page.evaluate( + () => + new Promise((resolve) => { + const place = () => { + const popup = document.querySelector(".ui-select-popup")?.getBoundingClientRect(); + return `${document.scrollingElement!.scrollTop} ${document.querySelector(".content")!.scrollTop} ${popup?.top} ${popup?.left}`; + }; + let last = ""; + let same = 0; + window.cmuxAfterPaint(() => { + const now = place(); + same = now === last ? same + 1 : 0; + last = now; + if (same < 2) return true; + resolve(); + return false; + }); + }), + ); + }; + for (const [name, engine] of engines) { describe(name, () => { let browser: Browser; @@ -137,6 +170,7 @@ await requireBrowserLane("settings-scroll.test.ts", async () => { beforeAll(async () => { browser = await engine.launch({ headless: true }); page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); + await installPaintedFrames(page); }); afterAll(async () => { @@ -155,14 +189,12 @@ await requireBrowserLane("settings-scroll.test.ts", async () => { if (state.documentScrolls || state.scrollers.length !== 1) steps.push(`${step}: ${JSON.stringify(state)}`); if (state.contentTop >= state.contentEnd && step > 0) { // Past the end the wheel goes nowhere: never on to the document. - for (let extra = 0; extra < 5; extra += 1) await page.mouse.wheel(0, 240); - await page.waitForTimeout(100); + await wheel(page, 240, 5); const after = await scrollState(page); if (after.documentScrolls) steps.push(`past the end: ${JSON.stringify(after)}`); break; } - await page.mouse.wheel(0, 120); - await page.waitForTimeout(16); + await wheel(page, 120); } const end = await scrollState(page); expect(steps).toEqual([]); @@ -184,8 +216,7 @@ await requireBrowserLane("settings-scroll.test.ts", async () => { const problems: string[] = []; for (const delta of [200, 200, 200, -200, -200, -200, -200, -200, -200]) { await page.mouse.move(640, 100); - await page.mouse.wheel(0, delta); - await page.waitForTimeout(120); + await wheel(page, delta); const state = await scrollState(page); const place = await page.evaluate((selector) => { const popup = document.querySelector(".ui-select-popup"); diff --git a/webviews/test/settings-search-results.test.ts b/webviews/test/settings-search-results.test.ts index 3fed2a68fd03..0669c18d2384 100644 --- a/webviews/test/settings-search-results.test.ts +++ b/webviews/test/settings-search-results.test.ts @@ -10,13 +10,10 @@ // The engines come from `playwright install chromium webkit`. Where one is not installed, its // cases are skipped and say so. import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { spawn, type ChildProcess } from "node:child_process"; -import net from "node:net"; import path from "node:path"; import { chromium, webkit, type BrowserType, type Page } from "playwright"; -import { stopDevServer } from "./support/devServer"; import { requireBrowserLane } from "./support/requireBrowserLane"; -import { warmDevServer } from "./support/warmDevServer"; +import { warmViteServer } from "./support/warmDevServer"; await requireBrowserLane("settings-search-results.test.ts", async () => { setDefaultTimeout(120_000); @@ -34,7 +31,8 @@ await requireBrowserLane("settings-search-results.test.ts", async () => { } } - let server: ChildProcess | null = null; + const { createServer } = await import("vite"); + let vite: Awaited> | undefined; let base = ""; // The page's utility classes, compiled as the app build does (the settings dev server compiles none). let utilities = ""; @@ -43,34 +41,19 @@ await requireBrowserLane("settings-search-results.test.ts", async () => { if (engines.length === 0) return; const { compileTailwind } = await import("../scripts/agent-pane/tailwindCompile.mjs"); utilities = await compileTailwind(path.join(webviews, "src/pages/settings/tailwind.css")); - const port = await new Promise((resolve) => { - const probe = net.createServer().listen(0, "127.0.0.1", () => { - const { port } = probe.address() as net.AddressInfo; - probe.close(() => resolve(port)); - }); + // The settings dev server, in this process: `listen()` resolves once it takes requests. + vite = await createServer({ + configFile: path.join(webviews, "vite.config.settings-page.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", }); - server = spawn( - path.join(webviews, "node_modules/.bin/vp"), - ["dev", "--config", "vite.config.settings-page.mjs", "--port", String(port), "--strictPort"], - { cwd: webviews, stdio: ["ignore", "ignore", "ignore"] }, - ); - base = `http://127.0.0.1:${port}`; - const deadline = Date.now() + 60_000; - let listening = false; - while (!listening && Date.now() < deadline) { - try { - listening = (await fetch(`${base}/`)).ok; - } catch { - // Not listening yet. - } - if (!listening) await new Promise((resolve) => setTimeout(resolve, 250)); - } - if (!listening) throw new Error("settings dev server did not start"); - await warmDevServer(base, "/"); + await vite.listen(); + base = `http://127.0.0.1:${(vite.httpServer!.address() as { port: number }).port}`; + await warmViteServer(vite, "/"); }, 180_000); - afterAll(() => { - stopDevServer(server); + afterAll(async () => { + await vite?.close(); }); /** Types `query` into the search field and waits until the results have settled. */ diff --git a/webviews/test/transcript-follow.test.ts b/webviews/test/transcript-follow.test.ts index 2c6610621f6d..3ace8522953e 100644 --- a/webviews/test/transcript-follow.test.ts +++ b/webviews/test/transcript-follow.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; +import { installPaintedFrames } from "./support/paintedFrames"; import { requireBrowserLane } from "./support/requireBrowserLane"; import { warmViteServer } from "./support/warmDevServer"; @@ -53,32 +54,42 @@ await requireBrowserLane("transcript-follow.test.ts", async () => { const show = (page: Page, rows: Row[]) => page.evaluate((shown) => (window as unknown as { __showRows(rows: unknown): void }).__showRows(shown), rows); const frames = (page: Page, count = 2) => + page.evaluate(async (n) => { + for (let left = n; left > 0; left--) await window.cmuxPaintFrame(); + }, count); + /// How far the latest row's text ends below the bottom of the view (its own bottom padding is + /// blank), in the first painted frame after any glide has landed. + const belowView = (page: Page) => page.evaluate( - (n) => - new Promise((resolve) => { - const step = (left: number) => (left === 0 ? resolve() : requestAnimationFrame(() => step(left - 1))); - step(n); + () => + new Promise((resolve) => { + window.cmuxAfterPaint(() => { + const scroller = document.querySelector(".acpmux-scroll")!; + // A glide is a finite animation still running in the view; a looping one (a streaming + // mark) never lands, and a finished one that holds its last frame stays listed. + const gliding = scroller + .getAnimations({ subtree: true }) + .some( + (animation) => + animation.playState === "running" && animation.effect?.getComputedTiming().endTime !== Infinity, + ); + if (gliding) return true; + const view = scroller.getBoundingClientRect(); + const rows = [...document.querySelectorAll(".acpmux-row")]; + const last = rows.reduce((low, row) => + row.getBoundingClientRect().bottom > low.getBoundingClientRect().bottom ? row : low, + ); + const text = last.getBoundingClientRect().bottom - parseFloat(getComputedStyle(last).paddingBottom); + resolve(Math.round(text - view.bottom)); + return false; + }); }), - count, ); - /// How far the latest row's text ends below the bottom of the view (its own bottom padding is - /// blank), once any glide has landed. - const belowView = async (page: Page) => { - await page.waitForTimeout(400); - return page.evaluate(() => { - const view = document.querySelector(".acpmux-scroll")!.getBoundingClientRect(); - const rows = [...document.querySelectorAll(".acpmux-row")]; - const last = rows.reduce((low, row) => - row.getBoundingClientRect().bottom > low.getBoundingClientRect().bottom ? row : low, - ); - const text = last.getBoundingClientRect().bottom - parseFloat(getComputedStyle(last).paddingBottom); - return Math.round(text - view.bottom); - }); - }; test("a reply that streams past the viewport is followed, and so is the next turn", async () => { const { port } = vite!.httpServer!.address() as { port: number }; const page = await browser!.newPage({ viewport: { width: 900, height: 560 } }); + await installPaintedFrames(page); await page.goto(`http://127.0.0.1:${port}/test/browser/transcript-follow.html`); await page.waitForFunction(() => "__showRows" in window); const first = [