Merge remote-tracking branch 'origin/master' into worktree/context-source-cards

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
creatixchu
2026-08-05 19:14:56 +08:00
586 changed files with 5188 additions and 3470 deletions

View File

@@ -2,7 +2,6 @@
// 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'
@@ -12,27 +11,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
/**
* connectFreshWorkspace twin over the product default Chinese locale (the
* shared helper's anchors assume the English page every other scenario
* boots; this scenario deliberately keeps zh, so the localized picker
* copy is the anchor set).
*/
async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
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 })
}
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')

View File

@@ -0,0 +1,420 @@
// Web e2e scenario: the input card holds one horizontal position across the
// Chat and Trajectory tabs.
//
// The composer seat is the same node in both tabs, but it measures itself
// against a different edge in each (see
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
// scroller's content box — the box a space-consuming scrollbar shortens. A view
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
// Trajectory declares and which moves the column's own scrolling into the view)
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
// So the two tabs disagreed by exactly the bar's width for as long as the
// transcript overflowed: the card jumped sideways on every tab switch, and
// inside Chat alone at the moment a growing transcript started to scroll. The
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
// and states the overlay branch as a scroll container on the same axes, so both
// edges are the same edge.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
// can assert the declarations exist but not that the two states land in the same
// place. What is asserted here is the user-visible fact — the card does not move
// — measured as the distance between the two tabs' card rectangles.
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree before
// this change as much as after it and every comparison below holds vacuously —
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
// and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
// same two tabs through it, which is what keeps the equal rectangles above from
// being explained by a tab switch that never reached the layout. It is the
// reported symptom as a number: the card moves 4px, half the 8px band, on each
// edge.
//
// Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. 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 { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
/**
* Committed golden of where the input card sits in each tab, at a wide viewport
* (card at its width cap) and a narrow one (card shrinking with the column).
*
* Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture
* that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the reservation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the difference the fix removes rather than only its absence.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
const FIXTURE = createChatScrollFixture({
markerPrefix: 'TAB_GEOMETRY',
title: 'COMPOSER_TAB_GEOMETRY long session',
turns: 24,
})
const SEED_ID = 'composer-tab-geometry-web-e2e'
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
const NARROW_VIEWPORT = { width: 800, height: 1000 }
/**
* Resize to one measurement viewport after the responsive sidebar and center
* column finish their track transition.
* @param page - the page under test.
* @param viewport - the viewport dimensions to apply.
* @param sidebarCollapsed - the sidebar state expected at this width.
*/
async function setMeasuredViewport(
page: Page,
viewport: { width: number; height: number },
sidebarCollapsed: boolean,
): Promise<void> {
await page.setViewportSize(viewport)
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
state: sidebarCollapsed ? 'attached' : 'detached',
timeout: 10_000,
})
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
const deadline = performance.now() + 5_000
let previous = host.getBoundingClientRect().width
let stableFrames = 0
while (performance.now() < deadline) {
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const current = host.getBoundingClientRect().width
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
if (stableFrames >= 3) return
previous = current
}
throw new Error('conversation width did not settle after the viewport changed')
})
}
/**
* The pre-fix cascade, injected into the page: the reservation dropped and the
* overlay branch back to a hidden box. `!important` beats the module rules
* without a rebuild, and the id lets the control be lifted again in the same
* session.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
`
/** The column scroller and the input card as the browser lays them out, in one tab. */
interface TabMetrics {
/** Resolved `scrollbar-gutter` on the column's scroller. */
gutter: string
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
overflowX: string
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
overflowY: string
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** True when the column's scroller actually scrolls — only Chat does. */
scrolls: boolean
/** Left edge of the input card in viewport coordinates. */
cardLeft: number
/** Right edge of the input card. */
cardRight: number
/** Width of the input card, capped at the composer card max width. */
cardWidth: number
}
/** One tab's metrics beside the other's, plus the distances between them. */
interface TabComparison {
chat: TabMetrics
trajectory: TabMetrics
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
leftShift: number
/** Distance between the two tabs' card right edges. */
rightShift: number
/** Difference between the two tabs' card widths. */
widthShift: number
}
/**
* Measure the column scroller and the input card in the tab currently shown.
* @param page - the page under test.
* @returns the scroller's resolved overflow style and the card's rectangle.
*/
function measureTab(page: Page): Promise<TabMetrics> {
return page.evaluate(() => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
if (host === null) throw new Error('conversation column scroller not in the DOM')
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
if (card === null) throw new Error('no input card inside the composer seat')
const style = getComputedStyle(host)
const hostRect = host.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
return {
gutter: style.scrollbarGutter,
overflowX: style.overflowX,
overflowY: style.overflowY,
band: hostRect.width - host.clientWidth,
scrolls: host.scrollHeight > host.clientHeight,
cardLeft: cardRect.left,
cardRight: cardRect.right,
cardWidth: cardRect.width,
}
})
}
/**
* Show one tab and wait for the view that owns it to be laid out.
* @param page - the page under test.
* @param tab - the tab to show.
*/
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
await page.getByRole('tab', { name: tab, exact: true }).click()
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
// Both measurements are taken after a paint, so a rectangle read mid-transition
// cannot be reported as a shift the cascade did not cause.
await page.evaluate(() => new Promise<void>((settle) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
}))
}
/**
* Measure both tabs and the distances between them, leaving Chat shown.
* @param page - the page under test.
* @returns each tab's metrics and the card's displacement between them.
*/
async function compareTabs(page: Page): Promise<TabComparison> {
await showTab(page, 'Chat')
const chat = await measureTab(page)
await showTab(page, 'Trajectory')
const trajectory = await measureTab(page)
await showTab(page, 'Chat')
return {
chat,
trajectory,
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
}
}
/**
* Run the pre-fix cascade in the page for one measurement, then lift it.
* @param page - the page under test.
* @returns the comparison as the column laid out before this change.
*/
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
const style = document.createElement('style')
style.id = id
style.textContent = css
document.head.append(style)
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
try {
return await compareTabs(page)
} finally {
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
}
}
/**
* Open the seeded session from the sidebar search.
*
* Cold summaries carry the temp workspace's basename, so the persisted first
* message is the stable identity to search for, and the query itself drives the
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
* is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
for (;;) {
if (await results.count() === 1) break
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
await page.waitForTimeout(200)
}
await results.click()
}
/**
* Render the golden body.
* @param wide - comparison at the viewport where the card sits at its width cap.
* @param narrow - comparison at the viewport where the card shrinks with the column.
* @param control - comparison at the wide viewport with the reservation removed.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
const section = (name: string, comparison: TabComparison): string[] => [
`## ${name}`,
'',
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
`- Chat reserved band: ${String(comparison.chat.band)}px`,
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
'',
]
return [
'# Input card position across the Chat and Trajectory tabs',
'',
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
...section('Wide viewport, reservation removed in the page (control)', control),
].join('\n').trimEnd()
}
describe('web e2e: input card position across view tabs', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, FIXTURE.log, SEED_ID)
// Scrollbars must take layout space here or the scenario proves nothing;
// see the file header for the measurement behind dropping this argument.
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession(page)
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
.waitFor({ timeout: 30_000 })
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// Vacuity guard, in two parts. A transcript that does not overflow gives
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
// would make the tabs agree without the reservation doing anything.
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0)
// The reservation reaches both states, which is the whole change: the same
// band, on a box that scrolls and on one that only holds a view.
expect(comparison.chat.gutter).toBe('stable')
expect(comparison.trajectory.gutter).toBe('stable')
expect(comparison.trajectory.band).toBe(comparison.chat.band)
// Declared as a scroll container on both axes rather than left to compute:
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
// horizontal axis computes to `auto` beside a scrolling one.
expect(comparison.trajectory.overflowY).toBe('auto')
expect(comparison.trajectory.overflowX).toBe('hidden')
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
expect(comparison.trajectory.scrolls).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place when the tab changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its
// width cap, so the pre-fix shift showed up as a centring difference — half
// the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const capped = await measureTab(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const comparison = await compareTabs(page)
// The other geometry, and a different failure: below the cap the card takes
// the column's width, so an unreserved gutter changed its WIDTH by the whole
// band instead of shifting it by half. Asserted against the capped
// measurement rather than against the cap's pixel value, which belongs to
// the stylesheet.
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('moves the card again once the reservation is removed in the page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
// its bar and the Trajectory branch goes back to a hidden box with none, and
// the card moves by half the band on each edge.
const comparison = await compareTabsWithoutReservation(page)
expect(comparison.chat.gutter).toBe('auto')
expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
// Restoring the sheet restores the fix, so the control cannot leak into the
// remaining measurements.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed tab geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const wide = await compareTabs(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const narrow = await compareTabs(page)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const control = await compareTabsWithoutReservation(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The seeded session is generated in-process, so 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

@@ -104,6 +104,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await input.press('Enter')
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
// The golden encodes an empty composer, and the button arriving does not
// mean the submitted text is gone yet: under load the capture caught a
// textbox still holding `/plan`.
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
const planStyle = await planButton.evaluate((element) => {

View File

@@ -126,9 +126,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', {
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Select model', exact: true })
.waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
await page.getByRole('button', { name: 'Copy' }).first().focus()

View File

@@ -12,7 +12,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
@@ -22,6 +22,7 @@ import {
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 MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
@@ -160,7 +161,60 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
// Opened here rather than inherited: the credential test reloads the page
// to exercise the welcome step, so nothing carries an open dialog across.
await page.getByRole('button', { name: '设置', exact: true }).click()
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '模型' }).click()
const deepSeek = settings.getByText('DeepSeek', { exact: true }).first()
await deepSeek.waitFor({ timeout: 10_000 })
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
await settings.getByText('自定义设置').click()
await settings.getByRole('button', { name: /删除模型/ }).first().click()
await settings.getByRole('button', { name: '添加模型' }).click()
const customModelId = settings.getByLabel('模型 ID 2')
await customModelId.fill('private-preview')
await settings.getByLabel('显示名称 2').fill('Private Preview')
// Capacities live behind the row's own disclosure, as in the pi-ai form.
await settings.getByRole('button', { name: '容量 2' }).click()
await settings.getByLabel('上下文窗口 2').fill('131072')
await settings.getByLabel('最大输出 token 数 2').fill('64K')
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
await settings.getByRole('button', { name: '保存', exact: true }).click()
await customModelId.waitFor({ state: 'detached', timeout: 15_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('id: deepseek-v4-pro')
expect(document).toContain('id: private-preview')
expect(document).toContain('name: Private Preview')
expect(document).toContain('contextWindow: 131072')
expect(document).toContain('maxTokens: 64000')
expect(document).not.toContain('id: deepseek-v4-flash')
await page.keyboard.press('Escape')
// A connected Workspace is what puts a live composer — and its model
// trigger — on the page; the scaffold boots without one.
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true })
await modelTrigger.waitFor({ timeout: 10_000 })
await modelTrigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0)
await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
await assertFixtureInventory(
SNAPSHOT_DIR,
['missing.expected.md', 'models.expected.md', 'welcome.expected.md'],
)
})
})

View File

@@ -56,7 +56,7 @@ import type {} from '@deepseek-ai/dsh-agent'
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
/**
@@ -592,9 +592,9 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string,
}
/**
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
* holds exactly the expected files and every committed JSONL is a scrub
* fixed-point without a run-local browser RPC id.
* Fixture-inventory guard: the scenario directory holds exactly the expected
* files and every committed JSONL is a scrub fixed-point without a run-local
* browser RPC id.
* @param dir - the scenario snapshot directory.
* @param expected - the exact expected file inventory.
*/

