# Conflicts: # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/client/connection/src/client/fixture.ts # packages/host/apiproxy/src/api-proxy.ts
279 lines
15 KiB
TypeScript
279 lines
15 KiB
TypeScript
// 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 after general aria normalization. */
|
|
function withPresetRoot(snapshot: string): string {
|
|
const rootSuffix = `/${userRoot.split('/').pop()!}`
|
|
return snapshot.split('\n').map((line) => {
|
|
const rootStart = line.indexOf(rootSuffix)
|
|
if (rootStart === -1) return line
|
|
const pathStart = line.lastIndexOf(' ', rootStart) + 1
|
|
return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}`
|
|
}).join('\n')
|
|
}
|
|
|
|
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([])
|
|
})
|
|
})
|