Merge origin/master into codex/sandbox-policy-context

This commit is contained in:
NI0317
2026-07-31 15:03:11 +08:00
196 changed files with 4309 additions and 670 deletions

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

@@ -27,11 +27,12 @@ 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
// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error
// copy in the tree), 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()
@@ -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')
@@ -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

@@ -2,15 +2,18 @@
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// and the Language row (settings-scoped localization + persisted dsh.locale).
// and the Language row (settings-scoped localization + persisted dsh.locale),
// plus Permission as the persisted default for subsequently created sessions.
// Zero model calls: everything is pure client + persistence state on a blank
// frame, so there is no fixture and a stray stream would fail loud on the
// open llm seam.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
@@ -21,7 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: settings modal, appearance gesture, language switch', () => {
describe('web e2e: settings modal and General preferences', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -50,9 +53,9 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
// General is the active section by default; its skeleton rows plus the
// functional Language and Appearance rows render.
// General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
// Golden of the freshly opened dialog (default zh, General active).
@@ -73,6 +76,55 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stores Permission as the default for future sessions without changing an existing session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'danger-full-access' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
const selector = dialog.getByRole('button', { name: 'Full access' })
await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('permission:')
expect(document).toContain('defaultPreset: read-only')
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'danger-full-access' })
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
expect(created.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'read-only' }],
['sandbox/mode', { mode: 'read-only' }],
['approval/policy', { policy: 'ask' }],
])
await dialog.getByRole('button', { name: 'Read Only' }).click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const confirmation = page.getByRole('dialog', { name: '确认启用 Full access' })
const enable = confirmation.getByRole('button', { name: '启用 Full access' })
expect(await enable.isDisabled()).toBe(true)
await confirmation.getByRole('checkbox').click()
await enable.click()
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('flips the theme through the Appearance cubes and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>

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

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

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

@@ -0,0 +1,23 @@
- 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 "Edit":
- img
- 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

@@ -16,6 +16,7 @@
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages"
- textbox "Message the agent"
- button "Commands":

View File

@@ -16,6 +16,7 @@
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- button "2 queued messages" [disabled] [expanded]
- list:
- listitem:

View File

@@ -16,6 +16,7 @@
- img
- text: Context injection
- paragraph: partial
- status: Deep diving...
- list:
- listitem:
- text: Edited queue item

View File

@@ -10,11 +10,11 @@
- button "关闭":
- img
- text: 关闭
- text: 权限 选择默认权限模式
- button "Read only" [disabled]:
- text: Read only
- text: 权限 选择新会话的默认权限模式
- button "Full access":
- text: Full access
- img
- text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言
- text: 语言
- button "中文":
- text: 中文
- img

View File

@@ -23,6 +23,7 @@
- img
- img
- text: Ask question waiting
- status: Deep diving...
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]

View File

@@ -1,10 +1,13 @@
// 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).
// persisted group-by preference, the session hover card, and the session
// archive round trip (row menu → workspace.archiveSession RPC → durable
// global set → row hidden across reload). Zero model calls:
// workspace.create/rename/archiveSession are host RPCs with no model
// involvement, and the one session row the flat/hover/archive scenarios need
// comes from a seeded fixture (the seeded-history seed reused verbatim — no
// new recording).
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -413,6 +416,55 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
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.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

@@ -40,6 +40,7 @@
"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/message-actions.e2e.ts",
"tests/queue-actions.e2e.ts",