turn-tail-actions.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. // Web e2e scenario: assistant IconActions belong to the settled answer, so
  2. // they arrive with `turn/end` and not before. The recorded turn narrates in
  3. // plain text before its tool call, which is the event order that would show the
  4. // footer beside mid-turn narration for the seconds a tool runs and then move it
  5. // down. A `hang` sidecar on the SECOND model call parks the turn after the
  6. // narration and the tool result are durable, so the running state is stable by
  7. // construction rather than by timing; stopping from that park writes the
  8. // `turn/end` that hands the footer to the turn's transcript tail.
  9. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  10. import { existsSync } from 'node:fs'
  11. import { tmpdir } from 'node:os'
  12. import { fileURLToPath } from 'node:url'
  13. import { join } from 'node:path'
  14. import type { Browser, Page } from 'playwright'
  15. import { chromium } from 'playwright'
  16. import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
  17. import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  18. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  19. import {
  20. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  21. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  22. } from './scaffold.ts'
  23. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  24. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
  25. const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
  26. // Three goldens for the same message: parked mid-turn, aborted, and completed.
  27. const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
  28. const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
  29. const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md')
  30. const COMPLETED_EXPECTED = join(SNAPSHOT_DIR, 'completed.expected.md')
  31. const FOCUSED_EXPECTED = join(SNAPSHOT_DIR, 'focused.expected.md')
  32. const MODE = webSnapshotMode()
  33. // The recording must carry text in the SAME assistant message as the tool
  34. // call; a Think-only step would leave nothing for the footer to attach to and
  35. // the scenario would pass against either implementation.
  36. const NARRATION = 'Reading the workspace now.'
  37. const PROMPT = `Begin your reply with the plain sentence "${NARRATION}" as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop.`
  38. describe('web e2e: assistant IconActions wait for the turn to end', () => {
  39. let scaffold: WebScaffold | undefined
  40. let browser: Browser | undefined
  41. let page: Page
  42. let tripwire: ReturnType<typeof watchConsole>
  43. let sessionEvents: SessionEvent[]
  44. let sidecarDir: string | undefined
  45. afterEach(async () => {
  46. // close() carries the fixture-consumption tripwire, so its failure is the
  47. // scenario's failure; run every teardown step, then rethrow what failed.
  48. const failures: unknown[] = []
  49. await browser?.close().catch((error: unknown) => failures.push(error))
  50. browser = undefined
  51. const closing = scaffold
  52. scaffold = undefined
  53. await closing?.close().catch((error: unknown) => failures.push(error))
  54. if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  55. sidecarDir = undefined
  56. if (failures.length === 1) throw failures[0]
  57. if (failures.length > 1) throw new AggregateError(failures, 'turn-tail-actions teardown failed')
  58. })
  59. /** Boot scaffold + page, materializing the sidecar before the replay row installs. */
  60. async function launch(
  61. buildOverride?: (sidecarHome: string) => ReplayOverrideDoc,
  62. // Throughput snapshots require a nonzero interval between replayed chunks.
  63. paceMs = 1,
  64. ): Promise<void> {
  65. sessionEvents = []
  66. let overridePath: string | undefined
  67. if (buildOverride !== undefined) {
  68. sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
  69. overridePath = join(sidecarDir, 'replay.override.json')
  70. await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir)))
  71. }
  72. scaffold = await launchWebScaffold(
  73. MODE === 'record'
  74. ? {}
  75. : {
  76. replayFixture: FIXTURE,
  77. ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
  78. compareReplaySession: overridePath === undefined,
  79. paceMs,
  80. },
  81. )
  82. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  83. browser = await chromium.launch()
  84. page = await newEnglishPage(browser)
  85. tripwire = watchConsole(page)
  86. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  87. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  88. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  89. }
  90. /** Send the recorded prompt with the settled barrier pre-armed (returned wrapped so the caller can act mid-turn). */
  91. async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> {
  92. const input = page.locator('[data-composer-input]').first()
  93. await input.waitFor({ timeout: 10_000 })
  94. const settled = scaffold!.whenTurnSettled(timeoutMs)
  95. await input.fill(PROMPT)
  96. await input.press('Enter')
  97. return { settled }
  98. }
  99. it.skipIf(MODE !== 'record')('records the narrate-then-call turn live through the composer', async () => {
  100. await launch()
  101. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-record'))
  102. const { settled } = await sendPrompt(180_000)
  103. const sessionId = await settled
  104. await recordFixture(scaffold!, sessionId, FIXTURE)
  105. }, 200_000)
  106. it.skipIf(MODE === 'record')('matches the canonical persisted session', async () => {
  107. await launch()
  108. const { settled } = await sendPrompt(30_000)
  109. await settled
  110. })
  111. it.skipIf(MODE === 'record')('withholds the footer while the turn runs and grants it at turn/end', async () => {
  112. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  113. let marker = ''
  114. // Patch the SECOND call: the first one delivers the narration and the tool
  115. // call as recorded, so the park happens with a durable mid-turn message.
  116. await launch((sidecarHome) => {
  117. marker = join(sidecarHome, '.hang-ready')
  118. return { patches: [{ at: 1, entry: { kind: 'hang', readyFile: marker } }] }
  119. })
  120. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions'))
  121. // The barrier is armed before the park and awaited only after the stop
  122. // click, so its budget must cover the whole parked phase: marker poll,
  123. // live-state polls, and two captures with their stability windows. The
  124. // replay default (30s) leaves no headroom on a slow runner.
  125. const { settled } = await sendPrompt(120_000)
  126. // The marker IS the synchronization: the second call is provably parked,
  127. // so the first step's message and tool result are already durable.
  128. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
  129. expect(await page.locator('[data-turn-process]').count()).toBe(0)
  130. await expect.poll(
  131. () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
  132. { timeout: 10_000 },
  133. ).toBe(true)
  134. await page.locator('[data-streaming="true"]')
  135. .getByText('partial', { exact: true })
  136. .waitFor({ timeout: 10_000 })
  137. // Only the user bubble owns a footer (clock + copy; user bubbles carry no
  138. // branch action): the narration is not the answer yet.
  139. const copyButtons = page.getByRole('button', { name: 'Copy' })
  140. await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(1)
  141. expect(await page.getByRole('button', { name: 'Branch into a new conversation' }).count()).toBe(0)
  142. await copyButtons.first().focus()
  143. const running = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  144. await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
  145. // Closing the turn from the park is the state change under test: an
  146. // aborted turn is durably closed, so its transcript tail (the frozen
  147. // partial) takes the seat while the mid-turn narration keeps none.
  148. await page.getByRole('button', { name: 'Stop generating' }).click()
  149. await settled
  150. expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted'])
  151. await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 })
  152. await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
  153. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
  154. await copyButtons.last().focus()
  155. const settledAria = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  156. await compareOrRefreshGolden(SETTLED_EXPECTED, settledAria, MODE)
  157. expect(tripwire.pageErrors).toEqual([])
  158. expect(tripwire.warnings).toEqual([])
  159. }, 120_000)
  160. it.skipIf(MODE === 'record')('shows exact completed-Turn usage and expands its available facts', async () => {
  161. await launch(undefined, 5)
  162. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-usage-expanded'))
  163. const { settled } = await sendPrompt(120_000)
  164. await settled
  165. const trigger = page.getByRole('button', { name: /Usage 15\.8K tok/ })
  166. await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1)
  167. expect(await trigger.getAttribute('aria-expanded')).toBe('false')
  168. // The usage pill carries the icon and the turn total; the time pill beside
  169. // it carries the run time, and both keep their details dialog-only.
  170. expect(await trigger.textContent()).toBe('Usage 15.8K tok')
  171. const timeTrigger = page.getByRole('button', { name: /^Ran for \S+$/ })
  172. expect(await timeTrigger.count()).toBe(1)
  173. expect(await page.locator('[data-turn-tail]').getByText(/tok\/s|TTFT/).count()).toBe(0)
  174. expect(await page.getByRole('dialog').count()).toBe(0)
  175. await trigger.click()
  176. expect(await trigger.getAttribute('aria-expanded')).toBe('true')
  177. const dialog = page.getByRole('dialog', { name: 'Turn usage' })
  178. expect(await dialog.count()).toBe(1)
  179. expect(await dialog.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
  180. expect(await dialog.getByText('49.7%', { exact: true }).count()).toBe(1)
  181. expect(await dialog.getByText('7,891 tok', { exact: true }).count()).toBe(1)
  182. expect(await dialog.getByText('7,808 tok', { exact: true }).count()).toBe(1)
  183. expect(await dialog.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1)
  184. expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1)
  185. await page.keyboard.press('Escape')
  186. expect(await page.getByRole('dialog').count()).toBe(0)
  187. await timeTrigger.click()
  188. const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
  189. expect(await timeDialog.count()).toBe(1)
  190. expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
  191. expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
  192. await page.keyboard.press('Escape')
  193. await trigger.click()
  194. const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  195. await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)
  196. const warningStart = tripwire.warnings.length
  197. await page.reload({ waitUntil: 'load' })
  198. await expect.poll(() => timeTrigger.count(), { timeout: 15_000 }).toBe(1)
  199. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  200. await timeTrigger.click()
  201. expect(await timeDialog.count()).toBe(1)
  202. expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
  203. expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
  204. expect(tripwire.pageErrors).toEqual([])
  205. expect(tripwire.warnings).toEqual([])
  206. }, 120_000)
  207. it.skipIf(MODE === 'record')('folds the Turn process after the completed reply becomes the answer', async () => {
  208. await launch(undefined, 5)
  209. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-completed'))
  210. const { settled } = await sendPrompt()
  211. await settled
  212. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  213. const process = page.locator('[data-turn-process]')
  214. await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
  215. expect(await process.getAttribute('aria-expanded')).toBe('false')
  216. expect(await process.evaluate(element => getComputedStyle(element).borderBottomWidth)).toBe('1px')
  217. const processBottom = await process.evaluate(element =>
  218. element.closest<HTMLElement>('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom)
  219. const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
  220. element.closest<HTMLElement>('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top)
  221. expect(answerTop).toBe((processBottom ?? 0) + 8)
  222. await process.focus()
  223. const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  224. await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE)
  225. expect(tripwire.pageErrors).toEqual([])
  226. expect(tripwire.warnings).toEqual([])
  227. }, 60_000)
  228. it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => {
  229. await launch()
  230. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
  231. const { settled } = await sendPrompt()
  232. await settled
  233. const process = page.locator('[data-turn-process]')
  234. const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
  235. await process.waitFor({ timeout: 10_000 })
  236. expect(await process.getAttribute('aria-expanded')).toBe('false')
  237. expect(await tool.isVisible()).toBe(false)
  238. await page.getByRole('button', { name: 'Settings', exact: true }).click()
  239. const dialog = page.getByRole('dialog', { name: 'Settings' })
  240. await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
  241. await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
  242. await page.keyboard.press('Escape')
  243. await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0)
  244. await tool.waitFor({ state: 'visible', timeout: 10_000 })
  245. await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
  246. .toMatch(/ui-chat:\n\s+transcriptView: normal/)
  247. await page.getByRole('button', { name: 'Settings', exact: true }).click()
  248. const restored = page.getByRole('dialog', { name: 'Settings' })
  249. await restored.getByRole('button', { name: 'Normal', exact: true }).click()
  250. await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
  251. await page.keyboard.press('Escape')
  252. await process.waitFor({ timeout: 10_000 })
  253. expect(await process.getAttribute('aria-expanded')).toBe('false')
  254. expect(await tool.isVisible()).toBe(false)
  255. expect(tripwire.pageErrors).toEqual([])
  256. expect(tripwire.warnings).toEqual([])
  257. }, 60_000)
  258. it.skipIf(MODE === 'record')('keeps a focused process member open when the completed reply arrives', async () => {
  259. await launch(undefined, 200)
  260. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused'))
  261. const { settled } = await sendPrompt()
  262. const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
  263. await tool.waitFor({ timeout: 30_000 })
  264. await tool.focus()
  265. expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
  266. await settled
  267. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  268. const process = page.locator('[data-turn-process]')
  269. await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
  270. expect(await process.getAttribute('aria-expanded')).toBe('true')
  271. expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
  272. const focused = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  273. await compareOrRefreshGolden(FOCUSED_EXPECTED, focused, MODE)
  274. expect(tripwire.pageErrors).toEqual([])
  275. expect(tripwire.warnings).toEqual([])
  276. }, 60_000)
  277. it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
  278. await assertFixtureInventory(
  279. SNAPSHOT_DIR,
  280. [
  281. 'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.v3.jsonl',
  282. 'settled.expected.md', 'usage-expanded.expected.md',
  283. ],
  284. )
  285. })
  286. })