question-composer.e2e.ts 21 KB

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