test(web-shell): add mermaid, split-view + sidebar visual scenarios (#6964)

* 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 <svg>) 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 <wenshao@example.com>
This commit is contained in:
Shaojin Wen 2026-07-16 08:58:28 +08:00 committed by GitHub
parent 7c1a93477c
commit 2deefbf9c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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
// `<svg id="mermaid-N">`. 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(