Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706
# Conflicts: # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md # .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml # apps/web/tests/scaffold.ts # docs/module-graph.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/package.json # packages/client/test-runtime/README.i18n.yaml # packages/client/test-runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-theme/README.i18n.yaml # packages/client/ui-theme/README.md # packages/client/ui-theme/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.zh.md # pnpm-lock.yaml
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-group": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
|
||||
272
apps/web/tests/agent-preset-authoring.e2e.ts
Normal file
272
apps/web/tests/agent-preset-authoring.e2e.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
// Web e2e scenario: the agent-preset settings section as copy-only authoring.
|
||||
// The browser never edits composition text — a shipped preset opens in a
|
||||
// read-only viewer, the copy dialog collects an id and an optional display
|
||||
// name, and the host copies the whole directory. The section's other job is
|
||||
// getting the user TO the files: this lane pins `nativeOpen: false` (see the
|
||||
// overlay), so the location affordance answers the preset directory as text —
|
||||
// the deterministic branch a golden can hold on every platform.
|
||||
//
|
||||
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { Locator } from 'playwright'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url))
|
||||
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
|
||||
const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
|
||||
const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
|
||||
const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
|
||||
/** The shipped roster, beside the composition that names it. */
|
||||
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
|
||||
const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let userRoot: string
|
||||
|
||||
/** The settings dialog, opened on the Agent-presets section. */
|
||||
function settingsDialog(): Locator {
|
||||
return page.getByRole('dialog', { name: '设置' })
|
||||
}
|
||||
|
||||
/** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */
|
||||
function withPresetRoot(snapshot: string): string {
|
||||
return snapshot.split(userRoot).join('{{presetRoot}}')
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: OVERLAY,
|
||||
agentPresets: {
|
||||
roots: [
|
||||
{ path: SHIPPED_PRESETS, trust: 'system' },
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
default: 'standard',
|
||||
},
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
// 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 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('offers the roster with copy as the only way to create', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = settingsDialog()
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
|
||||
// The intro carries the guidance a create button used to imply, and the
|
||||
// shipped rows offer view/copy but never delete or a location — their
|
||||
// install is overwritten by upgrades and is not the user's to manage.
|
||||
expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
|
||||
expect(snapshot).not.toContain('新建预设')
|
||||
expect(snapshot).toContain('查看: 标准模式')
|
||||
expect(snapshot).not.toContain('删除: 标准模式')
|
||||
expect(snapshot).not.toContain('打开目录')
|
||||
}, 60_000)
|
||||
|
||||
it('views a shipped composition read-only instead of editing it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
|
||||
const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
|
||||
await viewer.waitFor({ timeout: 10_000 })
|
||||
|
||||
// The real shipped composition, not a golden: the viewer shows whatever
|
||||
// the deployment ships, and this lane only asserts it is shown read-only.
|
||||
const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8')
|
||||
expect(await viewer.locator('pre').textContent()).toBe(shipped)
|
||||
expect(await viewer.getByRole('textbox').count()).toBe(0)
|
||||
// The header X and the footer button share the 关闭 name; the footer one
|
||||
// is last in the dialog.
|
||||
await viewer.getByRole('button', { name: '关闭' }).last().click()
|
||||
await viewer.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
}, 60_000)
|
||||
|
||||
it('copies 极简模式 whole under a new id and lands in its files', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
|
||||
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
|
||||
await copyDialog.waitFor({ timeout: 10_000 })
|
||||
|
||||
const dialogSnapshot = await captureStableAria(
|
||||
page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE)
|
||||
// Two fields and nothing else: the id is the directory name the host
|
||||
// needs up front; description and composition live in the files.
|
||||
expect(dialogSnapshot).toContain('标识符')
|
||||
expect(dialogSnapshot).not.toContain('描述')
|
||||
|
||||
await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
|
||||
await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
|
||||
await copyDialog.getByRole('button', { name: '创建' }).click()
|
||||
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
|
||||
// The new row lands in the custom group, and — with no desktop opener —
|
||||
// its directory is revealed as text right away: landing in the files is
|
||||
// the completion of a copy, not a follow-up.
|
||||
await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 })
|
||||
// The copy dialog is detached, so the settings dialog is the only one
|
||||
// left (it names itself via aria-labelledby, which a CSS attribute
|
||||
// selector cannot address).
|
||||
const snapshot = withPresetRoot(
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
|
||||
await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('{{presetRoot}}/my-agent')
|
||||
|
||||
// The host copied the whole directory and rewrote only the display
|
||||
// metadata: the composition is byte-identical to the shipped source, the
|
||||
// description rides along for the user to edit in place, and neither the
|
||||
// source's name nor its roster order survives into the copy.
|
||||
const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8')
|
||||
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
|
||||
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
|
||||
expect(metadata).toContain('name: 我的模式')
|
||||
expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。')
|
||||
expect(metadata).not.toContain('order:')
|
||||
}, 60_000)
|
||||
|
||||
it('deletes the copy after confirmation and reclaims the roster', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete'))
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '删除: 我的模式' }).click()
|
||||
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
|
||||
await confirm.waitFor({ timeout: 10_000 })
|
||||
await confirm.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
|
||||
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
|
||||
// Custom group gone with its only member; the shipped set stands.
|
||||
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
|
||||
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
|
||||
}, 60_000)
|
||||
|
||||
it('marks damaged presets broken and clears a ghost through delete', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged'))
|
||||
// The two hand-edit damage shapes: a composition that no longer parses,
|
||||
// and a directory whose composition file was deleted outright.
|
||||
await mkdir(join(userRoot, 'broken-yaml'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n')
|
||||
await mkdir(join(userRoot, 'ghost'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n')
|
||||
|
||||
// The section reads the roster when it mounts; hop away and back.
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '通用设置' }).click()
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = withPresetRoot(
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
|
||||
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
|
||||
// Both damage shapes surface as marked, unselectable, uncopyable cards
|
||||
// that still carry their metadata and the discovery-reported reason.
|
||||
expect(snapshot).toContain('已损坏: broken-yaml')
|
||||
expect(snapshot).toContain('已损坏: 幽灵预设')
|
||||
expect(snapshot).toContain('not valid YAML')
|
||||
expect(snapshot).toContain('agent.cordis.yml is missing')
|
||||
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
|
||||
// A broken card offers no "set default" affordance at all — the aria name
|
||||
// IS the broken marking, so the picking name must not exist.
|
||||
expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
|
||||
|
||||
// The ghost's way out is the card's own delete — and the id it blocked
|
||||
// is claimable again immediately afterwards.
|
||||
await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click()
|
||||
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
|
||||
await confirm.waitFor({ timeout: 10_000 })
|
||||
await confirm.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
|
||||
|
||||
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
|
||||
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
|
||||
await copyDialog.waitFor({ timeout: 10_000 })
|
||||
await copyDialog.getByPlaceholder('my-agent').fill('ghost')
|
||||
await copyDialog.getByRole('button', { name: '创建' }).click()
|
||||
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
// Leave the roster as the earlier tests shaped it.
|
||||
await dialog.getByRole('button', { name: '删除: ghost' }).click()
|
||||
const cleanup = page.getByRole('dialog', { name: '删除该预设?' })
|
||||
await cleanup.waitFor({ timeout: 10_000 })
|
||||
await cleanup.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await cleanup.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true })
|
||||
}, 60_000)
|
||||
|
||||
it('starts a creator-mode session from the section', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator'))
|
||||
// Without a workspace the flow only stages (there is no session to land
|
||||
// in until one is connected); connect first so the gesture carries all
|
||||
// the way to a composed host session.
|
||||
await settingsDialog().getByRole('button', { name: '关闭' }).last().click()
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = settingsDialog()
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click()
|
||||
|
||||
// Leaving settings is part of the gesture: the flow lands on the
|
||||
// new-session screen with the self-referential preset staged, and the
|
||||
// blank session the flow produces composes from it on the host.
|
||||
await dialog.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => {
|
||||
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {},
|
||||
}),
|
||||
})
|
||||
const body = await response.json() as {
|
||||
result: { value?: { sessions: unknown[] } }
|
||||
}
|
||||
return JSON.stringify(body.result.value?.sessions ?? body.result)
|
||||
}, { timeout: 15_000 }).toContain('"agentPreset":"cordis"')
|
||||
}, 60_000)
|
||||
|
||||
it('drove every surface without a page error or a stream warning', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
12
apps/web/tests/agent-preset-authoring.overlay.yml
Normal file
12
apps/web/tests/agent-preset-authoring.overlay.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
# The authoring lane drives the location affordance. A real desktop open
|
||||
# would pop a file manager on the machine running the tests and the
|
||||
# capability itself is platform-detected (macOS yes, headless Linux CI no),
|
||||
# so the gateway is pinned headless: `hasDocument` is false everywhere and
|
||||
# `openDocument` answers the directory as text — the same branch on every
|
||||
# host, and the one whose rendering a golden can hold. A patch replaces the
|
||||
# row's complete config, so the shipped routing defaults ride along.
|
||||
- id: api-gateway
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
nativeOpen: false
|
||||
153
apps/web/tests/agent-preset-selection.e2e.ts
Normal file
153
apps/web/tests/agent-preset-selection.e2e.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Web e2e scenario: agent-preset selection. The roster's `roots` is an
|
||||
// assembly fact the CLI entry resolves and patches in, so every other lane
|
||||
// boots with an empty roster and no preset surface at all; this is the one
|
||||
// lane that mounts the SHIPPED presets and puts them in front of a browser.
|
||||
//
|
||||
// Two surfaces, one host rule: a session's composition is fixed when the
|
||||
// session starts. Before that, the new-session chip stages the choice beside
|
||||
// the workspace picker — the only screen where it still works. After it, the
|
||||
// session header names what the session runs and offers no control at all,
|
||||
// because the host answers `agent-preset-locked` to anything else.
|
||||
//
|
||||
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
|
||||
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 {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', import.meta.url))
|
||||
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
|
||||
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
|
||||
const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
|
||||
/** The shipped roster, beside the composition that names it. */
|
||||
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'agent-preset-selection-web-e2e'
|
||||
|
||||
/**
|
||||
* A settled one-turn session with no model content: this lane asserts chrome
|
||||
* around a conversation, not a conversation, and a recorded turn would tie
|
||||
* the golden to a provider's wording for no gain.
|
||||
* @returns a tokenized session log ending on a closed turn.
|
||||
*/
|
||||
function seedLog(): string {
|
||||
const time = 1784974100000
|
||||
const at = (index: number, event: Record<string, unknown>): string =>
|
||||
JSON.stringify({ ...event, seq: index, time: time + index })
|
||||
return [
|
||||
JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }),
|
||||
at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }),
|
||||
at(1, {
|
||||
type: 'user/message',
|
||||
data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } },
|
||||
surfaceOp: 'append',
|
||||
}),
|
||||
at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }),
|
||||
at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: agent-preset selection', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' },
|
||||
})
|
||||
// A resumed session runs what it was created with; seeding one that
|
||||
// records `minimal` is what makes the header label a claim about the
|
||||
// session rather than an echo of the current default.
|
||||
await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('offers the chip on the new-session screen, beside the workspace picker', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero'))
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
|
||||
// The chip opens on the deployment default, by the name that preset
|
||||
// publishes rather than its directory name.
|
||||
expect(snapshot).toContain('标准模式')
|
||||
})
|
||||
|
||||
it('names every preset and what it is for', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu'))
|
||||
await page.getByRole('button', { name: '标准模式' }).click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
||||
// Every shipped preset, each with the sentence saying what it composes —
|
||||
// the id alone never said what a preset does.
|
||||
expect(snapshot).toContain('极简模式')
|
||||
expect(snapshot).toContain('创造模式')
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
it('applies the staged pick to the blank session, and the host honors it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage'))
|
||||
await page.getByRole('button', { name: '标准模式' }).click()
|
||||
await page.getByRole('menuitem', { name: /极简模式/ }).click()
|
||||
|
||||
// The chip stages; the blank session the workspace connect produced is
|
||||
// what the stage lands on. The host's own answer is what comes back.
|
||||
await expect.poll(async () => {
|
||||
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {},
|
||||
}),
|
||||
})
|
||||
const body = await response.json() as {
|
||||
result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } }
|
||||
}
|
||||
return JSON.stringify(body.result.value?.sessions ?? body.result)
|
||||
}, { timeout: 15_000 }).toContain('minimal')
|
||||
})
|
||||
|
||||
it('labels a resumed session with the preset it was created under', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
|
||||
// The seeded session's cwd is the scaffold root rather than the connected
|
||||
// workspace, so it lists under Ungrouped; the group collapses by default.
|
||||
await page.getByRole('treeitem', { name: /^Ungrouped/ }).click()
|
||||
await page.locator('[role="treeitem"]').last().click()
|
||||
await page.getByText('Seeded turn.').waitFor({ timeout: 15_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('极简模式')
|
||||
// Static chrome, not a control: the header can only report a composition
|
||||
// the host would refuse to change.
|
||||
expect(snapshot).not.toContain('button "极简模式"')
|
||||
})
|
||||
|
||||
it('drove every surface without a page error or a stream warning', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -9,8 +9,8 @@
|
||||
// model content as the question composer: the turn cannot complete without it).
|
||||
//
|
||||
// Geometry is the point of the scenario. The command is unbounded model text,
|
||||
// and before the cap a long one grew the card until the refuse/allow buttons
|
||||
// left the viewport — an approval the user could see and not answer.
|
||||
// and an uncapped card grows with it until the refuse/allow buttons leave the
|
||||
// viewport — an approval the user could see and not answer.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -36,8 +36,8 @@ const MODE = webSnapshotMode()
|
||||
|
||||
// Irreducible payload: the command has to be long enough to pass the card's
|
||||
// height cap, which is the only shape that reproduces an action row pushed off
|
||||
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
|
||||
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
|
||||
// screen. Unrelated tokens, not a repeated word — the model compresses a
|
||||
// repeated word into `printf 'alpha %.0s' {1..400}` when recording, and a
|
||||
// short command proves nothing here. The formula keeps the source small; the
|
||||
// model receives the expanded literal it has to put in the command.
|
||||
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
|
||||
@@ -89,7 +89,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
await input.fill('')
|
||||
|
||||
// Read-only: the mode whose denial the model escalates from. Switched
|
||||
// through the shipped access-mode chip, not a test-only seam.
|
||||
// through the shipped access-mode chip, not a test-only override.
|
||||
await page.locator('[aria-label^="Access mode"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await expect.poll(
|
||||
@@ -115,9 +115,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// The regression this scenario exists for: an uncapped card grew with
|
||||
// the command until the action row left the viewport. Measured at the
|
||||
// lane baseline and at a short viewport, on the live panel.
|
||||
// The uncapped-card hazard the header names, measured at the lane
|
||||
// baseline and at a short viewport, on the live panel.
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
for (const height of [1000, 700]) {
|
||||
await page.setViewportSize({ width: 900, height })
|
||||
|
||||
@@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Resolve the resident approval so the ordinary composer bar (which owns
|
||||
// ContextMeter) resumes without replacing the session shell. This minimal
|
||||
// boot graph intentionally does not mount the separate question UI plugin.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
@@ -160,6 +161,14 @@ function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>):
|
||||
.join('')
|
||||
}
|
||||
|
||||
function messageKey(event: SessionEvent<'user/message'>): string {
|
||||
return conversationContextKey('input-message', String(event.data.id))
|
||||
}
|
||||
|
||||
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
|
||||
}
|
||||
|
||||
describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& userText(event).includes(spec.userMarker)
|
||||
)), { timeout: 15_000 }).toBe(true)
|
||||
const echoedUser = sessionEvents.slice(eventStart).find(
|
||||
(event): event is SessionEvent<'user/message'> => (
|
||||
event.type === 'user/message'
|
||||
@@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
),
|
||||
)
|
||||
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
|
||||
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await userRow.textContent()).toContain(spec.userMarker)
|
||||
@@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
|
||||
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
|
||||
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
|
||||
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
|
||||
|
||||
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
|
||||
// input only navigates to the semantic target; assertions pin content identity
|
||||
// and interaction routing rather than scroll geometry or mounted row counts.
|
||||
// Long-history Chat behavior contract that stays valid under a virtualized
|
||||
// renderer: wheel input only navigates to the semantic target; assertions pin
|
||||
// content identity and interaction routing rather than scroll geometry or
|
||||
// mounted row counts.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -10,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
@@ -115,6 +117,18 @@ function requiredEvent<T extends SessionEvent['type']>(
|
||||
return event
|
||||
}
|
||||
|
||||
function messageKey(event: SessionEvent<'user/message'>): string {
|
||||
return conversationContextKey('input-message', String(event.data.id))
|
||||
}
|
||||
|
||||
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
|
||||
}
|
||||
|
||||
function turnTailKey(turn: number): string {
|
||||
return conversationContextKey('turn-tail', String(turn))
|
||||
}
|
||||
|
||||
describe('web e2e: long Chat interaction contract', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -176,8 +190,10 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
const expectedUserText = textContent(branchUserEvent.data.content)
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
|
||||
const toolUserKey = messageKey(toolUserEvent)
|
||||
const toolAssistantKey = assistantKey(toolAssistantEvent)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
|
||||
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
|
||||
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
|
||||
|
||||
@@ -186,28 +202,27 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
expect(await call1.count()).toBe(1)
|
||||
expect(await call2.count()).toBe(1)
|
||||
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
|
||||
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
|
||||
expect(await call1.textContent()).toContain(toolMarker1)
|
||||
expect(await call2.textContent()).toContain(toolMarker2)
|
||||
|
||||
const expectedOrder = [
|
||||
`node:${String(toolUserEvent.seq)}`,
|
||||
`call:${TARGET_CALL_1}`,
|
||||
`call:${TARGET_CALL_2}`,
|
||||
`node:${String(toolAssistantEvent.seq)}`,
|
||||
toolUserKey,
|
||||
conversationContextKey('tool-call', TARGET_CALL_1),
|
||||
conversationContextKey('tool-call', TARGET_CALL_2),
|
||||
toolAssistantKey,
|
||||
]
|
||||
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
|
||||
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
|
||||
.filter((key): key is string => key !== undefined && keys.includes(key))
|
||||
), expectedOrder)
|
||||
expect(actualOrder).toEqual(expectedOrder)
|
||||
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
|
||||
const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
|
||||
))))
|
||||
expect(groupKeys[0]).not.toBeNull()
|
||||
expect(groupKeys[1]).toBe(groupKeys[0])
|
||||
expect(toolKinds).toEqual(['tool-call', 'tool-call'])
|
||||
|
||||
const summary1 = call1.locator('[data-sample="bash"]')
|
||||
const summary2 = call2.locator('[data-sample="bash"]')
|
||||
@@ -219,9 +234,12 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
|
||||
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
|
||||
const branchUserKey = messageKey(branchUserEvent)
|
||||
const branchAssistantKey = assistantKey(branchAssistantEvent)
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
|
||||
const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
|
||||
expect(await userRow.textContent()).toContain(branchUserMarker)
|
||||
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
@@ -230,8 +248,8 @@ describe('web e2e: long Chat interaction contract', () => {
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
|
||||
.toBe(expectedUserText)
|
||||
|
||||
await assistantRow.hover()
|
||||
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await turnTailRow.hover()
|
||||
await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -26,7 +26,7 @@ const MODE = webSnapshotMode()
|
||||
|
||||
// The scenario's one drive prompt: elicits one program with a bash sub-call
|
||||
// and a failing read the program tolerates — the sub-row set the assertions
|
||||
// (and the PR gif) need. Never asserted against model prose.
|
||||
// need. Never asserted against model prose.
|
||||
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
|
||||
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows'))
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// The parent run_code row wears the code variant with the model-authored
|
||||
// description as its summary (the PR1 presentCall contract).
|
||||
// description as its summary (the presentCall contract).
|
||||
const codeRow = page.locator('[data-variant="code"]').first()
|
||||
await codeRow.waitFor({ timeout: 10_000 })
|
||||
// Nested rows are visible WITHOUT any expand interaction, inside the
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
|
||||
// highlight, the chips and the ghost hint.
|
||||
//
|
||||
// Two layers can only stay together by moving together. They now do: both sit
|
||||
// Two layers can only stay together by moving together. They do: both sit
|
||||
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
|
||||
// tall as the whole draft — so one offset, applied by the browser, moves the
|
||||
// caret and the words in the same frame. Scrolling the textarea and assigning
|
||||
@@ -192,7 +192,7 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
* 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 caret-to-glyph
|
||||
* platform, not the behavior. What is recorded is the cap, the caret-to-glyph
|
||||
* relation, 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.
|
||||
@@ -297,10 +297,10 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
|
||||
// A layer that breaks lines somewhere else puts the words under the wrong
|
||||
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
|
||||
// draft. The three now share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; before,
|
||||
// only the textarea scrolled, and WebKit reserved gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox did not.
|
||||
// draft. All three share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; with
|
||||
// only the textarea scrolling, WebKit reserves gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox do not.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
// The mirror decides the box height, so it belongs in the same equality —
|
||||
@@ -315,7 +315,7 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
// The reported symptom, isolated. A scroll offset changes and the caret's
|
||||
// distance to its own glyphs is re-read before the task ends — before any
|
||||
// `scroll` listener could have run. With the layers on one scrollport the
|
||||
// browser moved both, so the distance is unchanged; with the glyph layer
|
||||
// browser moves both, so the distance is unchanged; with the glyph layer
|
||||
// catching up in a listener it is off by the whole delta until a later
|
||||
// frame, which is a caret flying away from its text mid-gesture.
|
||||
const metrics = await measureComposer(page)
|
||||
@@ -348,9 +348,10 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
|
||||
// The other way the box moves, and the one that depends on the browser: the
|
||||
// textarea no longer scrolls, so revealing the caret after an edit is a
|
||||
// scroll-into-view that has to walk up to the scrollport. Scroll away from
|
||||
// the caret first, so the edit has somewhere to bring it back from.
|
||||
// textarea holds no scroll offset of its own, so revealing the caret after
|
||||
// an edit is a scroll-into-view that has to walk up to the scrollport.
|
||||
// Scroll away from the caret first, so the edit has somewhere to bring it
|
||||
// back from.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.press('End')
|
||||
await input.hover()
|
||||
@@ -368,9 +369,9 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
|
||||
// The composer suppresses the native paste — the machine owns the draft and
|
||||
// the undo log — and restores the caret programmatically, which reveals
|
||||
// nothing on its own: measured in chromium and WebKit, the view stayed
|
||||
// where it was while the caret sat at the end of the pasted block. The
|
||||
// restore now scrolls it into view, and this is the case that proves it.
|
||||
// nothing on its own: in chromium and WebKit the view stays put while the
|
||||
// caret sits at the end of the pasted block, so the restore scrolls it
|
||||
// into view; this case pins it.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill('one short line')
|
||||
await input.press('End')
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
// 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.
|
||||
// Without a shared reservation the two tabs disagree by exactly the bar's
|
||||
// width for as long as the transcript overflows: the card jumps sideways on
|
||||
// every tab switch, and inside Chat alone at the moment a growing transcript
|
||||
// starts to scroll. The column 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
|
||||
@@ -26,14 +26,14 @@
|
||||
//
|
||||
// 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
|
||||
// container's bar consumes no layout width at all, so the two tabs agree with
|
||||
// and without the reservation and every comparison below holds vacuously —
|
||||
// measured: the unreserved 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`
|
||||
// The scenario runs that unreserved 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
|
||||
@@ -64,7 +64,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry',
|
||||
* 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.
|
||||
* carries the shift the unreserved cascade produces rather than only its absence.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -114,10 +114,10 @@ async function setMeasuredViewport(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The unreserved cascade, injected into the page: the reservation dropped and
|
||||
* the overlay branch forced 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 = `
|
||||
@@ -221,9 +221,9 @@ async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-fix cascade in the page for one measurement, then lift it.
|
||||
* Run the unreserved 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.
|
||||
* @returns the comparison as the column lays out without the reservation.
|
||||
*/
|
||||
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
|
||||
await page.evaluate(({ id, css }) => {
|
||||
@@ -328,7 +328,7 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
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
|
||||
// The reservation reaches both states, which is the whole point: 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')
|
||||
@@ -348,8 +348,8 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
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.
|
||||
// width cap, so the unreserved cascade's shift shows 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)
|
||||
@@ -363,7 +363,7 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
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
|
||||
// the column's width, so an unreserved gutter changes 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.
|
||||
@@ -379,17 +379,17 @@ describe('web e2e: input card position across view tabs', () => {
|
||||
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.
|
||||
// never reached the layout. Under the unreserved cascade the Chat scroller
|
||||
// keeps its bar and the Trajectory branch becomes 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.
|
||||
// Restoring the sheet restores the reservation, so the control cannot leak
|
||||
// into the remaining measurements.
|
||||
const restored = await compareTabs(page)
|
||||
expect(restored.leftShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Web e2e scenario: the conversation column scrolls on one axis only, as the
|
||||
// browser actually lays it out. The reported symptom was a horizontal
|
||||
// scrollbar under the whole center column once the window (or the sidebar
|
||||
// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the
|
||||
// column and becoming user-scrollable.
|
||||
// browser actually lays it out. The hazard: a horizontal scrollbar appears
|
||||
// under the whole center column once the window (or the sidebar drag) narrows
|
||||
// it — the hero's decorative backdrop ellipse bleeds past the column and
|
||||
// becomes user-scrollable.
|
||||
//
|
||||
// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
|
||||
// hero box (ConversationRoot.module.css) so the blur scales with the input
|
||||
// card. What changed is the scroll container: `[data-conversation-scroll]`
|
||||
// scrolls vertically, and a box that scrolls in one axis computes the other
|
||||
// axis's initial `visible` to `auto`, so the bleed came back as a bar. The
|
||||
// fix states `overflow-x: hidden` there.
|
||||
// card. The scroll container is where the bar comes from:
|
||||
// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller
|
||||
// computes the other axis's initial `visible` to `auto`, so the bleed becomes
|
||||
// a bar; `overflow-x: hidden` on the scroller prevents it.
|
||||
//
|
||||
// Only a real engine reports that pair — the bleed and the resulting scroll
|
||||
// range — so the scenario sweeps viewport widths that bracket the glow's
|
||||
@@ -36,7 +36,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-over
|
||||
* Committed golden of the one-axis relation at every stop. It records
|
||||
* relations and booleans, never absolute coordinates: the column width follows
|
||||
* the viewport and the sidebar, and a golden carrying pixels would document the
|
||||
* platform instead of the change.
|
||||
* platform instead of the behavior.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -60,17 +60,20 @@ interface ColumnMetrics {
|
||||
columnWidth: number
|
||||
/** Resolved `overflow-x` on the conversation scroll container. */
|
||||
overflowX: string
|
||||
/** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */
|
||||
/**
|
||||
* True when the glow's box reaches past the column's content edge — the
|
||||
* condition the `overflow-x: hidden` declaration has to survive.
|
||||
*/
|
||||
glowBleeds: boolean
|
||||
/**
|
||||
* `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
|
||||
* `auto` both report the same value, because `hidden` clips the bleed rather
|
||||
* than reflowing it away. Recorded because it is the vacuity guard in
|
||||
* numbers — it must stay positive at the narrow stops, or the scenario has
|
||||
* stopped reproducing the situation the fix is for.
|
||||
* stopped reproducing the situation `overflow-x: hidden` exists for.
|
||||
*/
|
||||
bleedRange: number
|
||||
/** True when the column still scrolls vertically — the axis the fix must not take away. */
|
||||
/** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */
|
||||
scrollsVertically: boolean
|
||||
}
|
||||
|
||||
@@ -108,9 +111,10 @@ function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
|
||||
* This is the one signal that separates the two states, and it is why the
|
||||
* scenario needs a real engine: `overflow-x: hidden` leaves the box
|
||||
* programmatically scrollable and leaves `scrollWidth` untouched, so every
|
||||
* property reading agrees across the fix. Only refusing an actual input event
|
||||
* differs — measured at the 1200px stop, the shipped column stays at 0 while
|
||||
* the same page with `overflow-x: auto` forced on lands at its scroll boundary.
|
||||
* property reading agrees across the two overflow modes. Only refusing an
|
||||
* actual input event differs — measured at the 1200px stop, the shipped
|
||||
* column stays at 0 while the same page with `overflow-x: auto` forced on
|
||||
* lands at its scroll boundary.
|
||||
* @param page - the page under test.
|
||||
* @returns `scrollLeft` after one horizontal wheel over the column.
|
||||
*/
|
||||
@@ -175,10 +179,10 @@ type ColumnStop = ColumnMetrics & {
|
||||
* Render the golden body: one line per stop, relations only.
|
||||
*
|
||||
* Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
|
||||
* which the fix pins to 0 by construction. The bleed is recorded as a boolean
|
||||
* rather than its width, so the golden survives any platform whose column
|
||||
* lands a pixel off — a fixture that has to be re-recorded per platform
|
||||
* documents the platform, not the change.
|
||||
* which the shipped overflow mode pins to 0 by construction. The bleed is
|
||||
* recorded as a boolean rather than its width, so the golden survives any
|
||||
* platform whose column lands a pixel off — a fixture that has to be
|
||||
* re-recorded per platform documents the platform, not the behavior.
|
||||
* @param stops - the measured stops, in sweep order.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
@@ -272,18 +276,19 @@ describe('web e2e: the conversation column scrolls on one axis', () => {
|
||||
// The reported symptom, stated directly: a horizontal wheel over the
|
||||
// column moves nothing, at every stop.
|
||||
expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
|
||||
// The axis the column is a scroller for must survive the fix.
|
||||
// The axis the column is a scroller for must survive `overflow-x: hidden`.
|
||||
expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('reports the pre-fix state when the axis is opened back up', async () => {
|
||||
it('scrolls horizontally again once the axis is opened back up (control)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
|
||||
// The mutation control, run in the page rather than against a second
|
||||
// build: it restores exactly what the fix changed — the initial `visible`
|
||||
// that a one-axis scroller computes to `auto` — and shows the same gesture,
|
||||
// at the same timing, carrying the column to its positive scroll boundary.
|
||||
// build: it lifts exactly the `overflow-x: hidden` declaration, so the
|
||||
// initial `visible` that a one-axis scroller computes to `auto` takes
|
||||
// over, and shows the same gesture, at the same timing, carrying the
|
||||
// column to its positive scroll boundary.
|
||||
// Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
|
||||
// Injected with an id rather than through `addStyleTag`, so the teardown
|
||||
// below can take the sheet out again by selector: it must not outlive this
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
|
||||
// composer's effort pane — the levels a settings profile declares are exactly
|
||||
// what the picker offers, and picking one records it with the default route.
|
||||
// what the picker offers, and picking one records it with the Agent default.
|
||||
// Zero model calls: declaring, describing, and switching are settings/llm
|
||||
// traffic only, so there is no fixture and a stray stream would fail loud.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
@@ -77,8 +77,8 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
|
||||
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// Picking a level is the same gesture that saves the default target, so
|
||||
// the effort lands in the gateway's settings section beside the route.
|
||||
// Picking a level is the same gesture that saves the default selection, so
|
||||
// the effort lands in the Agent default Settings section beside provider/model.
|
||||
await page.getByRole('menuitemradio', { name: 'High' }).click()
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# deepseek-official default would be a route nothing serves. This scenario
|
||||
# starts the default on its own declared reasoning model so the effort pane
|
||||
# describes that model from the first open.
|
||||
- id: api-gateway
|
||||
- id: agent-default-model
|
||||
config:
|
||||
provider: acme-gateway
|
||||
model: acme-think
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Web e2e scenario: switching models in the composer is how this deployment's
|
||||
// default is chosen. The gesture writes the `api-gateway` settings section, a
|
||||
// default is chosen. The gesture writes the shared `agent-default-model` settings section, a
|
||||
// session created afterwards starts from it, and a session that already logged
|
||||
// a route keeps deriving from its own log — the tier order the gateway
|
||||
// resolves on every read.
|
||||
// Zero model calls: the switch is settings/llm-domain traffic only, so there
|
||||
// is no fixture and a stray stream would fail loud on the open seam. Both
|
||||
// is no fixture and a stray stream would fail loud because the adapter registry is empty. Both
|
||||
// routes are declared host-side (not through the UI, which has its own
|
||||
// scenario) through the pi-ai adapter the shipped tree already mounts: a
|
||||
// fixture-less scaffold registers no adapter at all, so the routes the
|
||||
@@ -21,7 +21,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Points the shipped `api-gateway` default at this scenario's own route. */
|
||||
/** Points the shipped shared Agent default at this scenario's own route. */
|
||||
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
||||
|
||||
/** The route this scenario starts on, patched over the shipped default. */
|
||||
@@ -110,12 +110,12 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
||||
|
||||
// The switch is what sets the default: the gateway's own settings section
|
||||
// The switch is what sets the default: the shared Agent-route settings section
|
||||
// now names it, beside the provider profiles the Models page writes.
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('api-gateway:')
|
||||
).toContain('agent-default-model:')
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain(`provider: ${ROUTE}`)
|
||||
expect(document).toContain(`model: ${MODEL}`)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# The fixture-less web scaffold registers no adapter, so the shipped
|
||||
# deepseek-official default would be a route nothing serves — which the
|
||||
# composer now correctly refuses to type into. This scenario declares its own
|
||||
# composer refuses to type into. This scenario declares its own
|
||||
# pi-ai routes and starts the default on one of them.
|
||||
- id: api-gateway
|
||||
- id: agent-default-model
|
||||
config:
|
||||
provider: origin-gateway
|
||||
model: origin-large
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// client; THIS spec pins the same flow through HTTP RPC + SSE + the host
|
||||
// gateway), reload replays everything from the log (zero further model
|
||||
// calls), and the theme scenario proves the shipped dark palette actually
|
||||
// cascades: attribute -> alias token flip -> painted surface change. Per the
|
||||
// lane's scope ruling there is no theme/layout golden (aria is color-blind);
|
||||
// the hero's waiting state gets the one golden here.
|
||||
// cascades: attribute -> alias token flip -> painted surface change. No
|
||||
// theme/layout golden: aria snapshots are color-blind (lane scope: the
|
||||
// browser-e2e-lane Agent Note); the hero's waiting state gets the one golden
|
||||
// here.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -112,8 +113,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
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`.
|
||||
// mean the submitted text is gone yet: under load the capture can catch
|
||||
// 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)
|
||||
@@ -232,7 +233,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
|
||||
it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
|
||||
// This scenario pins the ThemeService's DOM contract seam directly (the
|
||||
// This scenario pins the ThemeService's DOM contract directly (the
|
||||
// body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
|
||||
// user gesture above it (Settings -> Appearance cubes) is owned by
|
||||
// settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
|
||||
// and transient-retry recovery, all through the real composition and wire.
|
||||
// The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
|
||||
// The model adapter is dsh-llm-replay with override sidecars: `hang` (+ a
|
||||
// readyFile marker) makes mid-stream cancel deterministic by construction,
|
||||
// `throw` entries express provider failures by stable code, and `{ patches }`
|
||||
// augmentation injects a transient throw before the recorded success so
|
||||
|
||||
@@ -176,6 +176,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
// The child row is published before its inherited title rename settles;
|
||||
// wait for that second RPC projection before freezing the ARIA tree.
|
||||
await expect.poll(
|
||||
() => page.locator('[role="treeitem"][aria-selected="true"]').textContent(),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('Use the read tool twice (2)')
|
||||
const tree = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="Sessions"]',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// provider status. The customized-settings fold writes the curated
|
||||
// reasoning field as a merge patch. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud on the open seam. The provider under test is
|
||||
// stray stream would fail loud because the adapter registry is empty. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference. The deletion dialog distinguishes a
|
||||
// reference-free profile from a page-managed key before the credential and
|
||||
@@ -85,8 +85,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
const key = dialog.getByLabel('API 密钥')
|
||||
const save = dialog.getByRole('button', { name: '保存', exact: true })
|
||||
|
||||
// The paste that used to save cleanly and then fail the first turn with a
|
||||
// ByteString TypeError now names the field that holds it.
|
||||
// A key no HTTP header can carry would save cleanly and fail the first
|
||||
// turn with a ByteString TypeError; the form names the offending field
|
||||
// instead.
|
||||
await key.fill('sk-\u{1F600}minimax')
|
||||
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
// its single row and overflow sideways instead of folding. Soft-wrapping
|
||||
// here is what shredded the column alignment this card exists to hold.
|
||||
// here shreds the column alignment this card exists to hold.
|
||||
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
|
||||
const pane = node as HTMLElement
|
||||
const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the
|
||||
# Loader overlay for the real-host smoke (`dsh web --patch`): 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
|
||||
|
||||
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal file
162
apps/web/tests/produced-file-mentions.e2e.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds
|
||||
// a built write turn (zero model calls) whose closing message names the written
|
||||
// file three ways: by unique basename (links), ambiguously (stays inert), and
|
||||
// as a file the turn never touched (stays inert). Package tests cover the
|
||||
// resolver in isolation; only the assembled application shows a real write's
|
||||
// locations reaching the prose as an opener. The click itself is not driven
|
||||
// here: it hands the path to the Host's opener, which would launch a real
|
||||
// application on the machine running the suite (the produced-files restraint).
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'produced-file-mentions-web-e2e'
|
||||
const DONE = 'FILE_MENTION_DONE'
|
||||
|
||||
/** One-part text content for a built message. */
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
return [{ type: 'text', text: value }]
|
||||
}
|
||||
|
||||
/** The files the built turn writes; `notes.md` is named in prose but never written. */
|
||||
const WRITES = ['site/report.html', 'a/style.css', 'b/style.css']
|
||||
|
||||
/** Build a settled write turn whose closing prose mentions files in inline code. */
|
||||
function mentionFixture(): string {
|
||||
const session = Session.create(SessionId('produced-file-mentions-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Produced file mentions',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const calls = WRITES.map((path, index) => ({
|
||||
path,
|
||||
callId: CallId(`file-mention-${String(index)}`),
|
||||
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
|
||||
}))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: calls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
id: call.callId,
|
||||
name: 'write',
|
||||
arguments: call.args,
|
||||
})),
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
for (const call of calls) {
|
||||
const source = session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: call.callId,
|
||||
name: 'write',
|
||||
arguments: call.args,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: call.callId,
|
||||
content: text(`Created ${call.path}`),
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createAssistantMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
|
||||
'',
|
||||
DONE,
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: inline-code mentions of produced files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, mentionFixture(), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
// Exactly one prose mention links: `report.html` resolves to the written
|
||||
// path; the shared `style.css` basename and unwritten `notes.md` stay code.
|
||||
const mentions = page.locator('[class*="markdown"] code button')
|
||||
await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await mentions.first().innerText()).toBe('report.html')
|
||||
expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
|
||||
expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
|
||||
// The turn still ends with its produced-files row (all three writes).
|
||||
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
|
||||
// api-proxy recomputes presentation views from logged args/result content)
|
||||
// must render as a bash-shaped terminal card with the parsed exit-status
|
||||
// pill — not the generic console-fenced card the pwsh presenter used to
|
||||
// emit. The seed is authored, not recorded: its header line carries no `cwd`
|
||||
// pill — not a generic console-fenced card. The seed is authored, not
|
||||
// recorded: its header line carries no `cwd`
|
||||
// field (seedSession writes the session cwd itself, and a Windows temp path
|
||||
// substituted into the header would not round-trip through its JSON parse),
|
||||
// and no event references the workspace, so the lane replays on any host
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenario: fresh round trip. A real chromium types a prompt into the
|
||||
// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo
|
||||
// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless)
|
||||
// in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless)
|
||||
// or the live adapter (record). Drive steps run in every mode and wait only
|
||||
// on generic completion (whenTurnSettled — never model-content selectors, so
|
||||
// record cannot hang on a live model answering differently); assertion steps
|
||||
|
||||
@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
async function writeSkill(root: string, name: string): Promise<void> {
|
||||
@@ -37,10 +39,25 @@ it('isolates replay skill discovery from every ambient host root', async () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold()
|
||||
const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
|
||||
expect(names).not.toContain('ambient-dsh')
|
||||
expect(names).not.toContain('ambient-agents')
|
||||
expect(names).not.toContain('ambient-bundled')
|
||||
const ctx = scaffold.ctx
|
||||
// Local skill discovery belongs to the agent's preset LAYER of the host
|
||||
// registry, so the roots under test are only reachable through a composed
|
||||
// agent's view — the same scope the gateway's `skill.list` resolves for a
|
||||
// browser request about a session.
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('hermetic-skills'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const skills = ctx.get('skills')
|
||||
if (skills === undefined) throw new Error('the composition mounts no skill registry')
|
||||
const names = (await skills.list({ cwd: scaffold.workspaceCwd, scope: handle.agent })).map(skill => skill.name)
|
||||
expect(names).not.toContain('ambient-dsh')
|
||||
expect(names).not.toContain('ambient-agents')
|
||||
expect(names).not.toContain('ambient-bundled')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await scaffold?.close()
|
||||
|
||||
@@ -32,6 +32,7 @@ import { expect } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import Group from '@cordisjs/plugin-group'
|
||||
import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import {
|
||||
addHarnessSourceSection,
|
||||
@@ -85,6 +86,8 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
||||
const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
/** The installation anchor whose dependency surface the profile module fallback mirrors. */
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
/** The deployment's own agent-preset root, shipped beside the app's config. */
|
||||
const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets')
|
||||
|
||||
// Replay publishes the provider catalog the gateway routes to (providers
|
||||
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
|
||||
@@ -151,7 +154,7 @@ export interface WebScaffold {
|
||||
mode: WebSnapshotMode
|
||||
/** Browser-facing origin for the bound test server. */
|
||||
baseUrl: string
|
||||
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
|
||||
/** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */
|
||||
ctx: Context
|
||||
/** Temp project directory sessions run in (bash/fs tool cwd). */
|
||||
workspaceCwd: string
|
||||
@@ -226,6 +229,20 @@ export interface LaunchOptions {
|
||||
/** Credential reference resolved by the shipped search provider. */
|
||||
apiKeyEnv: string
|
||||
}
|
||||
/**
|
||||
* Replace the roster the scaffold mounts by default (the shipped directory
|
||||
* at `system` trust, default `standard`). Supply this only to change WHICH
|
||||
* presets a scenario sees — a writable user root, a different default —
|
||||
* never to turn the roster on: without one every session composes an agent
|
||||
* with no tools, no persona, and no token meter, which is not a shape the
|
||||
* product ever boots in. The patch lands after the default, so it wins.
|
||||
*/
|
||||
agentPresets?: {
|
||||
/** Roots to discover, in precedence order; the shipped directory is `system`. */
|
||||
roots: { path: string; trust: 'system' | 'user' }[]
|
||||
/** The preset a session that names none is composed from. */
|
||||
default: string
|
||||
}
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
/**
|
||||
@@ -283,6 +300,34 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// paths at load, and an in-process boot must NEVER touch the developer's
|
||||
// real ~/.dsh document or credential file.
|
||||
const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
|
||||
// Skill discovery is model-visible input, and its roots now resolve inside a
|
||||
// PRESET — a subtree this lane's include patches cannot reach, because the
|
||||
// roster mounts it directly per session rather than as a row of the booted
|
||||
// tree. The row's documented fallback is the environment, so pin that: the
|
||||
// whole scaffold lifetime, not just the boot, since presets mount when a
|
||||
// session is created. Without this a developer's real ~/.dsh/skills silently
|
||||
// enters replay requests and goldens while CI sees none. `DSH_HOME` follows
|
||||
// the resolved harness home so a scaffold sharing another's home — the
|
||||
// cross-port persistence scenario — pins the same roots the settings and
|
||||
// credentials rows were configured with.
|
||||
const skillRootEnvironment = {
|
||||
DSH_HOME: harnessHome,
|
||||
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
|
||||
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
|
||||
}
|
||||
const originalSkillRootEnvironment = Object.fromEntries(
|
||||
Object.keys(skillRootEnvironment).map(key => [key, process.env[key]]),
|
||||
)
|
||||
let skillRootEnvironmentRestored = false
|
||||
const restoreSkillRootEnvironment = (): void => {
|
||||
if (skillRootEnvironmentRestored) return
|
||||
skillRootEnvironmentRestored = true
|
||||
for (const [key, value] of Object.entries(originalSkillRootEnvironment)) {
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key)
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
Object.assign(process.env, skillRootEnvironment)
|
||||
let persistenceRoot: string
|
||||
try {
|
||||
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
|
||||
@@ -312,6 +357,18 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...basePatches,
|
||||
...surfacePatches,
|
||||
...extraOverlayPatches,
|
||||
// The roster's `roots` is an assembly fact AppCLIEntry resolves and patches
|
||||
// in, exactly like `distIndex` on the webserver row — the shipped preset
|
||||
// directory sits beside the composition that names it, and no config author
|
||||
// chooses it. This lane boots the shipped tree WITHOUT AppCLIEntry, so it
|
||||
// has to supply the same fact or the roster resolves nothing and every
|
||||
// session composes an agent with no tools, no persona, and no token meter.
|
||||
// Only the shipped root: a developer's own `~/.dsh/.agent-presets` must not be
|
||||
// able to change a golden.
|
||||
{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
|
||||
},
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
|
||||
@@ -362,6 +419,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// disable+insert pair.
|
||||
{ id: 'directory-picker', disabled: true },
|
||||
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
|
||||
...options.agentPresets === undefined
|
||||
? []
|
||||
: [{ id: 'agent-presets', config: options.agentPresets }],
|
||||
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
@@ -401,6 +461,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
// `cordis:group` beside it, exactly as `boot()` registers it: a group row is
|
||||
// how a preset gives one `isolate` realm to a provider and its consumers,
|
||||
// and a preset resolving package names from its own directory cannot reach
|
||||
// `@cordisjs/plugin-group` by name.
|
||||
ctx.loader.builtins.group = Group
|
||||
// The shipped CLI deliberately has no dependency on this opt-in package.
|
||||
// Keep the Loader row real without broadening the product installation.
|
||||
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
|
||||
@@ -439,7 +504,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
|
||||
// No fixture and no shipped adapter would leave the tree with ZERO
|
||||
// provider routes — a state no product composition has, and one the
|
||||
// composer now correctly refuses to type into. Register the same routes
|
||||
// composer refuses to type into. Register the same routes
|
||||
// a fixture would, with streaming that still fails loud: the scenario
|
||||
// issues no model calls, and one that slipped in must not pass quietly.
|
||||
ctx.effect(() => ctx.llm.registerAdapter(
|
||||
@@ -451,6 +516,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
|
||||
restoreCredentialEnvironment()
|
||||
restoreSkillRootEnvironment()
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
|
||||
}
|
||||
@@ -498,6 +564,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
} finally {
|
||||
restoreCredentialEnvironment()
|
||||
restoreSkillRootEnvironment()
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
|
||||
},
|
||||
@@ -564,6 +631,8 @@ export function fixtureUserPrompts(fixtureText: string): string[] {
|
||||
* @param scaffold - the target scaffold.
|
||||
* @param fixtureText - raw recorded session.jsonl contents.
|
||||
* @param id - the seeded session id (stable for deterministic goldens).
|
||||
* @param agentPreset - the preset the recorded session was composed from,
|
||||
* for scenarios asserting what a resumed session reports running.
|
||||
* @returns the seeded id.
|
||||
*/
|
||||
/**
|
||||
@@ -587,7 +656,12 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i
|
||||
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
|
||||
}
|
||||
|
||||
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
|
||||
export async function seedSession(
|
||||
scaffold: WebScaffold,
|
||||
fixtureText: string,
|
||||
id: string,
|
||||
agentPreset?: string,
|
||||
): Promise<SessionId> {
|
||||
const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id))
|
||||
if (events.length === 0) throw new Error('seed fixture has no events')
|
||||
const last = events[events.length - 1]!
|
||||
@@ -600,6 +674,7 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
|
||||
createdAt: Date.now() - 60_000,
|
||||
cwd: scaffold.workspaceCwd,
|
||||
delegationDepth: 0,
|
||||
...agentPreset === undefined ? {} : { agentPreset },
|
||||
}
|
||||
const seeder = new Context()
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Assembled search-card snapshot: boots the real built workspace client 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
|
||||
// session, and pins the search card the `grep` turn (fixture turn 67) 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
|
||||
@@ -51,7 +51,7 @@ describe('assembled search card', () => {
|
||||
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).
|
||||
// is turn 66, the grep card turn 67).
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
@@ -19,6 +19,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import { join } from 'node:path'
|
||||
@@ -83,7 +84,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
/**
|
||||
* 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.
|
||||
* @returns the assigned seq, so later `sourceEventSeqs` cite the pushed event directly.
|
||||
*/
|
||||
const at = (event: Record<string, unknown>): number => {
|
||||
const taken = seq++
|
||||
@@ -91,11 +92,15 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
return taken
|
||||
}
|
||||
const commandId = 'cmd-seeded-manual-compact'
|
||||
const compactionId = 'compact-seeded-manual-compact'
|
||||
at({
|
||||
type: 'command/run',
|
||||
data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
|
||||
})
|
||||
const startSeq = at({ type: 'compact/start', data: { turn: null } })
|
||||
const startSeq = at({
|
||||
type: 'compact/start',
|
||||
data: { compactionId, sourceCommandId: commandId, turn: null },
|
||||
})
|
||||
// Load-bearing exactness: the projections subtract this count verbatim, so
|
||||
// it must equal what the host's fold prices for these nodes. The estimator
|
||||
// prices message CONTENT only, so a minimal wrapper per storage shape is
|
||||
@@ -124,6 +129,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
const summarySeq = at({
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
compactionId,
|
||||
sourceCommandId: commandId,
|
||||
summary: [{
|
||||
type: 'text',
|
||||
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
|
||||
@@ -142,12 +149,17 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
source: {
|
||||
kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
|
||||
},
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: first, end: last },
|
||||
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
|
||||
})
|
||||
at({ type: 'compact/end', data: { turn: null } })
|
||||
at({
|
||||
type: 'compact/end',
|
||||
data: { compactionId, sourceCommandId: commandId, turn: null },
|
||||
})
|
||||
at({
|
||||
type: 'command/done',
|
||||
data: {
|
||||
@@ -184,10 +196,22 @@ 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])
|
||||
const meter = scaffold.ctx.get('tokenMeter')
|
||||
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
|
||||
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
|
||||
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
|
||||
// The meter belongs to an agent's preset, not to the process — token
|
||||
// accounting is per session. It is used here as a pure pricing function
|
||||
// over fixture content, so a throwaway composition is enough to reach one.
|
||||
const priced = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('seeded-history-pricing'),
|
||||
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
let realizedWithCompaction: string
|
||||
try {
|
||||
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
|
||||
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
|
||||
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
|
||||
} finally {
|
||||
await priced.dispose()
|
||||
}
|
||||
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -234,10 +258,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const projections = body.result.value?.projections
|
||||
expect(projections).toBeDefined()
|
||||
expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
|
||||
// The seed carries a session/title event: the title unit must serve it.
|
||||
// The seed carries a session/title event: the title unit is host-plane, so
|
||||
// it folds the detached log and serves the value with nothing composed.
|
||||
expect(typeof projections?.values.title).toBe('string')
|
||||
// tool-todo is composed but the seed has no todo/write: whole-value null,
|
||||
// key PRESENT (absence would mean the unit never registered).
|
||||
// `todos` IS here, as its empty fold (null). Its unit is registered by
|
||||
// `tool-todo` inside the default preset's STANDING mount, which the read
|
||||
// itself ensures — deterministically, not because some unrelated session
|
||||
// happens to be composed. A present-but-null key is what keeps the
|
||||
// client's "omitted key = capability absent → clear the row" rule from
|
||||
// wiping preset-owned projections on cold reads.
|
||||
expect(projections?.values).toHaveProperty('todos', null)
|
||||
})
|
||||
|
||||
@@ -263,8 +292,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
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.
|
||||
// The pinned hazard: compaction shadows the surface on the model side
|
||||
// only — the prompt and full tool output must stay on screen.
|
||||
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
|
||||
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
|
||||
@@ -317,7 +317,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await selector.click()
|
||||
await page.getByRole('menuitem', { name: 'English' }).click()
|
||||
// The settings-owned copy re-registers localized: dialog title, nav,
|
||||
// Appearance labels. (Only the settings namespaces are localized today —
|
||||
// Appearance labels. (Only the settings namespaces are localized —
|
||||
// the rest of the app's copy is intentionally out of this row's scope.)
|
||||
const enDialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await enDialog.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
@@ -30,6 +31,7 @@ const EXPECTED_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'interrupt_agent',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
@@ -65,11 +67,26 @@ afterEach(async () => {
|
||||
|
||||
it('assembles the shipped Web catalog with the confined access default', async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
|
||||
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
|
||||
// The packaged ripgrep binary ships with the dependency, so the pair is a
|
||||
// fixed roster member on every host.
|
||||
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
|
||||
const ctx = scaffold.ctx
|
||||
// The catalog belongs to an AGENT, not to the process: every model-facing row
|
||||
// now lives in a preset mounted under one session's scope, so the global
|
||||
// layer holds nothing and a caller must name the agent to see anything. This
|
||||
// composes from the deployment default — what a session that names no preset
|
||||
// gets — which is the shape this test has always been about.
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([])
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('shipped-composition'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort()
|
||||
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
|
||||
// The packaged ripgrep binary ships with the dependency, so the pair is a
|
||||
// fixed roster member on every host.
|
||||
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
// `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
|
||||
@@ -82,18 +99,18 @@ it('assembles the shipped Web catalog with the confined access default', async (
|
||||
expect(scaffold.ctx.approval.config.policy).toBe('ask')
|
||||
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
|
||||
const handle = await scaffold.ctx.agents.create({
|
||||
const commandHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('shipped-command-catalog'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
try {
|
||||
expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({
|
||||
expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
|
||||
name: 'feedback',
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
await commandHandle.dispose()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Web e2e scenario: the sidebar session list's scrollbar as the browser
|
||||
// actually lays it out — the observable half of the themed-scrollbar change
|
||||
// actually lays it out — the observable half of the themed scrollbars
|
||||
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
|
||||
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
|
||||
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
|
||||
@@ -17,8 +17,8 @@
|
||||
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
|
||||
// content, consuming no layout width unless something reserves space. That is
|
||||
// the mode in which the reported symptom exists at all, so this environment
|
||||
// reproduces it rather than merely approximating it — measured against clean
|
||||
// master, where the list's band is 0 and the bar covers 7px of the relative
|
||||
// reproduces it rather than merely approximating it — without either
|
||||
// declaration the list's band is 0 and the bar covers 7px of the relative
|
||||
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
|
||||
// the bar and nothing can be covered; a headed run under xvfb behaves that way
|
||||
// and cannot show the symptom.)
|
||||
@@ -40,9 +40,8 @@
|
||||
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
|
||||
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
|
||||
// so it abuts the timestamp without covering it; `band` catches that case.
|
||||
// Removing both — the actual master state — is what produces the reported
|
||||
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
|
||||
// the other assertions in its test silenced.
|
||||
// Removing both is what produces the reported overlap, and `timeCoveredBy`
|
||||
// measures it at 7.
|
||||
//
|
||||
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
|
||||
// to `transparent` while the pointer is outside the column), so every
|
||||
@@ -135,7 +134,8 @@ interface ListMetrics {
|
||||
/**
|
||||
* Measure the sidebar list in the page.
|
||||
* @param page - the page under test.
|
||||
* @returns the list's resolved scrollbar style and the geometry the fix changes.
|
||||
* @returns the list's resolved scrollbar style and the geometry the
|
||||
* scrollbar-gutter/thin-scrollbar declarations shape.
|
||||
*/
|
||||
function measureList(page: Page): Promise<ListMetrics> {
|
||||
return page.evaluate(() => {
|
||||
@@ -201,8 +201,8 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
// The bar is drawn in the rightmost `barWidth` of the border box, whether
|
||||
// or not that space was reserved. Its width comes from the sheet where the
|
||||
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
|
||||
// what this chromium paints, measured against master where the rule is
|
||||
// absent. Taking the UA width as the fallback is what keeps the assertion
|
||||
// what this chromium paints, measured with the rule absent. Taking the
|
||||
// UA width as the fallback is what keeps the assertion
|
||||
// honest: assuming 0 there would report no occlusion precisely in the
|
||||
// state that has it.
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
|
||||
@@ -261,13 +261,14 @@ async function measurePalette(page: Page): Promise<PaletteMetrics> {
|
||||
|
||||
/**
|
||||
* Render the golden body: the resolved scrollbar style of the list in each
|
||||
* palette, plus the geometric relations the fix establishes.
|
||||
* palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
|
||||
* declarations establish.
|
||||
*
|
||||
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
|
||||
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
|
||||
* committing them would make the golden fail on a machine whose fonts measure
|
||||
* differently — a fixture that has to be re-recorded per platform documents the
|
||||
* platform, not the change. What is recorded instead is the band, the overlap,
|
||||
* platform, not the behavior. What is recorded instead is the band, the overlap,
|
||||
* and the two orderings, each of which is a difference or a comparison and so
|
||||
* survives any layout that keeps the reservation.
|
||||
* @param light - metrics measured under the light palette.
|
||||
@@ -417,12 +418,13 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(metrics.scrollbarEdgeOffset).toBe(2)
|
||||
expect(metrics.rowEdgeInset).toBe(12)
|
||||
// The reported symptom, stated directly: no part of the row's relative time
|
||||
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
|
||||
// covered part. Unlike the client-edge comparison below it does not go
|
||||
// vacuous under an overlay scrollbar, because it measures against the bar's
|
||||
// own width rather than against a content edge the overlay bar does not
|
||||
// move. It is not a replacement for the band assertion above; see the file
|
||||
// header for which regression each one catches.
|
||||
// lies under the bar. Without either declaration it measures 7 — the `h`
|
||||
// of `1h` is the covered part. Unlike the client-edge comparison below it
|
||||
// does not go vacuous under an overlay scrollbar, because it measures
|
||||
// against the bar's own width rather than against a content edge the
|
||||
// overlay bar does not move. It is not a replacement for the band
|
||||
// assertion above; see the file header for which regression each one
|
||||
// catches.
|
||||
expect(metrics.timeCoveredBy).toBe(0)
|
||||
// Corollaries of the reservation, kept because they pin where the band sits
|
||||
// rather than only that it exists: the time ends inside the content area,
|
||||
@@ -451,7 +453,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(quiet.band).toBeGreaterThan(0)
|
||||
expect(quiet.timeCoveredBy).toBe(0)
|
||||
// Scrolling without a pointer — what a keyboard or a touch drag does —
|
||||
// leaves the column quiet. This is the change's one deliberate loss, and
|
||||
// leaves the column quiet. This is the one deliberate loss, and
|
||||
// it is pinned here rather than only described, so making a scroll
|
||||
// re-reveal the bar has to be a decision rather than a side effect.
|
||||
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
|
||||
|
||||
@@ -27,7 +27,7 @@ const MODE = webSnapshotMode()
|
||||
const HOLD_PROVIDER = 'web-test-hold'
|
||||
const HOLD_MODEL = 'hold'
|
||||
|
||||
/** Model seam that completes the owner turn, then holds its delegated child open. */
|
||||
/** Model stub that completes the owner turn, then holds its delegated child open. */
|
||||
class StagedAdapter extends LlmAdapter {
|
||||
activeCalls = 0
|
||||
private calls = 0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// the composer (issue #1470). The entered `/name args` line claims into
|
||||
// skill.invoke: the real host forwards the gesture as an ordinary user
|
||||
// prompt, injects the rendered body as instructions context named after the
|
||||
// skill, and starts a turn answered by the replay seam. The transcript shows
|
||||
// skill, and starts a turn answered by the replay adapter. The transcript shows
|
||||
// the gesture bubble, the collapsed context-injection row, and the reply.
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -109,6 +109,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
|
||||
await composer.press('Enter')
|
||||
|
||||
@@ -133,8 +134,9 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
|
||||
expect(injected).not.toContain(ARGS_TEXT)
|
||||
await injectionRow.click()
|
||||
|
||||
// The injection started a turn; the replay seam answers it.
|
||||
// The injection started a turn; the replay adapter answers it.
|
||||
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
|
||||
await settled
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
|
||||
// Real-host smoke: spawn `dsh web` with a real key, walk the full flow
|
||||
// list in a real chromium, screenshot every screen into .artifacts/ for the
|
||||
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
|
||||
// convention); vitest.web.config.ts loads the repo-root .env before this file
|
||||
@@ -11,8 +11,9 @@
|
||||
// (frame/handle) rides local names that survive hashing as suffixes; prefer
|
||||
// data-* for anything new.
|
||||
//
|
||||
// Flow order matters: chat rounds first (5 depends on 3's session), geometry
|
||||
// and theme after, reload recovery last. Tests run sequentially in-file.
|
||||
// Flow order matters: chat rounds first (the bash round reuses the first
|
||||
// send's session), geometry and theme after, reload recovery last. Tests run
|
||||
// sequentially in-file.
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -119,7 +120,7 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
|
||||
/** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
|
||||
async function screen(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
|
||||
}
|
||||
@@ -465,7 +466,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key)', () => {
|
||||
let child: ChildProcess
|
||||
let sessionsDir: string
|
||||
let baseUrl: string
|
||||
@@ -518,7 +519,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('1 cold start: loading page settles into the three-column frame', async () => {
|
||||
it('cold start: loading page settles into the three-column frame', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
|
||||
@@ -527,7 +528,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '01-cold-start')
|
||||
})
|
||||
|
||||
it('2+3 empty-state first send completes a real model round', async () => {
|
||||
it('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
|
||||
@@ -590,7 +591,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click leaves the default details column closed', async () => {
|
||||
it('bash differential rendering: tool row click leaves the default details column closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
@@ -604,12 +605,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await toolRow.click()
|
||||
// Tool rows no longer drive layout.openDetails; the default column stays closed.
|
||||
// Tool rows do not drive layout.openDetails; the default column stays closed.
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await screen(page, '09-details-closed')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and resets across reload', async () => {
|
||||
it('sidebar drag widens the column and resets across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
|
||||
const before = await firstTrack(page)
|
||||
const handle = page.locator('[class*="handle"]').first()
|
||||
@@ -627,10 +628,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
expect(await firstTrack(page)).toBe(before)
|
||||
})
|
||||
|
||||
it('7 dark mode: the body attribute cascades the token sheets', async () => {
|
||||
it('dark mode: the body attribute cascades the token sheets', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-dark'))
|
||||
// theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
|
||||
// in P-I, so the acceptance drives the documented mechanism directly.
|
||||
// The body attribute is the documented cascade mechanism; the Settings
|
||||
// gesture is owned by settings-chrome.e2e.ts — drive the attribute
|
||||
// directly here.
|
||||
const dark = await page.evaluate(() => {
|
||||
document.body.setAttribute('data-ds-dark-theme', '')
|
||||
return getComputedStyle(document.body).backgroundColor
|
||||
@@ -643,7 +645,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
expect(dark).not.toBe(light)
|
||||
})
|
||||
|
||||
it('8 reload recovery: history replays after a fresh boot', async () => {
|
||||
it('reload recovery: history replays after a fresh boot', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-reload'))
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
- dialog "复制预设 · 复制自 极简模式":
|
||||
- heading "复制预设 · 复制自 极简模式" [level=2]
|
||||
- button "关闭":
|
||||
- img
|
||||
- paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。
|
||||
- text: 标识符
|
||||
- textbox "标识符":
|
||||
- /placeholder: my-agent
|
||||
- text: 名称
|
||||
- textbox "名称":
|
||||
- /placeholder: 选择器中显示的名字,缺省用标识符
|
||||
- alert: 请填写标识符。
|
||||
- button "取消"
|
||||
- button "创建" [disabled]
|
||||
@@ -0,0 +1,81 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 标准模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 代码模式"':
|
||||
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
|
||||
- code: code
|
||||
- 'button "查看: 代码模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 代码模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 极简模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 创造模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- heading "自定义" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "设为默认: 我的模式"':
|
||||
- text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- code: my-agent
|
||||
- 'button "查看路径: 我的模式"':
|
||||
- img
|
||||
- text: 查看路径
|
||||
- 'button "复制: 我的模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- 'button "删除: 我的模式"':
|
||||
- img
|
||||
- text: 删除
|
||||
- paragraph:
|
||||
- text: 预设文件:
|
||||
- code: {{presetRoot}}/my-agent
|
||||
- button "用「创造模式」创作自定义预设":
|
||||
- img
|
||||
- text: 用「创造模式」创作自定义预设
|
||||
@@ -0,0 +1,93 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 标准模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 代码模式"':
|
||||
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
|
||||
- code: code
|
||||
- 'button "查看: 代码模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 代码模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 极简模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 创造模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- heading "自定义" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "已损坏: broken-yaml" [disabled]':
|
||||
- text: broken-yaml 已损坏 自定义 暂无描述。
|
||||
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
|
||||
- code: broken-yaml
|
||||
- 'button "查看路径: broken-yaml"':
|
||||
- img
|
||||
- text: 查看路径
|
||||
- 'button "复制: broken-yaml" [disabled]':
|
||||
- img
|
||||
- text: 预设已损坏,无法复制
|
||||
- 'button "删除: broken-yaml"':
|
||||
- img
|
||||
- text: 删除
|
||||
- listitem:
|
||||
- 'button "已损坏: 幽灵预设" [disabled]':
|
||||
- text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
|
||||
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
|
||||
- code: ghost
|
||||
- 'button "查看路径: 幽灵预设"':
|
||||
- img
|
||||
- text: 查看路径
|
||||
- 'button "复制: 幽灵预设" [disabled]':
|
||||
- img
|
||||
- text: 预设已损坏,无法复制
|
||||
- 'button "删除: 幽灵预设"':
|
||||
- img
|
||||
- text: 删除
|
||||
- button "用「创造模式」创作自定义预设":
|
||||
- img
|
||||
- text: 用「创造模式」创作自定义预设
|
||||
@@ -0,0 +1,63 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 标准模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 代码模式"':
|
||||
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
|
||||
- code: code
|
||||
- 'button "查看: 代码模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 代码模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 极简模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"':
|
||||
- img
|
||||
- text: 查看
|
||||
- 'button "复制: 创造模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- button "用「创造模式」创作自定义预设":
|
||||
- img
|
||||
- text: 用「创造模式」创作自定义预设
|
||||
@@ -0,0 +1,4 @@
|
||||
- navigation "Session hierarchy":
|
||||
- button "Seeded turn" [disabled]
|
||||
- img
|
||||
- text: 极简模式
|
||||
@@ -0,0 +1,8 @@
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- img
|
||||
@@ -0,0 +1,7 @@
|
||||
- menu:
|
||||
- menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。":
|
||||
- text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
- img
|
||||
- menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。"
|
||||
- menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。"
|
||||
- menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。"
|
||||
@@ -14,10 +14,10 @@
|
||||
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Using ONE run_code program: run" [disabled]'
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use only Cordis tools. First" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the bash tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Plan a small change: add" [disabled]'
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的预设。
|
||||
- button "标准模式":
|
||||
- text: 标准模式
|
||||
- img
|
||||
- text: 权限 选择新会话的默认权限模式
|
||||
- button "Workspace Write":
|
||||
- text: Workspace Write
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "/user-invoke-demo and confirm the fixtur" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Ask a research subagent to"
|
||||
- text: /
|
||||
- button "event-sourcing researcher" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Explain event sourcing in one sentence. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled]
|
||||
- button "Commands" [disabled]:
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write
|
||||
- button "Stop generating"
|
||||
- button "Send message" [disabled]
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Begin your reply with the" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Begin your reply with the" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// 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.
|
||||
// moment `open()` lands; driving `data-phase=settling` on the conversation
|
||||
// root from that flip would hide the composer seat and the header
|
||||
// (`visibility:hidden`) for the whole `session.history` round-trip — the
|
||||
// center column blanks and repaints like 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
|
||||
@@ -19,8 +19,9 @@
|
||||
// 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.
|
||||
// what makes the assertions non-vacuous: without the phase exemption, the
|
||||
// held window is exactly when `settling` would be painted and the composer
|
||||
// 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
|
||||
|
||||
319
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal file
319
apps/web/tests/subagent-interrupt-ui.e2e.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// Web e2e scenario: the composer's independent Stop interrupts a running
|
||||
// continuable child. The child holds its model turn open through a replay
|
||||
// hang entry; the browser proves Send and Stop coexist, the parent-offline
|
||||
// disabled-Send-with-Stop composer, the subagent.interrupt
|
||||
// (never session.cancel) transport, the parked follow-up, and the FIFO resume
|
||||
// on a waking send.
|
||||
//
|
||||
// Replay-binding note: only the PRIMARY script can hang, and scripts bind by
|
||||
// first-call order, so the child issues the composition's first model call
|
||||
// (claiming the overridden primary) and the parent's one UI prompt — needed
|
||||
// so the non-blank parent renders its header catalog — binds to a derived
|
||||
// child fixture afterwards.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-interrupt', import.meta.url))
|
||||
const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const LABEL = 'event-sourcing researcher'
|
||||
const INITIAL = 'Explain event sourcing in one sentence.'
|
||||
const REARM = 'Keep working until I stop you again.'
|
||||
const REARM_WAKE = 'Start that queued work now.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const WAKING = 'And add one concrete example.'
|
||||
const REARMED_ANSWER = 're-armed setup answer'
|
||||
const PARKED_ANSWER = 'parked follow-up answer'
|
||||
const WAKING_ANSWER = 'waking answer'
|
||||
|
||||
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
|
||||
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve on one exact child's next aborted turn end. */
|
||||
function waitForAbortedTurn(scaffold: WebScaffold, childId: SessionId): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error('interrupt did not reach an aborted turn/end'))
|
||||
}, 30_000)
|
||||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
if (session.id !== childId || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
if (event.data.reason.kind === 'aborted') resolve()
|
||||
else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
|
||||
function textCompletion(text: string): object {
|
||||
return {
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let sidecarRoot: string
|
||||
let rearmedReadyFile: string
|
||||
let parent: Agent
|
||||
let childId: SessionId
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-'))
|
||||
const readyFile = join(sidecarRoot, 'hang-ready')
|
||||
rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready')
|
||||
// The child claims this whole-script replacement: the offline and online
|
||||
// interrupt paths each hold one turn, then the parked and waking turns settle.
|
||||
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
|
||||
{ kind: 'hang', readyFile },
|
||||
{ kind: 'hang', readyFile: rearmedReadyFile },
|
||||
textCompletion(REARMED_ANSWER),
|
||||
textCompletion(PARKED_ANSWER),
|
||||
textCompletion(WAKING_ANSWER),
|
||||
]))
|
||||
await writeFile(
|
||||
join(sidecarRoot, 'session.jsonl'),
|
||||
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
|
||||
)
|
||||
// The parent's one prompted turn replays this recorded single text-only
|
||||
// call (binding is positional, not lineage-aware).
|
||||
const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl')
|
||||
const base = await readFile(BASE_FIXTURE, 'utf8')
|
||||
const [header, ...events] = base.trimEnd().split('\n')
|
||||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||||
await writeFile(parentTurnPath, [
|
||||
header
|
||||
.replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"')
|
||||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442'),
|
||||
...events,
|
||||
'',
|
||||
].join('\n'))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(sidecarRoot, 'session.jsonl'),
|
||||
replayOverride: join(sidecarRoot, 'replay.override.json'),
|
||||
replayChildFixtures: [parentTurnPath],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const root = scaffold.ctx.agents.roots()[0]
|
||||
if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||||
parent = root
|
||||
// The child's first model call claims the primary override and holds.
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: LABEL,
|
||||
signal: new AbortController().signal,
|
||||
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
|
||||
})
|
||||
childId = started.childId
|
||||
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
|
||||
|
||||
// One prompted parent turn makes the parent non-blank so the session
|
||||
// header (and its subagent catalog action) renders.
|
||||
const parentSettled = scaffold.whenTurnSettled()
|
||||
const parentInput = page.locator('textarea:enabled').first()
|
||||
await parentInput.fill('Ask a research subagent to explain event sourcing.')
|
||||
await parentInput.press('Enter')
|
||||
expect(await parentSettled).toBe(parent.id)
|
||||
|
||||
// Reload onto the restart baseline (the proven route to a freshly
|
||||
// discovered catalog), with the child still live and running host-side.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarRoot !== undefined) {
|
||||
await rm(sidecarRoot, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed')
|
||||
})
|
||||
|
||||
it('interrupts the live child through the parent-offline composer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
|
||||
// Simulate a parent that went offline: the catalog delivers
|
||||
// parentAvailable: false while the child Activation stays live (the
|
||||
// interrupt RPC itself needs no live parent — covered host-side by
|
||||
// subagent-interrupt.e2e.ts).
|
||||
const pattern = '**/api/subagent.list'
|
||||
await page.route(pattern, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const body = await response.json() as {
|
||||
result: { ok: true; value: { parentAvailable: boolean } } | { ok: false }
|
||||
}
|
||||
if (body.result.ok) body.result.value.parentAvailable = false
|
||||
await route.fulfill({ response, json: body })
|
||||
})
|
||||
try {
|
||||
await page.getByRole('button', { name: /1 subagent/ }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.getByRole('textbox', {
|
||||
name: 'Parent session offline; sending is unavailable but you can still stop the run',
|
||||
})
|
||||
await input.waitFor({ timeout: 15_000 })
|
||||
expect(await input.isDisabled()).toBe(true)
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
expect(await stop.isEnabled()).toBe(true)
|
||||
const send = page.getByRole('button', { name: 'Send message' })
|
||||
expect(await send.count()).toBe(1)
|
||||
expect(await send.isDisabled()).toBe(true)
|
||||
await compareOrRefreshGolden(
|
||||
OFFLINE_COMPOSER_EXPECTED,
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
|
||||
// Keep the continuable Activation resident after this first abort. The
|
||||
// direct setup queue does not change the parent-offline UI contract: its
|
||||
// input and Send remain disabled throughout the exercised browser path.
|
||||
await scaffold.ctx.subagents.followup(
|
||||
parent,
|
||||
childId,
|
||||
[{ type: 'text', text: REARM }],
|
||||
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
||||
)
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
}).result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
|
||||
await aborted
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
|
||||
|
||||
// Wake the parked setup message only after cancellation converges. A
|
||||
// second hang keeps the parent-available case independent from this stop.
|
||||
await scaffold.ctx.subagents.followup(
|
||||
parent,
|
||||
childId,
|
||||
[{ type: 'text', text: REARM_WAKE }],
|
||||
{ source: { kind: 'user' }, signal: new AbortController().signal },
|
||||
)
|
||||
await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
|
||||
expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
|
||||
} finally {
|
||||
await page.unroute(pattern)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
|
||||
// Reselect the child with the truthful catalog: parent available again.
|
||||
await page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
.getByRole('button').first().click()
|
||||
await page.getByRole('button', { name: /1 subagent/ }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||||
await input.waitFor({ timeout: 15_000 })
|
||||
expect(await input.isDisabled()).toBe(false)
|
||||
|
||||
// Queue a follow-up through Send while independent Stop remains available.
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
await input.fill(FOLLOWUP)
|
||||
await page.getByRole('button', { name: 'Send message' }).click()
|
||||
expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
|
||||
.toMatchObject({ ok: true })
|
||||
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
}).result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
// The addressed child stops through its own RPC, never the generic one.
|
||||
expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
|
||||
await aborted
|
||||
|
||||
// Parked: the Activation stays resident and idle with the retained
|
||||
// follow-up; the primary returns to Send without a new turn starting.
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
|
||||
const child = scaffold.ctx.agents.get(childId)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.inbox.nextTurn).toHaveLength(2)
|
||||
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
|
||||
|
||||
// Only the waking send resumes the parked queue, FIFO, to settlement.
|
||||
await input.fill(WAKING)
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
expect(userTexts).toEqual([INITIAL, REARM, REARM_WAKE, FOLLOWUP, WAKING])
|
||||
const turnEndKinds = loaded.events
|
||||
.filter(event => event.type === 'turn/end')
|
||||
.map(event => event.data.reason.kind)
|
||||
expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md'])
|
||||
})
|
||||
})
|
||||
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal file
176
apps/web/tests/subagent-interrupt.e2e.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
|
||||
// composition. A live continuable child holds its model turn open through a
|
||||
// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
|
||||
// proves from the real session state that the turn aborted, the follow-up
|
||||
// parked without auto-starting a new turn, and a later waking send resumed the
|
||||
// preserved FIFO order. No browser: the RPC surface is the product surface
|
||||
// under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const INITIAL = 'Explain event sourcing in one sentence.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const WAKING = 'And add one concrete example.'
|
||||
|
||||
type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** POST one unary RPC through the real HTTP carrier and unwrap its result. */
|
||||
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
|
||||
const response = await fetch(`${baseUrl}/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`,
|
||||
method,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json() as { result: RpcResult<T> }).result
|
||||
}
|
||||
|
||||
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
|
||||
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** One text-only scripted model completion (no tool calls: real tools are mounted). */
|
||||
function textCompletion(text: string): object {
|
||||
return {
|
||||
kind: 'chunks',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
|
||||
let scaffold: WebScaffold
|
||||
let sidecarRoot: string
|
||||
let readyFile: string
|
||||
let parentId: SessionId
|
||||
let childId: SessionId
|
||||
|
||||
beforeAll(async () => {
|
||||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-'))
|
||||
readyFile = join(sidecarRoot, 'hang-ready')
|
||||
// Whole-script replacement: the child's three model calls are the hang
|
||||
// (turn 1, interrupted), the parked follow-up's turn, and the waking turn.
|
||||
// The parent never runs a turn, so the child claims this primary script.
|
||||
await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
|
||||
{ kind: 'hang', readyFile },
|
||||
textCompletion('resumed response one'),
|
||||
textCompletion('resumed response two'),
|
||||
]))
|
||||
// Header-only primary fixture: the bare-array override replaces the
|
||||
// derived script entirely; the path only anchors replay installation.
|
||||
await writeFile(
|
||||
join(sidecarRoot, 'session.jsonl'),
|
||||
'{"type":"session","version":0,"id":"primary","createdAt":0}\n',
|
||||
)
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(sidecarRoot, 'session.jsonl'),
|
||||
replayOverride: join(sidecarRoot, 'replay.override.json'),
|
||||
})
|
||||
|
||||
// A live parent Agent through the real API; no workspace or browser.
|
||||
const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', {
|
||||
cwd: scaffold.workspaceCwd,
|
||||
})
|
||||
if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)
|
||||
parentId = sessionId(created.value.sessionId)
|
||||
const parent = scaffold.ctx.agents.get(parentId)
|
||||
if (parent === undefined) throw new Error('created parent session did not publish a live Agent')
|
||||
|
||||
const started = await scaffold.ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'event-sourcing researcher',
|
||||
signal: new AbortController().signal,
|
||||
request: { prompt: [{ type: 'text', text: INITIAL }], parent },
|
||||
})
|
||||
childId = started.childId
|
||||
// The hang entry writes readyFile after its prefix chunks, immediately
|
||||
// before waiting for cancellation: the deterministic "turn is open" gate.
|
||||
await waitFor(() => existsSync(readyFile), 'the held child turn to open')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
await rm(sidecarRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt teardown failed')
|
||||
})
|
||||
|
||||
it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
|
||||
// Queue the follow-up while the turn is still open, then interrupt.
|
||||
const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: FOLLOWUP }],
|
||||
})
|
||||
expect(queued).toMatchObject({ ok: true })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
})
|
||||
expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
// accepted acknowledges the admitted cancel, not quiescence: wait for the
|
||||
// aborted turn/end (the composition's first turn/end) before asserting.
|
||||
expect(await settled).toBe(childId)
|
||||
|
||||
// Parked, not resumed: the Activation stays resident with an idle driver,
|
||||
// the follow-up is retained, and no second turn opened.
|
||||
const child = scaffold.ctx.agents.get(childId)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.status).toBe('idle')
|
||||
expect(child!.inbox.nextTurn).toHaveLength(1)
|
||||
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1)
|
||||
expect((lastEnd)?.data.reason.kind).toBe('aborted')
|
||||
|
||||
// Only an explicit waking send resumes the parked queue, FIFO, then the
|
||||
// child runs both turns to completion and settles.
|
||||
const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: WAKING }],
|
||||
})
|
||||
expect(waking).toMatchObject({ ok: true })
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||||
// Human-origin messages only: the real composition also injects
|
||||
// runtime-context snapshots as non-user-source messages.
|
||||
const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING])
|
||||
const turnEndKinds = loaded.events
|
||||
.filter(event => event.type === 'turn/end')
|
||||
.map(event => (event).data.reason.kind)
|
||||
expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed'])
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
|
||||
// client.js` bundles through AppWebEntry's ModuleLoader path against the
|
||||
// keyless FixtureApiClient transport, opens the fixture session, and pins the
|
||||
// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`)
|
||||
// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`)
|
||||
// reaches — the `todo_write` tool row and the dock's plan strip.
|
||||
//
|
||||
// The row is pinned as three separate fields on purpose. `summary=` is the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Web e2e scenario: assistant IconActions belong to the settled answer, so
|
||||
// they arrive with `turn/end` and not before. The recorded turn narrates in
|
||||
// plain text before its tool call, which is the shape that used to hand the
|
||||
// plain text before its tool call, which is the shape that would hand the
|
||||
// footer to mid-turn narration for the seconds a tool runs and then move it
|
||||
// down. A `hang` sidecar on the SECOND model call parks the turn after the
|
||||
// narration and the tool result are durable, so the running state is stable by
|
||||
|
||||
@@ -91,8 +91,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
).not.toBeUndefined()
|
||||
// First adoption births a blank Session+Agent whose workspace attach must
|
||||
// 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.
|
||||
// a delete mints a fresh blank Session+Agent too (no cwd-based reuse
|
||||
// exists), 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)
|
||||
@@ -254,7 +254,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
// 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).
|
||||
// (no cwd-based blank reuse exists, so the account is never empty).
|
||||
await adoptDirectory(scaffold.workspaceCwd)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
|
||||
@@ -482,7 +482,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
|
||||
// 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.
|
||||
// it — the hazard this scenario pins.
|
||||
const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
|
||||
await card.hover()
|
||||
await page.waitForTimeout(POINTER_HOLD_MS)
|
||||
@@ -515,10 +515,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
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.
|
||||
// without the gap-crossing grace, that return trip fires the list's
|
||||
// pointerleave and closes the menu — a hesitating pointer loses 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()
|
||||
|
||||
@@ -61,11 +61,16 @@
|
||||
"tests/skill-user-invoke.e2e.ts",
|
||||
"tests/permission-policy-context.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/agent-preset-selection.e2e.ts",
|
||||
"tests/agent-preset-authoring.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/produced-files.e2e.ts",
|
||||
"tests/produced-file-mentions.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/subagent-interrupt.e2e.ts",
|
||||
"tests/subagent-interrupt-ui.e2e.ts",
|
||||
"tests/sidebar-subagent-activity.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts",
|
||||
"tests/skill-tool-row.e2e.ts",
|
||||
|
||||
@@ -73,10 +73,13 @@ const BOOT_GRAMMAR_FILES: readonly string[] = [
|
||||
'dist/json.mjs',
|
||||
]
|
||||
|
||||
/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces today). */
|
||||
/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */
|
||||
const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf']
|
||||
|
||||
/** npm package name of a resolved module id (the segment after the LAST `node_modules/` — pnpm nests the real package under an inner node_modules). */
|
||||
/**
|
||||
* npm package name of a resolved module id (the segment after the LAST
|
||||
* `node_modules/` — pnpm nests the real package under an inner node_modules).
|
||||
*/
|
||||
function npmPackageOf(id: string): string | undefined {
|
||||
const parts = id.split('/node_modules/')
|
||||
if (parts.length === 1) return undefined
|
||||
@@ -94,7 +97,7 @@ export default defineConfig({
|
||||
output: {
|
||||
// Output layout: the two main chunks stay at assets/ root; lazy
|
||||
// @shikijs/langs grammar chunks group under assets/langs/; fonts
|
||||
// (today all KaTeX faces referenced by vendor.css) group under
|
||||
// (all KaTeX faces referenced by vendor.css) group under
|
||||
// assets/fonts/. Sourcemaps need no arrangement: rollup writes each
|
||||
// .map next to its js and references it by bare relative filename.
|
||||
chunkFileNames(chunk): string {
|
||||
@@ -128,7 +131,8 @@ export default defineConfig({
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
// so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
|
||||
// Only the shell's normal-package surface is aliased — plugin packages are
|
||||
// NEVER bundled here (web2 shell self-sufficiency); they arrive as runtime
|
||||
// NEVER bundled here (shell self-sufficiency — see
|
||||
// packages/client/web/README.md); they arrive as runtime
|
||||
// bundles through the client module system. Order matters — subpath
|
||||
// aliases must win over bare-name prefixes.
|
||||
alias: [
|
||||
|
||||
Reference in New Issue
Block a user