View File

@@ -245,12 +245,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
await page.getByRole('button', {
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the selected id, but no catalog
// adapter exists to provide its presentation name.
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the routed id, but without an
// advertised catalog row the selector prompts for a listed replacement.
await page.getByRole('button', { name: 'Select model', exact: true })
.waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)

View File

@@ -61,6 +61,30 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
await openDocument.waitFor({ timeout: 10_000 })
let openRequests = 0
await page.route('**/api/settings.openDocument', async (route) => {
const envelope = route.request().postDataJSON() as {
rpcId: string
payload: Record<string, never>
}
expect(envelope.payload).toEqual({})
openRequests += 1
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
type: 'server-response',
rpcId: envelope.rpcId,
result: { ok: true, value: { opened: true } },
}),
})
})
await openDocument.click()
await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
await page.unroute('**/api/settings.openDocument')
// Golden of the freshly opened dialog (default zh, General active).
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)

View File

@@ -26,8 +26,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok

View File

@@ -0,0 +1,37 @@
# Input card position across the Chat and Trajectory tabs
## Wide viewport (1680px, card at its cap)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Narrow viewport (800px, card shrinking with the column)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Wide viewport, reservation removed in the page (control)
- Chat: scrollbar-gutter auto, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
- Trajectory scroller scrolls: false
- Trajectory reserved band: 0px
- input card left edge moves between tabs: 4px
- input card right edge moves between tabs: 4px
- input card width changes between tabs: 0px

