question-composer.e2e.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  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, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  21. } from './scaffold.ts'
  22. import {
  23. connectFreshWorkspace, expandTurnProcesses, newEnglishPage, saveFailureShot,
  24. } from './support.ts'
  25. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url))
  26. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  27. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  28. const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
  29. const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
  30. // Final golden: the answered transcript — the question resolved into its tool
  31. // round trip and the final reply, the state the composer goldens cannot see.
  32. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
  33. const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md')
  34. const ANSWERED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'answered-expanded.expected.md')
  35. const MODE = webSnapshotMode()
  36. const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e'
  37. // The composer's own growth cap, in text lines (QuestionComposer.module.css
  38. // .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
  39. // carry different padding, and a cap measured in border-box pixels silently
  40. // means a different line count in each — which is exactly how the optionless
  41. // field came to stop two thirds of a line short.
  42. const CAP_LINES = 6
  43. /**
  44. * Measure a saturated answer field: how many whole text lines it grew to, and
  45. * whether it took over the scrolling once it stopped growing.
  46. * @param field - the composer's custom-answer textarea.
  47. * @returns whole text lines the content box holds, and whether the field scrolls.
  48. */
  49. async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: boolean }> {
  50. await field.fill('x\n'.repeat(40))
  51. return field.evaluate((el: HTMLTextAreaElement) => {
  52. const style = getComputedStyle(el)
  53. const text = el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
  54. return {
  55. textLines: Math.round(text / parseFloat(style.lineHeight)),
  56. scrolls: el.scrollHeight > el.clientHeight,
  57. }
  58. })
  59. }
  60. // The options carry long descriptions on purpose: the squeeze assertion below
  61. // needs option copy that WRAPS, which is the only text layout that reproduces a
  62. // collapsed row painting its copy outside its own box.
  63. 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.'
  64. function isRecord(value: unknown): value is Record<string, unknown> {
  65. return typeof value === 'object' && value !== null && !Array.isArray(value)
  66. }
  67. /** Replace the successful Tool settlement and omit the answer-dependent model step. */
  68. function cancelledFixture(fixture: string): string {
  69. let replaced = false
  70. const lines: string[] = []
  71. for (const line of fixture.trimEnd().split('\n')) {
  72. const event: unknown = JSON.parse(line)
  73. if (!isRecord(event)) throw new Error('question fixture event is invalid')
  74. if (event.type === 'session') {
  75. // Keep the derived session's relative-time header stable as the source
  76. // fixture ages.
  77. event.createdAt = Date.now()
  78. lines.push(JSON.stringify(event))
  79. continue
  80. }
  81. if (replaced) {
  82. const data = event.data
  83. if ((event.type === 'step/end' && isRecord(data) && data.step === 1)
  84. || event.type === 'turn/end') lines.push(line)
  85. continue
  86. }
  87. if (event.type !== 'tool/result') {
  88. lines.push(line)
  89. continue
  90. }
  91. const data = event.data
  92. if (!isRecord(data)) throw new Error('question fixture tool/result data is invalid')
  93. const message = data.message
  94. if (!isRecord(message) || !Array.isArray(message.content) || !isRecord(message.content[0])) {
  95. throw new Error('question fixture tool/result message is invalid')
  96. }
  97. message.content[0].content = [{
  98. type: 'text',
  99. text: 'Error: the user cancelled ask_user_question',
  100. }]
  101. message.content[0].isError = true
  102. data.error = {
  103. name: 'UserQuestionError',
  104. message: 'the user cancelled ask_user_question',
  105. code: 'ASK_CANCELLED',
  106. }
  107. replaced = true
  108. lines.push(JSON.stringify(event))
  109. }
  110. if (!replaced) throw new Error('question fixture has no tool/result event')
  111. return `${lines.join('\n')}\n`
  112. }
  113. describe('web e2e: resident question composer round trip', () => {
  114. let scaffold: WebScaffold
  115. let browser: Browser
  116. let page: Page
  117. let tripwire: ReturnType<typeof watchConsole>
  118. const sessionEvents: SessionEvent[] = []
  119. let answeredSession: SessionId | undefined
  120. beforeAll(async () => {
  121. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
  122. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  123. browser = await chromium.launch()
  124. page = await newEnglishPage(browser)
  125. tripwire = watchConsole(page)
  126. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  127. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  128. // Fresh world: connect a Workspace so the composer scenarios start live.
  129. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  130. }, 120_000)
  131. afterAll(async () => {
  132. await browser?.close()
  133. await scaffold?.close()
  134. })
  135. it('asks through the composer, answers, and completes with the answer logged', async () => {
  136. onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
  137. if (MODE !== 'record') {
  138. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  139. }
  140. const input = page.locator('[data-composer-input]').first()
  141. await input.waitFor({ timeout: 10_000 })
  142. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
  143. await input.fill(PROMPT)
  144. await input.press('Enter')
  145. // The composer takes over the input area while the tool blocks. Its
  146. // presence is a STABLE waiting state (not a transient): it stays until
  147. // answered, so a plain waitFor is race-free.
  148. const composer = page.locator('[data-question-key]')
  149. await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
  150. await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
  151. const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
  152. await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
  153. await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  154. if (MODE !== 'record') {
  155. // This golden owns the stable question surface; the answered-state
  156. // golden below owns the resulting transcript.
  157. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  158. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  159. const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
  160. await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
  161. }
  162. // Squeezed card: the option rows are the capped card's scroll content, so
  163. // shrinking the seat must push overflow into the option list, never
  164. // collapse a row below the height its own copy needs — a collapsed row
  165. // paints its centered copy outside the row box, over the title and the
  166. // neighbouring rows. Measured on the live composer at seat heights that
  167. // force the cap, then restored for the answer gesture below. Replay only:
  168. // record mode must reach the recording write below, not abort on layout.
  169. if (MODE !== 'record') {
  170. const original = page.viewportSize() ?? { width: 1680, height: 1000 }
  171. for (const height of [520, 440, 380]) {
  172. await page.setViewportSize({ width: 900, height })
  173. const squeeze = await composer.evaluate((card) => {
  174. // Role/ARIA selectors, not the CSS-module class names: the built
  175. // client hashes those.
  176. const rows = [...card.querySelectorAll<HTMLElement>(
  177. '[role="radio"], [role="checkbox"], [aria-expanded]',
  178. )]
  179. const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
  180. const box = row.getBoundingClientRect()
  181. const inner = child.getBoundingClientRect()
  182. return Math.max(box.top - inner.top, inner.bottom - box.bottom)
  183. })))
  184. const list = card.querySelector<HTMLElement>('[data-question-scroll]')
  185. return {
  186. rows: rows.length,
  187. spill: Math.max(...spill),
  188. // Wrapped option text is what overflows a collapsed row, and a
  189. // scrolling list proves the seat is genuinely capped. Without both,
  190. // the spill assertion would hold vacuously.
  191. wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
  192. scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
  193. }
  194. })
  195. expect(squeeze.rows).toBeGreaterThan(0)
  196. expect(squeeze.wrappedRows).toBeGreaterThan(0)
  197. expect(squeeze.scrolls).toBe(true)
  198. // Sub-pixel tolerance: every row's copy stays inside its border box.
  199. expect(squeeze.spill).toBeLessThan(0.6)
  200. }
  201. await page.setViewportSize(original)
  202. }
  203. // Multi-line custom answer: the field is a textarea whose hidden mirror
  204. // owns the box height, so a soft-wrapped or line-broken draft GROWS the
  205. // field instead of scrolling one line, and Shift+Enter breaks the line
  206. // rather than continuing the flow. Measured on the live composer because
  207. // only a real engine soft-wraps; growth stops at the mirror's cap, past
  208. // which the textarea is the one thing that scrolls. Replay only, same as
  209. // the squeeze above: record mode must reach the recording write.
  210. const custom = composer.getByRole('textbox')
  211. if (MODE !== 'record') {
  212. const oneLineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
  213. await custom.fill('a'.repeat(120))
  214. const wrapped = await custom.evaluate(el => ({
  215. height: el.getBoundingClientRect().height,
  216. scrolls: el.scrollHeight > el.clientHeight,
  217. }))
  218. expect(wrapped.height).toBeGreaterThan(oneLineHeight * 1.5)
  219. expect(wrapped.scrolls).toBe(false)
  220. await custom.fill('')
  221. await custom.press('Shift+Enter')
  222. await custom.press('Shift+Enter')
  223. expect(await custom.inputValue()).toBe('\n\n')
  224. expect(await composer.getByText('Which color do you prefer?').count()).toBeGreaterThan(0)
  225. expect(await custom.evaluate(el => el.getBoundingClientRect().height))
  226. .toBeGreaterThan(oneLineHeight * 2.5)
  227. expect(await capMetrics(custom)).toEqual({ textLines: CAP_LINES, scrolls: true })
  228. await custom.fill('')
  229. }
  230. const blue = composer.getByRole('checkbox', { name: 'Blue' })
  231. await blue.click()
  232. await custom.fill('Include accessibility notes')
  233. expect(await blue.getAttribute('aria-checked')).toBe('true')
  234. expect(await custom.inputValue()).toBe('Include accessibility notes')
  235. if (MODE !== 'record') {
  236. // A strict Session-slot switch remounts the composer. Open a fresh blank
  237. // Session, then return to the still-waiting request and require its
  238. // Session-scoped store to restore both option and free-text drafts.
  239. const originalRow = page.locator('[role="treeitem"]')
  240. .filter({ hasText: 'Use the ask_user_question tool' }).first()
  241. await page.getByRole('button', { name: 'New session', exact: true }).last().click()
  242. await page.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
  243. await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0)
  244. await originalRow.click()
  245. await composer.waitFor({ timeout: 15_000 })
  246. expect(await blue.getAttribute('aria-checked')).toBe('true')
  247. expect(await custom.inputValue()).toBe('Include accessibility notes')
  248. // This golden now owns the composed state after a real A -> B -> A
  249. // Session cycle, not merely the state before the remount.
  250. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  251. await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
  252. }
  253. await custom.press('Enter')
  254. const sessionId = await settled
  255. if (MODE === 'record') {
  256. await recordFixture(scaffold, sessionId, FIXTURE)
  257. return
  258. }
  259. answeredSession = sessionId
  260. // World state: the tool result carries the chosen answer, and DONE lands.
  261. const results = sessionEvents.filter(e => e.type === 'tool/result')
  262. const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
  263. block.type === 'tool-result'
  264. ? block.content.filter(item => item.type === 'text').map(item => item.text)
  265. : [],
  266. )).at(-1)
  267. expect(JSON.parse(answerText ?? '')).toEqual({
  268. answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
  269. })
  270. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  271. // Composer gone; regular input restored.
  272. expect(await page.locator('[data-question-key]').count()).toBe(0)
  273. expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
  274. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  275. // The default golden pins Compact mode before process disclosure.
  276. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  277. await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
  278. // Keep the ask_user_question card's readable answer in the expanded golden
  279. // even though Compact mode hides the process by default.
  280. await expandTurnProcesses(page)
  281. const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true })
  282. await answeredRow.click()
  283. await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
  284. expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1)
  285. expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1)
  286. expect(await page.getByText(/"answers"/).count()).toBe(0)
  287. const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  288. await compareOrRefreshGolden(ANSWERED_EXPANDED_EXPECTED, expanded, MODE)
  289. expect(tripwire.pageErrors).toEqual([])
  290. expect(tripwire.warnings).toEqual([])
  291. }, 200_000)
  292. // The fixture's question carries options, so the round trip above only ever
  293. // exercises the inline shape. The optionless shape is the one that carries
  294. // padding, which is where a cap measured in box pixels drifts off the line
  295. // count — so it is asked straight through the user-questions seam (the same
  296. // service the tool calls; no model round is involved in a layout metric).
  297. it.skipIf(MODE === 'record')('grows the optionless answer to the same cap', async () => {
  298. onTestFailed(() => saveFailureShot(page, 'web-e2e-question-optionless'))
  299. const sessionId = answeredSession
  300. expect(sessionId).toBeDefined()
  301. const agent = scaffold.ctx.agents.get(sessionId as SessionId)
  302. expect(agent).toBeDefined()
  303. const asked = scaffold.ctx.userQuestions.ask({
  304. agent: agent as NonNullable<typeof agent>,
  305. questions: [{ id: 'free', header: 'More', question: 'Anything else?' }],
  306. })
  307. const composer = page.locator('[data-question-key]')
  308. await composer.waitFor({ timeout: 30_000 })
  309. const field = composer.getByRole('textbox')
  310. // The empty field reserves its two lines AND the textarea fills that frame:
  311. // a reserved box the control does not fill leaves a strip that looks like
  312. // the field but takes no click.
  313. expect(await field.evaluate((el) => {
  314. const frame = el.parentElement as HTMLElement
  315. const style = getComputedStyle(frame)
  316. const inner = frame.getBoundingClientRect().height
  317. - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)
  318. return {
  319. reserved: Math.round(frame.getBoundingClientRect().height),
  320. fills: Math.abs(el.getBoundingClientRect().height - inner) < 0.5,
  321. }
  322. })).toEqual({ reserved: 64, fills: true })
  323. // The same cap the inline shape stops at — the assertion a border-box cap fails.
  324. expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
  325. // Settle the wait so teardown is not racing a pending question.
  326. await composer.getByRole('button', { name: 'Skip this question' }).click()
  327. expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
  328. await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
  329. }, 60_000)
  330. })
  331. describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
  332. let cancelledScaffold: WebScaffold
  333. let cancelledBrowser: Browser
  334. let cancelledPage: Page
  335. let cancelledTripwire: ReturnType<typeof watchConsole>
  336. beforeAll(async () => {
  337. cancelledScaffold = await launchWebScaffold({})
  338. await seedSession(
  339. cancelledScaffold,
  340. cancelledFixture(await readFile(FIXTURE, 'utf8')),
  341. CANCELLED_SEED_ID,
  342. )
  343. cancelledBrowser = await chromium.launch()
  344. cancelledPage = await newEnglishPage(cancelledBrowser)
  345. cancelledTripwire = watchConsole(cancelledPage)
  346. await cancelledPage.goto(cancelledScaffold.authenticatedUrl, { waitUntil: 'load' })
  347. await cancelledPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  348. const groupRow = cancelledPage.locator('[role="treeitem"]').first()
  349. await groupRow.waitFor({ timeout: 15_000 })
  350. await groupRow.click()
  351. const sessionRow = cancelledPage.locator('[role="treeitem"]').nth(1)
  352. await sessionRow.waitFor({ timeout: 10_000 })
  353. await sessionRow.click()
  354. }, 120_000)
  355. afterAll(async () => {
  356. await cancelledBrowser?.close()
  357. await cancelledScaffold?.close()
  358. })
  359. it('expands to the cancellation verdict and original questions', async () => {
  360. onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row'))
  361. const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true })
  362. await row.waitFor({ timeout: 15_000 })
  363. await row.click()
  364. await cancelledPage
  365. .getByText('This question set was cancelled before answers were submitted.', { exact: true })
  366. .waitFor({ timeout: 10_000 })
  367. await cancelledPage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
  368. expect(await cancelledPage.getByText(/"questions"/).count()).toBe(0)
  369. expect(await cancelledPage
  370. .getByText('Error: the user cancelled ask_user_question', { exact: true }).count()).toBe(0)
  371. const snapshot = (await captureStableAria(
  372. cancelledPage,
  373. '[class*="centerCol"]',
  374. cancelledScaffold.workspaceCwd,
  375. )).split(CANCELLED_SEED_ID).join('{{seededId}}')
  376. await compareOrRefreshGolden(CANCELLED_EXPECTED, snapshot, MODE)
  377. expect(cancelledTripwire.pageErrors).toEqual([])
  378. expect(cancelledTripwire.warnings).toEqual([])
  379. }, 60_000)
  380. it('keeps the fixture inventory closed', async () => {
  381. await assertFixtureInventory(SNAPSHOT_DIR, [
  382. 'session.jsonl',
  383. 'ui.expected.md',
  384. 'sidebar.expected.md',
  385. 'composed.expected.md',
  386. 'answered.expected.md',
  387. 'cancelled.expected.md',
  388. 'answered-expanded.expected.md',
  389. ])
  390. })
  391. })