question-composer.e2e.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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, newEnglishPage, 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. const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
  26. const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
  27. // Final golden: the answered transcript — the question resolved into its tool
  28. // round trip and the final reply, the state the composer goldens cannot see.
  29. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
  30. const MODE = webSnapshotMode()
  31. // The options carry long descriptions on purpose: the squeeze assertion below
  32. // needs option copy that WRAPS, which is the only shape that reproduces a
  33. // collapsed row painting its copy outside its own box.
  34. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
  35. describe('web e2e: resident question composer round trip', () => {
  36. let scaffold: WebScaffold
  37. let browser: Browser
  38. let page: Page
  39. let tripwire: ReturnType<typeof watchConsole>
  40. const sessionEvents: SessionEvent[] = []
  41. beforeAll(async () => {
  42. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
  43. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  44. browser = await chromium.launch()
  45. page = await newEnglishPage(browser)
  46. tripwire = watchConsole(page)
  47. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  48. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  49. // Fresh world: connect a Workspace so the composer scenarios start live.
  50. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  51. }, 120_000)
  52. afterAll(async () => {
  53. await browser?.close()
  54. await scaffold?.close()
  55. })
  56. it('asks through the composer, answers, and completes with the answer logged', async () => {
  57. onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
  58. if (MODE !== 'record') {
  59. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  60. }
  61. const input = page.locator('textarea').first()
  62. await input.waitFor({ timeout: 10_000 })
  63. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
  64. await input.fill(PROMPT)
  65. await input.press('Enter')
  66. // The composer takes over the input area while the tool blocks. Its
  67. // presence is a STABLE waiting state (not a transient): it stays until
  68. // answered, so a plain waitFor is race-free.
  69. const composer = page.locator('[data-question-key]')
  70. await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
  71. await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
  72. const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
  73. await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
  74. await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  75. if (MODE !== 'record') {
  76. // This golden owns the stable question surface; the answered-state
  77. // golden below owns the resulting transcript.
  78. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  79. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  80. const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
  81. await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
  82. }
  83. // Squeezed card: the option rows are the capped card's scroll content, so
  84. // shrinking the seat must push overflow into the option list, never
  85. // collapse a row below the height its own copy needs — a collapsed row
  86. // paints its centered copy outside the row box, over the title and the
  87. // neighbouring rows. Measured on the live composer at seat heights that
  88. // force the cap, then restored for the answer gesture below. Replay only:
  89. // record mode must reach the recording write below, not abort on layout.
  90. if (MODE !== 'record') {
  91. const original = page.viewportSize() ?? { width: 1680, height: 1000 }
  92. for (const height of [520, 440, 380]) {
  93. await page.setViewportSize({ width: 900, height })
  94. const squeeze = await composer.evaluate((card) => {
  95. // Role/ARIA selectors, not the CSS-module class names: the built
  96. // client hashes those.
  97. const rows = [...card.querySelectorAll<HTMLElement>(
  98. '[role="radio"], [role="checkbox"], [aria-expanded]',
  99. )]
  100. const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
  101. const box = row.getBoundingClientRect()
  102. const inner = child.getBoundingClientRect()
  103. return Math.max(box.top - inner.top, inner.bottom - box.bottom)
  104. })))
  105. const list = card.querySelector<HTMLElement>('[data-question-scroll]')
  106. return {
  107. rows: rows.length,
  108. spill: Math.max(...spill),
  109. // Wrapped copy is the shape that overflows a collapsed row, and a
  110. // scrolling list proves the seat is genuinely capped. Without both,
  111. // the spill assertion would hold vacuously.
  112. wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
  113. scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
  114. }
  115. })
  116. expect(squeeze.rows).toBeGreaterThan(0)
  117. expect(squeeze.wrappedRows).toBeGreaterThan(0)
  118. expect(squeeze.scrolls).toBe(true)
  119. // Sub-pixel tolerance: every row's copy stays inside its border box.
  120. expect(squeeze.spill).toBeLessThan(0.6)
  121. }
  122. await page.setViewportSize(original)
  123. }
  124. const blue = composer.getByRole('checkbox', { name: 'Blue' })
  125. await blue.click()
  126. const custom = composer.getByRole('textbox')
  127. await custom.fill('Include accessibility notes')
  128. expect(await blue.getAttribute('aria-checked')).toBe('true')
  129. expect(await custom.inputValue()).toBe('Include accessibility notes')
  130. if (MODE !== 'record') {
  131. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  132. await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
  133. }
  134. await custom.press('Enter')
  135. const sessionId = await settled
  136. if (MODE === 'record') {
  137. await recordFixture(scaffold, sessionId, FIXTURE)
  138. return
  139. }
  140. // World state: the tool result carries the chosen answer, and DONE lands.
  141. const results = sessionEvents.filter(e => e.type === 'tool/result')
  142. const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
  143. block.type === 'tool-result'
  144. ? block.content.filter(item => item.type === 'text').map(item => item.text)
  145. : [],
  146. )).at(-1)
  147. expect(JSON.parse(answerText ?? '')).toEqual({
  148. answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
  149. })
  150. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  151. // Composer gone; regular input restored.
  152. expect(await page.locator('[data-question-key]').count()).toBe(0)
  153. expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
  154. await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  155. // Golden of the answered transcript: the ask_user_question round trip
  156. // rendered as history (question tool row + DONE), composer takeover gone.
  157. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  158. await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
  159. expect(tripwire.pageErrors).toEqual([])
  160. expect(tripwire.warnings).toEqual([])
  161. }, 200_000)
  162. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  163. await assertFixtureInventory(SNAPSHOT_DIR, [
  164. 'session.jsonl',
  165. 'ui.expected.md',
  166. 'sidebar.expected.md',
  167. 'composed.expected.md',
  168. 'answered.expected.md',
  169. ])
  170. })
  171. })