View File

@@ -30,8 +30,8 @@
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode on, press to turn off": Plan
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: Details

View File

@@ -24,8 +24,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok

View File

@@ -51,8 +51,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok

View File

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭

View File

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭

View File

@@ -0,0 +1,71 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- button "编辑"
- text: DeepSeek deepseek-official API 密钥
- textbox "API 密钥":
- /placeholder: 已配置——输入新值可替换
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com
- text: 推理强度
- combobox "推理强度":
- option "默认" [selected]
- option "off"
- option "high"
- option "max"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: deepseek-v4-pro
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: DeepSeek-V4-Pro
- button "容量 1":
- img
- button "删除模型 1":
- img
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: private-preview
- textbox "显示名称 2":
- /placeholder: 显示名称
- text: Private Preview
- button "容量 2" [expanded]:
- img
- button "删除模型 2":
- img
- text: 上下文窗口
- textbox "上下文窗口 2":
- /placeholder: 1M
- text: "131072"
- text: 最大输出 token 数
- textbox "最大输出 token 数 2":
- /placeholder: 256K
- text: 64K
- button "添加模型":
- img
- text: 添加模型
- button "取消"
- button "保存"
- button "添加提供方":
- img
- text: 添加提供方

View File

@@ -47,8 +47,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -45,8 +45,8 @@
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok

