queue-actions.e2e.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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 COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
  23. const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
  24. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  25. const MODE = webSnapshotMode()
  26. const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
  27. const REMOVE = 'Queue item to remove'
  28. const EDIT = 'Queue item to edit'
  29. const EDITED = 'Edited queue item'
  30. describe('web e2e: queue row actions', () => {
  31. let scaffold: WebScaffold | undefined
  32. let browser: Browser | undefined
  33. let page: Page
  34. let overrideDir: string | undefined
  35. afterEach(async () => {
  36. const failures: unknown[] = []
  37. await browser?.close().catch((error: unknown) => failures.push(error))
  38. browser = undefined
  39. const closing = scaffold
  40. scaffold = undefined
  41. await closing?.close().catch((error: unknown) => failures.push(error))
  42. if (overrideDir !== undefined) {
  43. await rm(overrideDir, { recursive: true, force: true })
  44. .catch((error: unknown) => failures.push(error))
  45. }
  46. overrideDir = undefined
  47. if (failures.length === 1) throw failures[0]
  48. if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed')
  49. })
  50. it.skipIf(MODE === 'record')('edits and removes exact pending occurrences', async () => {
  51. overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
  52. const readyFile = join(overrideDir, '.hang-ready')
  53. const overridePath = join(overrideDir, 'replay.override.json')
  54. await writeFile(overridePath, JSON.stringify({
  55. patches: [{ at: 0, entry: { kind: 'hang', readyFile } }],
  56. }))
  57. const sessionEvents: SessionEvent[] = []
  58. scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
  59. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  60. browser = await chromium.launch()
  61. page = await newEnglishPage(browser)
  62. const tripwire = watchConsole(page)
  63. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  64. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  65. await connectFreshWorkspace(page)
  66. onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
  67. const input = page.locator('textarea').first()
  68. const settled = scaffold.whenTurnSettled()
  69. await input.fill(ACTIVE_PROMPT)
  70. await input.press('Enter')
  71. await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
  72. for (const text of [REMOVE, EDIT]) {
  73. await input.fill(text)
  74. await input.press('Enter')
  75. }
  76. const queueHeader = page.getByRole('button', { name: '2 条排队消息' })
  77. await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
  78. .toBe('false')
  79. const collapsedSnapshot = await captureStableAria(
  80. page,
  81. '[class*="centerCol"]',
  82. scaffold.workspaceCwd,
  83. )
  84. await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE)
  85. await queueHeader.click()
  86. await expect.poll(
  87. () => page.getByRole('button', { name: '删除排队消息' }).count(),
  88. { timeout: 10_000 },
  89. ).toBe(2)
  90. const editRow = page.getByText(EDIT, { exact: true }).locator('..')
  91. await editRow.getByRole('button', { name: '编辑排队消息' }).click()
  92. const editor = page.getByRole('textbox', { name: '编辑排队消息' })
  93. await editor.fill(EDITED)
  94. const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  95. await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE)
  96. await page.getByRole('button', { name: '保存排队消息' }).click()
  97. await page.getByText(EDITED, { exact: true }).waitFor()
  98. const removeRow = page.getByText(REMOVE, { exact: true }).locator('..')
  99. await removeRow.getByRole('button', { name: '删除排队消息' }).click()
  100. await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
  101. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  102. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  103. expect(sessionEvents.filter(event => event.type === 'user/message')).toHaveLength(1)
  104. expect(tripwire.pageErrors).toEqual([])
  105. expect(tripwire.warnings).toEqual([])
  106. const editedRow = page.getByText(EDITED, { exact: true }).locator('..')
  107. await editedRow.getByRole('button', { name: '删除排队消息' }).click()
  108. await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0)
  109. await page.getByRole('button', { name: 'Stop generating' }).click()
  110. await settled
  111. }, 120_000)
  112. it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
  113. await assertFixtureInventory(
  114. SNAPSHOT_DIR,
  115. ['collapsed.expected.md', 'editing.expected.md', 'ui.expected.md'],
  116. )
  117. })
  118. })