question-composer.e2e.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. // Web e2e scenario: the resident question composer. The shipped composition
  2. // already exposes ask_user_question (the ui-question row's node half mounts
  3. // the tool), so a recorded turn where the model asks blocks mid-turn on the
  4. // real userInteraction seam: the composer renders in the browser, the test
  5. // answers through it, and the turn completes with the answer in the log.
  6. // Replay is fully deterministic — the question content arrives from replayed
  7. // chunks, the composer wait is real, and the answer click is the test's own
  8. // gesture (the ONE place a drive step legitimately reacts to model content:
  9. // the 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, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  19. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  20. } from './scaffold.ts'
  21. import { connectFreshWorkspace, saveFailureShot } from './support.ts'
  22. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
  23. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  24. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  25. // Second golden: the answered transcript — the question resolved into its
  26. // tool round trip and the final reply, the state the waiting golden cannot see.
  27. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
  28. const MODE = webSnapshotMode()
  29. const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.'
  30. describe('web e2e: resident question composer round trip', () => {
  31. let scaffold: WebScaffold
  32. let browser: Browser
  33. let page: Page
  34. let tripwire: ReturnType<typeof watchConsole>
  35. const sessionEvents: SessionEvent[] = []
  36. beforeAll(async () => {
  37. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
  38. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  39. browser = await chromium.launch()
  40. page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
  41. tripwire = watchConsole(page)
  42. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  43. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  44. // Fresh world: connect a Workspace so the composer scenarios start live.
  45. await connectFreshWorkspace(page)
  46. }, 120_000)
  47. afterAll(async () => {
  48. await browser?.close()
  49. await scaffold?.close()
  50. })
  51. it('asks through the composer, answers, and completes with the answer logged', async () => {
  52. onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
  53. if (MODE !== 'record') {
  54. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  55. }
  56. const input = page.locator('textarea').first()
  57. await input.waitFor({ timeout: 10_000 })
  58. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
  59. await input.fill(PROMPT)
  60. await input.press('Enter')
  61. // The composer takes over the input area while the tool blocks. Its
  62. // presence is a STABLE waiting state (not a transient): it stays until
  63. // answered, so a plain waitFor is race-free.
  64. const composer = page.locator('[data-question-key]')
  65. await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
  66. await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
  67. if (MODE !== 'record') {
  68. // This golden owns the stable question surface; the answered-state
  69. // golden below owns the resulting transcript.
  70. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  71. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  72. }
  73. await composer.getByRole('radio', { name: 'Blue' }).click()
  74. // Submit: Enter on the focused option (the composer's documented submit).
  75. await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
  76. const sessionId = await settled
  77. if (MODE === 'record') {
  78. await recordFixture(scaffold, sessionId, FIXTURE)
  79. return
  80. }
  81. // World state: the tool result carries the chosen answer, and DONE lands.
  82. const results = sessionEvents.filter(e => e.type === 'tool/result')
  83. expect(JSON.stringify(results.at(-1))).toContain('Blue')
  84. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  85. // Composer gone; regular input restored.
  86. expect(await page.locator('[data-question-key]').count()).toBe(0)
  87. await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  88. // Golden of the answered transcript: the ask_user_question round trip
  89. // rendered as history (question tool row + DONE), composer takeover gone.
  90. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  91. await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
  92. expect(tripwire.pageErrors).toEqual([])
  93. expect(tripwire.warnings).toEqual([])
  94. }, 200_000)
  95. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  96. await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
  97. })
  98. })