agent-preset-authoring.e2e.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. // Web e2e scenario: the agent-preset settings section as copy-only authoring.
  2. // The browser never edits composition text — a shipped preset opens in a
  3. // read-only viewer, the copy dialog collects an id and an optional display
  4. // name, and the host copies the whole directory. The section's other job is
  5. // getting the user TO the files: this lane pins `nativeOpen: false` (see the
  6. // overlay), so the location affordance answers the preset directory as text —
  7. // the deterministic branch a golden can hold on every platform.
  8. //
  9. // Zero model calls: no replay fixture mounts, so a stray stream fails loud.
  10. import { existsSync } from 'node:fs'
  11. import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
  12. import { tmpdir } from 'node:os'
  13. import { fileURLToPath } from 'node:url'
  14. import { join } from 'node:path'
  15. import type { Browser, Page } from 'playwright'
  16. import { chromium } from 'playwright'
  17. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  18. import type { Locator } from 'playwright'
  19. import {
  20. captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
  21. webSnapshotMode, type WebScaffold,
  22. } from './scaffold.ts'
  23. import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
  24. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/agent-preset-authoring', import.meta.url))
  25. const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
  26. const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
  27. const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
  28. const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
  29. /** The shipped roster, bundled inside the `dsh-agent-presets` package. */
  30. const SHIPPED_PRESETS = fileURLToPath(new URL('../../../packages/preset/agent-presets/presets', import.meta.url))
  31. const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
  32. const MODE = webSnapshotMode()
  33. describe('web e2e: agent-preset authoring is a host-side copy', () => {
  34. let scaffold: WebScaffold
  35. let browser: Browser
  36. let page: Page
  37. let tripwire: ReturnType<typeof watchConsole>
  38. let userRoot: string
  39. /** The settings dialog, opened on the Agent-presets section. */
  40. function settingsDialog(): Locator {
  41. return page.getByRole('dialog', { name: '设置' })
  42. }
  43. beforeAll(async () => {
  44. userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
  45. scaffold = await launchWebScaffold({
  46. extraOverlayPath: OVERLAY,
  47. agentPresets: {
  48. // The shipped root is the plugin's own, prepended before this.
  49. roots: [{ path: userRoot, trust: 'user' }],
  50. default: 'standard',
  51. },
  52. })
  53. browser = await chromium.launch()
  54. // The scenario asserts the shipped Chinese copy, so the browser asks for it.
  55. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  56. tripwire = watchConsole(page)
  57. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  58. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  59. }, 120_000)
  60. afterAll(async () => {
  61. await browser?.close()
  62. await scaffold?.close()
  63. await rm(userRoot, { recursive: true, force: true })
  64. })
  65. it('offers the roster with copy as the only way to create', async () => {
  66. onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section'))
  67. await page.getByRole('button', { name: '设置', exact: true }).click()
  68. const dialog = settingsDialog()
  69. await dialog.waitFor({ timeout: 10_000 })
  70. await dialog.getByRole('button', { name: 'Agent 预设' }).click()
  71. await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
  72. // The intro copy also names 标准模式. Wait for the roster's own action so
  73. // the snapshot cannot land between the section shell and its cards.
  74. await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 })
  75. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  76. await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
  77. const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' })
  78. expect(await toggle.getAttribute('aria-checked')).toBe('true')
  79. // The intro states the copy path directly, and the shipped rows offer
  80. // view/copy but never delete or a location — their
  81. // install is overwritten by upgrades and is not the user's to manage.
  82. expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
  83. expect(snapshot).not.toContain('新建预设')
  84. expect(snapshot).toContain('查看: 标准模式')
  85. expect(snapshot).not.toContain('删除: 标准模式')
  86. expect(snapshot).not.toContain('打开目录')
  87. // The rest of this scenario exercises the existing default and Creator
  88. // actions with the beta picker enabled by default.
  89. }, 60_000)
  90. it('views a shipped composition read-only instead of editing it', async () => {
  91. onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
  92. const dialog = settingsDialog()
  93. await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
  94. const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
  95. await viewer.waitFor({ timeout: 10_000 })
  96. // The real shipped composition, not a golden: the viewer shows whatever
  97. // the deployment ships, and this lane only asserts it is shown read-only.
  98. const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8')
  99. expect(await viewer.locator('pre').textContent()).toBe(shipped)
  100. expect(await viewer.getByRole('textbox').count()).toBe(0)
  101. // The header X and the footer button share the 关闭 name; the footer one
  102. // is last in the dialog.
  103. await viewer.getByRole('button', { name: '关闭' }).last().click()
  104. await viewer.waitFor({ state: 'detached', timeout: 10_000 })
  105. }, 60_000)
  106. it('copies 极简模式 whole under a new id and lands in its files', async () => {
  107. onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
  108. const dialog = settingsDialog()
  109. await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
  110. const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
  111. await copyDialog.waitFor({ timeout: 10_000 })
  112. const dialogSnapshot = await captureStableAria(
  113. page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd)
  114. await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE)
  115. // Two fields and nothing else: the id is the directory name the host
  116. // needs up front; description and composition live in the files.
  117. expect(dialogSnapshot).toContain('标识符')
  118. expect(dialogSnapshot).not.toContain('描述')
  119. await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
  120. await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
  121. await copyDialog.getByRole('button', { name: '创建' }).click()
  122. await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
  123. // The new row lands in the custom group, and — with no desktop opener —
  124. // its directory is revealed as text right away: landing in the files is
  125. // the completion of a copy, not a follow-up.
  126. await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 })
  127. await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 })
  128. // The copy dialog is detached, so the settings dialog is the only one
  129. // left (it names itself via aria-labelledby, which a CSS attribute
  130. // selector cannot address).
  131. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, {
  132. replacements: [[userRoot, '{{presetRoot}}']],
  133. })
  134. await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
  135. expect(snapshot).toContain('{{presetRoot}}/my-agent')
  136. // The host copied the whole directory and rewrote only the display
  137. // metadata: the composition is byte-identical to the shipped source, the
  138. // description rides along for the user to edit in place, and neither the
  139. // source's name nor its roster order survives into the copy.
  140. const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8')
  141. expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
  142. const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
  143. expect(metadata).toContain('name: 我的模式')
  144. expect(metadata).toContain('description: 仅提供持久 shell 的单工具编码 Agent。')
  145. expect(metadata).not.toContain('order:')
  146. }, 60_000)
  147. it('deletes the copy after confirmation and reclaims the roster', async () => {
  148. onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete'))
  149. const dialog = settingsDialog()
  150. await dialog.getByRole('button', { name: '删除: 我的模式' }).click()
  151. const confirm = page.getByRole('dialog', { name: '删除该预设?' })
  152. await confirm.waitFor({ timeout: 10_000 })
  153. await confirm.getByRole('button', { name: '删除', exact: true }).click()
  154. await confirm.waitFor({ state: 'detached', timeout: 10_000 })
  155. await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
  156. expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
  157. // The custom group outlives its only member: the heading stays with the
  158. // creator entry so the place to author a preset never disappears.
  159. expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
  160. expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
  161. expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
  162. }, 60_000)
  163. it('marks damaged presets broken and clears a ghost through delete', async () => {
  164. onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged'))
  165. // The two hand-edit damage shapes: a composition that no longer parses,
  166. // and a directory whose composition file was deleted outright.
  167. await mkdir(join(userRoot, 'broken-yaml'), { recursive: true })
  168. await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n')
  169. await mkdir(join(userRoot, 'ghost'), { recursive: true })
  170. await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n')
  171. // The section reads the roster when it mounts; hop away and back.
  172. const dialog = settingsDialog()
  173. await dialog.getByRole('button', { name: '通用设置' }).click()
  174. await dialog.getByRole('button', { name: 'Agent 预设' }).click()
  175. await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
  176. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, {
  177. replacements: [[userRoot, '{{presetRoot}}']],
  178. })
  179. await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
  180. // Both damage shapes surface as marked, unselectable, uncopyable cards
  181. // that still carry their metadata and the discovery-reported reason.
  182. expect(snapshot).toContain('加载失败: broken-yaml')
  183. expect(snapshot).toContain('加载失败: 幽灵预设')
  184. expect(snapshot).toContain('not valid YAML')
  185. expect(snapshot).toContain('agent.cordis.yml is missing')
  186. expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
  187. expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
  188. // A broken card offers no "set default" affordance at all — the aria name
  189. // IS the broken marking, so the picking name must not exist.
  190. expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
  191. // The ghost's way out is the card's own delete — and the id it blocked
  192. // is claimable again immediately afterwards.
  193. await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click()
  194. const confirm = page.getByRole('dialog', { name: '删除该预设?' })
  195. await confirm.waitFor({ timeout: 10_000 })
  196. await confirm.getByRole('button', { name: '删除', exact: true }).click()
  197. await confirm.waitFor({ state: 'detached', timeout: 10_000 })
  198. await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
  199. expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
  200. await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
  201. const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
  202. await copyDialog.waitFor({ timeout: 10_000 })
  203. await copyDialog.getByPlaceholder('my-agent').fill('ghost')
  204. await copyDialog.getByRole('button', { name: '创建' }).click()
  205. await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
  206. await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
  207. // Leave the roster as the earlier tests shaped it.
  208. await dialog.getByRole('button', { name: '删除: ghost' }).click()
  209. const cleanup = page.getByRole('dialog', { name: '删除该预设?' })
  210. await cleanup.waitFor({ timeout: 10_000 })
  211. await cleanup.getByRole('button', { name: '删除', exact: true }).click()
  212. await cleanup.waitFor({ state: 'detached', timeout: 10_000 })
  213. await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true })
  214. }, 60_000)
  215. it('starts a creator-mode session from the section', async () => {
  216. onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator'))
  217. // Without a workspace the flow only stages (there is no session to land
  218. // in until one is connected); connect first so the gesture carries all
  219. // the way to a composed host session.
  220. await settingsDialog().getByRole('button', { name: '关闭' }).last().click()
  221. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
  222. await page.getByRole('button', { name: '设置', exact: true }).click()
  223. const dialog = settingsDialog()
  224. await dialog.waitFor({ timeout: 10_000 })
  225. await dialog.getByRole('button', { name: 'Agent 预设' }).click()
  226. await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click()
  227. // Leaving settings is part of the gesture: the flow lands on the
  228. // new-session screen with the self-referential preset staged, and the
  229. // blank session the flow produces composes from it on the host.
  230. await dialog.waitFor({ state: 'detached', timeout: 10_000 })
  231. await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
  232. await expect.poll(async () => {
  233. const response = await scaffold.hostFetch('/api/session/list', {
  234. method: 'POST',
  235. headers: { 'content-type': 'application/json' },
  236. body: JSON.stringify({
  237. type: 'client-request', rpcId: 'creator-draft-stage', method: 'session/list',
  238. payload: { args: { _request: {} } },
  239. }),
  240. })
  241. const body = await response.json() as {
  242. result: { value?: { items: unknown[] } }
  243. }
  244. return JSON.stringify(body.result.value?.items ?? body.result)
  245. }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"')
  246. }, 60_000)
  247. it('drove every surface without a page error or a stream warning', () => {
  248. expect(tripwire.pageErrors).toEqual([])
  249. expect(tripwire.warnings).toEqual([])
  250. })
  251. })