View File

@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭

View File

@@ -88,6 +88,28 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
.waitFor({ timeout: 15_000 })
}
/**
* {@link connectFreshWorkspace} over the product default Chinese locale: the
* English helper's anchors assume the locale every other scenario boots, so a
* scenario that deliberately keeps zh needs the localized picker copy.
* @param page - the browser page under test.
* @param root - workspace parent directory.
* @param name - directory created under `root` and connected.
*/
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('button', { name: '选择工作区' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
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 })
}
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
export async function saveFailureShot(page: Page, name: string): Promise<void> {
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))

View File

@@ -90,9 +90,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
{ timeout: 10_000 },
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; the reuse path (same
// canonical cwd already has a blank session) creates no agent, so callers
// opt in only where a fresh attach is possible.
// settle before a test may delete the registration; re-registration after
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
// path is gone), so callers opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
@@ -251,8 +251,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
// 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.
// a supported reversible flow. It creates a fresh Workspace id and does
// NOT re-adopt the retained (non-blank) Session; the New Session flow
// mints a fresh blank session and attaches it to the new registration
// (the old cwd-only blank reuse is gone, so the account is never empty).
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
@@ -261,7 +263,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
expect(reregistered?.id).toBeDefined()
expect(reregistered?.id).not.toBe(workspace.id)
expect(reregistered?.sessionIds).toEqual([])
await expect.poll(
() => reregistered?.sessionIds ?? [],
{ timeout: 10_000 },
).not.toEqual([])
expect(reregistered?.sessionIds).not.toContain(SEED_ID)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')