Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/web-shell/client/e2e/visuals/harness.spec.ts
Original file line number Diff line number Diff line change
@@ -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(`
<style>
@keyframes spin { to { transform: rotate(360deg); } }
#loop { width: 10px; height: 10px; animation: spin 800ms linear infinite; }
#once { width: 10px; height: 10px; animation: spin 10s linear 1; }
</style>
<div id="loop"></div>
<div id="once"></div>
`);
// 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');
});
36 changes: 36 additions & 0 deletions packages/web-shell/client/e2e/visuals/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,48 @@ export async function captureScreenshot(
name: string,
): Promise<void> {
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
await freezeLoopingAnimations(page);
Comment thread
wenshao marked this conversation as resolved.
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<void> {
await page.evaluate(
/* global document, requestAnimationFrame */
async () => {
for (const animation of document.getAnimations()) {
if (animation.effect?.getTiming().iterations === Infinity) {
animation.pause();
animation.currentTime = 0;
}
}
Comment thread
wenshao marked this conversation as resolved.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
},
);
}

/**
* Record a continuous flow to `<output>/video/<name>.webm`. A dedicated
* browser context owns the video lifecycle so the file can be saved under a
Expand Down
68 changes: 66 additions & 2 deletions packages/web-shell/client/e2e/visuals/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand Down
Loading