question-composer.e2e.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  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 { TIMED_WAIT_PARAMETER } from '@deepseek-ai/dsh-user-questions'
  19. import {
  20. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  21. launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  22. } from './scaffold.ts'
  23. import {
  24. connectFreshWorkspace, expandTurnProcesses, newEnglishPage, saveFailureShot,
  25. } from './support.ts'
  26. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url))
  27. const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
  28. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  29. const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
  30. const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
  31. // Final golden: the answered transcript — the question resolved into its tool
  32. // round trip and the final reply, the state the composer goldens cannot see.
  33. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
  34. const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md')
  35. const ANSWERED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'answered-expanded.expected.md')
  36. // The timed tool's second settlement: the wait expired, and the answer arrived
  37. // afterwards. Its golden owns the state the reported symptom lands in.
  38. const LATE_ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'late-answered.expected.md')
  39. const QUEUED_EXPECTED = join(SNAPSHOT_DIR, 'queued.expected.md')
  40. const MODE = webSnapshotMode()
  41. const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e'
  42. const LATE_ANSWERED_SEED_ID = 'ask-question-late-answered-row-web-e2e'
  43. const QUEUED_SEED_ID = 'ask-question-queued-row-web-e2e'
  44. // The composer's own growth cap, in text lines (QuestionComposer.module.css
  45. // .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
  46. // carry different padding, and a cap measured in border-box pixels silently
  47. // means a different line count in each — which is exactly how the optionless
  48. // field came to stop two thirds of a line short.
  49. const CAP_LINES = 6
  50. /**
  51. * Measure a saturated answer field: how many whole text lines it grew to, and
  52. * whether it took over the scrolling once it stopped growing.
  53. * @param field - the composer's custom-answer textarea.
  54. * @returns whole text lines the content box holds, and whether the field scrolls.
  55. */
  56. async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: boolean }> {
  57. await field.fill('x\n'.repeat(40))
  58. return field.evaluate((el: HTMLTextAreaElement) => {
  59. const style = getComputedStyle(el)
  60. const text = el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
  61. return {
  62. textLines: Math.round(text / parseFloat(style.lineHeight)),
  63. scrolls: el.scrollHeight > el.clientHeight,
  64. }
  65. })
  66. }
  67. // The options carry long descriptions on purpose: the squeeze assertion below
  68. // needs option copy that WRAPS, which is the only text layout that reproduces a
  69. // collapsed row painting its copy outside its own box.
  70. 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.'
  71. function isRecord(value: unknown): value is Record<string, unknown> {
  72. return typeof value === 'object' && value !== null && !Array.isArray(value)
  73. }
  74. /** The batch the derived late reply submits, distinct from the in-time answer. */
  75. const LATE_ANSWERS = [{ id: 'color', selected: ['Green'], custom: 'Answered after the timeout' }]
  76. /**
  77. * The `ask_user_question` schema as the timed tool declares it, in the shape a
  78. * request header records. A late reply can only follow a timed call, and the
  79. * `userQuestions` projection tells a timed call from a legacy one by exactly
  80. * this schema in the header in effect; the recorded fixture's header carries
  81. * the placeholder token of a legacy recording, so the derived fixture must
  82. * carry the timed schema for its call to be tracked at all.
  83. */
  84. const TIMED_ASK_USER_QUESTION_SCHEMA = {
  85. name: 'ask_user_question',
  86. description: 'Ask brief, direct, self-contained questions about missing information, preferences, or decisions.',
  87. parameters: {
  88. type: 'object',
  89. properties: {
  90. questions: { type: 'array', items: { type: 'object' } },
  91. [TIMED_WAIT_PARAMETER]: { type: 'integer' },
  92. },
  93. required: ['questions'],
  94. },
  95. }
  96. /**
  97. * What the timed tool records when its foreground wait expires. Both readers of
  98. * a recorded result take `pending` and `callId` out of this one JSON object —
  99. * the `userQuestions` projection to keep the call answerable, the question row
  100. * to tell a timeout from an answer batch; the `message` is the model's own
  101. * continuation instruction and no reader parses it.
  102. * @param callId - the call whose questions stay answerable.
  103. * @returns the recorded result text.
  104. */
  105. function pendingResultText(callId: string): string {
  106. return JSON.stringify({
  107. pending: true,
  108. callId,
  109. message: 'No answer batch arrived before the timeout. This is pending, not a skipped answer.',
  110. })
  111. }
  112. /**
  113. * The message a late answer lands as: the timed question's `callId` names it,
  114. * and it carries both the questions it answers and the batch itself.
  115. * @param callId - the call the reply settles.
  116. * @param questions - the call's questions, echoed from its recorded arguments.
  117. * @returns the reply, as the inbox splice and the durable message both record it.
  118. */
  119. function lateReplyMessage(callId: string, questions: unknown): Record<string, unknown> {
  120. return {
  121. content: [{
  122. type: 'text',
  123. text: JSON.stringify({
  124. kind: 'answer_to_pending_question', tool: 'ask_user_question', callId, questions, answers: LATE_ANSWERS,
  125. }),
  126. }],
  127. source: { kind: 'user-question-reply', callId, outcome: 'answered' },
  128. role: 'user',
  129. id: '{{message:7}}',
  130. }
  131. }
  132. /**
  133. * The turn a late reply opens: the reply enters the agent inbox, the next turn
  134. * claims it, and it lands as this Session's durable user message.
  135. * @param callId - the call the reply settles.
  136. * @param questions - the call's questions, echoed from its recorded arguments.
  137. * @returns the appended fixture lines, ending in the closing `turn/end`.
  138. */
  139. function lateReplyTail(callId: string, questions: unknown): string[] {
  140. const message = lateReplyMessage(callId, questions)
  141. return [
  142. { type: 'agent/inbox/spliced', data: { target: 'next-turn', start: 0, inserted: [message] } },
  143. { type: 'turn/start', data: { turn: 2 } },
  144. { type: 'agent/inbox/spliced', data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] } },
  145. { type: 'step/start', data: { turn: 2, step: 1 } },
  146. { type: 'user/message', data: message, surfaceOp: 'append' },
  147. { type: 'step/end', data: { turn: 2, step: 1 } },
  148. { type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } },
  149. ].map(event => JSON.stringify(event))
  150. }
  151. /**
  152. * Derive one settlement other than the recorded answer batch. The model step
  153. * that follows the Tool settlement reads the answer, so every derivation drops
  154. * it and keeps the recorded turn closed.
  155. * @param fixture - the recorded round trip.
  156. * @param outcome - `cancelled` settles the call as the user's own dismissal;
  157. * `queued` and `late-answered` settle the expired wait and append its reply
  158. * before or after the next turn admits it.
  159. * @returns the derived fixture text.
  160. */
  161. function resettledFixture(fixture: string, outcome: 'cancelled' | 'queued' | 'late-answered'): string {
  162. let asked: { callId: string; questions: unknown } | undefined
  163. let replaced = false
  164. const lines: string[] = []
  165. for (const line of fixture.trimEnd().split('\n')) {
  166. const event: unknown = JSON.parse(line)
  167. if (!isRecord(event)) throw new Error('question fixture event is invalid')
  168. if (event.type === 'session') {
  169. // Keep the derived session's relative-time header stable as the source
  170. // fixture ages.
  171. event.createdAt = Date.now()
  172. lines.push(JSON.stringify(event))
  173. continue
  174. }
  175. if (replaced) {
  176. const data = event.data
  177. if ((event.type === 'step/end' && isRecord(data) && data.step === 1)
  178. || event.type === 'turn/end') lines.push(line)
  179. continue
  180. }
  181. if (event.type === 'request/header' && outcome !== 'cancelled') {
  182. // The recording was made under the blocking legacy tool; the derived
  183. // timed settlement needs the header that tool's timed sibling records.
  184. const data = event.data
  185. if (!isRecord(data) || !isRecord(data.header)) throw new Error('question fixture request/header is invalid')
  186. data.header.tools = [TIMED_ASK_USER_QUESTION_SCHEMA]
  187. lines.push(JSON.stringify(event))
  188. continue
  189. }
  190. if (event.type === 'tool/call') {
  191. const data = event.data
  192. if (!isRecord(data) || typeof data.callId !== 'string' || typeof data.arguments !== 'string') {
  193. throw new Error('question fixture tool/call is invalid')
  194. }
  195. const args: unknown = JSON.parse(data.arguments)
  196. if (!isRecord(args)) throw new Error('question fixture tool/call arguments are invalid')
  197. asked = { callId: data.callId, questions: args.questions }
  198. lines.push(line)
  199. continue
  200. }
  201. if (event.type !== 'tool/result') {
  202. lines.push(line)
  203. continue
  204. }
  205. const data = event.data
  206. if (!isRecord(data)) throw new Error('question fixture tool/result data is invalid')
  207. const message = data.message
  208. if (!isRecord(message) || !Array.isArray(message.content) || !isRecord(message.content[0])) {
  209. throw new Error('question fixture tool/result message is invalid')
  210. }
  211. if (asked === undefined) throw new Error('question fixture settles a call it never made')
  212. if (outcome === 'cancelled') {
  213. message.content[0].content = [{
  214. type: 'text',
  215. text: 'Error: the user cancelled ask_user_question',
  216. }]
  217. message.content[0].isError = true
  218. data.error = {
  219. name: 'UserQuestionError',
  220. code: 'ASK_CANCELLED',
  221. }
  222. } else {
  223. message.content[0].content = [{ type: 'text', text: pendingResultText(asked.callId) }]
  224. }
  225. replaced = true
  226. lines.push(JSON.stringify(event))
  227. }
  228. if (!replaced || asked === undefined) throw new Error('question fixture has no settled ask_user_question call')
  229. if (outcome === 'queued') {
  230. const end = lines.pop()
  231. const final: unknown = end === undefined ? undefined : JSON.parse(end)
  232. if (!isRecord(final) || final.type !== 'turn/end') throw new Error('question fixture does not end its first turn')
  233. return `${[...lines, lateReplyTail(asked.callId, asked.questions)[0], end].join('\n')}\n`
  234. }
  235. const tail = outcome === 'cancelled' ? [] : lateReplyTail(asked.callId, asked.questions)
  236. return `${[...lines, ...tail].join('\n')}\n`
  237. }
  238. /** One derived transcript, seeded into its own scaffold and opened in the browser. */
  239. interface SeededTranscript {
  240. readonly scaffold: WebScaffold
  241. readonly browser: Browser
  242. readonly page: Page
  243. readonly tripwire: ReturnType<typeof watchConsole>
  244. }
  245. /**
  246. * Seed one derived fixture and open its Session in a fresh browser.
  247. * @param fixtureText - the derived fixture to persist.
  248. * @param seedId - the seeded Session id, which its golden tokenizes.
  249. * @returns the scaffold, browser, page, and console tripwire teardown closes.
  250. */
  251. async function openSeededTranscript(fixtureText: string, seedId: string): Promise<SeededTranscript> {
  252. const scaffold = await launchWebScaffold({})
  253. let browser: Browser | undefined
  254. try {
  255. await seedSession(scaffold, fixtureText, seedId)
  256. browser = await chromium.launch()
  257. const page = await newEnglishPage(browser)
  258. const tripwire = watchConsole(page)
  259. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  260. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  261. const groupRow = page.locator('[role="treeitem"]').first()
  262. await groupRow.waitFor({ timeout: 15_000 })
  263. await groupRow.click()
  264. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  265. await sessionRow.waitFor({ timeout: 10_000 })
  266. await sessionRow.click()
  267. return { scaffold, browser, page, tripwire }
  268. } catch (error) {
  269. await Promise.allSettled([browser?.close(), scaffold.close()])
  270. throw error
  271. }
  272. }
  273. describe('web e2e: resident question composer round trip', () => {
  274. let scaffold: WebScaffold
  275. let browser: Browser
  276. let page: Page
  277. let tripwire: ReturnType<typeof watchConsole>
  278. const sessionEvents: SessionEvent[] = []
  279. let answeredSession: SessionId | undefined
  280. beforeAll(async () => {
  281. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
  282. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  283. browser = await chromium.launch()
  284. page = await newEnglishPage(browser)
  285. tripwire = watchConsole(page)
  286. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  287. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  288. // Fresh world: connect a Workspace so the composer scenarios start live.
  289. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  290. }, 120_000)
  291. afterAll(async () => {
  292. await browser?.close()
  293. await scaffold?.close()
  294. })
  295. it('asks through the composer, answers, and completes with the answer logged', async () => {
  296. onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
  297. if (MODE !== 'record') {
  298. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  299. }
  300. const input = page.locator('[data-composer-input]').first()
  301. await input.waitFor({ timeout: 10_000 })
  302. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
  303. await input.fill(PROMPT)
  304. await input.press('Enter')
  305. // The composer takes over the input area while the tool blocks. Its
  306. // presence is a STABLE waiting state (not a transient): it stays until
  307. // answered, so a plain waitFor is race-free.
  308. const composer = page.locator('[data-question-key]')
  309. await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
  310. await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
  311. const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
  312. await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
  313. await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  314. await expect.poll(() => selectedRow.getByText('Answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  315. expect(await selectedRow.getByText('now', { exact: true }).count()).toBe(0)
  316. if (MODE !== 'record') {
  317. // This golden owns the stable question surface; the answered-state
  318. // golden below owns the resulting transcript.
  319. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  320. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  321. const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
  322. await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
  323. }
  324. // Squeezed card: the option rows are the capped card's scroll content, so
  325. // shrinking the seat must push overflow into the option list, never
  326. // collapse a row below the height its own copy needs — a collapsed row
  327. // paints its centered copy outside the row box, over the title and the
  328. // neighbouring rows. Measured on the live composer at seat heights that
  329. // force the cap, then restored for the answer gesture below. Replay only:
  330. // record mode must reach the recording write below, not abort on layout.
  331. if (MODE !== 'record') {
  332. const original = page.viewportSize() ?? { width: 1680, height: 1000 }
  333. for (const height of [520, 440, 380]) {
  334. await page.setViewportSize({ width: 900, height })
  335. const squeeze = await composer.evaluate((card) => {
  336. // Role/ARIA selectors, not the CSS-module class names: the built
  337. // client hashes those.
  338. const rows = [...card.querySelectorAll<HTMLElement>(
  339. '[role="radio"], [role="checkbox"], [aria-expanded]',
  340. )]
  341. const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
  342. const box = row.getBoundingClientRect()
  343. const inner = child.getBoundingClientRect()
  344. return Math.max(box.top - inner.top, inner.bottom - box.bottom)
  345. })))
  346. const list = card.querySelector<HTMLElement>('[data-question-scroll]')
  347. return {
  348. rows: rows.length,
  349. spill: Math.max(...spill),
  350. // Wrapped option text is what overflows a collapsed row, and a
  351. // scrolling list proves the seat is genuinely capped. Without both,
  352. // the spill assertion would hold vacuously.
  353. wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
  354. scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
  355. }
  356. })
  357. expect(squeeze.rows).toBeGreaterThan(0)
  358. expect(squeeze.wrappedRows).toBeGreaterThan(0)
  359. expect(squeeze.scrolls).toBe(true)
  360. // Sub-pixel tolerance: every row's copy stays inside its border box.
  361. expect(squeeze.spill).toBeLessThan(0.6)
  362. }
  363. await page.setViewportSize(original)
  364. }
  365. // Multi-line custom answer: the field is a textarea whose hidden mirror
  366. // owns the box height, so a soft-wrapped or line-broken draft GROWS the
  367. // field instead of scrolling one line, and Shift+Enter breaks the line
  368. // rather than continuing the flow. Measured on the live composer because
  369. // only a real engine soft-wraps; growth stops at the mirror's cap, past
  370. // which the textarea is the one thing that scrolls. Replay only, same as
  371. // the squeeze above: record mode must reach the recording write.
  372. const custom = composer.getByRole('textbox')
  373. if (MODE !== 'record') {
  374. const oneLineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
  375. await custom.fill('a'.repeat(120))
  376. const wrapped = await custom.evaluate(el => ({
  377. height: el.getBoundingClientRect().height,
  378. scrolls: el.scrollHeight > el.clientHeight,
  379. }))
  380. expect(wrapped.height).toBeGreaterThan(oneLineHeight * 1.5)
  381. expect(wrapped.scrolls).toBe(false)
  382. await custom.fill('')
  383. await custom.press('Shift+Enter')
  384. await custom.press('Shift+Enter')
  385. expect(await custom.inputValue()).toBe('\n\n')
  386. expect(await composer.getByText('Which color do you prefer?').count()).toBeGreaterThan(0)
  387. expect(await custom.evaluate(el => el.getBoundingClientRect().height))
  388. .toBeGreaterThan(oneLineHeight * 2.5)
  389. expect(await capMetrics(custom)).toEqual({ textLines: CAP_LINES, scrolls: true })
  390. await custom.fill('')
  391. }
  392. const blue = composer.getByRole('checkbox', { name: 'Blue' })
  393. await blue.click()
  394. await custom.fill('Include accessibility notes')
  395. expect(await blue.getAttribute('aria-checked')).toBe('true')
  396. expect(await custom.inputValue()).toBe('Include accessibility notes')
  397. if (MODE !== 'record') {
  398. // Replay only, like the measurements above: record mode must reach the
  399. // recording write. This fixture composes the shipped legacy tool, whose
  400. // request carries no call id and whose row offers no pill: the projection
  401. // never lists a legacy call, so the transcript reads exactly as it did
  402. // before timed questions existed. Reopening a hidden panel belongs to the
  403. // timed tool; the late-answered scenario below and the ui-user-questions
  404. // unit specs cover it.
  405. expect(await page.getByRole('button', { name: 'Answer', exact: true }).count()).toBe(0)
  406. // A strict Session-slot switch remounts the composer. Open a fresh blank
  407. // Session, then return to the still-waiting request and require its
  408. // Session-scoped store to restore both option and free-text drafts.
  409. const originalRow = page.locator('[role="treeitem"]')
  410. .filter({ hasText: 'Use the ask_user_question tool' }).first()
  411. await page.getByRole('button', { name: 'New session', exact: true }).last().click()
  412. // Scope to the tree: the wide sidebar's New Session button carries the
  413. // same visible label, and an unscoped match would either settle on the
  414. // button before the row exists or trip strict mode once it does.
  415. await page.getByRole('tree', { name: 'Sessions' })
  416. .getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
  417. await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0)
  418. await originalRow.click()
  419. await composer.waitFor({ timeout: 15_000 })
  420. expect(await blue.getAttribute('aria-checked')).toBe('true')
  421. expect(await custom.inputValue()).toBe('Include accessibility notes')
  422. // This golden now owns the composed state after a real A -> B -> A
  423. // Session cycle, not merely the state before the remount.
  424. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
  425. await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
  426. }
  427. await custom.press('Enter')
  428. const sessionId = await settled
  429. if (MODE === 'record') {
  430. await recordFixture(scaffold, sessionId, FIXTURE)
  431. return
  432. }
  433. answeredSession = sessionId
  434. // World state: the tool result carries the chosen answer, and DONE lands.
  435. const results = sessionEvents.filter(e => e.type === 'tool/result')
  436. const answerText = results.flatMap(event => event.data.message.content
  437. .filter(item => item.type === 'text')
  438. .map(item => item.text),
  439. ).at(-1)
  440. expect(JSON.parse(answerText ?? '')).toEqual({
  441. answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
  442. })
  443. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  444. // Composer gone; regular input restored.
  445. expect(await page.locator('[data-question-key]').count()).toBe(0)
  446. expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
  447. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  448. // The default golden pins Compact mode before process disclosure.
  449. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  450. await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
  451. // Keep the ask_user_question card's readable answer in the expanded golden
  452. // even though Compact mode hides the process by default.
  453. await expandTurnProcesses(page)
  454. // A legacy call's answered row carries no pill and no read-only panel; its
  455. // accessible name is the bare summary, as before timed questions existed.
  456. const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true })
  457. await answeredRow.click()
  458. await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
  459. expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1)
  460. expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1)
  461. expect(await page.getByText(/"answers"/).count()).toBe(0)
  462. expect(await page.getByRole('button', { name: 'View answers', exact: true }).count()).toBe(0)
  463. const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  464. await compareOrRefreshGolden(ANSWERED_EXPANDED_EXPECTED, expanded, MODE)
  465. expect(tripwire.pageErrors).toEqual([])
  466. expect(tripwire.warnings).toEqual([])
  467. }, 200_000)
  468. // The fixture's question carries options, so the round trip above only ever
  469. // exercises the inline shape. The optionless shape is the one that carries
  470. // padding, which is where a cap measured in box pixels drifts off the line
  471. // count — so it is asked straight through the user-questions seam (the same
  472. // service the tool calls; no model round is involved in a layout metric).
  473. it.skipIf(MODE === 'record')('grows the optionless answer to the same cap', async () => {
  474. onTestFailed(() => saveFailureShot(page, 'web-e2e-question-optionless'))
  475. const sessionId = answeredSession
  476. expect(sessionId).toBeDefined()
  477. const agent = scaffold.ctx.agents.get(sessionId as SessionId)
  478. expect(agent).toBeDefined()
  479. const asked = scaffold.ctx.userQuestions.ask({
  480. agent: agent as NonNullable<typeof agent>,
  481. questions: [{ id: 'free', header: 'More', question: 'Anything else?' }],
  482. })
  483. const composer = page.locator('[data-question-key]')
  484. await composer.waitFor({ timeout: 30_000 })
  485. const field = composer.getByRole('textbox')
  486. // The empty field reserves its two lines AND the textarea fills that frame:
  487. // a reserved box the control does not fill leaves a strip that looks like
  488. // the field but takes no click.
  489. expect(await field.evaluate((el) => {
  490. const frame = el.parentElement as HTMLElement
  491. const style = getComputedStyle(frame)
  492. const inner = frame.getBoundingClientRect().height
  493. - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)
  494. return {
  495. reserved: Math.round(frame.getBoundingClientRect().height),
  496. fills: Math.abs(el.getBoundingClientRect().height - inner) < 0.5,
  497. }
  498. })).toEqual({ reserved: 64, fills: true })
  499. // The same cap the inline shape stops at — the assertion a border-box cap fails.
  500. expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
  501. // Settle the wait so teardown is not racing a pending question.
  502. await composer.getByRole('button', { name: 'Skip' }).click()
  503. expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
  504. await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
  505. }, 60_000)
  506. })
  507. describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
  508. let seeded: SeededTranscript
  509. beforeAll(async () => {
  510. seeded = await openSeededTranscript(
  511. resettledFixture(await readFile(FIXTURE, 'utf8'), 'cancelled'),
  512. CANCELLED_SEED_ID,
  513. )
  514. }, 120_000)
  515. afterAll(async () => {
  516. await seeded?.browser.close()
  517. await seeded?.scaffold.close()
  518. })
  519. it('expands to the cancellation verdict and original questions', async () => {
  520. const { page: cancelledPage, scaffold, tripwire } = seeded
  521. onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row'))
  522. await expandTurnProcesses(cancelledPage)
  523. const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true })
  524. await row.waitFor({ timeout: 15_000 })
  525. await row.click()
  526. await cancelledPage
  527. .getByText('This question set was cancelled before answers were submitted.', { exact: true })
  528. .waitFor({ timeout: 10_000 })
  529. await cancelledPage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
  530. expect(await cancelledPage.getByText(/"questions"/).count()).toBe(0)
  531. expect(await cancelledPage
  532. .getByText('Error: the user cancelled ask_user_question', { exact: true }).count()).toBe(0)
  533. const snapshot = (await captureStableAria(
  534. cancelledPage,
  535. '[class*="centerCol"]',
  536. scaffold.workspaceCwd,
  537. )).split(CANCELLED_SEED_ID).join('{{seededId}}')
  538. await compareOrRefreshGolden(CANCELLED_EXPECTED, snapshot, MODE)
  539. expect(tripwire.pageErrors).toEqual([])
  540. expect(tripwire.warnings).toEqual([])
  541. }, 60_000)
  542. })
  543. describe.skipIf(MODE === 'record')('web e2e: queued question reply', () => {
  544. let seeded: SeededTranscript
  545. beforeAll(async () => {
  546. seeded = await openSeededTranscript(
  547. resettledFixture(await readFile(FIXTURE, 'utf8'), 'queued'),
  548. QUEUED_SEED_ID,
  549. )
  550. }, 120_000)
  551. afterAll(async () => {
  552. await seeded?.browser.close()
  553. await seeded?.scaffold.close()
  554. })
  555. it('keeps a submitted reply read-only across a browser reload', async () => {
  556. const { page, scaffold, tripwire } = seeded
  557. onTestFailed(() => saveFailureShot(page, 'web-e2e-question-queued-row'))
  558. await expandTurnProcesses(page)
  559. const row = page.locator('[data-tool="ask_user_question"]')
  560. await row.waitFor()
  561. await page.getByRole('button', { name: 'View answers', exact: true }).waitFor()
  562. expect(await page.locator('[data-question-key]').count()).toBe(0)
  563. await page.reload({ waitUntil: 'load' })
  564. await expandTurnProcesses(page)
  565. await row.waitFor()
  566. await page.getByRole('button', { name: 'View answers', exact: true }).waitFor()
  567. expect(await page.locator('[data-question-key]').count()).toBe(0)
  568. await page.getByRole('button', { name: 'View answers', exact: true }).click()
  569. await page.getByText('Answered after the timeout', { exact: true }).waitFor()
  570. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  571. .split(QUEUED_SEED_ID).join('{{seededId}}')
  572. await compareOrRefreshGolden(QUEUED_EXPECTED, snapshot, MODE)
  573. expect(tripwire.pageErrors).toEqual([])
  574. expect(tripwire.warnings).toEqual([])
  575. }, 60_000)
  576. })
  577. // The timed tool's own second settlement. The recorded result is the expired
  578. // wait, so the answers live in the reply that landed a turn later and reach the
  579. // row only through the `userQuestions` projection — the whole chain the reported
  580. // symptom broke on, where an answered question kept no way back to its answers.
  581. describe.skipIf(MODE === 'record')('web e2e: late-answered question transcript', () => {
  582. let seeded: SeededTranscript
  583. beforeAll(async () => {
  584. seeded = await openSeededTranscript(
  585. resettledFixture(await readFile(FIXTURE, 'utf8'), 'late-answered'),
  586. LATE_ANSWERED_SEED_ID,
  587. )
  588. }, 120_000)
  589. afterAll(async () => {
  590. await seeded?.browser.close()
  591. await seeded?.scaffold.close()
  592. })
  593. it('reads a late answer as an answered question and reopens its answers', async () => {
  594. const { page: latePage, scaffold, tripwire } = seeded
  595. onTestFailed(() => saveFailureShot(latePage, 'web-e2e-question-late-answered-row'))
  596. const reply = latePage.locator('[data-question-reply]')
  597. await reply.waitFor({ state: 'attached', timeout: 15_000 })
  598. expect(await reply.count()).toBe(1)
  599. expect(await latePage.locator('[data-chat-flow-kind="turn-trigger"]').count()).toBe(0)
  600. expect(await reply.isVisible()).toBe(false)
  601. // Whether the answer beat the timeout is the agent's pacing, not something
  602. // a reader of the row has to reason about.
  603. await expandTurnProcesses(latePage)
  604. await reply.waitFor({ state: 'visible', timeout: 15_000 })
  605. const row = latePage.locator('[data-tool="ask_user_question"]')
  606. await row.waitFor({ timeout: 15_000 })
  607. await row.getByRole('button', { name: 'Ask question', exact: true }).click()
  608. await latePage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
  609. expect(await latePage.getByText('Green', { exact: true }).count()).toBeGreaterThanOrEqual(1)
  610. expect(await latePage.getByText('Answered after the timeout', { exact: true }).count()).toBeGreaterThanOrEqual(1)
  611. // The recorded timeout is the row's state, never its transcript copy.
  612. expect(await latePage.getByText(/"pending"/).count()).toBe(0)
  613. // The same read-only panel an in-time answer reopens, over the answers the
  614. // late reply carried.
  615. await latePage.getByRole('button', { name: 'View answers', exact: true }).click()
  616. const review = latePage.locator('[data-question-key]')
  617. await review.waitFor({ timeout: 10_000 })
  618. await review.getByText('Answered', { exact: true }).waitFor({ timeout: 10_000 })
  619. const recorded = review.getByRole('checkbox', { name: 'Green' })
  620. expect(await recorded.getAttribute('aria-checked')).toBe('true')
  621. expect(await recorded.isDisabled()).toBe(true)
  622. expect(await review.getByRole('checkbox', { name: 'Blue' }).getAttribute('aria-checked')).toBe('false')
  623. const recordedCustom = review.getByRole('textbox')
  624. expect(await recordedCustom.inputValue()).toBe('Answered after the timeout')
  625. expect(await recordedCustom.isDisabled()).toBe(true)
  626. expect(await review.getByRole('button', { name: 'Submit' }).count()).toBe(0)
  627. expect(await review.getByRole('button', { name: 'Skip' }).count()).toBe(0)
  628. const snapshot = (await captureStableAria(
  629. latePage,
  630. '[class*="centerCol"]',
  631. scaffold.workspaceCwd,
  632. )).split(LATE_ANSWERED_SEED_ID).join('{{seededId}}')
  633. await compareOrRefreshGolden(LATE_ANSWERED_EXPECTED, snapshot, MODE)
  634. expect(tripwire.pageErrors).toEqual([])
  635. expect(tripwire.warnings).toEqual([])
  636. }, 60_000)
  637. it('keeps the fixture inventory closed', async () => {
  638. await assertFixtureInventory(SNAPSHOT_DIR, [
  639. 'session.v3.jsonl',
  640. 'ui.expected.md',
  641. 'sidebar.expected.md',
  642. 'composed.expected.md',
  643. 'answered.expected.md',
  644. 'cancelled.expected.md',
  645. 'answered-expanded.expected.md',
  646. 'late-answered.expected.md',
  647. 'queued.expected.md',
  648. ])
  649. })
  650. })