plan-review.e2e.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. // Web e2e scenario: the plan-review takeover. The shipped composition mounts
  2. // plan mode and its client seat, so `/plan <task>` enters plan mode for real
  3. // and the recorded turn ends on exit_plan_mode blocking against the live
  4. // userInteraction seam. The composer is then occupied by the plan decision
  5. // card — not the generic question flow — and approving it through the card
  6. // completes the turn with the approval in the log.
  7. // Replay is deterministic: the plan content arrives from replayed chunks, the
  8. // review wait is real, and the approve click is the test's own gesture (the
  9. // turn cannot complete without it, in record and replay alike).
  10. import { readFile } from 'node:fs/promises'
  11. import { fileURLToPath } from 'node:url'
  12. import { join } from 'node:path'
  13. import type { Browser, Page } from 'playwright'
  14. import { chromium } from 'playwright'
  15. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  16. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  17. import {
  18. assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
  19. compareOrRefreshGolden, fixtureUserPrompts,
  20. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  21. } from './scaffold.ts'
  22. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  23. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/plan-review', import.meta.url))
  24. const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
  25. // The waiting golden owns the decision card; the approved golden owns the
  26. // transcript the approval leaves behind — the state the card cannot see.
  27. const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
  28. const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
  29. const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
  30. const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md')
  31. const MODE = webSnapshotMode()
  32. // One command line: /plan enters plan mode and submits the rest as the turn's
  33. // message. The task is deliberately self-contained (nothing to explore in a
  34. // fresh workspace) so the recorded turn is a plan and its review, and the
  35. // approved continuation is one word.
  36. const TASK = 'Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. '
  37. + 'Call exit_plan_mode with a short plan of at most five bullet points. '
  38. + 'Once the plan is approved, reply with the single word DONE and stop.'
  39. const LINE = `/plan ${TASK}`
  40. describe('web e2e: plan review takeover round trip', () => {
  41. let scaffold: WebScaffold
  42. let browser: Browser
  43. let page: Page
  44. let tripwire: ReturnType<typeof watchConsole>
  45. const sessionEvents: SessionEvent[] = []
  46. beforeAll(async () => {
  47. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
  48. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  49. browser = await chromium.launch()
  50. // English page: the decision copy is the surface under test, and the
  51. // golden pins one language.
  52. page = await newEnglishPage(browser)
  53. tripwire = watchConsole(page)
  54. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  55. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  56. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  57. }, 120_000)
  58. afterAll(async () => {
  59. await browser?.close()
  60. await scaffold?.close()
  61. })
  62. it('reviews the plan on a decision card and approves through it', async () => {
  63. onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review'))
  64. if (MODE !== 'record') {
  65. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK])
  66. }
  67. const input = page.locator('[data-composer-input]').first()
  68. await input.waitFor({ timeout: 10_000 })
  69. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
  70. await input.fill(LINE)
  71. await input.press('Enter')
  72. // The card takes over the input area while exit_plan_mode blocks. Its
  73. // presence is a STABLE waiting state (it stays until answered), so a plain
  74. // waitFor is race-free.
  75. const card = page.locator('[data-plan-review-key]')
  76. await card.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
  77. // The plan-review request must NOT land on the generic question flow.
  78. expect(await page.locator('[data-question-key]').count()).toBe(0)
  79. await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
  80. const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
  81. await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
  82. await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  83. if (MODE !== 'record') {
  84. const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd)
  85. await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE)
  86. const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
  87. await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
  88. }
  89. await card.getByRole('button', { name: 'Approve' }).click()
  90. // Park the pointer: the card unmounts and the ContextMeter ring lands
  91. // under the click position, whose 200ms hover delay would arm a tooltip
  92. // into the aria captures below.
  93. await page.mouse.move(0, 0)
  94. const sessionId = await settled
  95. if (MODE === 'record') {
  96. await recordFixture(scaffold, sessionId, FIXTURE)
  97. return
  98. }
  99. // World state: the approval reached the tool, and plan mode is left behind.
  100. const results = sessionEvents.filter(e => e.type === 'tool/result')
  101. expect(JSON.stringify(results.at(-1))).toContain('Plan approved')
  102. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  103. // Card gone; regular input restored.
  104. expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
  105. expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
  106. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  107. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  108. await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
  109. const expanded = await captureExpandedTurnProcessAria(
  110. page,
  111. '[class*="centerCol"]',
  112. scaffold.workspaceCwd,
  113. )
  114. await compareOrRefreshGolden(APPROVED_EXPANDED_EXPECTED, expanded, MODE)
  115. expect(tripwire.pageErrors).toEqual([])
  116. expect(tripwire.warnings).toEqual([])
  117. }, 200_000)
  118. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  119. await assertFixtureInventory(SNAPSHOT_DIR, [
  120. 'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md',
  121. 'approved.expected.md', 'approved-expanded.expected.md',
  122. ])
  123. })
  124. })