queue-actions.e2e.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. // Keyless browser coverage for pending queue actions through the shipped Web
  2. // composition and real HTTP/SSE wire. A replay override parks the active turn
  3. // so two ordinary follow-ups remain addressable while the page edits one and
  4. // removes one. The queue uses an existing recorded model
  5. // call; this scenario owns only the user-visible mid-turn golden.
  6. import { existsSync } from 'node:fs'
  7. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  8. import { tmpdir } from 'node:os'
  9. import { fileURLToPath } from 'node:url'
  10. import { join } from 'node:path'
  11. import type { Browser, Page } from 'playwright'
  12. import { chromium } from 'playwright'
  13. import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
  14. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  15. import {
  16. assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  17. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  18. } from './scaffold.ts'
  19. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  20. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url))
  21. const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
  22. const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
  23. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  24. const MODE = webSnapshotMode()
  25. const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
  26. const REMOVE = 'Queue item to remove'
  27. const EDIT = 'Queue item to edit'
  28. const EDITED = 'Edited queue item'
  29. describe('web e2e: queue row actions', () => {
  30. let scaffold: WebScaffold | undefined
  31. let browser: Browser | undefined
  32. let page: Page
  33. let overrideDir: string | undefined
  34. afterEach(async () => {
  35. const failures: unknown[] = []
  36. await browser?.close().catch((error: unknown) => failures.push(error))
  37. browser = undefined
  38. const closing = scaffold
  39. scaffold = undefined
  40. await closing?.close().catch((error: unknown) => failures.push(error))
  41. if (overrideDir !== undefined) {
  42. await rm(overrideDir, { recursive: true, force: true })
  43. .catch((error: unknown) => failures.push(error))
  44. }
  45. overrideDir = undefined
  46. if (failures.length === 1) throw failures[0]
  47. if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed')
  48. })
  49. it.skipIf(MODE === 'record')('edits and removes exact pending occurrences', async () => {
  50. overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
  51. const readyFile = join(overrideDir, '.hang-ready')
  52. const overridePath = join(overrideDir, 'replay.override.json')
  53. await writeFile(overridePath, JSON.stringify({
  54. patches: [{ at: 0, entry: { kind: 'hang', readyFile } }],
  55. }))
  56. const sessionEvents: SessionEvent[] = []
  57. scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
  58. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  59. browser = await chromium.launch()
  60. page = await newEnglishPage(browser)
  61. const tripwire = watchConsole(page)
  62. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  63. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  64. await connectFreshWorkspace(page)
  65. onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
  66. const input = page.locator('textarea').first()
  67. const settled = scaffold.whenTurnSettled()
  68. await input.fill(ACTIVE_PROMPT)
  69. await input.press('Enter')
  70. await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
  71. for (const text of [REMOVE, EDIT]) {
  72. await input.fill(text)
  73. await input.press('Enter')
  74. }
  75. await expect.poll(
  76. () => page.getByRole('button', { name: '删除排队消息' }).count(),
  77. { timeout: 10_000 },
  78. ).toBe(2)
  79. const editRow = page.getByText(EDIT, { exact: true }).locator('..')
  80. await editRow.getByRole('button', { name: '编辑排队消息' }).click()
  81. const editor = page.getByRole('textbox', { name: '编辑排队消息' })
  82. await editor.fill(EDITED)
  83. const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  84. await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE)
  85. await page.getByRole('button', { name: '保存排队消息' }).click()
  86. await page.getByText(EDITED, { exact: true }).waitFor()
  87. const removeRow = page.getByText(REMOVE, { exact: true }).locator('..')
  88. await removeRow.getByRole('button', { name: '删除排队消息' }).click()
  89. await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
  90. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  91. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  92. expect(sessionEvents.filter(event => event.type === 'user/message')).toHaveLength(1)
  93. expect(tripwire.pageErrors).toEqual([])
  94. expect(tripwire.warnings).toEqual([])
  95. const editedRow = page.getByText(EDITED, { exact: true }).locator('..')
  96. await editedRow.getByRole('button', { name: '删除排队消息' }).click()
  97. await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0)
  98. await page.getByRole('button', { name: 'Stop generating' }).click()
  99. await settled
  100. }, 120_000)
  101. it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
  102. await assertFixtureInventory(SNAPSHOT_DIR, ['editing.expected.md', 'ui.expected.md'])
  103. })
  104. })