Merge branch 'worktree-llm-dynamic-config' into worktree-llm-web-config

# Conflicts:
#	apps/cli/cordis.yml
#	apps/cli/package.json
#	apps/cli/tests/tui-keyless-smoke.e2e.ts
#	apps/web/tests/details-session-lifecycle.e2e.ts
#	apps/web/tests/snapshots/code-mode-round/ui.expected.md
#	apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
#	apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
#	apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
#	apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
#	apps/web/tests/snapshots/live-interactions/cancel.expected.md
#	apps/web/tests/snapshots/live-interactions/error-auth.expected.md
#	apps/web/tests/snapshots/live-interactions/retry.expected.md
#	apps/web/tests/snapshots/message-actions/ui.expected.md
#	apps/web/tests/snapshots/question-composer/answered.expected.md
#	apps/web/tests/snapshots/seeded-history/ui.expected.md
#	apps/web/tests/snapshots/steering/mid-steer.expected.md
#	apps/web/tests/snapshots/steering/settled.expected.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/user/guide/config.i18n.yaml
#	docs/user/guide/config.md
#	docs/user/guide/config.zh.md
#	docs/user/guide/index.i18n.yaml
#	docs/user/guide/index.md
#	docs/user/guide/index.zh.md
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/cordis-agent/cordis.yml
#	examples/cordis-agent/tests/cordis-tools.e2e.ts
#	examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/tui-agent/code-mode.cordis.yml
#	examples/tui-agent/cordis.yml
#	packages/examples/tui-demo/README.md
#	packages/examples/tui-demo/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/pty/tool-bash-persistent/README.i18n.yaml
#	packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
#	packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
#	pnpm-lock.yaml
#	scripts/snapshots/python-sdk-single-exe/advanced/result.json
#	scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl
#	scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl
#	scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl
This commit is contained in:
Yichen Jiang
2026-07-30 20:15:40 +08:00
477 changed files with 11033 additions and 4650 deletions

View File

