From 2deefbf9c7e1bf7c8a27c656e64bdfd3370f6e72 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 16 Jul 2026 08:58:28 +0800 Subject: [PATCH] test(web-shell): add mermaid, split-view + sidebar visual scenarios (#6964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(web-shell): add a mermaid diagram visual scenario Add a `mermaid diagram` scenario to the visuals suite so the preview covers the Mermaid rendering surface — an assistant message with a mermaid fenced flowchart. It renders the real MermaidBlock (async mermaid import, injected ) in light and dark, waiting on the rendered SVG so the capture is never the "rendering…" placeholder. This is the surface the diagram zoom/pan work (#6881) enriches, so once the before/after preview lands it gives that PR a real before/after target instead of an unrelated canned screenshot. * test(web-shell): add a split-view (+ maximize) visual scenario Add a `split view` scenario: enter the two-pane split via the `?split=a,b` deep link, then maximize one pane (#6951). Captures the tiled state (both panes, with the maximize controls) and the maximized state (one pane filling, restore control) in light and dark, driving the real SplitView against the mock daemon serving two sessions. * test(web-shell): add a sidebar attention-badge visual scenario Add a `sidebar attention` scenario: four sessions in distinct states — waiting-on-permission, waiting-on-user-question, running, idle — so the sidebar renders #6956's "Waiting for approval" / "User input needed" attention pills. Renders in light and dark; asserts on session names (present with or without the pills) so the frame is the same shape on main and the PR, letting the before/after preview surface the pills. * test(web-shell): derive the split view's second session from the scenario list Addresses a review suggestion: the split view test hardcoded the 'previous-session' id, which only worked because it is in createWebShellDaemonScenario's default sessions list. Derive the second pane's session from the scenario's own list instead (and throw a clear error if absent), so a future rename/removal of that default surfaces as a self-explaining failure rather than a confusing SSE connection timeout. * test(web-shell): tidy split copy and mermaid width in visual scenarios Address review nits on the visual scenarios: - Split scenario: the mock replays the same events into both panes, so "Here is the first pane of the split." read wrong in the second pane. Use pane-neutral copy ("Here are the two sessions, side by side."). - Mermaid scenario: the flowchart's rightmost node clipped at the code-block edge at the 1280px capture viewport. Shorten the node labels (same nodes and flow) so the whole diagram fits with margin. Re-ran both scenarios (light + dark) locally: 4/4 pass, and confirmed in the captures that the diagram no longer clips and the neutral copy reads correctly in both panes. * test(web-shell): capture split-view restore and assert all sidebar sessions Address review nits on the visual scenarios: - Split view: after maximize, click "Restore pane" and capture the restored tiled layout, asserting the maximize control returns on both panes — so a regression in the restore path is caught, not just the tiled and maximized states. - Sidebar attention: assert all four session names render (not just the two waiting ones). The running session is also the loaded one, so its name also shows in the main view — scope the running/idle checks to the sidebar landmark so the match stays unambiguous. --------- Co-authored-by: wenshao --- .../client/e2e/visuals/screenshots.spec.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 50a5fa6c26..906d3b4a82 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -14,6 +14,7 @@ import { } from '../utils/mockDaemon'; import { captureScreenshot, + completeReplay, fillComposer, gotoSession, installScenario, @@ -60,6 +61,181 @@ for (const theme of THEMES) { await captureScreenshot(page, `session-transcript-${theme}`); }); + test(`mermaid diagram`, async ({ page }, testInfo) => { + const scenario = createWebShellDaemonScenario({ + events: [ + userTextEvent('Diagram the tool-approval flow so I can review it.', { + id: 1, + }), + assistantTextEvent( + 'Here is the tool-approval flow:\n\n' + + '```mermaid\n' + + 'flowchart LR\n' + + ' A[Tool call] --> B{Trusted?}\n' + + ' B -->|Yes| C[Run]\n' + + ' B -->|No| D{Approve?}\n' + + ' D -->|Yes| C\n' + + ' D -->|No| E[Cancel]\n' + + ' C --> F[Result]\n' + + '```', + { id: 2 }, + ), + turnCompleteEvent('prompt-mermaid', { id: 3 }), + ], + }); + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + await gotoSession(page, scenario, daemon, theme); + + // MermaidBlock lazy-imports `mermaid` and renders asynchronously (behind a + // ~150ms timer), swapping a "rendering…" placeholder for the injected + // ``. Wait for that SVG so the diagram is captured + // rendered — not the placeholder — on every run. + await expect( + page.locator('[data-web-shell-message-list] svg[id^="mermaid-"]'), + ).toBeVisible(); + await captureScreenshot(page, `mermaid-diagram-${theme}`); + }); + + test(`split view`, async ({ page }, testInfo) => { + const scenario = createWebShellDaemonScenario({ + events: [ + userTextEvent('Review two sessions side by side.', { id: 1 }), + // Pane-neutral copy: the mock replays these same events into *both* + // panes, so wording that names "the first pane" would read wrong in + // the second one. + assistantTextEvent('Here are the two sessions, side by side.', { + id: 2, + }), + turnCompleteEvent('prompt-split', { id: 3 }), + ], + }); + // Derive the second pane's session from the scenario's OWN sessions list + // rather than hardcoding an id: a rename/removal of the default entry + // would otherwise surface here as a confusing SSE connection timeout + // instead of a clear, self-explaining error. + const secondSessionId = scenario.sessions.find( + (s) => s.sessionId !== scenario.sessionId, + )?.sessionId; + if (!secondSessionId) { + throw new Error( + 'split view scenario expects a second session in the list', + ); + } + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + // Load the primary session (this also primes the theme), then enter the + // split via the `?split=a,b` deep link so two panes render side by side. + await gotoSession(page, scenario, daemon, theme); + await page.goto( + `/session/${encodeURIComponent(scenario.sessionId)}` + + `?split=${encodeURIComponent(scenario.sessionId)},${encodeURIComponent(secondSessionId)}` + + `&theme=${theme}`, + ); + await expect(page.locator('[data-testid="split-view"]')).toBeVisible(); + // Both panes reconnect on the split navigation; settle each replay so + // neither pane is stuck on the loading state. + await completeReplay( + page, + daemon, + scenario.sessionId, + scenario.events.length, + ); + await completeReplay(page, daemon, secondSessionId, 0); + // The maximize control only appears with 2+ panes (#6951); waiting on it + // confirms the split actually rendered both panes. + await expect( + page.getByRole('button', { name: 'Maximize pane' }).first(), + ).toBeVisible(); + await captureScreenshot(page, `split-view-${theme}`); + + // Maximize the first pane (#6951): it fills the split and the other pane + // hides; the button flips to "Restore pane". + await page.getByRole('button', { name: 'Maximize pane' }).first().click(); + await expect( + page.getByRole('button', { name: 'Restore pane' }), + ).toBeVisible(); + await captureScreenshot(page, `split-view-maximized-${theme}`); + + // 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. + 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) => { + const stamp = '2026-07-03T00:00:00.000Z'; + const base = { + workspaceCwd: '/tmp/qwen-web-shell-e2e', + createdAt: stamp, + updatedAt: stamp, + clientCount: 1, + }; + const scenario = createWebShellDaemonScenario({ + sessionId: 'sess-running', + displayName: 'Run test suite', + sessions: [ + { + ...base, + sessionId: 'sess-approval', + displayName: 'Deploy to staging', + hasActivePrompt: true, + isWaitingForPermission: true, + }, + { + ...base, + sessionId: 'sess-question', + displayName: 'Refactor auth module', + hasActivePrompt: true, + isWaitingForUserQuestion: true, + }, + { + ...base, + sessionId: 'sess-running', + displayName: 'Run test suite', + hasActivePrompt: true, + }, + { + ...base, + sessionId: 'sess-idle', + displayName: 'Draft release notes', + clientCount: 0, + hasActivePrompt: false, + }, + ], + }); + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + await gotoSession(page, scenario, daemon, theme); + // The sidebar lists every session; #6956 adds an attention pill to the + // ones waiting on the user. Assert on session names (present on both + // `main` and the PR) so the frame is the same shape either way — the pill + // itself is the PR's diff that the before/after preview surfaces. + await expect(page.getByText('Deploy to staging')).toBeVisible(); + await expect(page.getByText('Refactor auth module')).toBeVisible(); + // Assert all four sessions render (not just the two waiting ones), so a + // regression that truncates the running or idle session is caught. The + // running session is also the loaded one, so its name shows in the main + // view too — scope to the sidebar landmark to keep the match unambiguous. + const sidebar = page.getByRole('complementary'); + await expect(sidebar.getByText('Run test suite')).toBeVisible(); + await expect(sidebar.getByText('Draft release notes')).toBeVisible(); + await captureScreenshot(page, `sidebar-attention-${theme}`); + }); + test(`slash menu`, async ({ page }, testInfo) => { const scenario = createWebShellDaemonScenario(); const daemon = await installScenario(