diff --git a/packages/webui/test/browser/harness.mjs b/packages/webui/test/browser/harness.mjs index a7af431b..2b69ead1 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 736787e2..c78c6fb4 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 b3182ed1..6ebf391f 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 67f47c28..26eaf83c 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 fff9dac6..32ee4046 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); }); diff --git a/packages/webui/test/integration/agent-transcript.test.ts b/packages/webui/test/integration/agent-transcript.test.ts index bcbd9833..2469a187 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();