@@ -18,7 +18,7 @@ import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, 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))
@@ -44,7 +44,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -118,14 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
}, 60_000)
it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel open', async () => {
it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
const nest = page.locator('[data-subcalls]').first()
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
await nest.locator('[data-sample="bash-global"]').first().click()
// Tool rows do not drive layout geometry; the Session's default panel stays open.
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
// Tool rows do not drive layout geometry; the Session's default panel stays closed.
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
})
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {

View File

@@ -12,7 +12,7 @@ import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url))
@@ -62,7 +62,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -1,14 +1,16 @@
// Keyless browser regression for the details column's Session ownership.
// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership.
// Keyless browser regression for the details column's default visibility and Session ownership.
// The shipped composition starts closed after selection and reload, retains an explicitly opened width through
// unselected states, and closes it only when a different Session takes ownership.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
@@ -40,7 +42,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 })
await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await appFrame(page).waitFor({ timeout: 30_000 })
@@ -52,7 +54,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await scaffold?.close()
})
it('retains geometry through hero and closes it for a different Session', async () => {
it('starts and reloads closed, then stays closed across Session ownership changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle'))
const settled = scaffold.whenTurnSettled()
const input = page.locator('textarea').first()
@@ -61,11 +63,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
// The localized chrome names the sidebar entry 新建会话 (zh default locale).
await page.getByRole('button', { name: '新建会话' }).last().click()
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await appFrame(page).waitFor({ timeout: 30_000 })
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
@@ -73,8 +82,8 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first()
await original.click()
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
const ungrouped = page.getByText('Ungrouped', { exact: true })
const ungroupedRow = ungrouped.locator('..').locator('..')

View File

@@ -20,7 +20,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -43,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -90,7 +90,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -167,6 +167,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// "no crash, composer recovers, turn logged as error".
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
// The blank workspace also has an enabled composer. Wait for the driven
// session's only visible message before capturing its no-error-copy state.
await expect.poll(() => page.getByText(PROMPT, { exact: true }).first().isVisible(), { timeout: 10_000 }).toBe(true)
// Golden of the same gap: the prompt bubble alone, no error copy in the
// tree — the diff that changes when web-error-surface lands.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
@@ -175,6 +178,29 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => {
await launch(() => ({
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }],
}))
const { settled } = await sendPrompt()
await settled
await page.getByRole('tab', { name: 'Trajectory' }).click()
const tailRequest = page.locator('tr[data-request-only="true"]').last()
await tailRequest.waitFor({ timeout: 10_000 })
const requestMarker = tailRequest.getByRole('button', { name: /Request #/ })
const markerWithinTable = await requestMarker.evaluate((element) => {
const marker = element.getBoundingClientRect()
const table = element.closest('table')?.getBoundingClientRect()
if (table === undefined) throw new Error('request marker has no table')
return marker.bottom <= table.bottom
})
expect(markerWithinTable).toBe(true)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => {
const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
expect(derived).toHaveLength(1)

View File

@@ -12,7 +12,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url))
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
@@ -40,7 +40,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -75,7 +75,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', {
name: '选择模型,当前 deepseek-v4-flash',
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.

View File

@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
@@ -56,7 +56,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
slotErrors = []
page.on('console', (message) => {
@@ -173,24 +173,24 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
}, 60_000)
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column open', async () => {
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click()
const bashRow = page.locator('[data-sample="bash-global"]').first()
await bashRow.waitFor({ timeout: 15_000 })
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
await bashRow.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// The card's own controls are outside the summary row and must not open
// details either — the terminal card is read in place.
await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// Read summaries are host-open file links; they also must not open details.
const fileLink = page.locator('[data-variant="read"] button').first()
await fileLink.waitFor({ timeout: 10_000 })
await fileLink.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
}, 60_000)
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {

View File

@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -44,7 +44,7 @@ describe('web e2e: resident question composer round trip', () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -16,7 +16,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
@@ -63,7 +63,7 @@ describe('web e2e: queue row actions', () => {
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, 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))
@@ -43,7 +43,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -106,7 +106,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true)
expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1)
await page.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash',
name: 'Select model, current DeepSeek-V4-Flash',
}).waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)

View File

@@ -1,6 +1,6 @@
// Shared scaffold for the keyless browser e2e lane (Agent Note:
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
// Boots the REAL web composition — the shipped apps/cli/cordis.yml through
// Boots the REAL web composition — the shipped base plus web overlay through
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
@@ -9,7 +9,7 @@
// from live session memory), refresh (keyless replay that rewrites goldens).
//
// Composition divergences from `dsh web`, all deliberate, all via include
// patches over the SAME tree (never a second yml): temp persistenceRoot;
// patches after the shipped surface overlay: temp persistenceRoot;
// workspace-context disabled (recorded fixtures must not embed this repo's
// AGENTS.md); session-title-llm disabled (its fire-and-forget title call
// would race the loop for the session's replay cursor); webserver pinned to
@@ -28,7 +28,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot'
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import SessionStore, {
@@ -60,8 +60,9 @@ export function webSnapshotMode(): WebSnapshotMode {
throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
}
/** The shipped composition under test: apps/cli's config tree. */
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
/** The shipped composition under test: apps/cli's shared base and web overlay. */
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/config/base.cordis.yml')
const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml')
// Replay publishes the provider catalog the gateway routes to (providers
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
@@ -169,7 +170,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// The include patch set — the same mechanism AppCLIEntry and the ACP
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
// stops matching a row fails the boot sweep loudly instead of drifting).
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_OVERLAY_PATH)
const patches: PatchOptions[] = [
...surfacePatches,
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
// storage-json's './.storages' yml default is cwd-relative and resolves
// per write; the scaffold restores the original cwd after boot, so the

View File

@@ -17,7 +17,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
@@ -49,7 +49,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -125,7 +125,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the selected id, but no catalog
// adapter exists to provide its presentation name.
name: '选择模型,当前 deepseek-v4-flash',
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
@@ -140,9 +140,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
const fileLink = page.locator('[data-variant="read"] button').first()
await fileLink.waitFor({ timeout: 10_000 })
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
await fileLink.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// Path label survives from the recorded args (a.txt).
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
})

