question-composer.e2e.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. // Web e2e scenario: the resident question composer. The shipped composition
  2. // already exposes ask_user_question (the ui-user-questions 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, Locator, 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 type { SessionId } from '@deepseek-ai/dsh-session/types'
  18. import {
  19. assertFixtureInventory, captureStableAria, 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/question-composer', import.meta.url))
  24. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  25. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  26. const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
  27. const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
  28. // Final golden: the answered transcript — the question resolved into its tool
  29. // round trip and the final reply, the state the composer goldens cannot see.
  30. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
  31. const MODE = webSnapshotMode()
  32. // The composer's own growth cap, in text lines (QuestionComposer.module.css
  33. // .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
  34. // carry different padding, and a cap measured in border-box pixels silently
  35. // means a different line count in each — which is exactly how the optionless
  36. // field came to stop two thirds of a line short.
  37. const CAP_LINES = 6
  38. /**
  39. * Measure a saturated answer field: how many whole text lines it grew to, and
  40. * whether it took over the scrolling once it stopped growing.
  41. * @param field - the composer's custom-answer textarea.
  42. * @returns whole text lines the content box holds, and whether the field scrolls.
  43. */
  44. async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: boolean }> {
  45. await field.fill('x\n'.repeat(40))
  46. return field.evaluate((el: HTMLTextAreaElement) => {
  47. const style = getComputedStyle(el)
  48. const text = el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
  49. return {
  50. textLines: Math.round(text / parseFloat(style.lineHeight)),
  51. scrolls: el.scrollHeight > el.clientHeight,
  52. }
  53. })
  54. }
  55. // The options carry long descriptions on purpose: the squeeze assertion below
  56. // needs option copy that WRAPS, which is the only text layout that reproduces a
  57. // collapsed row painting its copy outside its own box.
  58. 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.'
  59. describe('web e2e: resident question composer round trip', () => {
  60. let scaffold: WebScaffold
  61. let browser: Browser
  62. let page: Page
  63. let tripwire: ReturnType<typeof watchConsole>
  64. const sessionEvents: SessionEvent[] = []
  65. let answeredSession: SessionId | undefined
  66. beforeAll(async () => {
  67. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
  68. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  69. browser = await chromium.launch()
  70. page = await newEnglishPage(browser)
  71. tripwire = watchConsole(page)
  72. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  73. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  74. // Fresh world: connect a Workspace so the composer scenarios start live.
  75. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  76. }, 120_000)
  77. afterAll(async () => {
  78. await browser?.close()
  79. await scaffold?.close()
  80. })
  81. it('asks through the composer, answers, and completes with the answer logged', async () => {
  82. onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
  83. if (MODE !== 'record') {
  84. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  85. }
  86. const input = page.locator('textarea').first()
  87. await input.waitFor({ timeout: 10_000 })
  88. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
  89. await input.fill(PROMPT)
  90. await input.press('Enter')
  91. // The composer takes over the input area while the tool blocks. Its
  92. // presence is a STABLE waiting state (not a transient): it stays until
  93. // answered, so a plain waitFor is race-free.
  94. const composer = page.locator('[data-question-key]')
  95. await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
  96. await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
  97. const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
  98. await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
  99. await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  100. if (MODE !== 'record') {
  101. // This golden owns the stable question surface; the answered-state
  102. // golden below owns the resulting transcript.
  103. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  104. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  105. const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
  106. await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
  107. }
  108. // Squeezed card: the option rows are the capped card's scroll content, so
  109. // shrinking the seat must push overflow into the option list, never
  110. // collapse a row below the height its own copy needs — a collapsed row
  111. // paints its centered copy outside the row box, over the title and the
  112. // neighbouring rows. Measured on the live composer at seat heights that
  113. // force the cap, then restored for the answer gesture below. Replay only:
  114. // record mode must reach the recording write below, not abort on layout.
  115. if (MODE !== 'record') {
  116. const original = page.viewportSize() ?? { width: 1680, height: 1000 }
  117. for (const height of [520, 440, 380]) {
  118. await page.setViewportSize({ width: 900, height })
  119. const squeeze = await composer.evaluate((card) => {
  120. // Role/ARIA selectors, not the CSS-module class names: the built
  121. // client hashes those.
  122. const rows = [...card.querySelectorAll<HTMLElement>(
  123. '[role="radio"], [role="checkbox"], [aria-expanded]',
  124. )]
  125. const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
  126. const box = row.getBoundingClientRect()
  127. const inner = child.getBoundingClientRect()
  128. return Math.max(box.top - inner.top, inner.bottom - box.bottom)
  129. })))
  130. const list = card.querySelector<HTMLElement>('[data-question-scroll]')
  131. return {
  132. rows: rows.length,
  133. spill: Math.max(...spill),
  134. // Wrapped option text is what overflows a collapsed row, and a
  135. // scrolling list proves the seat is genuinely capped. Without both,
  136. // the spill assertion would hold vacuously.
  137. wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
  138. scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
  139. }
  140. })
  141. expect(squeeze.rows).toBeGreaterThan(0)
  142. expect(squeeze.wrappedRows).toBeGreaterThan(0)
  143. expect(squeeze.scrolls).toBe(true)
  144. // Sub-pixel tolerance: every row's copy stays inside its border box.
  145. expect(squeeze.spill).toBeLessThan(0.6)
  146. }
  147. await page.setViewportSize(original)
  148. }
  149. // Multi-line custom answer: the field is a textarea whose hidden mirror
  150. // owns the box height, so a soft-wrapped or line-broken draft GROWS the
  151. // field instead of scrolling one line, and Shift+Enter breaks the line
  152. // rather than continuing the flow. Measured on the live composer because
  153. // only a real engine soft-wraps; growth stops at the mirror's cap, past
  154. // which the textarea is the one thing that scrolls. Replay only, same as
  155. // the squeeze above: record mode must reach the recording write.
  156. const custom = composer.getByRole('textbox')
  157. if (MODE !== 'record') {
  158. const oneLineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
  159. await custom.fill('a'.repeat(120))
  160. const wrapped = await custom.evaluate(el => ({
  161. height: el.getBoundingClientRect().height,
  162. scrolls: el.scrollHeight > el.clientHeight,
  163. }))
  164. expect(wrapped.height).toBeGreaterThan(oneLineHeight * 1.5)
  165. expect(wrapped.scrolls).toBe(false)
  166. await custom.fill('')
  167. await custom.press('Shift+Enter')
  168. await custom.press('Shift+Enter')
  169. expect(await custom.inputValue()).toBe('\n\n')
  170. expect(await composer.getByText('Which color do you prefer?').count()).toBeGreaterThan(0)
  171. expect(await custom.evaluate(el => el.getBoundingClientRect().height))
  172. .toBeGreaterThan(oneLineHeight * 2.5)
  173. expect(await capMetrics(custom)).toEqual({ textLines: CAP_LINES, scrolls: true })
  174. await custom.fill('')
  175. }
  176. const blue = composer.getByRole('checkbox', { name: 'Blue' })
  177. await blue.click()
  178. await custom.fill('Include accessibility notes')
  179. expect(await blue.getAttribute('aria-checked')).toBe('true')
  180. expect(await custom.inputValue()).toBe('Include accessibility notes')
  181. if (MODE !== 'record') {
  182. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  183. await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
  184. }
  185. await custom.press('Enter')
  186. const sessionId = await settled
  187. if (MODE === 'record') {
  188. await recordFixture(scaffold, sessionId, FIXTURE)
  189. return
  190. }
  191. answeredSession = sessionId
  192. // World state: the tool result carries the chosen answer, and DONE lands.
  193. const results = sessionEvents.filter(e => e.type === 'tool/result')
  194. const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
  195. block.type === 'tool-result'
  196. ? block.content.filter(item => item.type === 'text').map(item => item.text)
  197. : [],
  198. )).at(-1)
  199. expect(JSON.parse(answerText ?? '')).toEqual({
  200. answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
  201. })
  202. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  203. // Composer gone; regular input restored.
  204. expect(await page.locator('[data-question-key]').count()).toBe(0)
  205. expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
  206. await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  207. // Golden of the answered transcript: the ask_user_question round trip
  208. // rendered as history (question tool row + DONE), composer takeover gone.
  209. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  210. await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
  211. expect(tripwire.pageErrors).toEqual([])
  212. expect(tripwire.warnings).toEqual([])
  213. }, 200_000)
  214. // The fixture's question carries options, so the round trip above only ever
  215. // exercises the inline shape. The optionless shape is the one that carries
  216. // padding, which is where a cap measured in box pixels drifts off the line
  217. // count — so it is asked straight through the user-questions seam (the same
  218. // service the tool calls; no model round is involved in a layout metric).
  219. it.skipIf(MODE === 'record')('grows the optionless answer to the same cap', async () => {
  220. onTestFailed(() => saveFailureShot(page, 'web-e2e-question-optionless'))
  221. const sessionId = answeredSession
  222. expect(sessionId).toBeDefined()
  223. const agent = scaffold.ctx.agents.get(sessionId as SessionId)
  224. expect(agent).toBeDefined()
  225. const asked = scaffold.ctx.userQuestions.ask({
  226. agent: agent as NonNullable<typeof agent>,
  227. questions: [{ id: 'free', header: 'More', question: 'Anything else?' }],
  228. })
  229. const composer = page.locator('[data-question-key]')
  230. await composer.waitFor({ timeout: 30_000 })
  231. const field = composer.getByRole('textbox')
  232. // The empty field reserves its two lines AND the textarea fills that frame:
  233. // a reserved box the control does not fill leaves a strip that looks like
  234. // the field but takes no click.
  235. expect(await field.evaluate((el) => {
  236. const frame = el.parentElement as HTMLElement
  237. const style = getComputedStyle(frame)
  238. const inner = frame.getBoundingClientRect().height
  239. - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)
  240. return {
  241. reserved: Math.round(frame.getBoundingClientRect().height),
  242. fills: Math.abs(el.getBoundingClientRect().height - inner) < 0.5,
  243. }
  244. })).toEqual({ reserved: 64, fills: true })
  245. // The same cap the inline shape stops at — the assertion a border-box cap fails.
  246. expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
  247. // Settle the wait so teardown is not racing a pending question.
  248. await composer.getByRole('button', { name: 'Skip this question' }).click()
  249. expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
  250. await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
  251. }, 60_000)
  252. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  253. await assertFixtureInventory(SNAPSHOT_DIR, [
  254. 'session.jsonl',
  255. 'ui.expected.md',
  256. 'sidebar.expected.md',
  257. 'composed.expected.md',
  258. 'answered.expected.md',
  259. ])
  260. })
  261. })