test(web): connect a Workspace in the e2e boot path and refresh goldens

The startup-selection flow leaves a fresh world (no Workspace) in the
locked view state, so every e2e scenario that types into the composer
now connects one first via the shared connectFreshWorkspace helper
(hero picker create-by-name dialog; the default 'workspace' name keeps
the session-header cwd assertions intact).

Golden refreshes carry the current composer chrome: the plan/model
control seats are empty until their owning plugins register (the seats
shipped without occupants on this branch), the sidebar shows the
connected workspace group pre-send, and the bash details material
renders Input/code/Output as separate nodes. The cancel scenario polls
the frozen-partial swap instead of counting synchronously — the abort
frame reaches the browser over SSE after the host settles.
This commit is contained in:
imccyu
2026-07-27 08:25:00 +08:00
parent 250e2415ae
commit ecdaf0dc24
19 changed files with 61 additions and 71 deletions
+3 -1
View File
@@ -18,7 +18,7 @@ import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
@@ -48,6 +48,8 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
+3 -1
View File
@@ -20,7 +20,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -47,6 +47,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
+7 -3
View File
@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -94,6 +94,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}
/**
@@ -134,9 +136,11 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
// Composer recovered; no streaming node lingers.
// Composer recovered; no streaming node lingers. The host settled first
// (awaited above), but the abort frame reaches the browser over SSE — the
// frozen-partial swap is eventually consistent, so poll rather than count.
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
// Golden of the aborted end-state: the prompt bubble plus the frozen
// partial ('partial' is the hang entry's replayed prefix) and no more.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+3 -1
View File
@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -45,6 +45,8 @@ describe('web e2e: resident question composer round trip', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
+3 -1
View File
@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
@@ -47,6 +47,8 @@ describe('web e2e: fresh round trip through the real assembly', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
+3 -1
View File
@@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, probeFreePort, requireDist, saveFailureShot } from './support.ts'
import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
/** Repo-root .env → process.env (never overrides an already-set variable). */
function loadRootEnv(): void {
@@ -404,6 +404,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
it('2+3 empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
// Fresh world: connect a Workspace so the composer starts live.
await connectFreshWorkspace(page)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')
@@ -23,13 +23,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -19,13 +19,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -11,7 +11,11 @@
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions": No sessions yet
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- button "设置":
- img
- text: 设置
@@ -23,13 +27,10 @@
- textbox "Describe what you want to build"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
- text: 详情
- button "关闭详情"
- text: 点击消息流中的工具行查看详情
@@ -15,13 +15,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -12,13 +12,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -10,13 +10,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -15,13 +15,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -1,3 +1,5 @@
- text: bash
- button "关闭详情"
- text: "Input { \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" } Output NAVIGATION_OK"
- text: Input
- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
- text: Output NAVIGATION_OK
@@ -21,13 +21,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -24,13 +24,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
@@ -21,13 +21,7 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- combobox "Plan mode":
- option "Plan" [selected]
- option "Agent"
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- combobox "Model":
- option "DeepSeek-V4-Pro High" [selected]
- option "DeepSeek-V4-Pro"
- button "Send message" [disabled]
+3 -1
View File
@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -71,6 +71,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page)
}, 120_000)
afterAll(async () => {
+25
View File
@@ -32,6 +32,31 @@ export function probeFreePort(): Promise<number> {
})
}
/**
* Drive the hero's workspace picker through its create-by-name dialog until
* the live composer unlocks. A fresh world has no Workspace, so the boot
* lands in the locked view state (startup auto-selection has nothing to
* select); every scenario that types into the composer must connect one
* first. The default name 'workspace' keeps the session header cwd at
* <workspaceRoot>/workspace — the materialization proof several scenarios
* assert.
* @param page - the page under test.
* @param name - workspace name typed into the create dialog.
*/
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('New workspace name').fill(name)
await dialog.getByRole('button', { name: 'Create workspace' }).click()
// The pick connected the workspace: the blank session's live composer
// replaces the locked placeholder and enables.
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
.waitFor({ timeout: 15_000 })
}
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
export async function saveFailureShot(page: Page, name: string): Promise<void> {
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))