View File

@@ -67,7 +67,7 @@ import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
@@ -275,7 +275,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
browser = await chromium.launch()
// Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
// the list with room to spare.
page = await browser.newPage({ viewport: { width: 1680, height: 800 } })
page = await newEnglishPage(browser, 800)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -17,7 +17,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
@@ -80,7 +80,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
scaffold = await launchWebScaffold({})
await seedSkills(scaffold.workspaceCwd)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

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, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
@@ -376,7 +376,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
)
baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
page.on('pageerror', e => pageErrors.push(String(e)))
await page.goto(baseUrl, { waitUntil: 'load' })
}, 120_000)
@@ -454,7 +454,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '07-back-to-chat')
})
it('5 bash differential rendering: tool row click leaves the default details column open', async () => {
it('5 bash differential rendering: tool row click leaves the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
const input = page.locator('textarea').first()
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
@@ -466,11 +466,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
const toolRow = page.locator('[data-sample="bash-global"]')
await toolRow.waitFor({ timeout: 120_000 })
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(360)
expect(await detailsTrack(page)).toBe(0)
await toolRow.click()
// Tool rows no longer drive layout.openDetails; the default column stays open.
expect(await detailsTrack(page)).toBe(360)
await screen(page, '09-details-open')
// Tool rows no longer drive layout.openDetails; the default column stays closed.
expect(await detailsTrack(page)).toBe(0)
await screen(page, '09-details-closed')
}, 150_000)
it('6 sidebar drag widens the column and resets across reload', async () => {

View File

@@ -33,11 +33,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -47,11 +47,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -30,11 +30,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,9 +1,9 @@
- button "新建会话"
- button "收起侧边栏":
- button "New session"
- button "Collapse sidebar":
- img
- button "新建会话":
- button "New session":
- img
- text: 新会话
- text: New Session
- text: Workspaces
- button "Group by":
- img
@@ -17,9 +17,9 @@
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- button "设置":
- button "Settings":
- img
- text: 设置
- text: Settings
- text: Let's start building
- button "Choose workspace":
- img
@@ -29,7 +29,8 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -22,11 +22,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -19,11 +19,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -12,11 +12,12 @@
- button "编辑":
- img
- button "▸ 上下文注入"
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -22,11 +22,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -32,11 +32,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 deepseek-v4-flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]

View File

@@ -19,7 +19,7 @@
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
- text: Ask question 1/1 answered
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
- img
- img
@@ -30,11 +30,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,23 +1,17 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- text: 1 / 1
- button "上一题" [disabled]:
- img
- button "下一题" [disabled]:
- img
- button "放弃整组问题":
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Blue":
- text: 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- img
- radio "Green":
- text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- img
- button "其他,请填写自定义答案":
- img
- text: 其他,请填写自定义答案
- radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer"
- button "Previous question" [disabled]:
- img
- text: 1 / 1
- button "Next question" [disabled]:
- img
- status
- button "跳过本题"
- button "提交" [disabled]
- button "Skip this question"
- button "Submit" [disabled]

View File

@@ -26,11 +26,12 @@
- img
- button "取消编辑":
- img
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"

View File

@@ -20,11 +20,12 @@
- img
- button "删除排队消息":
- img
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"

View File

@@ -31,11 +31,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 deepseek-v4-flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]

View File

@@ -1,3 +1,3 @@
- listbox "Trigger suggestions":
- text: 技能
- text: Skills
- option "policy-shared Available to both model and user invocation" [selected]

View File

@@ -19,27 +19,21 @@
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
- text: Ask question waiting
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
- text: 1 / 1
- button "上一题" [disabled]:
- img
- button "下一题" [disabled]:
- img
- button "放弃整组问题":
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Yes":
- text: 1 Yes
- img
- radio "No":
- text: 2 No
- img
- button "其他,请填写自定义答案":
- img
- text: 其他,请填写自定义答案
- radio "Yes": 1 Yes
- radio "No": 2 No
- textbox "Type your answer"
- button "Previous question" [disabled]:
- img
- text: 1 / 1
- button "Next question" [disabled]:
- img
- status
- button "跳过本题"
- button "提交" [disabled]
- button "Skip this question"
- button "Submit" [disabled]

View File

@@ -19,7 +19,7 @@
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
- text: "Ask question 1/1 answered 插话 Interjection: include the word BANANA in your final reply."
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img
@@ -30,11 +30,12 @@
- button "在新对话中分支":
- img
- text: {{clock}}
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,10 +1,41 @@
- dialog "选择工作区目录":
- heading "选择工作区目录" [level=2]
- dialog "Select Workspace Directory":
- heading "Select Workspace Directory" [level=2]
- navigation:
- button "主目录"
- button "Home"
- img
- button "browse-golden"
- button "编辑路径"
- button "Edit path"
- list:
- listitem:
- button "adopted":
- img
- text: adopted
- img
- listitem:
- button "alpha-ws":
- img
- text: alpha-ws
- img
- listitem:
- button "beta-ws":
- img
- text: beta-ws
- img
- listitem:
- button "browse-golden":
- img
- text: browse-golden
- img
- listitem:
- button "same-name":
- img
- text: same-name
- img
- listitem:
- button "workspace":
- img
- text: workspace
- img
- list:
- listitem:
- button "alpha":
@@ -16,8 +47,9 @@
- img
- text: beta
- img
- button "新建文件夹":
- button "New folder":
- img
- text: 新建文件夹
- button "取消"
- button "打开"
- text: New folder
- button "Show hidden files"
- button "Cancel"
- button "Open"

View File

@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -67,7 +67,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
sessionEvents.push(event)
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

View File

@@ -2,13 +2,28 @@
import { existsSync, mkdirSync } from 'node:fs'
import { createServer } from 'node:net'
import { fileURLToPath } from 'node:url'
import type { Page } from 'playwright'
import type { Browser, Page } from 'playwright'
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/**
* Open the standard browser-test page with English selected before client
* boot. This keeps role locators and goldens deterministic across localized
* component migrations; the settings locale scenario deliberately bypasses
* this helper to cover the product's default Chinese state.
* @param browser - Playwright browser owning the page.
* @param height - Viewport height; width is fixed to the lane baseline.
* @returns the initialized page.
*/
export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
const page = await browser.newPage({ viewport: { width: 1680, height } })
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
return page
}
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
export function requireDist(): void {
if (!existsSync(DIST_INDEX)) {

View File

@@ -16,7 +16,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', import.meta.url))
// The seed is another scenario's committed fixture, reused read-only: this
@@ -42,12 +42,12 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
const agentsBefore = scaffold.ctx.agents.list().length
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(path)
await dialog.getByLabel('编辑路径').press('Enter')
await dialog.getByRole('button', { name: '打开' }).click()
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(path)
await dialog.getByLabel('Edit path').press('Enter')
await dialog.getByRole('button', { name: 'Open' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(path),
@@ -72,7 +72,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -180,7 +180,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// current selection while it moves into Ungrouped.
const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
await groupRow.waitFor({ timeout: 10_000 })
const groupSection = groupRow.locator('..')
// The header row is wrapped by its HoverCard anchor span, so the section
// is the nearest groupSection ancestor, not the immediate parent.
const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
await expect.poll(
() => groupSection.locator('[role="treeitem"]').count(),
@@ -363,15 +365,15 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
try {
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(staged)
await dialog.getByLabel('编辑路径').press('Enter')
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(staged)
await dialog.getByLabel('Edit path').press('Enter')
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
await dialog.getByRole('button', { name: '取消' }).click()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
} finally {
if (realHome === undefined) delete process.env.HOME
@@ -406,7 +408,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// card; no aria role — text anchors are the stable selector).
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// Leaving the anchor closes it with no delay.
await page.getByRole('button', { name: '设置' }).hover()
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)