Merge remote-tracking branch 'github/master' into xtr/trajectory-timeline-polish

# Conflicts:
#	packages/client/ui-trajectory/src/client/timeline.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
_Kerman
2026-07-31 20:01:16 +08:00
847 changed files with 94678 additions and 7001 deletions

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -112,11 +112,19 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
}, { 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 +133,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).

View File

@@ -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 () => {

View File

@@ -0,0 +1,397 @@
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
// GLYPHS, not just its caret.
//
// The composer paints its text in two stacked layers (see
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
// `<textarea>` carries the value, the selection and the caret but renders its
// own glyphs `color: transparent`, and every visible character is painted by the
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint. The backdrop is `position: absolute;
// inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
// browser links its scroll offset to the textarea's.
//
// So past the cap the textarea scrolled and the words did not: the caret walked
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
// backdrop on every textarea `scroll`, which is the one event every way of
// moving the box ends in.
//
// Mirroring an offset is only correct while both layers can reach it, so the
// geometry underneath is asserted here alongside the visible outcome: the
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
// caret after a final newline; `pre-wrap` collapses one), and one wrap width
// across all three layers (only the textarea scrolls, so only it can lose
// width to a scrollbar that consumes layout space). Either breaks the extent
// equality, and an unreachable offset clamps the glyphs below the caret.
//
// Only a real engine can show this. Scrolling is layout: jsdom reports
// `scrollHeight === clientHeight` for every element and never scrolls one, so
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx has
// to stub both offsets and can only prove the mirroring code path runs. What is
// asserted here instead is the user-visible fact that path exists for — after
// scrolling to the end of a long draft, the LAST line is the one on screen —
// measured with a DOM Range over the backdrop's own text.
//
// Zero model calls: a fresh workspace's blank session already carries a live
// composer, and the scenario only types into it. A stray stream would fail loud
// with NO_ADAPTER.
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, compareOrRefreshGolden, launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
/**
* Committed golden of the composer's two-layer scroll geometry. The change
* alters no DOM and no accessible name, so the aria goldens the other scenarios
* commit are byte-identical with and without it; this records the relations
* instead, which makes a shift in the cap or in the layer coupling a reviewable
* diff rather than an assertion someone has to reconstruct.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Marks the first and last line so a Range can find them in the backdrop's text. */
const FIRST_MARKER = 'FIRST-LINE-MARKER'
const LAST_MARKER = 'LAST-LINE-MARKER'
/** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
const DRAFT_LINES = 40
const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
if (index === 0) return FIRST_MARKER
if (index === DRAFT_LINES - 1) return LAST_MARKER
return `draft line ${String(index + 1).padStart(2, '0')}`
}).join('\n')
/**
* A draft ending in a newline: the shape whose layer extents diverge without
* the backdrop's trailing-line sentinel. A textarea reserves a line box for the
* caret after a final newline; `white-space: pre-wrap` collapses a text node's
* trailing newline and generates none, so the backdrop would come out exactly
* one line shorter and the mirrored offset would clamp a line above the caret.
*/
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
/** The composer's two text layers as the browser lays them out. */
interface ComposerMetrics {
/** True when the draft is taller than the capped box — the situation under test. */
overflows: boolean
/** Visible height of the textarea's content box: the cap in pixels. */
clientHeight: number
/** Whole lines that fit in the visible box, at the composer's own line-height. */
visibleLines: number
/** The textarea's scroll offset, which the caret and the selection follow. */
inputScrollTop: number
/** The backdrop's scroll offset, which every visible glyph follows. */
backdropScrollTop: number
/** True when the two layers agree — the coupling this scenario exists for. */
layersAgree: boolean
/**
* Top of the LAST draft line relative to the visible box's top, in pixels: at
* most `clientHeight` when that line is on screen. This is the reported
* symptom as a number — with the layers uncoupled the backdrop stays at offset
* 0, so the last line sits a full draft-height below the box.
*/
lastLineOffset: number
/** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
firstLineOffset: number
/** Furthest the textarea can scroll. */
inputMax: number
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
backdropMax: number
/** Content width the textarea wraps at. */
inputWrapWidth: number
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
backdropWrapWidth: number
/** Content width the hidden auto-grow mirror wraps at — it decides the box's height. */
mirrorWrapWidth: number
}
/**
* Measure both composer layers in the page.
* @param page - the page under test.
* @returns the two layers' offsets and where the draft's first and last lines sit.
*/
function measureComposer(page: Page): Promise<ComposerMetrics> {
return page.evaluate(({ first, last }) => {
const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
if (input === null) throw new Error('no live composer textarea in the DOM')
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
// The hidden auto-grow mirror: the textarea's next sibling, and the layer
// that decides the box's height, so its wrap width matters as much as the
// two that carry glyphs.
const mirror = input.nextElementSibling
if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
const box = input.getBoundingClientRect()
// The draft carries no chips or claim token, so the decoration walk emits it
// as one text node — the backdrop's first, ahead of the trailing-line
// sentinel React renders as a second one. Both markers live in that first
// node, which is what the Range below needs.
const text = backdrop.firstChild
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
const offsetOf = (marker: string): number => {
const at = text.data.indexOf(marker)
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
const range = document.createRange()
range.setStart(text, at)
range.setEnd(text, at + marker.length)
return range.getBoundingClientRect().top - box.top
}
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
// Each layer's own maximum, probed by asking for an impossible offset and
// reading back what it clamped to, then restored. Reading scrollHeight -
// clientHeight instead would compute the maximum rather than observe it.
const restore = input.scrollTop
const restoreBackdrop = backdrop.scrollTop
input.scrollTop = 1e7
backdrop.scrollTop = 1e7
const inputMax = input.scrollTop
const backdropMax = backdrop.scrollTop
input.scrollTop = restore
backdrop.scrollTop = restoreBackdrop
return {
inputMax,
backdropMax,
inputWrapWidth: input.clientWidth,
backdropWrapWidth: backdrop.clientWidth,
mirrorWrapWidth: mirror.clientWidth,
overflows: input.scrollHeight > input.clientHeight,
clientHeight: input.clientHeight,
visibleLines: Math.floor(input.clientHeight / lineHeight),
inputScrollTop: input.scrollTop,
backdropScrollTop: backdrop.scrollTop,
layersAgree: input.scrollTop === backdrop.scrollTop,
lastLineOffset: offsetOf(last),
firstLineOffset: offsetOf(first),
}
}, { first: FIRST_MARKER, last: LAST_MARKER })
}
/**
* Render the golden body.
*
* Absolute glyph coordinates are deliberately absent: they depend on font
* metrics and would make the fixture fail on a machine that measures text
* differently — a golden that needs re-recording per platform documents the
* platform, not the change. What is recorded is the cap, the layer agreement,
* and which lines are on screen, each a comparison that survives any layout
* keeping the coupling.
* @param top - metrics with the draft scrolled to its start.
* @param bottom - metrics with the draft scrolled to its end.
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string {
return [
'# Composer draft scrolling (14-line cap, two text layers)',
'',
'## At the start of the draft',
'',
`- draft overflows the capped box: ${String(top.overflows)}`,
`- visible lines: ${String(top.visibleLines)}`,
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
`- all three layers wrap at one width: ${String(
top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
)}`,
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
`- glyph layer tracks it: ${String(top.layersAgree)}`,
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
`- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
'',
'## Scrolled to the end of the draft',
'',
`- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
`- glyph layer tracks it: ${String(bottom.layersAgree)}`,
`- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
`- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
'',
'## Draft ending in a newline, scrolled to the end',
'',
`- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
`- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
`- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
].join('\n').trimEnd()
}
describe('web e2e: composer draft scrolling', () => {
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 })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
await page.locator('textarea:enabled').first().fill(DRAFT)
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('caps the draft box and keeps both text layers at the start', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
// Vacuity guard: without an overflowing draft there is nothing to scroll and
// every assertion below holds trivially.
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
// Typing the draft left the caret — and the box — at its end, so reach the
// start by the same gesture a user would, and leave it there for the wheel
// case below.
await page.locator('textarea:enabled').first().hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
const metrics = await measureComposer(page)
// The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
// 14 x 24px lines). The count, not the pixels: it is the figma constant and
// survives a device-pixel-ratio change.
expect(metrics.visibleLines).toBe(14)
// Resting state: the draft's head is what a 40-line draft shows, and its
// tail is far below the box. Both layers sit at the origin, which is why the
// uncoupled build looks correct until something scrolls.
expect(metrics.inputScrollTop).toBe(0)
expect(metrics.layersAgree).toBe(true)
expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('lays out all three text layers at one wrap width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
// The premise under the mirror, asserted rather than assumed. Only .input
// scrolls, so only .input can lose content width to a scrollbar that
// consumes layout space; a narrower .input wraps a long draft onto more
// lines, ends up taller, and its larger maximum makes the mirrored offset
// clamp below the caret. Measured on a standalone harness, an 8px width
// difference is worth 2 to 5 lines on a wrap-sensitive draft.
//
// This holds on the lane's engine and is what a regression would break —
// it is NOT vacuous: measured on the same app, WebKit reports 768 against
// 776 here, which is the divergence the Agent Note records as a
// pre-existing, engine-specific limitation. The mirror is unaffected there
// today because the extents still agree; this assertion is what would
// notice if the lane's engine ever moved into the same state.
const metrics = await measureComposer(page)
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
// The mirror decides the box height, so it belongs in the same equality —
// were it alone to wrap wider, the box would be measured too short and
// clip content before the 14-line cap, with every other assertion green.
expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
const input = page.locator('textarea:enabled').first()
await input.hover()
// One delta past the whole draft: the textarea clamps at its own end, and
// the wheel-chaining handler leaves it native because the box is not yet at
// its edge when the gesture starts (the chaining itself is owned by the
// unit spec).
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const metrics = await measureComposer(page)
// The coupling, stated directly.
expect(metrics.layersAgree).toBe(true)
// The reported symptom, stated as what the user sees: the end of the draft
// is on screen and its beginning is not. On the uncoupled build the glyph
// layer stays at offset 0, so `lastLineOffset` is still a full draft below
// the box and `firstLineOffset` is still 0 — the text never moved.
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
expect(metrics.firstLineOffset).toBeLessThan(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('typing at the end of a scrolled draft keeps the layers together', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
// The other way the box moves. Typing at the caret — parked at the draft's
// end by the wheel gesture — scrolls it into view, which is a `scroll` like
// any other; this pins that an edit is not a separate case needing its own
// mirror, which is why one listener is the whole implementation.
const input = page.locator('textarea:enabled').first()
await input.press('End')
await input.pressSequentially(' tail')
const metrics = await measureComposer(page)
expect(metrics.layersAgree).toBe(true)
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
// The layers reserve a final line box on different terms, so this shape is
// the one that separates equal extents from a mirror that clamps early.
const input = page.locator('textarea:enabled').first()
await input.fill(DRAFT_TRAILING_NEWLINE)
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
const extents = await measureComposer(page)
// The invariant the sentinel exists for. Without it the textarea measured
// 652 against the backdrop's 628 — one 24px line apart.
expect(extents.backdropMax).toBe(extents.inputMax)
await input.hover()
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
}, { timeout: 10_000 }).toBe(true)
const bottom = await measureComposer(page)
// At the very bottom the glyphs are level with the caret, not a line behind.
expect(bottom.layersAgree).toBe(true)
expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed composer scroll geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
const input = page.locator('textarea:enabled').first()
// Restore the pristine draft (the edit case appended to it) and return to
// its start, both through ordinary gestures.
await input.fill(DRAFT)
await input.hover()
await page.mouse.wheel(0, -2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
const top = await measureComposer(page)
await input.hover()
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
const bottom = await measureComposer(page)
await input.fill(DRAFT_TRAILING_NEWLINE)
await input.hover()
await page.mouse.wheel(0, 4000)
await expect.poll(async () => {
const m = await measureComposer(page)
return m.inputScrollTop === m.inputMax
}, { timeout: 10_000 }).toBe(true)
const trailingNewline = await measureComposer(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// Zero model calls, so the scenario records no session fixture: the geometry
// golden is the whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})

View File

@@ -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 () => {

View 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()
})
})

View File

@@ -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 () => {

View File

@@ -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' })
@@ -172,10 +172,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')

View File

@@ -27,14 +27,15 @@ 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 per interactive end-state: 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 — three genuinely different terminal surfaces of one fixture.
// 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,
// 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
@@ -95,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)
}
/**
@@ -133,6 +134,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// The marker IS the synchronization: the stream is provably parked in the
// hang (prefix chunks delivered to the loop) before the stop click.
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
await expect.poll(
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
@@ -151,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()
@@ -159,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
@@ -231,7 +238,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md',
'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
])
})
})

View File

@@ -64,14 +64,14 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
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). User and each turn's last content assistant both
// have copy + branch.
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
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)
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 () => {

View File

@@ -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 })

View File

@@ -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')

View File

@@ -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'])
})
})

View 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'])
})
})

View 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'

View File

@@ -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 () => {

View File

@@ -49,7 +49,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 () => {

View File

@@ -68,7 +68,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,7 +131,7 @@ 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([])

View File

@@ -48,7 +48,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 () => {

View File

@@ -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, {
@@ -100,6 +106,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
@@ -135,6 +147,19 @@ 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
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -196,13 +221,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
@@ -241,6 +270,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,6 +293,8 @@ 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.
@@ -266,6 +306,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
})
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')

View 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-global"]')).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)
})
})

View File

@@ -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,6 +319,22 @@ 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

View File

@@ -18,7 +18,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 +33,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 })
@@ -215,6 +217,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'])

View File

@@ -0,0 +1,83 @@
// 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 { 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',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'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 probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
let scaffold: WebScaffold | undefined
afterEach(async () => {
await scaffold?.close()
scaffold = undefined
})
it('assembles the shipped Web catalog and keeps its 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)
expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name)))
// `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())]),
)
// The Web surface keeps its shipped access default; the base's confined one
// reaches the TUI. Pinning both keeps a base change from moving Web silently.
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('danger-full-access')
expect(scaffold.ctx.approval.config.policy).toBe('never')
}, 120_000)

View File

@@ -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 () => {

View File

@@ -20,7 +20,7 @@ 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'
@@ -163,6 +163,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'],
@@ -188,8 +190,12 @@ describe('dsh web keyless CLI smoke', () => {
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 +203,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":""}}]}',
@@ -222,6 +228,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,6 +248,8 @@ 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'))
expect(workspaceMessage).toMatchInlineSnapshot(`
@@ -256,6 +265,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 +418,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 +467,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 +518,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')

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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 +22,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"
@@ -42,4 +44,4 @@
- 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

View File

@@ -0,0 +1,25 @@
# Composer draft scrolling (14-line cap, two text layers)
## At the start of the draft
- draft overflows the capped box: true
- visible lines: 14
- both layers share one scroll extent: true
- all three layers wrap at one width: true
- textarea scroll offset: 0px
- glyph layer tracks it: true
- first draft line is on screen: true
- last draft line is on screen: false
## Scrolled to the end of the draft
- textarea moved: true
- glyph layer tracks it: true
- first draft line has scrolled out above: true
- last draft line is on screen: true
## Draft ending in a newline, scrolled to the end
- both layers share one scroll extent: true
- glyph layer tracks the caret: true
- last draft line is on screen: true

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- button "Think The user wants me to:":
- img
- img
@@ -57,4 +59,4 @@
- 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

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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
@@ -37,4 +39,4 @@
- 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

View File

@@ -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"

View File

@@ -7,7 +7,7 @@
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- button "Add workspace":
- img
- button "Search sessions":
- img
@@ -16,7 +16,7 @@
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- treeitem "New Session" [selected]
- button "Settings":
- img
- text: Settings

View File

@@ -7,7 +7,7 @@
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- button "Add workspace":
- img
- button "Search sessions":
- img
@@ -16,7 +16,7 @@
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- treeitem "New Session" [selected]
- button "Settings":
- img
- text: Settings

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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
@@ -29,4 +31,4 @@
- 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

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- text: Stopped
- button "Copy":

View File

@@ -9,8 +9,13 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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

View File

@@ -0,0 +1,25 @@
- 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":
- img
- 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 "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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.":
@@ -31,4 +33,4 @@
- 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

View File

@@ -10,8 +10,6 @@
- tooltip "Copy"
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- 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

View File

@@ -1,6 +1,5 @@
- dialog "添加一个 API Key 开始使用":
- region "添加一个 API Key 开始使用":
- heading "添加一个 API Key 开始使用" [level=2]
- button "稍后配置":
- img
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
- button "稍后配置"
- button "前往配置"

View File

@@ -0,0 +1,9 @@
- region "内测声明":
- heading "内测声明" [level=2]
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
- paragraph:
- text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
- button "继续"

File diff suppressed because one or more lines are too long

View File

@@ -10,8 +10,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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
@@ -42,4 +44,4 @@
- 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

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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
@@ -37,4 +39,4 @@
- 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

View File

@@ -9,9 +9,12 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"
- textbox "Message the agent"
- button "Commands":

View File

@@ -9,9 +9,12 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages" [disabled] [expanded]
- list:
- listitem:

View File

@@ -9,9 +9,12 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- button "Context injection":
- img
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- list:
- listitem:
- text: Edited queue item

View 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.)

View File

@@ -9,8 +9,6 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- 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 +33,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

View File

@@ -9,8 +9,6 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- img
- 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 +33,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

View File

@@ -9,16 +9,20 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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...
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]

View File

@@ -9,8 +9,10 @@
- img
- button "Branch into a new conversation":
- img
- button "Edit":
- 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
@@ -38,4 +40,4 @@
- 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

View 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"}}}}

View File

@@ -0,0 +1,34 @@
- 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":
- img
- 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: Full access"': Full access
- 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

View 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)
})

View File

@@ -72,7 +72,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
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 () => {

View File

@@ -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.
@@ -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"]')

View File

@@ -0,0 +1,207 @@
// 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 {
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'
const RESULT_URL = 'https://docs.example.test/search'
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 one source.',
citations: [{
type: 'web_search_result_location',
url: RESULT_URL,
cited_text: 'Snapshot search excerpt.',
}],
},
{
type: 'web_search_tool_result',
content: [{
type: 'web_search_result',
url: RESULT_URL,
title: 'Snapshot Search Result',
page_age: '2026-07-31',
}],
},
],
}))
})
})
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 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)
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toContain(`[Snapshot Search Result](${RESULT_URL})`)
expect(searchResult.data.meta).toMatchObject({
sources: [{
url: RESULT_URL,
title: 'Snapshot Search Result',
snippet: 'Snapshot search excerpt.',
publishedAt: '2026-07-31',
}],
truncated: false,
})
})
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')('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'])
})
})

View File

@@ -1,14 +1,19 @@
// 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, and the session hover card. Zero model
// calls: workspace.create/rename are host RPCs with no model involvement,
// and the one session row the flat/hover 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), 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 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'
@@ -25,29 +30,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),
@@ -83,22 +118,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([])
@@ -164,7 +194,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))
@@ -222,7 +252,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 },
@@ -292,7 +322,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 },
@@ -308,12 +338,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)
@@ -363,13 +388,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)
@@ -384,14 +403,17 @@ 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).
/**
* 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()
@@ -399,20 +421,145 @@ 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)
it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
// The seeded session lives under Ungrouped (expanded by the hover-card
// test's gesture; converge again for order independence).
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
await page.waitForTimeout(50)
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
// Anchor on session rows (the rows carrying a session actions button),
// not a positional index, and assert the single-stray assumption loudly
// so a fixture gaining a second stray fails here instead of archiving
// the wrong row. CSS attribute match, not getByRole: the button is
// display:none until its row hovers, and role queries skip hidden nodes.
const sessionRows = ungroupedSection.locator('[role="treeitem"]')
.filter({ has: page.locator('button[aria-label^="Session actions for "]') })
await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1)
const sessionRow = sessionRows.first()
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
// Row menu: hover reveals the actions button; Archive session commits
// without a confirmation dialog (non-destructive: log + accounting stay).
await sessionRow.hover()
await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
await page.getByRole('menuitem', { name: 'Archive session' }).click()
// The row disappears on the archive-set echo; with no other visible
// stray, the whole Ungrouped bucket withdraws.
await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
// Durable on the host: the registry-global set carries the id while the
// session log itself stays in persistence untouched.
expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID))
// Reload: the hidden state is rebuilt from the workspace.list baseline.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// The archived row must not resurface (the Ungrouped bucket itself may
// reappear if selection restore lands on another stray — not this test's
// concern).
expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
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;

View File

@@ -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",
@@ -40,11 +41,16 @@
"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/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/startup-auto-selection.e2e.ts"
],
"references": [
{