Merge origin/master into worktree/composer-caret-binding
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
// the same locale-aware, in-page risk confirmation. Zero model calls: the
|
||||
// scenario boots the shipped Web composition and exercises the real
|
||||
// permission projection, client command path, HTTP RPC, and pushed update.
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
/**
|
||||
* connectFreshWorkspace twin over the product default Chinese locale (the
|
||||
@@ -19,13 +20,16 @@ import { saveFailureShot } from './support.ts'
|
||||
* boots; this scenario deliberately keeps zh, so the localized picker
|
||||
* copy is the anchor set).
|
||||
*/
|
||||
async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise<void> {
|
||||
async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('button', { name: '选择工作区' }).click()
|
||||
await page.getByRole('menuitem', { name: '新建工作区' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新建工作区' })
|
||||
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByLabel('新工作区名称').fill(name)
|
||||
await dialog.getByRole('button', { name: '创建工作区' }).click()
|
||||
await dialog.getByRole('button', { name: '编辑路径' }).click()
|
||||
const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
|
||||
await pathInput.fill(join(root, name))
|
||||
await pathInput.press('Enter')
|
||||
await dialog.getByRole('button', { name: '打开', exact: true }).click()
|
||||
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
|
||||
.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
@@ -49,11 +53,11 @@ describe('web e2e: Full access confirmation', () => {
|
||||
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
|
||||
// Keep the product default Chinese locale: the golden pins the actual
|
||||
// registered dictionary rather than a test-local translation callback.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page)
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -66,14 +70,7 @@ describe('web e2e: Full access confirmation', () => {
|
||||
const access = page.locator('button[aria-label^="访问模式"]').first()
|
||||
await access.waitFor({ timeout: 10_000 })
|
||||
|
||||
// Normalize the starting preset through the real command path. The
|
||||
// shipped web config may already start at Full access.
|
||||
if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('访问模式,当前:Workspace Write')
|
||||
}
|
||||
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write')
|
||||
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Web e2e scenario: a cancelled Bash call can settle without terminal-card
|
||||
// material. Borrow the real cancellation fixture and prove the keyed Bash row
|
||||
// still exposes the recorded command and full error without any model call.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'bash-abort-row-web-e2e'
|
||||
const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('expands the aborted row to its command and full error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row'))
|
||||
const row = page.locator('[data-sample="bash"]').first()
|
||||
const call = row.locator('xpath=..')
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1)
|
||||
await row.click()
|
||||
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
await call.getByText('IN', { exact: true }).waitFor()
|
||||
await call.getByText('OUT', { exact: true }).waitFor()
|
||||
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
|
||||
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
|
||||
await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
// The borrowed fixture's UTC date is still the previous day in PDT;
|
||||
// the disclosure golden must not depend on the runner timezone.
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,10 @@
|
||||
// layers, per-plugin CSS injection, and a rendered journey reaching chat
|
||||
// content from the keyless FixtureApiClient transport.
|
||||
//
|
||||
// Behavior assertions do NOT belong here: component and wiring behavior is
|
||||
// pinned by the per-package suites (SlotTestRuntime benches over src), which
|
||||
// this smoke's plugin set cannot influence — bundling, module-table
|
||||
// resolution, and boot layering are the only failure modes left to it.
|
||||
// Component behavior remains owned by per-package suites (SlotTestRuntime
|
||||
// benches over src). This smoke additionally pins the resident approval
|
||||
// fixture's cross-plugin projection because only the built connection/runtime/
|
||||
// workspace graph can prove that transport-to-row path end to end.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -105,18 +105,35 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
await within(tree).findByText('4 sessions')
|
||||
|
||||
// The resident approval fixture proves the assembled workspace plugin
|
||||
// distinguishes a blocked running session from an ordinarily busy one.
|
||||
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
|
||||
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
|
||||
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
|
||||
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
within(waitingRow).getByText('Waiting for approval')
|
||||
|
||||
// Opening a session reaches chat content through the fixture transport.
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
fireEvent.click(waitingTitle)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
// (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
|
||||
// The write turn's `hello fixture\n` proves the terminator rule end to end: a
|
||||
// trailing newline terminates its line, so the footer reads `+1` (not a
|
||||
// phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
|
||||
// it is absent from textContent — assert on the line body and the footer.
|
||||
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
|
||||
// fixture's raw text. The card is collapsed by default, so expand each edit/
|
||||
// write row first. The write turn's `hello fixture\n` proves the terminator
|
||||
// rule end to end: a trailing newline terminates its line, so the footer reads
|
||||
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
|
||||
// ::before, so it is absent from textContent — assert on the line body and the
|
||||
// footer.
|
||||
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
|
||||
expect(mutationRows.length).toBeGreaterThan(0)
|
||||
for (const row of mutationRows) {
|
||||
const toggle = row.querySelector('[data-expandable]')
|
||||
if (toggle !== null) act(() => { fireEvent.click(toggle) })
|
||||
}
|
||||
const diffCards = [...document.querySelectorAll('[data-diff]')]
|
||||
expect(diffCards.length).toBeGreaterThan(0)
|
||||
const footers = diffCards.map(card => card.textContent ?? '')
|
||||
@@ -125,13 +142,20 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
// web_search / web_fetch turns render their keyed WebRow cards, proving the
|
||||
// registration, wire projection, and card rendering survive the real bundle
|
||||
// path (not just the per-package src benches). The selector pins the KEYED
|
||||
// WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
|
||||
// WebBlock draws — the generic fallback renders the same WebBlock, so a silent
|
||||
// keyed-registration failure would still satisfy a bare `[data-web]` check.
|
||||
// path (not just the per-package src benches). WebRow composes ToolRow, so the
|
||||
// card is collapsed behind the row; the keyed row is pinned by its `data-tool`
|
||||
// (ToolRow sets it from the wire tool name).
|
||||
const webSearchRow = await waitFor(() => {
|
||||
const row = document.querySelector('[data-tool="web_search"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
|
||||
return row!
|
||||
}, { timeout: 10_000 })
|
||||
// Expand the web_search row to prove its WebBlock card renders end to end.
|
||||
const webToggle = webSearchRow.querySelector('[data-expandable]')
|
||||
if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
|
||||
expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
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)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -112,7 +112,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
// the bash sub-call landed in the bash sample registration.
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
await nest.waitFor({ timeout: 10_000 })
|
||||
expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1)
|
||||
expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1)
|
||||
// The failing read sub-call wears the same error state a native failed
|
||||
// row wears (the recorded program tolerates a read of missing.txt).
|
||||
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
|
||||
@@ -123,7 +123,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
await nest.locator('[data-sample="bash-global"]').first().click()
|
||||
await nest.locator('[data-sample="bash"]').first().click()
|
||||
// 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')
|
||||
})
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, 'composer-draft-scroll')
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
|
||||
await page.locator('textarea:enabled').first().fill(DRAFT)
|
||||
}, 180_000)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
84
apps/web/tests/core-web-profile.snapshot.ts
Normal file
84
apps/web/tests/core-web-profile.snapshot.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
|
||||
|
||||
describe('core Web profile', () => {
|
||||
let scaffold: WebScaffold
|
||||
let agentHandle: AgentHandle
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: CORE_WEB_OVERLAY,
|
||||
toolsMode: 'native',
|
||||
})
|
||||
agentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-smoke'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
|
||||
})
|
||||
|
||||
it('boots and executes both tools through the shipped Web composition', async () => {
|
||||
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
|
||||
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
|
||||
const signal = new AbortController().signal
|
||||
const bash = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-bash-smoke'),
|
||||
name: 'bash',
|
||||
arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const editor = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-editor-smoke'),
|
||||
name: 'str_replace_editor',
|
||||
arguments: { command: 'view', path: seedPath },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
|
||||
const text = (result: typeof bash): string => result.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
|
||||
.trimEnd()
|
||||
|
||||
expect({
|
||||
tools: scaffold.ctx.tools.schemas().map(tool => tool.name),
|
||||
bash: text(bash),
|
||||
editor: text(editor),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bash": "CORE_WEB_BASH_OK",
|
||||
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
|
||||
1 CORE_WEB_EDITOR_OK
|
||||
2",
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
const entries = [...scaffold.ctx.loader.entries()]
|
||||
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await appFrame(page).waitFor({ timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
71
apps/web/tests/goal-bar.e2e.ts
Normal file
71
apps/web/tests/goal-bar.e2e.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Keyless assembled-browser coverage for the goal bar over the shipped Web
|
||||
// bundles and FixtureApiClient wire. The command creates a real projected
|
||||
// goal in the fixture session; the golden pins the active strip, while the
|
||||
// clear gesture proves the acknowledged tombstone leaves neither stale chrome
|
||||
// nor a duplicate-mutation error.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-bar', import.meta.url))
|
||||
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
|
||||
const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: goal bar clear convergence', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders one active goal and clears it without exposing a stale error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
|
||||
// Startup reuses the fixture workspace's blank session, keeping this
|
||||
// command independent of alpha's running replay and pending question.
|
||||
const input = page.getByPlaceholder('Describe what you want to build')
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await input.fill('/goal guard rapid clear clicks')
|
||||
await input.press('Enter')
|
||||
|
||||
const bar = page.locator('[data-goal-bar]')
|
||||
await bar.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
|
||||
|
||||
const clear = bar.getByRole('button', { name: 'Clear goal' })
|
||||
await clear.evaluate((button) => {
|
||||
const control = button as HTMLButtonElement
|
||||
control.click()
|
||||
control.click()
|
||||
})
|
||||
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await page.getByText(/no current goal/iu).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md'])
|
||||
})
|
||||
})
|
||||
5
apps/web/tests/goal-bar.overlay.yml
Normal file
5
apps/web/tests/goal-bar.overlay.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
# The client-side FixtureApiClient intentionally rejects settings writes, so
|
||||
# this goal-only scenario omits the durable welcome step that would otherwise
|
||||
# cover the page. Onboarding owns separate assembled-browser coverage.
|
||||
- id: ui-settings-general
|
||||
disabled: true
|
||||
132
apps/web/tests/hmr-live.e2e.ts
Normal file
132
apps/web/tests/hmr-live.e2e.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Fiber } from 'cordis'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { REPO_ROOT } from './support.ts'
|
||||
|
||||
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
|
||||
return {
|
||||
argv,
|
||||
cwd,
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
||||
graceMs: 5_000,
|
||||
...env === undefined ? {} : { env },
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let output = ''
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
child.stdout?.off('data', onData)
|
||||
child.stderr?.off('data', onData)
|
||||
}
|
||||
const resolveOnce = (value: string): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolveReady(value)
|
||||
}
|
||||
const rejectOnce = (error: Error): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk: Buffer): void => {
|
||||
output += chunk.toString()
|
||||
const match = pattern.exec(output)
|
||||
if (match === null) return
|
||||
resolveOnce(match[1] ?? match[0])
|
||||
}
|
||||
const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onData)
|
||||
void child.done.then((outcome) => {
|
||||
rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
|
||||
}, (error: unknown) => {
|
||||
rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function stopTree(child: SubprocessHandle): Promise<void> {
|
||||
child.terminate()
|
||||
const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
|
||||
if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
|
||||
await child.done
|
||||
}
|
||||
|
||||
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
|
||||
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
|
||||
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
|
||||
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
|
||||
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
|
||||
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
|
||||
const originalSource = await readFile(sourcePath)
|
||||
const originalBundle = await readFile(bundlePath)
|
||||
const oldText = "Let's start building"
|
||||
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
|
||||
const newText = `HMR UPDATED ${'x'.repeat(80)}`
|
||||
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
|
||||
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
|
||||
|
||||
const subprocessCtx = new Context()
|
||||
let subprocessFiber: Fiber | undefined
|
||||
let watcher: SubprocessHandle | undefined
|
||||
let host: SubprocessHandle | undefined
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
|
||||
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
|
||||
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
|
||||
host = subprocessCtx.subprocess.spawn(spawnSpec(
|
||||
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
|
||||
world,
|
||||
{
|
||||
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
|
||||
DSH_HOME: join(world, '.dsh'),
|
||||
},
|
||||
))
|
||||
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', error => pageErrors.push(String(error)))
|
||||
await page.goto(baseUrl, { waitUntil: 'load' })
|
||||
await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
const pageIdentity = await page.evaluate(() => {
|
||||
const identity = crypto.randomUUID()
|
||||
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
|
||||
return identity
|
||||
})
|
||||
|
||||
await writeFile(sourcePath, updatedSource)
|
||||
await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
|
||||
.toBe(pageIdentity)
|
||||
expect(pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
} finally {
|
||||
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
|
||||
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
|
||||
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
|
||||
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
|
||||
}, 120_000)
|
||||
@@ -50,7 +50,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
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)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -93,7 +93,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
try {
|
||||
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
|
||||
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(activePage)
|
||||
await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
|
||||
const input = activePage.locator('textarea').first()
|
||||
await activePage.getByRole('button', { name: 'Commands' }).click()
|
||||
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
@@ -158,8 +158,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
}
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const observeTurn = async () => {
|
||||
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
|
||||
try {
|
||||
await input.press('Enter')
|
||||
if (MODE !== 'record') {
|
||||
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
|
||||
await expect.poll(async () => await liveTail.evaluate(element => (
|
||||
element.scrollWidth > element.clientWidth
|
||||
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
|
||||
)), { timeout: 10_000, interval: 10 }).toBe(true)
|
||||
}
|
||||
return await settled
|
||||
} finally {
|
||||
if (MODE !== 'record') await page.setViewportSize(originalViewport)
|
||||
}
|
||||
}
|
||||
const sessionId = await observeTurn()
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
@@ -172,10 +188,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Host: the session's durable header cwd is the workspace flow's
|
||||
// create-by-name target (<workspaceRoot>/workspace, the composer's
|
||||
// default draft name) — the proof the send went through workspace
|
||||
// materialization rather than a bare default-cwd session.
|
||||
// Host: the session's durable header cwd is the folder the workspace
|
||||
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
|
||||
// send went through workspace materialization rather than a bare
|
||||
// default-cwd session.
|
||||
const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
|
||||
expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
|
||||
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
|
||||
|
||||
@@ -28,14 +28,14 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// One golden pins the stable mid-turn loading state; the other three capture
|
||||
// what the user is left looking at after cancel, after a non-retryable failure
|
||||
// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error
|
||||
// copy in the tree), and after retry recovery.
|
||||
// what the user is left looking at after cancel, after a non-retryable failure,
|
||||
// and after retry recovery.
|
||||
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
|
||||
const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
|
||||
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
|
||||
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
|
||||
|
||||
// The recorded base: one text-only turn whose derived script the sidecars
|
||||
// patch. Kept deliberately tool-free so the derived script is exactly one
|
||||
@@ -96,7 +96,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
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)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => {
|
||||
await launch(() => ({
|
||||
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }],
|
||||
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
|
||||
}))
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth'))
|
||||
const { settled } = await sendPrompt()
|
||||
@@ -166,28 +166,28 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
|
||||
// AUTH is outside llm-retry's retryable set: no retry record.
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
|
||||
// Product gap found by this lane, pinned as-is: the client consumes no
|
||||
// agent/error frames and a pre-chunk failure freezes no partial, so THIS
|
||||
// failure renders no error copy anywhere — the user sees the send simply
|
||||
// stop. FIXME(web-error-surface): assert visible error text here once the
|
||||
// web UI grows an error rendering; until then the pinned contract is
|
||||
// "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 errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
|
||||
await errorStatus.waitFor({ timeout: 10_000 })
|
||||
expect(await errorStatus.textContent()).toContain('API key is invalid')
|
||||
expect(await errorStatus.textContent()).toContain('AUTH')
|
||||
expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
const requestMarker = page.locator('tr[data-request-only="true"]').last()
|
||||
.getByRole('button', { name: /Request #/ })
|
||||
await requestMarker.click()
|
||||
await page.getByText('API key is invalid', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
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' } }],
|
||||
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
|
||||
}))
|
||||
const { settled } = await sendPrompt()
|
||||
await settled
|
||||
|
||||
205
apps/web/tests/markdown-images.e2e.ts
Normal file
205
apps/web/tests/markdown-images.e2e.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session
|
||||
// assembled through the Session API is seeded cold into the real web
|
||||
// composition, then a separate image origin proves that the browser receives
|
||||
// a real network image while local-path Markdown remains inert alt text.
|
||||
import { createServer, type Server } from 'node:http'
|
||||
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 { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'markdown-images-web-e2e'
|
||||
const REMOTE_ALT = 'Remote test image'
|
||||
const LOCAL_ALT = 'Local test image'
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
)
|
||||
|
||||
interface ImageOrigin {
|
||||
server: Server
|
||||
url: string
|
||||
requests: Array<{ path: string | undefined; referer: string | undefined }>
|
||||
}
|
||||
|
||||
/** Start the deterministic remote image origin used by this browser scenario. */
|
||||
async function startImageOrigin(): Promise<ImageOrigin> {
|
||||
const requests: ImageOrigin['requests'] = []
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({ path: request.url, referer: request.headers.referer })
|
||||
response.writeHead(200, {
|
||||
'cache-control': 'no-store',
|
||||
'content-length': PNG.length,
|
||||
'content-type': 'image/png',
|
||||
})
|
||||
response.end(PNG)
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('image origin did not expose an IP socket')
|
||||
}
|
||||
return {
|
||||
server,
|
||||
url: `http://127.0.0.1:${String(address.port)}/image.png`,
|
||||
requests,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop one image origin after the browser and host release their requests. */
|
||||
async function stopServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
|
||||
function markdownImageFixture(remoteUrl: string): string {
|
||||
const session = new Session(SessionId('markdown-image-source'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the Markdown image policy.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Markdown image policy',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'## Markdown images',
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
'REMOTE_IMAGE_DONE',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const header = {
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}
|
||||
return [
|
||||
JSON.stringify(header),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: remote Markdown image rendering', () => {
|
||||
let scaffold: WebScaffold
|
||||
let imageOrigin: ImageOrigin
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
imageOrigin = await startImageOrigin()
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await stopServer(imageOrigin.server)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), {
|
||||
timeout: 15_000,
|
||||
}).toBe(1)
|
||||
|
||||
const image = page.getByRole('img', { name: REMOTE_ALT })
|
||||
await image.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), {
|
||||
timeout: 10_000,
|
||||
}).toBeGreaterThan(0)
|
||||
expect(await image.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
borderRadius: computed.borderRadius,
|
||||
decoding: element.getAttribute('decoding'),
|
||||
loading: element.getAttribute('loading'),
|
||||
maxWidth: computed.maxWidth,
|
||||
referrerPolicy: element.getAttribute('referrerpolicy'),
|
||||
}
|
||||
})).toEqual({
|
||||
borderRadius: '8px',
|
||||
decoding: 'async',
|
||||
loading: 'lazy',
|
||||
maxWidth: '100%',
|
||||
referrerPolicy: 'no-referrer',
|
||||
})
|
||||
expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
|
||||
expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
|
||||
expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history
|
||||
// fixture (zero model calls) and pins the settled conversation aria after the
|
||||
// user/assistant footers are focus-revealed — the surface package jsdom tests
|
||||
// cannot substitute for (docs/testing.md snapshot rule).
|
||||
// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic
|
||||
// completed-turn-tail fork case (zero model calls) and pins the settled
|
||||
// conversation aria after the footers are focus-revealed — the surface package
|
||||
// jsdom tests cannot substitute for (docs/testing.md snapshot rule).
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -25,6 +25,48 @@ const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'message-actions-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
const MID_TURN_TEXT = 'I will read both files before answering.'
|
||||
const SECOND_PROMPT = 'Now give the final answer.'
|
||||
|
||||
/**
|
||||
* Adapt the borrowed recording into response -> tools -> interrupted Think,
|
||||
* followed by one ordinary completed response. The first response keeps
|
||||
* copy/clock but is not a legal branch point; the second is the real turn tail.
|
||||
* @param raw - Recorded seeded-history JSONL.
|
||||
* @returns A contiguous, closed two-turn fixture.
|
||||
*/
|
||||
function completedTailFixture(raw: string): string {
|
||||
const kept: string[] = []
|
||||
for (const line of raw.trimEnd().split('\n')) {
|
||||
const row = JSON.parse(line) as {
|
||||
type: string
|
||||
seq?: number
|
||||
seq0?: number
|
||||
data?: { content?: unknown[] }
|
||||
}
|
||||
const firstSeq = row.seq ?? row.seq0
|
||||
if (firstSeq !== undefined && firstSeq >= 101) break
|
||||
if (row.type === 'assistant/message' && row.seq === 64) {
|
||||
const content = row.data?.content
|
||||
if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content')
|
||||
content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT })
|
||||
kept.push(JSON.stringify(row))
|
||||
} else {
|
||||
kept.push(line)
|
||||
}
|
||||
}
|
||||
const tail = [
|
||||
{ type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } },
|
||||
{ type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
{ type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } },
|
||||
{ type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } },
|
||||
{ type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n`
|
||||
}
|
||||
|
||||
describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await mkdir(sessionCwd, { recursive: true })
|
||||
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
|
||||
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
|
||||
const raw = completedTailFixture(await readFile(SEED, 'utf8'))
|
||||
expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
|
||||
it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
@@ -61,17 +103,25 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
|
||||
// hover/focus-within). User has three actions; each turn's last content
|
||||
// assistant has copy + branch.
|
||||
// hover/focus-within). Every durable message footer keeps branch visible,
|
||||
// but only the final assistant at a completed transcript tail enables it.
|
||||
const copyButtons = page.getByRole('button', { name: 'Copy' })
|
||||
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
|
||||
await copyButtons.first().focus()
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(1)
|
||||
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
|
||||
await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4)
|
||||
await expect.poll(
|
||||
() => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
|
||||
{ timeout: 5_000 },
|
||||
).toEqual(['true', 'true', 'true', null])
|
||||
await branchButtons.first().focus()
|
||||
await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
|
||||
.toBe('Available only on the last message of a completed turn')
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
|
||||
@@ -89,8 +139,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
|
||||
// Exercise the assistant action specifically; package coverage pins the
|
||||
// user action separately at its own event seq.
|
||||
// The last message action belongs to the completed second-turn assistant.
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
@@ -37,7 +37,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
|
||||
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace;
|
||||
// the read targets must live in that session cwd (pre-creation is safe:
|
||||
// create-by-name adopts an existing directory).
|
||||
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
@@ -67,6 +67,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The frame mounts before the asynchronous session-list baseline lands.
|
||||
// Search must target the settled seeded row, not the startup input that
|
||||
// the ready projection replaces.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -134,6 +138,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
const overlayLayout = await page.getByRole('table').evaluate((table) => {
|
||||
const host = table.closest('[data-conversation-scroll]')
|
||||
const seat = host?.querySelector('[data-composer-seat]') ?? null
|
||||
const pane = table.parentElement
|
||||
return {
|
||||
hostPosition: host === null ? null : getComputedStyle(host).position,
|
||||
paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX,
|
||||
paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth,
|
||||
seatPosition: seat === null ? null : getComputedStyle(seat).position,
|
||||
}
|
||||
})
|
||||
expect(overlayLayout).toEqual({
|
||||
hostPosition: 'relative',
|
||||
paneOverflowX: 'hidden',
|
||||
paneScrollableWidth: 0,
|
||||
seatPosition: 'absolute',
|
||||
})
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
slotErrors,
|
||||
@@ -147,14 +168,34 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
|
||||
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.locator('tr[data-kind="tool"]').first().click()
|
||||
await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
|
||||
const details = page.getByRole('complementary', { name: 'Event details' })
|
||||
await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX))
|
||||
.toBe('hidden')
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
|
||||
heading: getComputedStyle(heading).backgroundColor,
|
||||
panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor,
|
||||
}))
|
||||
expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel)
|
||||
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||
await page.getByRole('tab', { name: 'Result' }).click()
|
||||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
|
||||
await assistantSpan.hover()
|
||||
const timingTooltip = page.getByRole('tooltip')
|
||||
await timingTooltip.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
|
||||
const assistantTimingStyle = await assistantSpan.evaluate(node => ({
|
||||
background: getComputedStyle(node).backgroundImage,
|
||||
ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
|
||||
}))
|
||||
expect(assistantTimingStyle.background).toContain('linear-gradient')
|
||||
expect(assistantTimingStyle.ttft).toMatch(/%$/)
|
||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('complementary', { name: 'Event details' })
|
||||
.getByRole('button', { name: 'Close details' }).click()
|
||||
await details.getByRole('button', { name: 'Close details' }).click()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
@@ -177,7 +218,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
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()
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
@@ -187,7 +228,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
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 expanded terminal card is read in place.
|
||||
await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
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()
|
||||
@@ -203,10 +244,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
// tool-row interaction): open it if a previous case left it collapsed.
|
||||
// Expanded, the recorded command's own output sits in the message flow,
|
||||
// derived from the logged call/result presentations alone.
|
||||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
|
||||
const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first()
|
||||
const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
|
||||
await card.waitFor({ timeout: 15_000 })
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
|
||||
@@ -9,12 +9,18 @@ import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
|
||||
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
@@ -26,9 +32,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
const browserConsole: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
page.on('console', message => browserConsole.push(message.text()))
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
@@ -42,16 +49,61 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
|
||||
it('stores a key write-only and observes configured state without restarting', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
||||
const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await dialog.waitFor({ timeout: 15_000 })
|
||||
expect(await dialog.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
||||
const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
|
||||
expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
|
||||
expect(await welcome.locator('button').count()).toBe(1)
|
||||
|
||||
const mask = page.locator('[class*="onboardingMask"]')
|
||||
expect(await mask.count()).toBe(1)
|
||||
const maskStyles = await mask.evaluate((mask) => {
|
||||
const style = getComputedStyle(mask)
|
||||
const rect = mask.getBoundingClientRect()
|
||||
return {
|
||||
position: style.position,
|
||||
left: style.left,
|
||||
right: style.right,
|
||||
top: style.top,
|
||||
bottom: style.bottom,
|
||||
background: style.backgroundColor,
|
||||
backdropFilter: style.backdropFilter,
|
||||
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
||||
}
|
||||
})
|
||||
expect(maskStyles).toEqual({
|
||||
position: 'absolute',
|
||||
left: '0px',
|
||||
right: '0px',
|
||||
top: '80px',
|
||||
bottom: '0px',
|
||||
background: 'rgba(0, 0, 0, 0.24)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
|
||||
})
|
||||
|
||||
// Closing the process/page before acknowledgement writes nothing, so the
|
||||
// same durable profile presents the notice again after reload.
|
||||
const firstReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
||||
|
||||
await dialog.getByRole('button', { name: '前往配置' }).click()
|
||||
await dialog.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
await credentialStep.getByRole('button', { name: '前往配置' }).click()
|
||||
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
|
||||
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -78,6 +130,29 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
{ timeout: 10_000 },
|
||||
).toBe('已配置——输入新值可替换')
|
||||
|
||||
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
|
||||
|
||||
const secondReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
// A different stored copy version represents an intentional version bump:
|
||||
// the welcome step returns even though the credential is already ready.
|
||||
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
|
||||
}])
|
||||
const thirdReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
@@ -86,6 +161,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
171
apps/web/tests/permission-policy-context.e2e.ts
Normal file
171
apps/web/tests/permission-policy-context.e2e.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// Web acceptance for current sandbox-policy context. A real Chromium drives
|
||||
// the shipped /permission command through all three presets; record mode uses
|
||||
// the real provider, while replay keeps the same provider-authored behavior
|
||||
// keyless. Assertions read the exact durable header, runtime-context messages,
|
||||
// and tool calls, so assistant prose alone cannot satisfy the scenario.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
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 { canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/permission-policy-context', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const PROMPTS = [
|
||||
'Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy.',
|
||||
'Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools.',
|
||||
'Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools.',
|
||||
'Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion.',
|
||||
] as const
|
||||
|
||||
const PRESET_LABELS = ['Read Only', 'Full access', 'Workspace Write'] as const
|
||||
|
||||
function requestSystems(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== 'request/header') return []
|
||||
return typeof event.data.header.system === 'string' ? [event.data.header.system] : []
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeContexts(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return []
|
||||
return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
})
|
||||
}
|
||||
|
||||
function assistantTexts(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== 'assistant/message') return []
|
||||
const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('').replaceAll('**', '')
|
||||
return text.length === 0 ? [] : [text]
|
||||
})
|
||||
}
|
||||
|
||||
function callArgs(event: Extract<SessionEvent, { type: 'tool/call' }>): Record<string, unknown> {
|
||||
return JSON.parse(event.data.arguments) as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('web e2e: current sandbox policy reaches the model before tools', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let disposeApproval: (() => void) | undefined
|
||||
let sessionWorkspace: string | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE })
|
||||
disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
|
||||
scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
|
||||
sessionWorkspace = session.header.cwd
|
||||
sessionEvents.push(event)
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
disposeApproval?.()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('switches read-only, danger-full-access, and workspace-write through the real GUI command path', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-permission-policy-context'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
|
||||
}
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
|
||||
for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
|
||||
await input.fill(`/permission ${preset}`)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPTS[index] as string)
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
await expect.poll(() => input.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
}
|
||||
|
||||
await input.fill('/permission read-only')
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPTS[3])
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
|
||||
if (sessionId === undefined) throw new Error('permission-policy scenario completed no model turn')
|
||||
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}, 240_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('records cache-safe current policy before the corresponding model behavior', async () => {
|
||||
const systems = requestSystems(sessionEvents)
|
||||
expect(systems).toHaveLength(1)
|
||||
expect(systems[0]).not.toContain('Current DSH file policy:')
|
||||
expect(systems[0]).not.toContain('Approval policy:')
|
||||
expect(systems[0]).not.toContain('Approval prompts are disabled in this session')
|
||||
|
||||
const contexts = runtimeContexts(sessionEvents)
|
||||
expect(contexts).toHaveLength(4)
|
||||
expect(contexts[0]).toContain('Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.')
|
||||
expect(contexts[0]).toContain('Do not refuse a required modification from this policy alone')
|
||||
expect(contexts[0]).toContain('Approval policy: ask.')
|
||||
expect(contexts[1]).toContain('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.')
|
||||
expect(contexts[1]).toContain('Approval prompts are disabled in this session')
|
||||
|
||||
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
|
||||
expect(contexts[2]).toContain(`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(canonicalPath(sessionWorkspace))}. Some platform temporary areas may also be writable.`)
|
||||
expect(contexts[2]).toContain('Approval policy: ask.')
|
||||
expect(contexts[2]).not.toContain('Approval prompts are disabled in this session')
|
||||
expect(contexts[3]).toContain('Current DSH file policy: read-only.')
|
||||
|
||||
const answers = assistantTexts(sessionEvents)
|
||||
expect(answers.length).toBeGreaterThanOrEqual(4)
|
||||
expect(answers[0]).toMatch(/read-only.*(?:denied|cannot modify|cannot create or edit)/i)
|
||||
expect(answers[1]).toMatch(/does not restrict.*(?:file operations|(?:write\/edit tools|write and edit tools).*one-shot bash commands)/i)
|
||||
expect(answers[2]).toBe('WORKSPACE_POLICY_SEEN')
|
||||
const calls = sessionEvents.filter(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> => event.type === 'tool/call',
|
||||
)
|
||||
expect(calls.every(call => call.data.turn === 4)).toBe(true)
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2)
|
||||
const firstCall = calls[0]
|
||||
if (firstCall === undefined) throw new Error('neutral policy task produced no tool call')
|
||||
expect(callArgs(firstCall)['sandbox_permissions']).toBeUndefined()
|
||||
expect(calls.some(call => callArgs(call)['sandbox_permissions'] !== undefined)).toBe(true)
|
||||
expect(sessionEvents.some(event => event.type === 'tool/result'
|
||||
&& JSON.stringify(event.data).includes('[sandbox: file access denied under read-only mode]'))).toBe(true)
|
||||
expect(sessionEvents.some(event => event.type === 'approval/asked')).toBe(true)
|
||||
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
|
||||
expect(await readFile(join(sessionWorkspace, 'policy-neutral.txt'), 'utf8')).toBe('POLICY_NEUTRAL_OK')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stays clean and keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
|
||||
})
|
||||
})
|
||||
11
apps/web/tests/pin-browse-picker.overlay.yml
Normal file
11
apps/web/tests/pin-browse-picker.overlay.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the
|
||||
# in-browser directory picker. The shipped row is `-auto`, which resolves to
|
||||
# the native OS chooser on a loopback bind with a local display — an
|
||||
# interaction a Playwright page cannot drive, so the resolved backend would
|
||||
# otherwise decide whether the smoke passes. The disable+insert pair mirrors
|
||||
# apps/web/tests/scaffold.ts.
|
||||
- id: directory-picker
|
||||
disabled: true
|
||||
- insert:
|
||||
- id: directory-picker-browse
|
||||
name: '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
@@ -54,7 +54,7 @@ describe('web e2e: plan review takeover round trip', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
// Second golden: the answered transcript — the question resolved into its
|
||||
// tool round trip and the final reply, the state the waiting golden cannot see.
|
||||
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
|
||||
// Final golden: the answered transcript — the question resolved into its tool
|
||||
// round trip and the final reply, the state the composer goldens cannot see.
|
||||
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The options carry long descriptions on purpose: the squeeze assertion below
|
||||
// needs option copy that WRAPS, which is the only shape that reproduces a
|
||||
// collapsed row painting its copy outside its own box.
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
|
||||
|
||||
describe('web e2e: resident question composer round trip', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -49,7 +50,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
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)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await page.setViewportSize(original)
|
||||
}
|
||||
|
||||
await composer.getByRole('radio', { name: 'Blue' }).click()
|
||||
// Submit: Enter on the focused option (the composer's documented submit).
|
||||
await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
await blue.click()
|
||||
const custom = composer.getByRole('textbox')
|
||||
await custom.fill('Include accessibility notes')
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await custom.inputValue()).toBe('Include accessibility notes')
|
||||
if (MODE !== 'record') {
|
||||
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
|
||||
}
|
||||
await custom.press('Enter')
|
||||
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
@@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
}
|
||||
// World state: the tool result carries the chosen answer, and DONE lands.
|
||||
const results = sessionEvents.filter(e => e.type === 'tool/result')
|
||||
expect(JSON.stringify(results.at(-1))).toContain('Blue')
|
||||
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
|
||||
block.type === 'tool-result'
|
||||
? block.content.filter(item => item.type === 'text').map(item => item.text)
|
||||
: [],
|
||||
)).at(-1)
|
||||
expect(JSON.parse(answerText ?? '')).toEqual({
|
||||
answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
|
||||
})
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Composer gone; regular input restored.
|
||||
expect(await page.locator('[data-question-key]').count()).toBe(0)
|
||||
@@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl',
|
||||
'ui.expected.md',
|
||||
'composed.expected.md',
|
||||
'answered.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Keyless browser coverage for pending queue actions through the shipped Web
|
||||
// composition and real HTTP/SSE wire. A replay override parks the active turn
|
||||
// so two ordinary follow-ups remain addressable while the page edits one and
|
||||
// removes one. The queue uses an existing recorded model
|
||||
// call; this scenario owns only the user-visible mid-turn golden.
|
||||
// composition and real HTTP/SSE wire. Replay overrides park consecutive turns
|
||||
// so the page can edit and remove exact occurrences, then stop the active turn
|
||||
// while proving the preserved Queue advances in FIFO order.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
@@ -22,6 +22,8 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
|
||||
const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
|
||||
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
|
||||
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
@@ -29,6 +31,12 @@ const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing,
|
||||
const REMOVE = 'Queue item to remove'
|
||||
const EDIT = 'Queue item to edit'
|
||||
const EDITED = 'Edited queue item'
|
||||
const TAIL = 'Queue item preserved after stop'
|
||||
|
||||
/** Durable turn-end classifications observed by the scenario. */
|
||||
function turnEndReasons(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])
|
||||
}
|
||||
|
||||
describe('web e2e: queue row actions', () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
@@ -52,13 +60,19 @@ describe('web e2e: queue row actions', () => {
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('edits and removes exact pending occurrences', async () => {
|
||||
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
|
||||
const readyFile = join(overrideDir, '.hang-ready')
|
||||
const nextReadyFile = join(overrideDir, '.next-hang-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
await writeFile(overridePath, JSON.stringify({
|
||||
patches: [{ at: 0, entry: { kind: 'hang', readyFile } }],
|
||||
}))
|
||||
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
|
||||
expect(recorded).toHaveLength(1)
|
||||
const replay: ReplayEntry[] = [
|
||||
{ kind: 'hang', readyFile },
|
||||
{ kind: 'hang', readyFile: nextReadyFile },
|
||||
recorded[0]!,
|
||||
]
|
||||
await writeFile(overridePath, JSON.stringify(replay))
|
||||
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
|
||||
@@ -68,7 +82,7 @@ describe('web e2e: queue row actions', () => {
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
@@ -131,21 +145,123 @@ describe('web e2e: queue row actions', () => {
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(sessionEvents.filter(event => event.type === 'user/message')).toHaveLength(1)
|
||||
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
const editedRow = page.getByText(EDITED, { exact: true }).locator('..')
|
||||
await editedRow.getByRole('button', { name: 'Remove queued message' }).click()
|
||||
await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0)
|
||||
await input.fill(TAIL)
|
||||
await input.press('Enter')
|
||||
await expect.poll(
|
||||
() => page.getByRole('button', { name: 'Remove queued message' }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(2)
|
||||
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await expect.poll(() => existsSync(nextReadyFile), { timeout: 15_000 }).toBe(true)
|
||||
await page.getByText(TAIL, { exact: true }).waitFor()
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
|
||||
.toBe(1)
|
||||
|
||||
const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
|
||||
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
expect(turnEndReasons(sessionEvents)).toEqual(['aborted', 'aborted', 'completed'])
|
||||
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user'))
|
||||
.toHaveLength(3)
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
|
||||
const readyFile = join(overrideDir, '.hang-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
|
||||
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill('/goal Keep the composer context panels aligned')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
|
||||
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
|
||||
|
||||
const sessions = scaffold.ctx.sessions.list()
|
||||
expect(sessions).toHaveLength(1)
|
||||
sessions[0]!.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'Confirm the panel order', status: 'completed' },
|
||||
{ content: 'Align the panel widths', status: 'in_progress' },
|
||||
],
|
||||
})
|
||||
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
|
||||
|
||||
for (const text of ['Layout queue first', 'Layout queue second']) {
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
}
|
||||
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
|
||||
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
|
||||
.toBe('false')
|
||||
|
||||
const layoutSnapshot = await captureStableAria(
|
||||
page,
|
||||
'[class*="centerCol"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
|
||||
|
||||
const expectAlignedContextPanels = async () => {
|
||||
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
|
||||
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
|
||||
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
|
||||
expect(queuePanelBox).not.toBeNull()
|
||||
expect(todoBox).not.toBeNull()
|
||||
expect(goalBox).not.toBeNull()
|
||||
expect(todoBox!.y).toBeLessThan(goalBox!.y)
|
||||
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
|
||||
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
|
||||
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
|
||||
}
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 640, height: 1000 })
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
await queueHeader.click()
|
||||
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Clear goal' }).click()
|
||||
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
|
||||
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['collapsed.expected.md', 'editing.expected.md', 'ui.expected.md'],
|
||||
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
53
apps/web/tests/remote-welcome.e2e.ts
Normal file
53
apps/web/tests/remote-welcome.e2e.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Trusted non-loopback Web access must not wedge on the loopback-only
|
||||
// settings API while the mandatory product notice owns the viewport.
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('#root', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('advances process-locally and presents the notice again after reload', async () => {
|
||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
||||
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
await expect.poll(
|
||||
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(false)
|
||||
const reloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -9,20 +9,23 @@
|
||||
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
|
||||
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, 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))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
|
||||
const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The scenario's one drive prompt. Record sends it; replay asserts the
|
||||
@@ -35,6 +38,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let settledSessionId: SessionId | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -48,7 +52,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
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)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -69,11 +73,44 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
settledSessionId = sessionId
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 200_000)
|
||||
|
||||
it('records the Web surface, source checkout, and session cwd in the request header', async () => {
|
||||
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
|
||||
const agent = scaffold.ctx.agents.get(settledSessionId)
|
||||
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
|
||||
const system = agent.session.requestHeader()?.system
|
||||
if (system === undefined) throw new Error('the settled Web request has no system prompt')
|
||||
const prefix = system.split('\n\n').slice(0, 4).join('\n\n')
|
||||
.split(REPO_ROOT).join('{{sourceRoot}}')
|
||||
.split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}')
|
||||
.split(scaffold.baseUrl).join('{{webUrl}}')
|
||||
await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE)
|
||||
})
|
||||
|
||||
it('exposes the assembled Web URL to the real bash tool', async () => {
|
||||
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
|
||||
const agent = scaffold.ctx.agents.get(settledSessionId)
|
||||
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
|
||||
const result = await scaffold.ctx.tools.execute({
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
callId: CallId('web-url-probe'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
|
||||
description: 'Print current Web runtime',
|
||||
},
|
||||
agent,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toBe(`${scaffold.baseUrl}\nproduction\n`)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
|
||||
// Browser settled-poll after host completion (host strictly precedes render).
|
||||
@@ -129,6 +166,6 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
// masking its credential, without making a model call.
|
||||
//
|
||||
// Composition divergences from `dsh web`, all deliberate, all via include
|
||||
// patches after the shipped surface overlay: temp persistenceRoot; local skill
|
||||
// roots confined to the temp workspace; 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 port 0 with the built dist; ordinary keyless
|
||||
// modes disable llm-deepseek and fill the open llm seam post-boot with
|
||||
// installLlmReplay on the settled root ctx
|
||||
// patches after the shipped surface overlay, over the SAME tree (never a
|
||||
// second yml): temp persistenceRoot; host-level skill roots confined to the
|
||||
// temp workspace while project skill discovery remains real; 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 port 0 with the
|
||||
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
|
||||
// llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -32,6 +33,11 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -47,6 +53,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
|
||||
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
|
||||
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
|
||||
@@ -82,7 +89,7 @@ const REPLAY_PROVIDERS = [{
|
||||
export interface WebScaffold {
|
||||
/** The active snapshot mode this scaffold booted under. */
|
||||
mode: WebSnapshotMode
|
||||
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
|
||||
/** Browser-facing origin for the bound test server. */
|
||||
baseUrl: string
|
||||
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
|
||||
ctx: Context
|
||||
@@ -100,6 +107,12 @@ export interface WebScaffold {
|
||||
|
||||
/** Options for {@link launchWebScaffold}. */
|
||||
export interface LaunchOptions {
|
||||
/**
|
||||
* Optional product overlay applied after the shipped Web surface and before
|
||||
* the scaffold's hermetic test patches, matching AppCLIEntry's `--config`
|
||||
* ordering.
|
||||
*/
|
||||
extraOverlayPath?: string
|
||||
/**
|
||||
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
|
||||
* in replay/refresh modes; ignored in record mode (the real adapter
|
||||
@@ -108,6 +121,11 @@ export interface LaunchOptions {
|
||||
* mounts).
|
||||
*/
|
||||
replayFixture?: string
|
||||
/**
|
||||
* Recorded child logs assigned in child creation order. Each child owns its
|
||||
* own positional replay cursor across initial and continuation turns.
|
||||
*/
|
||||
replayChildFixtures?: string[]
|
||||
/**
|
||||
* Optional replay.override.json sidecar (whole-script replacement or
|
||||
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
|
||||
@@ -135,6 +153,25 @@ export interface LaunchOptions {
|
||||
* keyless first-run configuration lane; the default disables the adapter.
|
||||
*/
|
||||
deepSeekMissingCredential?: boolean
|
||||
/**
|
||||
* Patch the shipped DeepSeek search row to a deterministic endpoint and
|
||||
* credential reference. Browser search scenarios keep the real provider and
|
||||
* credentials seam while avoiding external search traffic and ambient keys.
|
||||
*/
|
||||
deepSeekSearch?: {
|
||||
/** Anthropic-compatible base URL; the provider appends `/messages`. */
|
||||
baseURL: string
|
||||
/** Credential reference resolved by the shipped search provider. */
|
||||
apiKeyEnv: string
|
||||
}
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
/**
|
||||
* Browse through a trusted non-loopback hostname that the browser resolves
|
||||
* to loopback (for example `*.localhost`). The test server stays bound to
|
||||
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
|
||||
*/
|
||||
remoteAuthority?: string
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -154,6 +191,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste
|
||||
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
|
||||
requireDist()
|
||||
const mode = webSnapshotMode()
|
||||
const browserHost = options.remoteAuthority ?? '127.0.0.1'
|
||||
if (mode === 'record') {
|
||||
// Both owning vitest configs (web unconditionally, snapshot in record
|
||||
// mode) load the repo-root .env before this file runs.
|
||||
@@ -196,13 +234,17 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// 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 extraOverlayPatches = options.extraOverlayPath === undefined
|
||||
? []
|
||||
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
|
||||
const patches: PatchOptions[] = [
|
||||
...surfacePatches,
|
||||
...extraOverlayPatches,
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
// storage-json's './.storages' yml default is cwd-relative and resolves
|
||||
// per write; the scaffold restores the original cwd after boot, so the
|
||||
// row gets an absolute temp root (removed with the workspace at close).
|
||||
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
|
||||
// to an absolute temp root (removed with the workspace at close) so tests
|
||||
// never write the user's harness home.
|
||||
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
|
||||
// Skill discovery is model-visible input. Pin every host-level root inside
|
||||
// the owned temp world so ~/.dsh, ~/.agents, and a bundled-root env setting
|
||||
@@ -226,7 +268,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
|
||||
// names in the ambient environment).
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
|
||||
{
|
||||
id: 'webserver',
|
||||
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
|
||||
},
|
||||
...options.remoteAuthority === undefined
|
||||
? []
|
||||
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
|
||||
{ id: 'settings', config: { dshHome: harnessHome } },
|
||||
{ id: 'credentials', config: { dshHome: harnessHome } },
|
||||
// The shipped directory-picker row is the -auto chooser, which resolves
|
||||
@@ -241,6 +289,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
: [],
|
||||
...options.deepSeekSearch === undefined
|
||||
? []
|
||||
: [{
|
||||
id: 'web-search-deepseek',
|
||||
config: {
|
||||
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
|
||||
baseURL: options.deepSeekSearch.baseURL,
|
||||
},
|
||||
}],
|
||||
...mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'llm-deepseek', disabled: true }],
|
||||
@@ -255,17 +312,25 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
try {
|
||||
process.chdir(workspaceCwd)
|
||||
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
|
||||
// This direct Loader harness supplies the same root-path capability as app-boot.
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
// The shipped CLI deliberately has no dependency on this opt-in package.
|
||||
// Keep the Loader row real without broadening the product installation.
|
||||
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
|
||||
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||
if (options.welcomeNoticePending !== true) {
|
||||
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
|
||||
}])
|
||||
}
|
||||
const boundPort = ctx.get('httpServer')?.port
|
||||
if (boundPort === undefined) {
|
||||
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
|
||||
@@ -281,6 +346,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
file: options.replayFixture,
|
||||
providers: REPLAY_PROVIDERS,
|
||||
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
}
|
||||
@@ -299,7 +365,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
return {
|
||||
harnessHome,
|
||||
mode,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
baseUrl: `http://${browserHost}:${port}`,
|
||||
ctx,
|
||||
workspaceCwd,
|
||||
persistenceRoot,
|
||||
@@ -451,18 +517,26 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
|
||||
* volatility collapse to stable tokens.
|
||||
*/
|
||||
function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
// The header breadcrumb renders the workspace's basename, not the full
|
||||
// The session heading renders the workspace's basename, not the full
|
||||
// path, so both spellings must collapse to the token.
|
||||
const base = workspaceCwd.split('/').pop()!
|
||||
return snapshot
|
||||
.split(workspaceCwd).join('{{cwd}}')
|
||||
.split(base).join('{{workspace}}')
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
|
||||
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
|
||||
.replace(
|
||||
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
|
||||
duration => duration.startsWith('~') ? duration : '{{duration}}',
|
||||
)
|
||||
.replace(
|
||||
/约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g,
|
||||
duration => duration.startsWith('约') ? duration : '{{duration}}',
|
||||
)
|
||||
// Message IconActions clocks widen by calendar day/year; collapse every
|
||||
// shape so goldens stay stable across midnight and year boundaries.
|
||||
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
|
||||
}
|
||||
|
||||
|
||||
170
apps/web/tests/search-card.snapshot.ts
Normal file
170
apps/web/tests/search-card.snapshot.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
|
||||
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
|
||||
// FixtureApiClient transport (no API key, no model round), opens the fixture
|
||||
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
|
||||
// the assembled application. The built-boot smoke proves the graph boots but
|
||||
// carries no behavior assertions by contract; this is the assembled-output check
|
||||
// that a broken SearchRow registration or a dropped card would fail — the
|
||||
// per-package suites bench over src and cannot see the bundled wiring.
|
||||
//
|
||||
// Keyless and deterministic: the fixture is the fake server, so the grep turn's
|
||||
// matches, its truncation summary, and its head/tail cap are fixed in the
|
||||
// fixture, not harvested from a live model. The recovery-footer arm is a pure
|
||||
// derivation over the result view, pinned at every render site by the
|
||||
// ui-conversation suite; here the fixture turn exercises the assembled card
|
||||
// shape and its cap.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt')
|
||||
const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
/** Normalize a rendered search card to a stable text shape: the kind, the banner
|
||||
* summary, each file header (path + count), each visible match line, the expand
|
||||
* control label, and the recovery footer. CSS-module class names carry a
|
||||
* per-build hash in one of two schemes — ui-primitives emits `_<name>_<hash>`
|
||||
* (name bounded by underscores), ui-conversation emits `<hash>_<name>` (name at
|
||||
* the end). `hasClass` matches a module class by its logical name under either,
|
||||
* without matching a longer name that contains it (`line` must not hit
|
||||
* `lineNumber`). */
|
||||
function hasClass(el: Element, name: string): boolean {
|
||||
return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
|
||||
}
|
||||
|
||||
function cardShape(root: Element): string {
|
||||
const card = root.querySelector('[data-search]')
|
||||
if (card === null) return '<no search card>'
|
||||
const pick = (from: Element, name: string): Element[] =>
|
||||
[...from.querySelectorAll('*')].filter(el => hasClass(el, name))
|
||||
const lines: string[] = [`kind=${card.getAttribute('data-search')}`]
|
||||
const summary = pick(card, 'summary')[0]?.textContent?.trim()
|
||||
if (summary !== undefined && summary !== '') lines.push(`summary=${summary}`)
|
||||
for (const header of pick(card, 'fileHeader')) lines.push(`file=${header.textContent?.trim() ?? ''}`)
|
||||
for (const row of pick(card, 'line')) lines.push(`line=${row.textContent?.trim() ?? ''}`)
|
||||
const expand = pick(card, 'expand')[0]?.textContent?.trim()
|
||||
if (expand !== undefined && expand !== '') lines.push(`expand=${expand}`)
|
||||
const recovery = pick(root, 'searchRecovery')[0]?.textContent?.trim()
|
||||
if (recovery !== undefined && recovery !== '') lines.push(`recovery=${recovery}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// English pinned before boot so the sidebar's role/text locators stay
|
||||
// deterministic (the built-boot smoke's convention).
|
||||
localStorage.setItem('dsh.locale', 'en')
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('assembled search card', () => {
|
||||
it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
// Wait for chat content to reach the fixture's later turns (the bash sample
|
||||
// is turn 65, the grep card turn 66).
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
// The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
|
||||
// by default, so wait for the summary row, then expand it to reach the card.
|
||||
await waitFor(() => {
|
||||
const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool'))
|
||||
expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep')
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// `data-tool` sits on the ToolRow root; the collapsed row is the expand
|
||||
// toggle. Click it so the card and its recovery footer mount, then shape the
|
||||
// whole row (the card lives inside ToolRow's body wrapper).
|
||||
const grepRow = document.querySelector('[data-tool="grep"]')!
|
||||
act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) })
|
||||
await waitFor(() => {
|
||||
expect(grepRow.querySelector('[data-search]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
const shape = cardShape(grepRow)
|
||||
if (refreshing) {
|
||||
mkdirSync(dirname(EXPECTED), { recursive: true })
|
||||
writeFileSync(EXPECTED, shape)
|
||||
}
|
||||
await expect(shape).toMatchFileSnapshot(EXPECTED)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
// Web e2e scenario: seeded history. A recorded session seeded cold through
|
||||
// the REAL persistence API renders purely from the log — the surface nothing
|
||||
// else covers: sidebar cold listing, the implicit resume/attach inside the
|
||||
// history RPC, history-page tool views, and the client fold of historical
|
||||
// history RPC, history-page tool views, and the client's log-ordered transcript
|
||||
// events — with ZERO model calls in replay (no replay fixture; a stray stream
|
||||
// fails loud on the open llm seam). The cold session also carries the one
|
||||
// keyless command-row surface: an Access-chip pick runs `/permission` on the
|
||||
// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the
|
||||
// host, so the settled row's copy has a golden here. The seed is a recorded
|
||||
// fixture under the
|
||||
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
|
||||
// live through the composer (real read tool against seeded workspace files)
|
||||
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
|
||||
@@ -34,6 +35,90 @@ const SEED_ID = 'seeded-history-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
/**
|
||||
* Append a complete, valid compaction transaction over the recorded turn's own
|
||||
* surface. The recording stays model-authentic and reusable; replay adds this
|
||||
* deterministic condition before seeding it cold, so the scenario pins the bug
|
||||
* this change fixes — a landed compaction must not erase history the reader
|
||||
* already saw — through the real host and the real browser.
|
||||
* @param raw - the committed seed fixture text.
|
||||
* @returns the fixture with a compacted turn appended.
|
||||
*/
|
||||
function withCompaction(raw: string): string {
|
||||
const lines = raw.trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
surfaceOp?: unknown
|
||||
data?: { turn?: unknown }
|
||||
})
|
||||
const surfaceSeqs = events
|
||||
.filter(event => event.surfaceOp === 'append'
|
||||
&& (event.type === 'user/message'
|
||||
|| event.type === 'assistant/message'
|
||||
|| event.type === 'tool/result'
|
||||
|| event.type === 'steering/message'))
|
||||
.map(event => event.seq)
|
||||
const first = surfaceSeqs[0]
|
||||
const last = surfaceSeqs.at(-1)
|
||||
const tail = events.at(-1)
|
||||
if (first === undefined || last === undefined || tail === undefined) {
|
||||
throw new Error('seeded-history compaction requires a non-empty closed surface')
|
||||
}
|
||||
// The transaction opens the turn after the recording's last closed one; read
|
||||
// it from the fixture so a re-recording with a different turn count stays
|
||||
// valid instead of appending a duplicate turn number.
|
||||
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
|
||||
if (typeof lastTurn !== 'number') {
|
||||
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
|
||||
}
|
||||
const turn = lastTurn + 1
|
||||
let seq = tail.seq + 1
|
||||
let time = tail.time + 1
|
||||
/**
|
||||
* Append one event at the next seq/time.
|
||||
* @param event - the event body, without seq/time.
|
||||
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
|
||||
*/
|
||||
const at = (event: Record<string, unknown>): number => {
|
||||
const taken = seq++
|
||||
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
|
||||
return taken
|
||||
}
|
||||
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
|
||||
const startSeq = at({ type: 'compact/start', data: { turn } })
|
||||
const summarySeq = at({
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
summary: [{
|
||||
type: 'text',
|
||||
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
|
||||
}],
|
||||
shadowedRange: { start: first, end: last },
|
||||
shadowedSeqs: surfaceSeqs,
|
||||
shadowedTokenCount: 10_000,
|
||||
provider: 'snapshot',
|
||||
model: 'snapshot-compactor',
|
||||
},
|
||||
})
|
||||
at({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: first, end: last },
|
||||
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
|
||||
})
|
||||
at({ type: 'compact/end', data: { turn } })
|
||||
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
describe('web e2e: seeded history renders through cold resume', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -42,7 +127,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace
|
||||
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace
|
||||
// (the composer's default draft name); the read-tool targets must live in
|
||||
// that session cwd. Pre-creating the directory is safe: create-by-name
|
||||
// adopts an existing directory.
|
||||
@@ -53,7 +138,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
if (MODE !== 'record') {
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
await seedSession(scaffold, withCompaction(raw), SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -119,11 +204,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await sessionRow.click()
|
||||
// Settled barrier for history: the recorded final assistant text renders.
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
// Tool cards render from logged tool/call + tool/result alone (views are
|
||||
// host-recomputed per page; the generic card is the documented default).
|
||||
const toolRows = page.locator('[data-variant], [data-sample]')
|
||||
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// The bug this fixes: the compaction shadowed the whole recorded surface on
|
||||
// the model side, and the prompt and full tool output are still on screen.
|
||||
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
|
||||
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
@@ -230,28 +319,65 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
|
||||
const marker = page.getByRole('button', { name: /Context compacted/ })
|
||||
await marker.waitFor({ timeout: 10_000 })
|
||||
expect(await marker.getAttribute('aria-expanded')).toBe('false')
|
||||
await marker.click()
|
||||
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
|
||||
timeout: 5_000,
|
||||
}).toBe(1)
|
||||
expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// Restore the shared page state for any later case.
|
||||
await marker.click()
|
||||
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
|
||||
// The Access chip submits `/permission <preset>` — a host command with no
|
||||
// model call, so the settled row renders keylessly over this cold history.
|
||||
// The row copy is the assertion: `permission · preset workspace-write`,
|
||||
// The row copy is the assertion: `permission · preset read-only`,
|
||||
// where neither half repeats the other (the dispatched `/` and its
|
||||
// argument stay out of the title, and the settlement text never restates
|
||||
// the command's own name).
|
||||
await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
|
||||
// Scoped to the row itself, so unrelated page text that happens to read
|
||||
// `permission` (a future resident slash menu) cannot satisfy or break it.
|
||||
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' })
|
||||
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
|
||||
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
|
||||
expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0)
|
||||
expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('fits short injected context without a scrollport', async () => {
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Short injected context.' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture' },
|
||||
}))
|
||||
|
||||
const disclosures = page.getByRole('button', { name: 'Context injection' })
|
||||
await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2)
|
||||
const disclosure = disclosures.nth(1)
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
const body = page.locator('[data-context-injection-body]')
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
|
||||
expect(bodyBox.height).toBeLessThan(141)
|
||||
expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
// No replay fixture was installed and the llm seam is open — any stray
|
||||
// stream would have failed the turn loudly. Cleanliness pins the wire.
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// section switching, both close paths), the Appearance preference row (the
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
|
||||
// and the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// plus Permission as the persisted default for subsequently created sessions.
|
||||
// the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// the busy-state Enter preference, plus Permission as the persisted default
|
||||
// for subsequently created sessions.
|
||||
// Zero model calls: everything is pure client + persistence state on a blank
|
||||
// frame, so there is no fixture and a stray stream would fail loud on the
|
||||
// open llm seam.
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
@@ -33,7 +34,9 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
// Chinese browser: the shared page asserts the localized settings surface
|
||||
// the client derives from it (the English default has its own spec below).
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -55,7 +58,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
// Golden of the freshly opened dialog (default zh, General active).
|
||||
@@ -80,12 +83,12 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
|
||||
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const selector = dialog.getByRole('button', { name: 'Full access' })
|
||||
const selector = dialog.getByRole('button', { name: 'Workspace Write' })
|
||||
await selector.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await selector.click()
|
||||
@@ -96,7 +99,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(document).toContain('permission:')
|
||||
expect(document).toContain('defaultPreset: read-only')
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
|
||||
expect(created.events.map(event => [event.type, event.data])).toEqual([
|
||||
@@ -180,6 +183,32 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('persists the busy-state Enter behavior across reload and restores Queue', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '排队发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '插话发送' }).click()
|
||||
await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '排队发送' }).click()
|
||||
await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue')
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('switches the settings surface language and persists dsh.locale', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
@@ -215,6 +244,30 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('opens an English browser in English without any stored preference', async () => {
|
||||
// A second page under a different browser language: nothing is persisted
|
||||
// for it, so the settings surface must follow the browser rather than the
|
||||
// product fallback the shared zh page shows.
|
||||
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
|
||||
const enTripwire = watchConsole(enPage)
|
||||
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
|
||||
try {
|
||||
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
// This page has no closing inventory spec to sweep its console, so the
|
||||
// scenario clears both tripwire channels itself.
|
||||
expect(enTripwire.pageErrors).toEqual([])
|
||||
expect(enTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await enPage.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
|
||||
|
||||
82
apps/web/tests/shipped-composition.e2e.ts
Normal file
82
apps/web/tests/shipped-composition.e2e.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Boots the shipped Web composition over the built dist this lane already uses
|
||||
// and asserts what that composition produces: the model-visible tool catalog
|
||||
// and the sandbox/approval knobs it ships with. No browser and no model call —
|
||||
// these are composition facts, and the browser scenarios in this lane cover the
|
||||
// surface itself.
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
/**
|
||||
* The catalog the shipped Web composition puts in front of the model, minus the
|
||||
* ripgrep-dependent pair below. The absences are deliberate, not incidental
|
||||
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
|
||||
* sandbox row confines, `web_fetch` chooses its own request target, and
|
||||
* `mcp_*` servers spawn outside `ctx.bash`. The composition Agent Note owns the
|
||||
* rationale and its sources.
|
||||
*/
|
||||
const EXPECTED_TOOLS = [
|
||||
'ask_user_question',
|
||||
'bash',
|
||||
'create_goal',
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
'send_message',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
'subagent_fork',
|
||||
'task_kill',
|
||||
'task_list',
|
||||
'task_output',
|
||||
'todo_write',
|
||||
'update_goal',
|
||||
'web_search',
|
||||
'workflow',
|
||||
'write',
|
||||
]
|
||||
|
||||
/**
|
||||
* `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED
|
||||
* ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair
|
||||
* is always present on every host — asserted as fixed members, not a host
|
||||
* dependency.
|
||||
*/
|
||||
const RIPGREP_TOOLS = ['glob', 'grep']
|
||||
|
||||
let scaffold: WebScaffold | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await scaffold?.close()
|
||||
scaffold = undefined
|
||||
})
|
||||
|
||||
it('assembles the shipped Web catalog with the confined access default', async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
|
||||
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
|
||||
// The packaged ripgrep binary ships with the dependency, so the pair is a
|
||||
// fixed roster member on every host.
|
||||
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
|
||||
// `workspace-write` is not "the workspace and nothing else": the shared roots
|
||||
// helper always admits the temp directories too. Pinning it against an
|
||||
// explicit mode keeps the claim independent of this surface's default, and
|
||||
// keeps a future boundary test from being run inside /tmp — where an
|
||||
// "escape" write succeeds by design and reads as a sandbox failure.
|
||||
expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual(
|
||||
expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]),
|
||||
)
|
||||
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
|
||||
expect(scaffold.ctx.approval.config.policy).toBe('ask')
|
||||
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
}, 120_000)
|
||||
@@ -84,7 +84,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -20,12 +20,14 @@ import { createServer } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { fileURLToPath, 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, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let out = ''
|
||||
@@ -163,6 +165,8 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-no-call',
|
||||
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -182,14 +186,18 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
|
||||
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
|
||||
|
||||
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
|
||||
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
|
||||
interface NativeProviderRequest {
|
||||
messages?: { role?: string; content?: string }[]
|
||||
tools?: { function?: { name?: string } }[]
|
||||
}
|
||||
let resolveProviderRequest!: (request: NativeProviderRequest) => void
|
||||
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
|
||||
resolveProviderRequest = resolve
|
||||
})
|
||||
const provider = createServer((request, response) => {
|
||||
@@ -197,7 +205,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
|
||||
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
|
||||
@@ -214,7 +222,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
@@ -222,6 +230,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
DEEPSEEK_API_KEY: 'keyless-web-workspace',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(workspace, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -241,8 +250,14 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
|
||||
}),
|
||||
])
|
||||
expect(captured.messages?.some(message =>
|
||||
message.role === 'user' && message.content?.includes('<available_skills>'))).toBe(false)
|
||||
const workspaceMessage = captured.messages?.find(message =>
|
||||
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
|
||||
const systemMessage = captured.messages?.find(message => message.role === 'system')
|
||||
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
|
||||
.replace('{{webUrl}}', baseUrl)
|
||||
expect(systemMessage?.content).toContain(expectedWebSection)
|
||||
expect(workspaceMessage).toMatchInlineSnapshot(`
|
||||
{
|
||||
"content": "<system-reminder>
|
||||
@@ -256,6 +271,13 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
"role": "user",
|
||||
}
|
||||
`)
|
||||
expect(captured.tools?.map(tool => tool.function?.name)
|
||||
.filter(name => name === 'web_search' || name === 'web_fetch'))
|
||||
.toMatchInlineSnapshot(`
|
||||
[
|
||||
"web_search",
|
||||
]
|
||||
`)
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
@@ -402,6 +424,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_TOOLS_MODE: 'code',
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(workspace, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -450,17 +473,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
|
||||
const port = await probeFreePort()
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
|
||||
// the global Harness home inside the temp world; tsx also needs the repo's
|
||||
// loader and tsconfig paths pointed at explicitly.
|
||||
// the host-level Harness and shared-agent homes inside the temp world; tsx
|
||||
// also needs the repo's loader and tsconfig paths pointed at explicitly.
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
|
||||
[
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
|
||||
// Pin the in-browser picker: the shipped `-auto` row would resolve to
|
||||
// the native OS chooser on this bind, and no page can drive that.
|
||||
'--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -495,8 +524,18 @@ 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'))
|
||||
// This scenario spawns its own server against a fresh $DSH_HOME, so the
|
||||
// first-run welcome notice is unacknowledged and its overlay owns pointer
|
||||
// events (the shared scaffold acknowledges it before boot instead). The
|
||||
// notice is anchored structurally, not by its copy: this spec sits in the
|
||||
// client TypeScript program, which does not reference the package that
|
||||
// owns the strings.
|
||||
const welcome = page.locator('[class*="onboardingOverlay"]')
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
await welcome.getByRole('button').click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
// Fresh world: connect a Workspace so the composer starts live.
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, sessionsDir)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await screen(page, '02-empty-state')
|
||||
@@ -555,7 +594,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
// Bash renders through the third-party sample registration. Match that
|
||||
// exact row: other clickable variants (for example Think disclosure)
|
||||
// may precede the tool call in document order.
|
||||
const toolRow = page.locator('[data-sample="bash-global"]')
|
||||
const toolRow = page.locator('[data-sample="bash"]')
|
||||
await toolRow.waitFor({ timeout: 120_000 })
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
|
||||
33
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
33
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
@@ -0,0 +1,33 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Run two shell commands: wait" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- 'button "Failed Bash Error: command aborted" [expanded]':
|
||||
- img
|
||||
- text: "Failed Bash Error: command aborted"
|
||||
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted"
|
||||
- button "Inspect"
|
||||
- 'button "Failed Bash Error: tool call aborted before dispatch"':
|
||||
- img
|
||||
- text: "Failed Bash Error: tool call aborted before dispatch"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- 'button "Think The user wants me to write a single `run_code` program that:"':
|
||||
- img
|
||||
- img
|
||||
@@ -20,7 +23,7 @@
|
||||
- img
|
||||
- text: Code Run bash echo and catch missing file read
|
||||
- img
|
||||
- text: Bash Echo CODE_ROUND_OK
|
||||
- text: Bash Echo CODE_ROUND_OK Failed
|
||||
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
|
||||
- img
|
||||
- text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
|
||||
@@ -37,9 +40,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 52% Input 17.2K tok · Output 252 tok
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Context 7% of 128K Cache hit 52% Input 17.2K tok · Output 252 tok
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user wants me to:":
|
||||
- img
|
||||
- img
|
||||
@@ -52,9 +55,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 77% Input 66.5K tok · Output 312 tok
|
||||
- text: 1 turns · 4 steps Tool call {{duration}} Context 13% of 128K Cache hit 77% Input 66.5K tok · Output 312 tok
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
|
||||
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
|
||||
You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
@@ -32,9 +35,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 99% Input 15.7K tok · Output 111 tok
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 99% Input 15.7K tok · Output 111 tok
|
||||
|
||||
8
apps/web/tests/snapshots/goal-bar/active.expected.md
Normal file
8
apps/web/tests/snapshots/goal-bar/active.expected.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- img
|
||||
- text: Ongoing Goal guard rapid clear clicks
|
||||
- button "Pause goal":
|
||||
- img
|
||||
- button "Edit goal":
|
||||
- img
|
||||
- button "Clear goal":
|
||||
- img
|
||||
@@ -1,6 +1,7 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Commands
|
||||
- option "goal set or view the goal for a long-running task" [selected]
|
||||
- option "compact Compact older conversation history" [selected]
|
||||
- option "goal set or view the goal for a long-running task"
|
||||
- option "permission Switch the permission preset (sandbox mode + approval policy)"
|
||||
- option "plan Enter or leave plan mode"
|
||||
- option "model Select the model for this conversation"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- img
|
||||
- button "Create workspace":
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
@@ -28,7 +28,7 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- img
|
||||
- button "Create workspace":
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
@@ -28,7 +28,7 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
@@ -24,9 +27,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 21 tok
|
||||
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 21 tok
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- text: Stopped
|
||||
- button "Copy":
|
||||
@@ -21,7 +24,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -7,14 +7,20 @@
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- status:
|
||||
- text: This turn failedAPI key is invalid
|
||||
- code: AUTH
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- group:
|
||||
- status: Retried model request (1/2) · {{duration}}
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
@@ -26,9 +29,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 79 tok
|
||||
- text: 1 turns · 1 steps Context 6% of 128K Cache hit 99% Input 7.8K tok · Output 79 tok
|
||||
|
||||
31
apps/web/tests/snapshots/markdown-images/ui.expected.md
Normal file
31
apps/web/tests/snapshots/markdown-images/ui.expected.md
Normal file
@@ -0,0 +1,31 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Markdown image policy" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Show the Markdown image policy. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- img "Remote test image"
|
||||
- paragraph: Local test image
|
||||
- paragraph: REMOTE_IMAGE_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
@@ -8,14 +8,19 @@
|
||||
- button "Copy":
|
||||
- img
|
||||
- tooltip "Copy"
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Edit":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- paragraph: I will read both files before answering.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn 7/25 {{clock}}
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
@@ -30,6 +35,12 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
|
||||
- text: Stopped Now give the final answer. 7/25 {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- paragraph: DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
@@ -39,9 +50,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
- button "Collapse calls": Calls
|
||||
- img
|
||||
- searchbox "Search trajectory"
|
||||
- region "Trajectory timeline"
|
||||
- region "Trajectory timeline":
|
||||
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "SYSTEM, Initial System Prompt":
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
- dialog "添加一个 API Key 开始使用":
|
||||
- region "添加一个 API Key 开始使用":
|
||||
- heading "添加一个 API Key 开始使用" [level=2]
|
||||
- button "稍后配置":
|
||||
- img
|
||||
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
|
||||
- button "稍后配置"
|
||||
- button "前往配置"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
- region "内测声明":
|
||||
- heading "内测声明" [level=2]
|
||||
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
||||
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
||||
- paragraph:
|
||||
- text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,
|
||||
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
|
||||
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
|
||||
- button "继续"
|
||||
124
apps/web/tests/snapshots/permission-policy-context/session.jsonl
Normal file
124
apps/web/tests/snapshots/permission-policy-context/session.jsonl
Normal file
File diff suppressed because one or more lines are too long
@@ -8,10 +8,13 @@
|
||||
- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."':
|
||||
- img
|
||||
- img
|
||||
@@ -37,9 +40,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 51% Input 10.2K tok · Output 346 tok
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Context 4% of 128K Cache hit 51% Input 10.2K tok · Output 346 tok
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}"
|
||||
- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
@@ -32,9 +35,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 95% Input 8.6K tok · Output 180 tok
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Context 3% of 128K Cache hit 95% Input 8.6K tok · Output 180 tok
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
- region "Which color do you prefer?":
|
||||
- text: Pick one
|
||||
- heading "Which color do you prefer?" [level=2]
|
||||
- button "Dismiss all questions":
|
||||
- img
|
||||
- group:
|
||||
- checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
|
||||
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
|
||||
- textbox "Type your answer": Include accessibility notes
|
||||
- button "Previous question" [disabled]:
|
||||
- img
|
||||
- text: 1 / 1
|
||||
- button "Next question" [disabled]:
|
||||
- img
|
||||
- status
|
||||
- button "Skip this question"
|
||||
- button "Submit"
|
||||
@@ -1,20 +1,20 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","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"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
|
||||
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
|
||||
{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}}
|
||||
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"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.\"}]}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"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.\"}]}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"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.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"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.\"}]}]}"}}
|
||||
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"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.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"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.\"}]}]}"}}
|
||||
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
- heading "Which color do you prefer?" [level=2]
|
||||
- button "Dismiss all questions":
|
||||
- img
|
||||
- radiogroup:
|
||||
- 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.
|
||||
- group:
|
||||
- checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
|
||||
- checkbox "Green": 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
|
||||
|
||||
@@ -7,17 +7,20 @@
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- button "2 queued messages"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- button "2 queued messages" [disabled] [expanded]
|
||||
@@ -21,6 +24,8 @@
|
||||
- img
|
||||
- button "Remove queued message":
|
||||
- img
|
||||
- button "Steer queued message":
|
||||
- img
|
||||
- listitem:
|
||||
- textbox "Edit queued message": Edited queue item
|
||||
- button "Save queued message":
|
||||
@@ -30,7 +35,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
43
apps/web/tests/snapshots/queue-actions/layout.expected.md
Normal file
43
apps/web/tests/snapshots/queue-actions/layout.expected.md
Normal file
@@ -0,0 +1,43 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
- text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- region "To-dos":
|
||||
- button "To-dos 1/2 tasks · 1 in progress"
|
||||
- img
|
||||
- text: Ongoing Goal Keep the composer context panels aligned
|
||||
- button "Pause goal":
|
||||
- img
|
||||
- button "Edit goal":
|
||||
- img
|
||||
- button "Clear goal":
|
||||
- img
|
||||
- button "2 queued messages"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
48
apps/web/tests/snapshots/queue-actions/preserved.expected.md
Normal file
48
apps/web/tests/snapshots/queue-actions/preserved.expected.md
Normal file
@@ -0,0 +1,48 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- text: Stopped
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Edited queue item {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Queue item preserved after stop
|
||||
- button "Edit queued message":
|
||||
- img
|
||||
- button "Remove queued message":
|
||||
- img
|
||||
- button "Steer queued message":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- list:
|
||||
@@ -20,10 +23,12 @@
|
||||
- img
|
||||
- button "Remove queued message":
|
||||
- img
|
||||
- button "Steer queued message":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
27
apps/web/tests/snapshots/search-card/grep-card.expected.txt
Normal file
27
apps/web/tests/snapshots/search-card/grep-card.expected.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
kind=matches
|
||||
summary=显示 9 / 共 42 处匹配 · 3 个文件
|
||||
file=packages/client/ui-primitives/src/SearchBlock.tsx3
|
||||
file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
|
||||
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
line=138: export function SearchBlock(props: SearchBlockProps) {
|
||||
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
line=35: const search = searchCardModel(block)
|
||||
line=52: search={search}
|
||||
line=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
expand=… 其余 4 行
|
||||
recovery=Found 9 of 42 matches
|
||||
|
||||
packages/client/ui-primitives/src/SearchBlock.tsx
|
||||
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
Line 138: export function SearchBlock(props: SearchBlockProps) {
|
||||
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
packages/client/ui-conversation/src/client/contract/search-card-model.ts
|
||||
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
|
||||
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
packages/client/ui-conversation/src/client/toolviews/search-row.tsx
|
||||
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
Line 35: const search = searchCardModel(block)
|
||||
Line 52: search={search}
|
||||
Line 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
|
||||
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)
|
||||
@@ -7,10 +7,9 @@
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Edit":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
|
||||
- img
|
||||
- img
|
||||
@@ -35,16 +34,19 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}}
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- img
|
||||
- text: permission preset workspace-write
|
||||
- text: permission preset read-only
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- 'button "Access mode, current: Read Only"': Read Only
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Edit":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
|
||||
- img
|
||||
- img
|
||||
@@ -35,6 +34,9 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}}
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
@@ -42,7 +44,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
- img
|
||||
- text: 关闭
|
||||
- text: 权限 选择新会话的默认权限模式
|
||||
- button "Full access":
|
||||
- text: Full access
|
||||
- button "Workspace Write":
|
||||
- text: Workspace Write
|
||||
- img
|
||||
- text: 语言
|
||||
- button "中文":
|
||||
@@ -28,3 +28,7 @@
|
||||
- button "跟随系统" [pressed]:
|
||||
- img
|
||||
- text: 跟随系统
|
||||
- text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送":
|
||||
- text: 排队发送
|
||||
- img
|
||||
|
||||
@@ -7,19 +7,26 @@
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
|
||||
- text: Running
|
||||
- button "Ask question waiting":
|
||||
- img
|
||||
- img
|
||||
- text: Ask question waiting
|
||||
- status: Deep diving...
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy":
|
||||
- img
|
||||
- region "Ready to continue?":
|
||||
- text: Checkpoint
|
||||
- heading "Ready to continue?" [level=2]
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "Edit":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
@@ -19,7 +22,12 @@
|
||||
- img
|
||||
- img
|
||||
- text: Ask question 1/1 answered
|
||||
- text: "Interjection Interjection: include the word BANANA in your final reply."
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- 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
|
||||
@@ -33,9 +41,9 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 156 tok
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Context 6% of 128K Cache hit 98% Input 15.8K tok · Output 156 tok
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- tree "Subagent sessions":
|
||||
- treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}}
|
||||
@@ -0,0 +1,6 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "Explain event sourcing in one (1) now" [selected]
|
||||
- treeitem "Ask a research subagent to now"
|
||||
@@ -0,0 +1,18 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher"
|
||||
- text: /
|
||||
- button "example editor" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Give one concrete event sourcing example. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- status:
|
||||
- strong: This subagent is read-only for now
|
||||
- text: The parent session is offline; reopen it to continue sending messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "Ask a research subagent to now"
|
||||
@@ -0,0 +1,3 @@
|
||||
- tree "Subagent sessions":
|
||||
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…
|
||||
- treeitem "Loading subagents" [disabled] [level=1]: Loading subagents…
|
||||
@@ -0,0 +1,8 @@
|
||||
- tree "Subagent sessions":
|
||||
- treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]:
|
||||
- button "Collapse event-sourcing researcher descendants":
|
||||
- img
|
||||
- text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}}
|
||||
- group:
|
||||
- treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}}
|
||||
@@ -0,0 +1,52 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- button "1 subagent":
|
||||
- text: 1 subagent
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Explain event sourcing in one sentence. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Now give the same explanation to a human reader. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 2 steps Context 6% of 128K Cache hit 99% Input 15.6K tok · Output 158 tok
|
||||
@@ -0,0 +1 @@
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal file
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal file
@@ -0,0 +1,12 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"}
|
||||
{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1785456000008,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"SEARCH_DONE"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1785456000009,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SEARCH_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1785456000010,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1785456000011,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
35
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
35
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
@@ -0,0 +1,35 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Search DeepSeek Harness snapshot search":
|
||||
- img
|
||||
- img
|
||||
- text: Search DeepSeek Harness snapshot search
|
||||
- paragraph: SEARCH_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Context 0% of 128K Cache hit 0% Input 22 tok · Output 7 tok
|
||||
@@ -4,7 +4,8 @@
|
||||
- button "Home"
|
||||
- img
|
||||
- button "browse-golden"
|
||||
- button "Edit path"
|
||||
- button "Edit path":
|
||||
- img
|
||||
- list:
|
||||
- listitem:
|
||||
- button "adopted":
|
||||
|
||||
119
apps/web/tests/startup-auto-selection.e2e.ts
Normal file
119
apps/web/tests/startup-auto-selection.e2e.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// Web e2e scenario: startup auto-selection keeps the hero on screen.
|
||||
//
|
||||
// A page load with a workspace already registered runs
|
||||
// `WorkspacesService.startInitialSelection`: it connects the most recent
|
||||
// workspace and opens its blank session. `openState` flips to `loading` the
|
||||
// moment `open()` lands, which used to drive `data-phase=settling` on the
|
||||
// conversation root — `visibility:hidden` over the composer seat and the
|
||||
// header for the whole `session.history` round-trip, so the center column went
|
||||
// blank and repainted, reading as a full-page refresh on every launch.
|
||||
//
|
||||
// The unit spec pins the phase condition over hand-built stores. What only the
|
||||
// assembled application can show is that the path a user actually takes
|
||||
// reaches it: the real selection service, the real client session opening over
|
||||
// the real /api transport, and a real browser deciding what is painted.
|
||||
//
|
||||
// The round-trip against a loopback host is far too fast to observe, so this
|
||||
// scenario HOLDS the `session.history` response open at the browser's network
|
||||
// boundary and asserts the visible frame while it is in flight. That gate is
|
||||
// what makes the assertions non-vacuous: with the exemption reverted the held
|
||||
// window is exactly when `settling` is painted and the composer is hidden.
|
||||
//
|
||||
// Zero model calls: registering a workspace and opening its blank session are
|
||||
// host RPCs with no model involvement. A stray stream would fail loud with
|
||||
// NO_ADAPTER.
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */
|
||||
const HISTORY_ROUTE = '**/api/session.history'
|
||||
|
||||
/**
|
||||
* The conversation root's own phase attribute. `div` disambiguates it from the
|
||||
* composer textarea, which carries an unrelated `data-phase` of its own.
|
||||
*/
|
||||
const ROOT_PHASE = 'div[data-phase]'
|
||||
|
||||
/** Every distinct `data-phase` the conversation root shows, in order, across one page load. */
|
||||
function recordedPhases(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
|
||||
}
|
||||
|
||||
describe('web e2e: startup auto-selection', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// A registered workspace is the precondition for auto-selection: the first
|
||||
// load has nothing to select, so the reload below is the path under test.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
|
||||
// Runs before any page script on the reload below, so the first phase the
|
||||
// root ever renders is recorded, not just the ones after a listener attaches.
|
||||
await page.addInitScript(() => {
|
||||
const phases: string[] = []
|
||||
;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
|
||||
setInterval(() => {
|
||||
const phase = document.querySelector('div[data-phase]')?.getAttribute('data-phase')
|
||||
if (phase === null || phase === undefined) return
|
||||
if (phases[phases.length - 1] !== phase) phases.push(phase)
|
||||
}, 8)
|
||||
})
|
||||
|
||||
let releaseHistory = (): void => {}
|
||||
const historyHeld = new Promise<void>((resolve) => { releaseHistory = resolve })
|
||||
let historyRequested = (): void => {}
|
||||
const historyInFlight = new Promise<void>((resolve) => { historyRequested = resolve })
|
||||
let gated = false
|
||||
await page.route(HISTORY_ROUTE, async (route) => {
|
||||
// Only the auto-selection's own round-trip is held; later pages must not
|
||||
// deadlock behind a gate this test has already released.
|
||||
if (gated) { await route.continue(); return }
|
||||
gated = true
|
||||
historyRequested()
|
||||
await historyHeld
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
const warningsBefore = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'commit' })
|
||||
await historyInFlight
|
||||
|
||||
// The frame a user sees while the session is still opening: hero phase, the
|
||||
// hero title, and a composer that is actually painted (`settling` hides the
|
||||
// seat with `visibility:hidden`, which Playwright reports as not visible).
|
||||
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
|
||||
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
|
||||
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
|
||||
expect(await page.locator('textarea').first().isVisible()).toBe(true)
|
||||
|
||||
releaseHistory()
|
||||
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
|
||||
.waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
||||
|
||||
// Settling is not merely absent from the frame sampled above: the root
|
||||
// never entered it at any point of the load.
|
||||
expect(await recordedPhases(page)).toEqual(['hero'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -1,16 +1,7 @@
|
||||
// Web e2e scenario: mid-turn steering, end to end. The product composer
|
||||
// deliberately exposes Queue only, so the steer is POSTed from the page
|
||||
// itself over the same same-origin /api transport the client uses.
|
||||
// TODO(web-steer-ui): Drive this through a dedicated steering interaction
|
||||
// once one exists. Everything downstream is product: the gateway
|
||||
// routes mode:'steer' to Agent.steer, the loop drains it at the step
|
||||
// boundary into a durable steering/message event, the SSE mux pushes it, and
|
||||
// the transcript renders the badged interjection bubble. The question
|
||||
// composer supplies the deterministic mid-turn window: while ask_user_question
|
||||
// blocks, the turn is provably running, so record and replay perform the
|
||||
// identical steer-then-answer sequence with zero timing dependence — and the
|
||||
// recorded final reply proves the steer reached the MODEL (it obeys an
|
||||
// instruction that only the steering message carries).
|
||||
// Web e2e scenarios for both steering entry points: QueueDock strictly
|
||||
// transfers one queued occurrence, while the complementary composer gestures
|
||||
// choose Queue or Steer. The question tool supplies a deterministic pending-
|
||||
// steering snapshot before the step can drain.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -27,16 +18,18 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// Two goldens for the two distinct states this interaction produces: the
|
||||
// mid-turn moment (steer ACCEPTED but deliberately invisible — the loop
|
||||
// drains steering at the step boundary, so no interjection bubble exists
|
||||
// while the question still blocks the step) and the settled transcript
|
||||
// (badged bubble in place, final reply obeying it). The pair pins the
|
||||
// timing semantics visually: if the client ever starts rendering pending
|
||||
// steers eagerly, the mid-steer golden flips first.
|
||||
// Two goldens pin the transient Host projection and its durable handoff: the
|
||||
// mid-turn state renders accepted steering from session/queue while the
|
||||
// question blocks admission, then the settled state renders the same message
|
||||
// from steering/message beside the reply that obeys it.
|
||||
const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
|
||||
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
// The question composer replaces the textarea, so fill → Queue row → Steer
|
||||
// must finish inside the first replay chunk window. At 15 ms that window is
|
||||
// shorter than Playwright's round trips; 100 ms supplies test-only headroom,
|
||||
// while larger values lengthen all three replay scenarios linearly.
|
||||
const REPLAY_PACE_MS = 100
|
||||
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.'
|
||||
const STEER = 'Interjection: include the word BANANA in your final reply.'
|
||||
@@ -57,22 +50,20 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let liveSessionId: string | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
||||
scaffold.ctx.on('session/event', (session, event) => {
|
||||
liveSessionId ??= session.id
|
||||
sessionEvents.push(event)
|
||||
})
|
||||
scaffold = await launchWebScaffold(MODE === 'record'
|
||||
? {}
|
||||
: { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
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)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -80,7 +71,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('steers during the blocked step; the interjection is logged, rendered, and obeyed', async () => {
|
||||
it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
|
||||
if (MODE !== 'record') {
|
||||
// The steer must NOT be a user/message — it lands as steering/message.
|
||||
@@ -92,37 +83,29 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
|
||||
// The blocked composer is the mid-turn barrier: its presence proves the
|
||||
// ask_user_question step is executing, i.e. the turn is running NOW.
|
||||
// Enter remains the Queue gesture. The row action then atomically moves
|
||||
// this exact occurrence into the current turn's steering outbox.
|
||||
await input.fill(STEER)
|
||||
await input.press('Enter')
|
||||
const queued = page.getByText(STEER, { exact: true })
|
||||
await queued.waitFor({ timeout: 10_000 })
|
||||
const queuedRow = page.getByRole('listitem').filter({ hasText: STEER })
|
||||
const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' })
|
||||
await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await steerButton.click({ timeout: 10_000 })
|
||||
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
|
||||
// A timeout while the Queue row remains means strict steer lost to a
|
||||
// closing window (`steer-unavailable`); inspect replay pacing first.
|
||||
await pendingSteering.waitFor({ timeout: 10_000 })
|
||||
|
||||
// The blocked composer keeps steering pending long enough to observe the
|
||||
// Host-authoritative mirror before the loop admits it durably.
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
|
||||
|
||||
// Steer through the real wire from the page (same envelope + endpoint the
|
||||
// web client's session.prompt uses). accepted:true is the transport proof.
|
||||
expect(liveSessionId).toBeDefined()
|
||||
const reply = await page.evaluate(async ({ sessionId, text }) => {
|
||||
const response = await fetch('/api/session.prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: crypto.randomUUID(),
|
||||
method: 'session.prompt',
|
||||
payload: { sessionId, mode: 'steer', content: [{ type: 'text', text }] },
|
||||
}),
|
||||
})
|
||||
return await response.json() as { result?: { ok?: boolean } }
|
||||
}, { sessionId: liveSessionId!, text: STEER })
|
||||
expect(reply.result?.ok).toBe(true)
|
||||
|
||||
if (MODE !== 'record') {
|
||||
// Mid-turn golden: the ACCEPTED steer is durable in the inbox but the
|
||||
// loop drains steering only at the step boundary, so no steering/message
|
||||
// exists yet and no interjection bubble renders — the composer still
|
||||
// blocks, alone. The DOM is stable here (no further SSE frames can
|
||||
// arrive until the question is answered), making this state capturable.
|
||||
expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0)
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(1)
|
||||
expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0)
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE)
|
||||
@@ -155,14 +138,14 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
expect(turnEnds).toHaveLength(1)
|
||||
expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
|
||||
|
||||
// Visible: the badged interjection bubble plus the reply that obeys it
|
||||
// Visible: the plain steering bubble plus the reply that obeys it
|
||||
// (steer text + final reply each contain the marker word).
|
||||
await expect.poll(() => page.getByText('Interjection', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(0)
|
||||
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.locator('[data-question-key]').count()).toBe(0)
|
||||
// Settled golden: badge + interjection between the question round trip
|
||||
// and the obeying reply, composer takeover gone.
|
||||
// Settled golden: steer text between the question round trip and the
|
||||
// obeying reply, composer takeover gone.
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -173,3 +156,120 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('web e2e: composer shortcut steers directly', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering'))
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled(30_000)
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await input.fill(STEER)
|
||||
await input.press('Meta+Enter')
|
||||
await expect.poll(() => input.inputValue(), { timeout: 5_000 }).toBe('')
|
||||
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
|
||||
await pendingSteering.waitFor({ timeout: 10_000 })
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await settled
|
||||
|
||||
const steerEvents = sessionEvents.filter(event => event.type === 'steering/message')
|
||||
expect(steerEvents).toHaveLength(1)
|
||||
expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1)
|
||||
await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(0)
|
||||
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
describe('web e2e: composer shortcut follows the swapped busy behavior', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('queues Cmd+Enter when plain Enter is configured to Steer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-swapped-shortcut'))
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Queue' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Steer' }).click()
|
||||
await dialog.getByRole('button', { name: 'Steer' }).waitFor({ timeout: 10_000 })
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
const settled = scaffold.whenTurnSettled(30_000)
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const queuedText = 'Queued by the complementary Cmd+Enter shortcut.'
|
||||
await input.fill(queuedText)
|
||||
await input.press('Meta+Enter')
|
||||
const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText })
|
||||
await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0)
|
||||
expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
|
||||
// Remove the asserted Queue row, then finish the recorded question turn
|
||||
// so replay teardown still proves that every fixture call was consumed.
|
||||
await queuedRow.getByRole('button', { name: 'Remove queued message' }).click()
|
||||
const composer = page.locator('[data-question-key]')
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await settled
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
516
apps/web/tests/subagent-conversation.e2e.ts
Normal file
516
apps/web/tests/subagent-conversation.e2e.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
|
||||
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
|
||||
const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url))
|
||||
const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
|
||||
const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
|
||||
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
|
||||
const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const LABEL = 'event-sourcing researcher'
|
||||
const ONE_SHOT_LABEL = 'event-sourcing reviewer'
|
||||
const NESTED_LABEL = 'example editor'
|
||||
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
|
||||
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
|
||||
|
||||
function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
|
||||
const [header, ...eventLines] = source.trimEnd().split('\n')
|
||||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||||
const childHeader = header
|
||||
.replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
|
||||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442')
|
||||
if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
|
||||
const continued = eventLines.map(line => line
|
||||
.replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
|
||||
.replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
|
||||
.replaceAll('"turn":1', '"turn":2'))
|
||||
return [childHeader, ...eventLines, ...continued, ''].join('\n')
|
||||
}
|
||||
|
||||
async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise<void> {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (scaffold.ctx.agents.get(id) !== undefined) {
|
||||
if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: persisted subagent conversation and human continuation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let sidecarRoot: string
|
||||
let childId: SessionId
|
||||
let oneShotId: SessionId
|
||||
let grandchildId: SessionId
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
|
||||
const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
|
||||
const childFixturePath = join(sidecarRoot, 'child.jsonl')
|
||||
await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: BASE_FIXTURE,
|
||||
replayChildFixtures: [childFixturePath],
|
||||
paceMs: 25,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const parent = scaffold.ctx.agents.roots()[0]
|
||||
if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||||
const parentSettled = scaffold.whenTurnSettled()
|
||||
const parentInput = page.locator('textarea:enabled').first()
|
||||
await parentInput.fill(PARENT_PROMPT)
|
||||
await parentInput.press('Enter')
|
||||
expect(await parentSettled).toBe(parent.id)
|
||||
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: LABEL,
|
||||
signal: new AbortController().signal,
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
|
||||
parent,
|
||||
},
|
||||
})
|
||||
childId = started.childId
|
||||
await waitForAgentToSettle(scaffold, childId)
|
||||
oneShotId = sessionId('recorded-one-shot')
|
||||
const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
|
||||
const oneShotAt = Date.now() - oneShotDurationMs
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: oneShotId,
|
||||
createdAt: oneShotAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
delegationDepth: 1,
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(oneShotId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: oneShotAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: oneShotAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Review the event sourcing explanation.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: oneShotAt + 2,
|
||||
data: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: oneShotAt + oneShotDurationMs,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId)
|
||||
grandchildId = sessionId('recorded-grandchild')
|
||||
const authoredAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: grandchildId,
|
||||
createdAt: authoredAt,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
parentSession: childId,
|
||||
origin: 'subagent',
|
||||
delegationDepth: 2,
|
||||
})
|
||||
await scaffold.ctx.sessionPersistence.append(grandchildId, [
|
||||
{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: authoredAt,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: authoredAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 2,
|
||||
time: authoredAt + 2,
|
||||
data: snapshotSubagentDescriptor({
|
||||
mode: 'continuable', provider: 'spawn', label: NESTED_LABEL,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'turn/end',
|
||||
seq: 3,
|
||||
time: authoredAt + 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId)
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
|
||||
{
|
||||
kind: 'child', id: oneShotId, mode: 'one-shot',
|
||||
label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
])
|
||||
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
|
||||
{
|
||||
kind: 'child', id: grandchildId, mode: 'continuable',
|
||||
label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
// These two cold fixtures were authored after the page's initial
|
||||
// session.list and intentionally emitted no session-added frame. Reload
|
||||
// to exercise the restart baseline that discovers their full lineage.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const catalogButton = page.getByRole('button', { name: /subagents/ })
|
||||
await catalogButton.waitFor({ timeout: 15_000 })
|
||||
await catalogButton.click()
|
||||
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||||
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
|
||||
await catalogTree.press('Escape')
|
||||
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarRoot !== undefined) {
|
||||
await rm(sidecarRoot, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
|
||||
})
|
||||
|
||||
it('keeps known descendants reachable across a stale empty catalog response', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
|
||||
const pattern = '**/api/subagent.list'
|
||||
let firstClaimed = false
|
||||
let emptyDelivered = false
|
||||
let trailingRequested = false
|
||||
let releaseCatalog = (): void => {}
|
||||
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
|
||||
await page.route(pattern, async (route) => {
|
||||
if (firstClaimed) {
|
||||
const response = await route.fetch()
|
||||
trailingRequested = true
|
||||
await catalogHeld
|
||||
await route.fulfill({ response })
|
||||
return
|
||||
}
|
||||
firstClaimed = true
|
||||
const response = await route.fetch()
|
||||
const body = await response.json() as {
|
||||
result: { ok: true; value: { entries: unknown[] } } | { ok: false }
|
||||
}
|
||||
if (body.result.ok) body.result.value.entries = []
|
||||
await route.fulfill({ response, json: body })
|
||||
emptyDelivered = true
|
||||
})
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
try {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
|
||||
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
|
||||
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||||
await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
|
||||
expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
|
||||
await compareOrRefreshGolden(
|
||||
STALE_CATALOG_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
releaseCatalog()
|
||||
await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
|
||||
await tree.press('Escape')
|
||||
} finally {
|
||||
releaseCatalog()
|
||||
await page.unroute(pattern)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands a persisted grandchild progressively without activating either level', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
expect(await page.getByRole('button', {
|
||||
name: `Expand ${ONE_SHOT_LABEL} descendants`,
|
||||
}).count()).toBe(0)
|
||||
const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) })
|
||||
expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
|
||||
expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
|
||||
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
|
||||
const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
|
||||
const childLabel = await childRow.getAttribute('aria-label')
|
||||
await page.waitForTimeout(1_100)
|
||||
expect(await childRow.getAttribute('aria-label')).toBe(childLabel)
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="Subagent sessions"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
|
||||
})
|
||||
|
||||
it('opens the completed child from persistence without activating it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(
|
||||
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(1)
|
||||
if (scaffold.ctx.agents.get(childId) !== undefined) {
|
||||
throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
|
||||
}
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor()
|
||||
const sidebar = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="Sessions"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
})
|
||||
|
||||
it('continues through FIFO follow-up admission and receives the child mux events', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
|
||||
const ended = new Promise<void>((resolveEnded, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error('subagent follow-up did not reach turn/end'))
|
||||
}, 30_000)
|
||||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (session.id !== childId || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
resolveEnded()
|
||||
})
|
||||
})
|
||||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||||
await input.fill(FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.get(childId)?.status,
|
||||
{ timeout: 10_000 },
|
||||
).toBe('running')
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button').first().click()
|
||||
const runningTrigger = page.getByRole('button', { name: '3 subagents running' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await runningTrigger.click()
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
await ended
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*not running`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByRole('button', { name: '3 subagents' })
|
||||
.locator('[data-state="ongoing"]').count()).toBe(0)
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||||
})
|
||||
|
||||
it('matches the settled addressed-conversation aria golden and stays clean', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('opens an unavailable persisted grandchild after recording the available child', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
|
||||
await page.getByRole('button', { name: '1 subagent' }).click()
|
||||
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||||
const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) })
|
||||
expect(await nestedRow.locator(':scope > *').count()).toBe(1)
|
||||
const clickArea = nestedRow.locator(':scope > *')
|
||||
const [treeBox, clickAreaBox] = await Promise.all([
|
||||
tree.boundingBox(),
|
||||
clickArea.boundingBox(),
|
||||
])
|
||||
expect(treeBox).not.toBeNull()
|
||||
expect(clickAreaBox).not.toBeNull()
|
||||
expect([
|
||||
Math.round(clickAreaBox!.x - treeBox!.x),
|
||||
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
|
||||
]).toEqual([5, 5])
|
||||
await compareOrRefreshGolden(
|
||||
BRANCHLESS_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
await nestedRow.click()
|
||||
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
const crumbs = await hierarchy.getByRole('button').allTextContents()
|
||||
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
await compareOrRefreshGolden(
|
||||
UNAVAILABLE_GRANDCHILD_EXPECTED,
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a one-shot child as permanently read-only history', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
|
||||
const parentSession = page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem')
|
||||
.last()
|
||||
await parentSession.click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
|
||||
await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork'))
|
||||
await page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem', { name: /Ask a research subagent to/ })
|
||||
.click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
|
||||
const forkResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/session.fork')
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
|
||||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||||
await expect.poll(
|
||||
() => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(3)
|
||||
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
expect(await hierarchy.getByRole('button').count()).toBe(1)
|
||||
await compareOrRefreshGolden(
|
||||
FORK_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
})
|
||||
|
||||
it('cold-resumes the original subagent while its ordinary fork stays active', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
|
||||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.locator('textarea:enabled').first().waitFor()
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
|
||||
const forkResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/session.fork')
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
const forkReceipt = await (await forkResponse).json() as {
|
||||
result: { ok: true; value: { sessionId: string } } | { ok: false }
|
||||
}
|
||||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||||
if (!forkReceipt.result.ok) return
|
||||
const forkId = sessionId(forkReceipt.result.value.sessionId)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||||
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.waitFor()
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
await input.fill(POST_FORK_FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
const promptReceipt = await (await promptResponse).json() as {
|
||||
result: { ok: true } | { ok: false; error: { code: string; message: string } }
|
||||
}
|
||||
if (!promptReceipt.result.ok) {
|
||||
throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`)
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
const messageIndex = loaded.events.findIndex(event => event.type === 'user/message'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP))
|
||||
return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end')
|
||||
}, { timeout: 30_000 }).toBe(true)
|
||||
expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared plumbing for the web smoke tests (dist location, free port, failure shots).
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
|
||||
@@ -9,11 +10,18 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met
|
||||
|
||||
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/**
|
||||
* Browser language a page must advertise to boot into the product's Chinese
|
||||
* surface: with no stored preference the client derives its initial locale
|
||||
* from the browser, and Playwright's default browser asks for English.
|
||||
*/
|
||||
export const ZH_BROWSER_LOCALE = 'zh-CN'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* component migrations; the scenarios asserting the Chinese surface bypass
|
||||
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
|
||||
* @param browser - Playwright browser owning the page.
|
||||
* @param height - Viewport height; width is fixed to the lane baseline.
|
||||
* @returns the initialized page.
|
||||
@@ -27,7 +35,7 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
|
||||
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
||||
export function requireDist(): void {
|
||||
if (!existsSync(DIST_INDEX)) {
|
||||
throw new Error('web app dist not built — run `pnpm --filter @deepseek-ai/dsh-frontend build` (pnpm run test:web does this first)')
|
||||
throw new Error('web app dist not built — run `pnpm run build` from the repository root (`pnpm run test:web` does this first)')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,23 +56,32 @@ 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
|
||||
* Drive the hero's workspace picker through the composed directory 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
|
||||
* first. With nothing to list, the chip gesture raises the dialog directly —
|
||||
* adding a workspace is the picker's only entry. The directory is staged here
|
||||
* and adopted through the path editor, which is idempotent across the repeated
|
||||
* connects a scenario may make; creating a folder from inside the dialog (the
|
||||
* product's other half of the same route) is covered by
|
||||
* workspace-management.e2e.ts. The default name 'workspace' keeps the session
|
||||
* header cwd at <root>/workspace, the materialization proof several scenarios
|
||||
* assert.
|
||||
* @param page - the page under test.
|
||||
* @param name - workspace name typed into the create dialog.
|
||||
* @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`).
|
||||
* @param name - folder name staged and adopted as the workspace.
|
||||
*/
|
||||
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
|
||||
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('button', { name: 'Choose workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
|
||||
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByLabel('New workspace name').fill(name)
|
||||
await dialog.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await dialog.getByRole('button', { name: 'Edit path' }).click()
|
||||
const pathInput = dialog.getByRole('textbox', { name: 'Edit path' })
|
||||
await pathInput.fill(join(root, name))
|
||||
await pathInput.press('Enter')
|
||||
await dialog.getByRole('button', { name: 'Open', exact: true }).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"]')
|
||||
|
||||
9
apps/web/tests/support/listen-probe.mjs
Normal file
9
apps/web/tests/support/listen-probe.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { Server } from 'node:net'
|
||||
|
||||
const marker = process.env.DSH_LISTEN_PROBE_MARKER
|
||||
const listen = Server.prototype.listen
|
||||
Server.prototype.listen = function (...args) {
|
||||
if (marker !== undefined) appendFileSync(marker, 'listen\n')
|
||||
return listen.apply(this, args)
|
||||
}
|
||||
63
apps/web/tests/vite-entry.e2e.ts
Normal file
63
apps/web/tests/vite-entry.e2e.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Bare Vite must fail before it can present a bootless shell as a working GUI. */
|
||||
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createServer } from 'node:net'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const WEB_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/** Reserve an available loopback port, then release it for the child invocation. */
|
||||
async function freePort(): Promise<number> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('port probe returned no address')
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
}))
|
||||
return address.port
|
||||
}
|
||||
|
||||
describe('Web development entry', () => {
|
||||
it('rejects the package dev alias with the full-host correction', async () => {
|
||||
const result = await execa('pnpm', ['run', 'dev'], { cwd: WEB_ROOT, reject: false })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toContain('apps/web is not a standalone application')
|
||||
expect(result.stderr).toContain('dsh web')
|
||||
})
|
||||
|
||||
it('rejects the standalone Vite server with the full-host correction', async () => {
|
||||
const probeRoot = mkdtempSync(join(tmpdir(), 'dsh-vite-listen-probe-'))
|
||||
const marker = join(probeRoot, 'listen-called')
|
||||
const port = await freePort()
|
||||
try {
|
||||
const probeModule = fileURLToPath(new URL('./support/listen-probe.mjs', import.meta.url))
|
||||
const result = await execa(join(WEB_ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port)], {
|
||||
cwd: WEB_ROOT,
|
||||
reject: false,
|
||||
timeout: 10_000,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_LISTEN_PROBE_MARKER: marker,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import ${pathToFileURL(probeModule).href}`.trim(),
|
||||
},
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toContain('apps/web is not a standalone application')
|
||||
expect(result.stderr).toContain('dsh web')
|
||||
expect(result.stderr).toContain('window.__DSH_BOOT__')
|
||||
expect(existsSync(marker), 'Vite called Server.listen before rejecting standalone serve mode').toBe(false)
|
||||
} finally {
|
||||
rmSync(probeRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
298
apps/web/tests/web-search-round.e2e.ts
Normal file
298
apps/web/tests/web-search-round.e2e.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
// Web e2e scenario for the shipped default search composition. A real browser
|
||||
// drives `web_search`; the model stream is replayed while the real DeepSeek
|
||||
// provider calls a deterministic local Anthropic-compatible endpoint through
|
||||
// the real credentials service.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
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 { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { WEB_SEARCH_MAX_RESULTS } from '@deepseek-ai/dsh-tool-web'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const QUERY = 'DeepSeek Harness snapshot search'
|
||||
const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
|
||||
const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
|
||||
const SEARCH_CREDENTIAL = 'snapshot-search-key'
|
||||
|
||||
/**
|
||||
* Provider results the double returns, exceeding the shipped `searchMaxResults`
|
||||
* so the seam's cap and the card's scroll container are both exercised. Each row
|
||||
* carries a title, a snippet, and a date, so 8 kept rows exceed the `.sources`
|
||||
* 320px max-height.
|
||||
*/
|
||||
const PROVIDER_RESULT_COUNT = 12
|
||||
|
||||
/** One provider result's URL, by 1-based provider order. */
|
||||
function resultUrl(ordinal: number): string {
|
||||
return `https://docs.example.test/search/${ordinal}`
|
||||
}
|
||||
|
||||
/** One provider result's title, by 1-based provider order. */
|
||||
function resultTitle(ordinal: number): string {
|
||||
return `Snapshot Search Result ${ordinal}`
|
||||
}
|
||||
|
||||
/** One provider result's citation excerpt, by 1-based provider order. */
|
||||
function resultSnippet(ordinal: number): string {
|
||||
return `Snapshot search excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
|
||||
}
|
||||
|
||||
/** One provider result's `page_age`, by 1-based provider order (July 2026 days 01..12). */
|
||||
function resultPageAge(ordinal: number): string {
|
||||
return `2026-07-${String(ordinal).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** The 1-based provider ordinals, in provider order. */
|
||||
const RESULT_ORDINALS = Array.from({ length: PROVIDER_RESULT_COUNT }, (_value, index) => index + 1)
|
||||
|
||||
interface CapturedSearchRequest {
|
||||
path: string
|
||||
apiKey: string | undefined
|
||||
body: unknown
|
||||
}
|
||||
|
||||
/** Start the deterministic DeepSeek Messages double used by the real provider. */
|
||||
async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> {
|
||||
const server = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
captured.push({
|
||||
path: request.url ?? '',
|
||||
apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
|
||||
body: JSON.parse(body) as unknown,
|
||||
})
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Found ${PROVIDER_RESULT_COUNT} sources.`,
|
||||
citations: RESULT_ORDINALS.map(ordinal => ({
|
||||
type: 'web_search_result_location',
|
||||
url: resultUrl(ordinal),
|
||||
cited_text: resultSnippet(ordinal),
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'web_search_tool_result',
|
||||
content: RESULT_ORDINALS.map(ordinal => ({
|
||||
type: 'web_search_result',
|
||||
url: resultUrl(ordinal),
|
||||
title: resultTitle(ordinal),
|
||||
page_age: resultPageAge(ordinal),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = server.address() as AddressInfo
|
||||
return { server, baseURL: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
describe('web e2e: shipped default web search', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let searchServer: Server | undefined
|
||||
let searchBaseURL: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const searchRequests: CapturedSearchRequest[] = []
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const search = await startSearchServer(searchRequests)
|
||||
searchServer = search.server
|
||||
searchBaseURL = search.baseURL
|
||||
scaffold = await launchWebScaffold({
|
||||
deepSeekSearch: {
|
||||
baseURL: search.baseURL,
|
||||
apiKeyEnv: SEARCH_CREDENTIAL_REF,
|
||||
},
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
|
||||
})
|
||||
await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL)
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (searchServer === undefined) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
searchServer.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('drives the recorded search to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('uses the real provider and persists the capped structured result', () => {
|
||||
expect(searchRequests).toHaveLength(1)
|
||||
expect(searchRequests[0]).toMatchObject({
|
||||
path: '/messages',
|
||||
apiKey: SEARCH_CREDENTIAL,
|
||||
body: {
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
|
||||
},
|
||||
})
|
||||
|
||||
const auxiliaryRequest = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
|
||||
event.type === 'web/deepseek-search-llm-request',
|
||||
)
|
||||
expect(auxiliaryRequest?.data).toEqual({
|
||||
endpoint: `${searchBaseURL}/messages`,
|
||||
apiVersion: '2023-06-01',
|
||||
body: searchRequests[0]?.body,
|
||||
})
|
||||
|
||||
const searchCall = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
|
||||
event.type === 'tool/call' && event.data.name === 'web_search',
|
||||
)
|
||||
if (searchCall === undefined) throw new Error('the replayed turn did not call web_search')
|
||||
const searchResult = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
|
||||
event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
|
||||
)
|
||||
if (searchResult === undefined) throw new Error('web_search produced no durable result')
|
||||
const content = searchResult.data.message.content[0]
|
||||
expect(content.isError).toBe(false)
|
||||
const rendered = content.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
// The seam caps the provider's list at the shipped searchMaxResults before
|
||||
// the tool renders it, so the kept prefix is model-visible and the dropped
|
||||
// suffix is not.
|
||||
for (const ordinal of RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS)) {
|
||||
expect(rendered).toContain(`[${resultTitle(ordinal)}](${resultUrl(ordinal)})`)
|
||||
}
|
||||
for (const ordinal of RESULT_ORDINALS.slice(WEB_SEARCH_MAX_RESULTS)) {
|
||||
expect(rendered).not.toContain(resultUrl(ordinal))
|
||||
}
|
||||
expect(rendered).toContain(
|
||||
`(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
|
||||
)
|
||||
expect(searchResult.data.meta).toMatchObject({
|
||||
sources: RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS).map(ordinal => ({
|
||||
url: resultUrl(ordinal),
|
||||
title: resultTitle(ordinal),
|
||||
snippet: resultSnippet(ordinal),
|
||||
publishedAt: resultPageAge(ordinal),
|
||||
})),
|
||||
truncated: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
|
||||
await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll'))
|
||||
const row = page.locator('[data-tool="web_search"] [data-expandable]').first()
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
|
||||
const card = page.locator('[data-web="search"]')
|
||||
const sources = card.locator('ol')
|
||||
await sources.waitFor({ timeout: 10_000 })
|
||||
// The card draws exactly the sources the model saw: the seam's cap, not the
|
||||
// provider's list length.
|
||||
expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS)
|
||||
// The list is complete in the DOM, so the card carries no expand control.
|
||||
expect(await card.locator('button').count()).toBe(0)
|
||||
expect(await card.getByText('来源列表已截断').isVisible()).toBe(true)
|
||||
|
||||
const geometry = await sources.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
maxHeight: computed.maxHeight,
|
||||
overflowY: computed.overflowY,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(geometry.maxHeight).toBe('320px')
|
||||
expect(geometry.overflowY).toBe('auto')
|
||||
expect(geometry.scrollHeight).toBeGreaterThan(geometry.clientHeight)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room'))
|
||||
// `overflow-y: auto` clips inline-start overflow with no way to scroll it
|
||||
// back, and markers are right-aligned to the content edge, so a marker wider
|
||||
// than `padding-left` silently loses its leading digits. `searchMaxResults`
|
||||
// is an unbounded positive integer, so measure the widest three-digit marker
|
||||
// in the list's own font and require the shipped padding to hold it.
|
||||
const marker = await page.locator('[data-web="search"] ol').evaluate((element) => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font:inherit'
|
||||
probe.textContent = '999. '
|
||||
element.append(probe)
|
||||
const widest = probe.getBoundingClientRect().width
|
||||
probe.remove()
|
||||
return { widest, paddingLeft: parseFloat(getComputedStyle(element).paddingLeft) }
|
||||
})
|
||||
expect(marker.paddingLeft).toBeGreaterThanOrEqual(marker.widest)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,20 @@
|
||||
// Web e2e scenarios: workspace management — the create-by-name dialog, the
|
||||
// rename round trip over the real wire (workspace.rename RPC + durable
|
||||
// registry), duplicate-name pre-check, the flat "In one list" view with its
|
||||
// persisted group-by preference, the session hover card, and the session
|
||||
// archive round trip (row menu → workspace.archiveSession RPC → durable
|
||||
// global set → row hidden across reload). Zero model calls:
|
||||
// workspace.create/rename/archiveSession are host RPCs with no model
|
||||
// involvement, and the one session row the flat/hover/archive scenarios need
|
||||
// comes from a seeded fixture (the seeded-history seed reused verbatim — no
|
||||
// new recording).
|
||||
// Web e2e scenarios: workspace management — adding a workspace through the
|
||||
// composed directory dialog (its own New folder affordance is the product's
|
||||
// one creation route), the dialog's path editor walking the panes with the
|
||||
// typed draft, same-basename directory adoption, the rename round
|
||||
// trip over the real wire (workspace.rename RPC + durable registry), the
|
||||
// duplicate-name pre-check, the
|
||||
// flat "In one list" view with its persisted group-by preference, the session
|
||||
// hover card and row action menu, and the session archive round trip (row
|
||||
// menu → workspace.archiveSession RPC → durable global set → row hidden
|
||||
// across reload). Zero model calls: workspace.create/rename/archiveSession
|
||||
// are host RPCs with no model involvement, and the one session row the
|
||||
// flat/hover/menu/archive scenarios need comes from a seeded fixture (the
|
||||
// seeded-history seed reused verbatim — no new recording).
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { join, sep } from 'node:path'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -28,29 +31,59 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo
|
||||
const MODE = webSnapshotMode()
|
||||
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
|
||||
const SEED_ID = 'workspace-management-web-e2e'
|
||||
// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled
|
||||
// to that contract if the shared grace tuning changes.
|
||||
const POINTER_TRANSIT_MS = 300
|
||||
const POINTER_HOLD_MS = 600
|
||||
|
||||
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
|
||||
describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
/**
|
||||
* Drive the in-app browser to a directory via its path-edit affordance,
|
||||
* confirm it, and wait for the adoption to settle host-side (workspace
|
||||
* registered + the flow's New-Session agent up), so later test steps can't
|
||||
* race the in-flight blank-session attach.
|
||||
* Raise the region header's directory dialog and drive it to a directory via
|
||||
* the path-edit affordance. Adding is the header button's only action, so
|
||||
* the click lands in the dialog with no menu in between.
|
||||
*/
|
||||
async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
|
||||
const agentsBefore = scaffold.ctx.agents.list().length
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
|
||||
async function browseTo(path: string): Promise<Locator> {
|
||||
await page.getByRole('button', { name: 'Add workspace' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
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()
|
||||
return dialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder inside `parent` through the dialog and adopt it — the
|
||||
* product's only route to a brand-new workspace directory.
|
||||
*/
|
||||
async function addNewFolderWorkspace(parent: string, name: string): Promise<void> {
|
||||
const dialog = await browseTo(parent)
|
||||
await dialog.getByRole('button', { name: 'New folder' }).click()
|
||||
await page.getByLabel('Folder name').fill(name)
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click()
|
||||
// Creating selects the new folder in the listing; Open adopts it.
|
||||
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(join(parent, name)),
|
||||
{ timeout: 10_000 },
|
||||
).not.toBeUndefined()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt an existing directory, waiting for the adoption to settle host-side
|
||||
* (workspace registered + the flow's New-Session agent up), so later test
|
||||
* steps can't race the in-flight blank-session attach.
|
||||
*/
|
||||
async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
|
||||
const agentsBefore = scaffold.ctx.agents.list().length
|
||||
const dialog = await browseTo(path)
|
||||
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(path),
|
||||
@@ -86,22 +119,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('creates two workspaces by name through the region-header dialog', async () => {
|
||||
it('adds two workspaces through the dialog, each on a folder it created', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
|
||||
const createByName = async (name: string): Promise<void> => {
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
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()
|
||||
await expect.poll(() => page.getByRole('dialog', { name: 'Create a new workspace' }).count(), { timeout: 10_000 }).toBe(0)
|
||||
const add = async (name: string): Promise<void> => {
|
||||
await addNewFolderWorkspace(scaffold.workspaceCwd, name)
|
||||
// The real workspace materializes in the tree as a group row.
|
||||
await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
await createByName('alpha-ws')
|
||||
await createByName('beta-ws')
|
||||
// Durable on the host: both registered, newest first (create prepends).
|
||||
await add('alpha-ws')
|
||||
await add('beta-ws')
|
||||
// Durable on the host: both registered, newest first (create prepends),
|
||||
// each titled after the folder the dialog made.
|
||||
const titles = scaffold.ctx.workspace.list().map(workspace => workspace.title)
|
||||
expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -167,7 +195,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
collect()
|
||||
})
|
||||
// Register the scaffold's existing project directory through the real UI.
|
||||
await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
|
||||
await adoptDirectory(scaffold.workspaceCwd, { waitForAgent: true })
|
||||
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
|
||||
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
|
||||
await workspace.attachSession(SessionId(SEED_ID))
|
||||
@@ -225,7 +253,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
// Re-registering the exact deleted path immediately, without a reload, is
|
||||
// a supported reversible flow. It creates a fresh Workspace id without
|
||||
// re-adopting the retained Session.
|
||||
await openLocalFolder(scaffold.workspaceCwd)
|
||||
await adoptDirectory(scaffold.workspaceCwd)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
|
||||
{ timeout: 10_000 },
|
||||
@@ -295,7 +323,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
collect()
|
||||
})
|
||||
|
||||
await openLocalFolder(oldPath)
|
||||
await adoptDirectory(oldPath)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(oldPath),
|
||||
{ timeout: 10_000 },
|
||||
@@ -311,12 +339,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
.getByRole('button', { name: 'Delete workspace' }).click()
|
||||
await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
|
||||
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
|
||||
const create = page.getByRole('dialog', { name: 'Create a new workspace' })
|
||||
await create.getByLabel('New workspace name').fill(title)
|
||||
await create.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0)
|
||||
await addNewFolderWorkspace(scaffold.workspaceCwd, title)
|
||||
const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
|
||||
expect(fresh?.id).toBeDefined()
|
||||
expect(fresh?.id).not.toBe(oldWorkspace.id)
|
||||
@@ -366,13 +389,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
process.env.HOME = scaffold.workspaceCwd
|
||||
process.env.USERPROFILE = scaffold.workspaceCwd
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Edit path' }).click()
|
||||
await dialog.getByLabel('Edit path').fill(staged)
|
||||
await dialog.getByLabel('Edit path').press('Enter')
|
||||
const dialog = await browseTo(staged)
|
||||
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)
|
||||
@@ -387,14 +404,54 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('shows the session hover card after a dwell on the row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
|
||||
// Expand Ungrouped to reveal the seeded session row, then dwell on it
|
||||
// (the card opens after a 500ms hover delay, portaled to body).
|
||||
it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => {
|
||||
// The panes must track the draft without leaving the editor, so the
|
||||
// typed text and what is listed under it never disagree.
|
||||
// Staged by this scenario itself (mkdir is recursive and idempotent), so
|
||||
// running it alone through -t sees the same tree the assertions describe.
|
||||
const staged = join(scaffold.workspaceCwd, 'browse-golden')
|
||||
await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true })
|
||||
await mkdir(join(staged, 'beta'), { recursive: true })
|
||||
const dialog = await browseTo(staged)
|
||||
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await dialog.getByRole('button', { name: 'Edit path' }).click()
|
||||
const path = dialog.getByLabel('Edit path')
|
||||
// A directory part no pane lists: the panes walk to it, landing the
|
||||
// ordinary two-pane Miller view (level | its children) with the editor
|
||||
// still up and the draft intact.
|
||||
await path.fill(`${join(staged, 'alpha')}${sep}`)
|
||||
await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2)
|
||||
expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`)
|
||||
// Erasing back past the separator walks the panes up, so the level being
|
||||
// typed is the last pane again (its children no longer stand to its
|
||||
// right) and the tail filters it.
|
||||
await path.fill(`${staged}${sep}al`)
|
||||
await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0)
|
||||
await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2)
|
||||
// A tail nobody matches is a name still being spelled: the level shows
|
||||
// whole instead of emptying under it.
|
||||
await path.fill(`${staged}${sep}zzz`)
|
||||
await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
/**
|
||||
* Expand Ungrouped and return its seeded session row. The only visible child
|
||||
* is the non-blank persisted Session; the blank Session created while
|
||||
* adopting the Workspace stays hidden.
|
||||
* @returns the session row locator, already present.
|
||||
*/
|
||||
async function seededSessionRow() {
|
||||
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
const ungroupedSection = ungroupedRow.locator('..')
|
||||
// Initial-current auto-expansion can race this following test's gesture;
|
||||
// converge on expanded rather than assuming which update wins first.
|
||||
// Initial-current auto-expansion can race this gesture; converge on
|
||||
// expanded rather than assuming which update wins first.
|
||||
await expect.poll(async () => {
|
||||
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
@@ -402,17 +459,72 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
}
|
||||
return await ungroupedRow.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
// The only visible child is the non-blank persisted Session; the blank
|
||||
// Session created while adopting the Workspace remains hidden.
|
||||
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
const row = ungroupedSection.locator('[role="treeitem"]').nth(1)
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
return row
|
||||
}
|
||||
|
||||
it('shows the session hover card after a dwell on the row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
|
||||
// Dwell on the seeded row; the card opens after a 500ms hover delay,
|
||||
// portaled to body.
|
||||
const sessionRow = await seededSessionRow()
|
||||
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
|
||||
await sessionRow.hover()
|
||||
// Card content: the full title plus the Idle status line (display-only
|
||||
// card; no aria role — text anchors are the stable selector).
|
||||
// Card content: the full title plus the Idle status line (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.
|
||||
// The card is REACHABLE: it sits 8px off the row, so getting to it means
|
||||
// crossing ground that belongs to neither. Hovering it must not dismiss
|
||||
// it — the regression this scenario guards.
|
||||
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
|
||||
await card.hover()
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1)
|
||||
// The full title is the card's primary value: activating anywhere on the
|
||||
// card writes it through the browser clipboard and localizes the success
|
||||
// feedback through the English locale seat.
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
const cardHeight = (await card.boundingBox())?.height
|
||||
await card.click()
|
||||
const copied = page.getByRole('status').getByText('Copied', { exact: true })
|
||||
await copied.waitFor({ timeout: 5_000 })
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
expect((await card.boundingBox())?.height).toBe(cardHeight)
|
||||
expect(await copied.isVisible()).toBe(true)
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle)
|
||||
// Leaving anchor and card together closes it after the grace.
|
||||
await page.getByRole('button', { name: 'Settings' }).hover()
|
||||
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
|
||||
await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
|
||||
const sessionRow = await seededSessionRow()
|
||||
// The trigger is display:none until its row hovers.
|
||||
await sessionRow.hover()
|
||||
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
|
||||
await trigger.click()
|
||||
const item = page.getByRole('menuitem', { name: 'Rename' })
|
||||
await item.waitFor({ timeout: 5_000 })
|
||||
// Into the list, then back up to the trigger across the 4px gap below it:
|
||||
// that return trip used to fire the list's pointerleave and close the
|
||||
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
|
||||
// the pointer ON the trigger, so entering the list has to come first for
|
||||
// the return to be a real departure.
|
||||
await item.hover()
|
||||
await page.waitForTimeout(POINTER_TRANSIT_MS)
|
||||
await trigger.hover()
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
|
||||
// ...and back down into the list, which must still be there to enter.
|
||||
await item.hover()
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
|
||||
// Pointer-leave dismissal still applies once the pointer genuinely leaves.
|
||||
await page.getByRole('button', { name: 'Settings' }).hover()
|
||||
await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
@@ -465,6 +577,27 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('opens folders with identical basenames as distinct workspaces', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-duplicate-basename'))
|
||||
const firstPath = join(scaffold.workspaceCwd, 'same-basename-a', 'xx')
|
||||
const secondPath = join(scaffold.workspaceCwd, 'same-basename-b', 'xx')
|
||||
await mkdir(firstPath, { recursive: true })
|
||||
await mkdir(secondPath, { recursive: true })
|
||||
|
||||
await adoptDirectory(firstPath, { waitForAgent: true })
|
||||
await adoptDirectory(secondPath, { waitForAgent: true })
|
||||
|
||||
const matchingWorkspaces = scaffold.ctx.workspace.list()
|
||||
.filter(workspace => workspace.title === 'xx')
|
||||
expect(matchingWorkspaces.map(workspace => workspace.path).sort())
|
||||
.toEqual([firstPath, secondPath].sort())
|
||||
await expect.poll(
|
||||
() => page.locator('button[aria-label="Workspace actions for xx"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(2)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
// The directory-browser aria golden is this spec's one owned artifact;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"exclude": [
|
||||
"tests/scaffold.ts",
|
||||
"tests/scaffold-hermetic.e2e.ts",
|
||||
"tests/core-web-profile.snapshot.ts",
|
||||
"tests/live-interactions.e2e.ts",
|
||||
"tests/question-composer.e2e.ts",
|
||||
"tests/approval-composer.e2e.ts",
|
||||
@@ -35,17 +36,27 @@
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/remote-welcome.e2e.ts",
|
||||
"tests/workspace-management.e2e.ts",
|
||||
"tests/replay-round-trip.e2e.ts",
|
||||
"tests/hmr-live.e2e.ts",
|
||||
"tests/seeded-history.e2e.ts",
|
||||
"tests/sidebar-scrollbar.e2e.ts",
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
"tests/composer-draft-scroll.e2e.ts",
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/web-search-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/markdown-images.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts"
|
||||
"tests/permission-policy-context.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
|
||||
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
|
||||
+ 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). '
|
||||
+ 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.'
|
||||
|
||||
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
|
||||
function rejectStandaloneServe(): Plugin {
|
||||
return {
|
||||
name: 'dsh-reject-standalone-web-serve',
|
||||
config(_config, env) {
|
||||
if (env.command === 'serve') throw new Error(STANDALONE_ERROR)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [rejectStandaloneServe(), react()],
|
||||
resolve: {
|
||||
// Workspace packages resolve to SOURCE: package.json exports point at lib
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
@@ -22,6 +36,7 @@ export default defineConfig({
|
||||
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user