From 5cc1cebe55ce6cff148d35b8dbe03bb124671d3d Mon Sep 17 00:00:00 2001 From: stevenjj33 Date: Tue, 10 Nov 2026 17:52:12 +0800 Subject: [PATCH 1/2] test(webui): wait for the shell's asset images before a baseline is taken The composer mascot is drawn from a : the browser starts on the poster PNG and upgrades to the WebP, and that upgrade aborts the first request. A frame taken inside that window shows the browser's broken-image placeholder instead of the picture, which reads as a few thousand pixels of difference and fails the baseline for reasons that have nothing to do with the layout under review. One spec already guarded against this. The guard now lives in the harness so every screenshot spec shares it, and it waits for the settled state rather than for a deadline: an image that never loads now fails with a legible message instead of a cryptic pixel diff. --- packages/webui/test/browser/harness.mjs | 21 +++++++++++++++++++ .../browser/plugin-catalogue-layout.spec.mjs | 3 ++- .../browser/responsive-baselines.spec.mjs | 10 ++++++++- .../test/browser/scheduling-panel.spec.mjs | 11 +++++++++- .../test/browser/visual-baselines.spec.mjs | 20 ++++++++++++------ 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/webui/test/browser/harness.mjs b/packages/webui/test/browser/harness.mjs index a7af431b4..2b69ead1a 100644 --- a/packages/webui/test/browser/harness.mjs +++ b/packages/webui/test/browser/harness.mjs @@ -56,6 +56,27 @@ export const PIXEL_BUDGET = { maxDiffPixels: 8 }; /** Screenshot options shared by every baseline comparison. */ export const screenshotOptions = { animations: "disabled", ...PIXEL_BUDGET }; +/** + * Waits for the shell's own asset images to settle before a baseline is + * taken. + * + * A screenshot records whatever is painted at that instant, and the shell + * draws its composer mascot from a ``: the browser starts on the + * poster PNG and upgrades to the WebP, and the upgrade aborts the first + * request. A frame taken inside that window shows the browser's broken-image + * placeholder instead of the picture, which is a few thousand pixels of + * difference that says nothing about the layout under review. + * + * The wait is on the settled state rather than on a deadline: an image that + * is still being fetched is not settled, and one that failed to load fails + * the poll with a legible message instead of a cryptic pixel diff. + */ +export async function waitForLocalImages(page) { + await expect.poll(() => page.locator('img[src^="/assets/"]').evaluateAll((images) => + images.every((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0), + )).toBe(true); +} + export async function configureFixture(page, setup) { await page.addInitScript({ content: `window.__WEBUI_FIXTURE_SETUP__ ??= []; window.__WEBUI_FIXTURE_SETUP__.push((${setup.toString()}));` }); } diff --git a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs index 736787e2a..c78c6fb4d 100644 --- a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs +++ b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs @@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { expect } from "@playwright/test"; -import { openApp, screenshotOptions, test } from "./harness.mjs"; +import { openApp, screenshotOptions, test, waitForLocalImages } from "./harness.mjs"; test("marketplace and management keep vertical categories pinned on the left", async ({ page }) => { await page.addInitScript(() => { @@ -430,6 +430,7 @@ async function expectCenteredDialog(dialog, page, fieldsSelector, expectedFields insideViewport: true, ...(fieldsSelector ? { fieldsDisplay: expectedFieldsDisplay } : {}), }); + await waitForLocalImages(page); await expect(page.locator('[data-webui-dialog-scrim="true"]')).toHaveScreenshot(screenshotOptions); } diff --git a/packages/webui/test/browser/responsive-baselines.spec.mjs b/packages/webui/test/browser/responsive-baselines.spec.mjs index b3182ed19..6ebf391fc 100644 --- a/packages/webui/test/browser/responsive-baselines.spec.mjs +++ b/packages/webui/test/browser/responsive-baselines.spec.mjs @@ -17,7 +17,13 @@ import { expect } from "@playwright/test"; -import { configureFixture, openApp, screenshotOptions, test } from "./harness.mjs"; +import { + configureFixture, + openApp, + screenshotOptions, + test, + waitForLocalImages, +} from "./harness.mjs"; /** The widths the contract's matrix covers. */ const WIDTHS = [1440, 1280, 1024, 900, 768, 640, 480]; @@ -35,6 +41,7 @@ for (const width of WIDTHS) { await page.setViewportSize({ width, height: 900 }); await configureFixture(page, () => undefined); await openApp(page, "#session=A"); + await waitForLocalImages(page); await expect(page.locator("#webui-root")).toHaveScreenshot(`shell-at-${width}.png`, screenshotOptions); }); } @@ -46,6 +53,7 @@ for (const width of PANEL_WIDTHS) { await openApp(page, "#session=A"); await page.getByTestId("workspace-panel-controls").getByRole("button", { name: "浏览器" }).click(); await expect(page.getByTestId("workspace-panel")).toBeVisible(); + await waitForLocalImages(page); await expect(page.locator("#webui-root")).toHaveScreenshot(`workspace-panel-at-${width}.png`, screenshotOptions); }); } diff --git a/packages/webui/test/browser/scheduling-panel.spec.mjs b/packages/webui/test/browser/scheduling-panel.spec.mjs index 67f47c28d..26eaf83c9 100644 --- a/packages/webui/test/browser/scheduling-panel.spec.mjs +++ b/packages/webui/test/browser/scheduling-panel.spec.mjs @@ -10,7 +10,13 @@ // is in when they click the rail. import { expect } from "@playwright/test"; -import { configureFixture, openApp, screenshotOptions, test } from "./harness.mjs"; +import { + configureFixture, + openApp, + screenshotOptions, + test, + waitForLocalImages, +} from "./harness.mjs"; test.describe("the scheduled-task panel", () => { test("opens from the rail and lists what is scheduled", async ({ page }) => { @@ -47,6 +53,7 @@ test.describe("the scheduled-task panel", () => { // The panel is a surface, not an overlay: the transcript is gone while it is open. await expect(page.locator('[data-webui-component="composer"]')).toHaveCount(0); + await waitForLocalImages(page); await expect(panel).toHaveScreenshot("scheduling-panel.png", screenshotOptions); // Closing returns to the conversation, and the panel's own control is what does it — the rail @@ -92,6 +99,7 @@ test.describe("the scheduled-task panel", () => { await page.locator('[data-webui-model-list-row="fixture/stub-model/"]').click(); await expect(modelField).toContainText("Stub Model"); await editor.locator('[data-webui-scheduling-field="prompt"]').fill("Summarize the day."); + await waitForLocalImages(page); await expect(editor).toHaveScreenshot("scheduling-editor.png", screenshotOptions); // The editor's submit control is the dialog footer's, so it is not inside the // editor's own element. @@ -160,6 +168,7 @@ test.describe("the scheduled-task panel", () => { runs.locator('[data-webui-scheduling-run-session="A"]').first(), ).toContainText("打开会话"); + await waitForLocalImages(page); await expect(panel).toHaveScreenshot("scheduling-panel-runs.png", screenshotOptions); // Opening the session a run produced leaves the panel and lands on that session. diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs b/packages/webui/test/browser/visual-baselines.spec.mjs index fff9dac64..32ee40469 100644 --- a/packages/webui/test/browser/visual-baselines.spec.mjs +++ b/packages/webui/test/browser/visual-baselines.spec.mjs @@ -4,13 +4,14 @@ import { expect } from "@playwright/test"; import { + openApp, + screenshotOptions, + test, + waitForLocalImages, assistantBody, emitAgentMessage, emitStream, - openApp, - screenshotOptions, startTurn, - test, } from "./harness.mjs"; /** @@ -54,9 +55,7 @@ test("shell and session rail layout baseline", async ({ page }) => { await expect(rail).toBeVisible(); await expect(rail.getByText("Synthetic session A")).toBeVisible(); await expect(rail.getByText("Synthetic session B")).toBeVisible(); - await expect.poll(() => page.locator('img[src^="/assets/"]').evaluateAll((images) => - images.every((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0), - )).toBe(true); + await waitForLocalImages(page); await expect(shell).toHaveScreenshot("shell-and-rail.png", screenshotOptions); }); @@ -104,6 +103,7 @@ test("transcript and composer layout baseline", async ({ page }) => { const transcript = page.locator('[data-webui-transcript="A"]'); await assertInViewport(transcript, page); await expect(page.getByPlaceholder("输入消息…(输入 / 唤起命令)")).toBeVisible(); + await waitForLocalImages(page); await expect(page.locator("#webui-root")).toHaveScreenshot("transcript-and-composer.png", screenshotOptions); }); @@ -120,6 +120,7 @@ test("settings dialog layout baseline", async ({ page }) => { // same wait the browser panel case uses, over the whole subtree, so this // records the settled frame rather than a lucky one. await settleAnimations(dialog); + await waitForLocalImages(page); await expect(dialog).toHaveScreenshot("settings-dialog.png", screenshotOptions); }); @@ -145,6 +146,7 @@ test("progress panel layout baseline", async ({ page }) => { const panel = page.getByTestId("progress-overview-panel"); await expect(panel).toContainText("Synthetic visual todo"); await assertInViewport(panel, page); + await waitForLocalImages(page); await expect(panel).toHaveScreenshot("progress-panel.png", screenshotOptions); }); @@ -159,6 +161,7 @@ test("embedded browser panel layout baseline", async ({ page }) => { }); await assertInViewport(panel, page); await expect(page.getByTestId("workspace-launcher")).toBeVisible(); + await waitForLocalImages(page); await expect(panel).toHaveScreenshot("embedded-browser-panel.png", screenshotOptions); }); @@ -171,6 +174,7 @@ test("composer command overlay layout baseline", async ({ page }) => { const overlay = page.locator('[data-webui-command-menu="true"]'); await expect(overlay).toBeVisible(); await assertInViewport(overlay, page); + await waitForLocalImages(page); await expect(overlay).toHaveScreenshot("composer-command-overlay.png", screenshotOptions); }); @@ -205,11 +209,13 @@ test("plugin marketplace and skills catalogue layout baselines", async ({ page } const grid = await pluginGrid.first().evaluate((node) => getComputedStyle(node).gridTemplateColumns.split(" ").length); expect(grid).toBe(2); await assertInViewport(pluginGrid.first(), page); + await waitForLocalImages(page); await expect(surface).toHaveScreenshot("plugin-marketplace.png", screenshotOptions); await surface.getByRole("button", { name: "技能", exact: true }).click(); await expect(surface.locator(".webui-plugin-grid .webui-plugin-row")).toHaveCount(2); await expect(surface.getByText("Release notes")).toBeVisible(); + await waitForLocalImages(page); await expect(surface).toHaveScreenshot("skill-marketplace.png", screenshotOptions); }); @@ -240,6 +246,7 @@ test("plugin management skill and MCP rows layout baselines", async ({ page }) = await expect(surface.getByText("Local checklist")).toBeVisible(); await expect(surface.getByLabel("启用 disabled-review")).toBeVisible(); await assertInViewport(surface.locator(".webui-plugin-list"), page); + await waitForLocalImages(page); await expect(surface).toHaveScreenshot("skill-management.png", screenshotOptions); await surface.locator('.webui-plugin-category-filter button', { hasText: "MCP" }).click(); @@ -247,5 +254,6 @@ test("plugin management skill and MCP rows layout baselines", async ({ page }) = // removed with the row redesign, so the baseline shows the rows and their actions. await expect(surface.getByText("local-http")).toBeVisible(); await expect(surface.getByText("missing-binary")).toBeVisible(); + await waitForLocalImages(page); await expect(surface).toHaveScreenshot("mcp-management.png", screenshotOptions); }); From 256d155f86f8a7a075d2d7fb64d3f46998fa2e45 Mon Sep 17 00:00:00 2001 From: stevenjj33 Date: Tue, 10 Nov 2026 17:52:12 +0800 Subject: [PATCH 2/2] test(webui): give the browser-frame wait its own budget The panel can only render a frame after the isolated Chromium has booted, opened the page and streamed its first picture back. That pipeline, not the product's responsiveness, is what the wait covers, and on a loaded CI runner it outlasts the 30s window the case was written against -- the same case failed that way five times across four commits, on macOS and Windows but never on Ubuntu. The failure now reports whether the panel is still showing its empty placeholder and whether a capability notice is on screen, so the next run says which of the two faults it hit. --- .../test/integration/agent-transcript.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/webui/test/integration/agent-transcript.test.ts b/packages/webui/test/integration/agent-transcript.test.ts index bcbd98339..2469a1874 100644 --- a/packages/webui/test/integration/agent-transcript.test.ts +++ b/packages/webui/test/integration/agent-transcript.test.ts @@ -1063,7 +1063,25 @@ it.each(["transcript", "cross-client", "external-active-mutation", "external-act await page.getByTestId("workspace-launcher-url").fill(pageUrl); await page.getByTestId("workspace-launcher-url-submit").click(); const browserFrame = page.getByTestId("browser-panel-frame"); - await browserExpect(browserFrame).toBeVisible({ timeout: 30_000 }); + // The panel can only render a frame after the isolated Chromium has + // booted, opened the page and streamed its first picture back over the + // event channel. That whole pipeline -- not the product's own + // responsiveness -- is what this wait covers, and on a loaded CI runner + // it can outlast the 30s window this test used to give it. Give the wait + // its own budget, and keep the failure readable: if the panel still + // shows its empty placeholder the frame never arrived, and if a + // capability notice is on screen the host could not serve a browser at + // all. Those are different faults and deserve different fixes. + try { + await browserExpect(browserFrame).toBeVisible({ timeout: 60_000 }); + } catch (error) { + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify({ + emptyFrameStillShowing: await page.getByTestId("browser-panel-frame-empty").count(), + capabilityNotices: await page.locator( + '[data-testid="browser-panel-unknown"], [data-testid="browser-panel-unauthorized"]', + ).allTextContents(), + })}`); + } const browserGeometry = await browserFrame.evaluate((node) => { const frame = node.getBoundingClientRect(); const panel = node.closest(".webui-browser-panel")?.getBoundingClientRect();