deepseek-harness/apps/web/tests/agent-preset-authoring.e2e.ts

281 lines
15 KiB
TypeScript
Raw Normal View History

refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
// 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'
fix(agent-presets,web): broken presets are roster rows, not gaps A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
2026-08-09 02:17:56 +08:00
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
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'
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
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')
fix(agent-presets,web): broken presets are roster rows, not gaps A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
2026-08-09 02:17:56 +08:00
const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
/** 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. */
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
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')
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
}
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 states the copy path directly, and the shipped rows offer
// view/copy but never delete or a location — their
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
// install is overwritten by upgrades and is not the user's to manage.
expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
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 的双工具编码 Agent。')
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
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)
// The custom group outlives its only member: the heading stays with the
// creator entry so the place to author a preset never disappears.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)
fix(agent-presets,web): broken presets are roster rows, not gaps A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
2026-08-09 02:17:56 +08:00
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 })
fix(agent-presets,web): broken presets are roster rows, not gaps A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
2026-08-09 02:17:56 +08:00
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('加载失败: 幽灵预设')
fix(agent-presets,web): broken presets are roster rows, not gaps A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
2026-08-09 02:17:56 +08:00
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)
fix(agent-presets,web): broken presets are roster rows, not gaps A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
2026-08-09 02:17:56 +08:00
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)
refactor(agent-presets,web): copy-only preset authoring with a path to the files The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics.
2026-08-08 22:35:26 +08:00
it('drove every surface without a page error or a stream warning', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})