question-composer.e2e.ts 21 KB

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