diff --git a/packages/web-shell/client/e2e/visuals/harness.spec.ts b/packages/web-shell/client/e2e/visuals/harness.spec.ts new file mode 100644 index 00000000000..0e9e596cb4d --- /dev/null +++ b/packages/web-shell/client/e2e/visuals/harness.spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, test } from '@playwright/test'; +import { freezeLoopingAnimations } from './harness'; + +// `freezeLoopingAnimations` is the load-bearing step that keeps spinner-bearing +// captures deterministic (see its docstring). It runs only implicitly via +// `captureScreenshot`, so pin its contract explicitly here: an infinite +// animation must be paused and rewound to time 0, while a finite one must be +// left alone for Playwright's own `animations: 'disabled'` to settle. +test('freezeLoopingAnimations pins infinite animations to frame 0 and leaves finite ones', async ({ + page, +}) => { + await page.setContent(` + +
+
+ `); + // Advance both animations past frame 0 first, so a freeze that did nothing + // would leave a non-zero currentTime and fail the assertion below. + await page.waitForTimeout(100); + + await freezeLoopingAnimations(page); + + const state = await page.evaluate( + /* global document */ + () => { + const animOf = (id: string) => { + const el = document.getElementById(id); + if (!el) throw new Error(`element #${id} not found`); + return el.getAnimations()[0]; + }; + const loop = animOf('loop'); + return { + loopPlayState: loop.playState, + loopCurrentTime: Number(loop.currentTime), + oncePlayState: animOf('once').playState, + }; + }, + ); + + // The infinite loop is paused at its first frame… + expect(state.loopPlayState).toBe('paused'); + expect(state.loopCurrentTime).toBe(0); + // …while the finite animation is untouched, still running toward completion. + expect(state.oncePlayState).toBe('running'); +}); diff --git a/packages/web-shell/client/e2e/visuals/harness.ts b/packages/web-shell/client/e2e/visuals/harness.ts index 21a5dcca635..a7bf5273d20 100644 --- a/packages/web-shell/client/e2e/visuals/harness.ts +++ b/packages/web-shell/client/e2e/visuals/harness.ts @@ -140,12 +140,48 @@ export async function captureScreenshot( name: string, ): Promise { mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + await freezeLoopingAnimations(page); await page.screenshot({ path: join(SCREENSHOTS_DIR, `${name}.png`), animations: 'disabled', }); } +/** + * Pin looping animations to their first frame before a capture. Playwright's + * `animations: 'disabled'` settles finite animations and is meant to reset + * infinite ones, but a GPU-composited transform loop — e.g. the sidebar's + * rotating activity spinner — is still captured mid-rotation at a random angle. + * That angle differs between the base and head render passes, so the view reads + * as "changed" against the 0.02% before/after threshold even when nothing did. + * Pausing the infinite Web Animations and rewinding them to time 0 pins them to + * a deterministic frame (verified: sidebar-attention drops from ~0.12% of pixels + * differing between identical renders to 0); a two-frame wait lets the compositor + * commit that frame before the capture reads it. + * + * Scope: this covers WAAPI and CSS `@keyframes` animations — everything + * `document.getAnimations()` reports. A spinner hand-rolled on a + * `requestAnimationFrame` loop instead would NOT be caught, and the flake would + * silently return; if a spinner reimplementation ever reintroduces it, this is + * the function to extend. `harness.spec.ts` pins the pause/rewind contract. + */ +export async function freezeLoopingAnimations(page: Page): Promise { + await page.evaluate( + /* global document, requestAnimationFrame */ + async () => { + for (const animation of document.getAnimations()) { + if (animation.effect?.getTiming().iterations === Infinity) { + animation.pause(); + animation.currentTime = 0; + } + } + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }, + ); +} + /** * Record a continuous flow to `/video/.webm`. A dedicated * browser context owns the video lifecycle so the file can be saved under a diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 99324562036..29abe49c4b8 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -266,12 +266,16 @@ for (const theme of THEMES) { // Restore the tiled layout (#6951): the solo pane returns to the split and // the hidden pane reappears — so the maximize control is back on both - // panes. Captures the restore path so a regression there is caught too. + // panes. Assert the restore path (behavioral coverage) but do NOT capture + // a screenshot: the restored layout is visually identical to the tiled + // `split view` shot above, and the reappearing pane re-renders its content + // just after this click, so the capture is byte-nondeterministic between + // identical runs — a flaky, redundant view that surfaces false-positive + // "changed" previews unrelated to the PR under review. await page.getByRole('button', { name: 'Restore pane' }).click(); await expect( page.getByRole('button', { name: 'Maximize pane' }).first(), ).toBeVisible(); - await captureScreenshot(page, `split-view-restored-${theme}`); }); test(`sidebar attention`, async ({ page }, testInfo) => { @@ -337,6 +341,66 @@ for (const theme of THEMES) { await captureScreenshot(page, `sidebar-attention-${theme}`); }); + test(`workspace sidebar`, async ({ page }, testInfo) => { + // Two workspaces make the sidebar group sessions per workspace and tag the + // primary one — the surface the "primary workspace" label/badge lives on. + // Every other scenario here is single-workspace, where that tag never + // renders (it is gated on more than one displayed workspace), so this is + // the only scenario that can surface a change to the workspace labels. + // + // Pin the primary workspace cwd and its loaded session name explicitly, + // rather than leaning on createWebShellDaemonScenario's defaults: the + // basename ("qwen-web-shell-e2e") and the settle-wait below both depend on + // them, so a rename of those defaults in mockDaemon.ts would otherwise + // turn this into a cryptic "not visible" failure. + const primaryCwd = '/tmp/qwen-web-shell-e2e'; + const primarySessionName = 'Run auth migration'; + const scenario = createWebShellDaemonScenario({ + workspaceCwd: primaryCwd, + displayName: primarySessionName, + capabilities: { + workspaces: [ + { + id: 'ws-primary', + cwd: primaryCwd, + primary: true, + trusted: true, + }, + { + id: 'ws-api', + cwd: '/tmp/qwen-api-service', + primary: false, + trusted: true, + }, + ], + }, + }); + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + await gotoSession(page, scenario, daemon, theme); + // Each workspace renders a section headed by its basename; the primary one + // also carries a "Primary" tag. Assert both workspace names and the tag so + // a regression in the grouping or the (removable) primary label fails an + // assertion, not only the visually-reviewed screenshot. + const sidebar = page.getByRole('complementary'); + await expect( + sidebar.getByText('qwen-web-shell-e2e', { exact: true }), + ).toBeVisible(); + await expect( + sidebar.getByText('qwen-api-service', { exact: true }), + ).toBeVisible(); + await expect(sidebar.getByText('Primary', { exact: true })).toBeVisible(); + // The primary workspace auto-expands and streams its session rows in via a + // per-workspace fetch. Wait for the loaded session's row before capturing + // so the async load has settled — otherwise the row list races the + // screenshot and the capture differs between runs. + await expect(sidebar.getByText(primarySessionName)).toBeVisible(); + await captureScreenshot(page, `workspace-sidebar-${theme}`); + }); + test(`slash menu`, async ({ page }, testInfo) => { const scenario = createWebShellDaemonScenario(); const daemon = await installScenario(