1
0

turn-tail-actions.e2e.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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. 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.jsonl')
  26. // Two goldens for the same message: parked mid-turn, then settled.
  27. const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
  28. const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
  29. const MODE = webSnapshotMode()
  30. // The recording must carry text in the SAME assistant message as the tool
  31. // call; a Think-only step would leave nothing for the footer to attach to and
  32. // the scenario would pass against either implementation.
  33. const NARRATION = 'Reading the workspace now.'
  34. 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.`
  35. describe('web e2e: assistant IconActions wait for the turn to end', () => {
  36. let scaffold: WebScaffold | undefined
  37. let browser: Browser | undefined
  38. let page: Page
  39. let tripwire: ReturnType<typeof watchConsole>
  40. let sessionEvents: SessionEvent[]
  41. let sidecarDir: string | undefined
  42. afterEach(async () => {
  43. // close() carries the fixture-consumption tripwire, so its failure is the
  44. // scenario's failure; run every teardown step, then rethrow what failed.
  45. const failures: unknown[] = []
  46. await browser?.close().catch((error: unknown) => failures.push(error))
  47. browser = undefined
  48. const closing = scaffold
  49. scaffold = undefined
  50. await closing?.close().catch((error: unknown) => failures.push(error))
  51. if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  52. sidecarDir = undefined
  53. if (failures.length === 1) throw failures[0]
  54. if (failures.length > 1) throw new AggregateError(failures, 'turn-tail-actions teardown failed')
  55. })
  56. /** Boot scaffold + page, materializing the sidecar before the replay row installs. */
  57. async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise<void> {
  58. sessionEvents = []
  59. let overridePath: string | undefined
  60. if (buildOverride !== undefined) {
  61. sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
  62. overridePath = join(sidecarDir, 'replay.override.json')
  63. await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir)))
  64. }
  65. scaffold = await launchWebScaffold(
  66. MODE === 'record'
  67. ? {}
  68. : {
  69. replayFixture: FIXTURE,
  70. ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
  71. compareReplaySession: overridePath === undefined,
  72. },
  73. )
  74. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  75. browser = await chromium.launch()
  76. page = await newEnglishPage(browser)
  77. tripwire = watchConsole(page)
  78. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  79. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  80. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  81. }
  82. /** Send the recorded prompt with the settled barrier pre-armed (returned wrapped so the caller can act mid-turn). */
  83. async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> {
  84. const input = page.locator('textarea').first()
  85. await input.waitFor({ timeout: 10_000 })
  86. const settled = scaffold!.whenTurnSettled(timeoutMs)
  87. await input.fill(PROMPT)
  88. await input.press('Enter')
  89. return { settled }
  90. }
  91. it.skipIf(MODE !== 'record')('records the narrate-then-call turn live through the composer', async () => {
  92. await launch()
  93. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-record'))
  94. const { settled } = await sendPrompt(180_000)
  95. const sessionId = await settled
  96. await recordFixture(scaffold!, sessionId, FIXTURE)
  97. }, 200_000)
  98. it.skipIf(MODE === 'record')('matches the canonical persisted session', async () => {
  99. await launch()
  100. const { settled } = await sendPrompt(30_000)
  101. await settled
  102. })
  103. it.skipIf(MODE === 'record')('withholds the footer while the turn runs and grants it at turn/end', async () => {
  104. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  105. let marker = ''
  106. // Patch the SECOND call: the first one delivers the narration and the tool
  107. // call as recorded, so the park happens with a durable mid-turn message.
  108. await launch((sidecarHome) => {
  109. marker = join(sidecarHome, '.hang-ready')
  110. return { patches: [{ at: 1, entry: { kind: 'hang', readyFile: marker } }] }
  111. })
  112. onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions'))
  113. // The barrier is armed before the park and awaited only after the stop
  114. // click, so its budget must cover the whole parked phase: marker poll,
  115. // three UI polls, and two captures with their stability windows. The
  116. // replay default (30s) leaves no headroom on a slow runner.
  117. const { settled } = await sendPrompt(120_000)
  118. // The marker IS the synchronization: the second call is provably parked,
  119. // so the first step's message and tool result are already durable.
  120. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
  121. await expect.poll(() => page.getByText(NARRATION, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  122. await expect.poll(
  123. () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
  124. { timeout: 10_000 },
  125. ).toBe(true)
  126. // Only the user bubble owns a footer (clock + copy; user bubbles carry no
  127. // branch action): the narration is not the answer yet.
  128. const copyButtons = page.getByRole('button', { name: 'Copy' })
  129. await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(1)
  130. expect(await page.getByRole('button', { name: 'Branch into a new conversation' }).count()).toBe(0)
  131. await copyButtons.first().focus()
  132. const running = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  133. await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
  134. // Closing the turn from the park is the state change under test: an
  135. // aborted turn is durably closed, so its transcript tail (the frozen
  136. // partial) takes the seat while the mid-turn narration keeps none.
  137. await page.getByRole('button', { name: 'Stop generating' }).click()
  138. await settled
  139. expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted'])
  140. await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
  141. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
  142. await copyButtons.last().focus()
  143. const settledAria = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  144. await compareOrRefreshGolden(SETTLED_EXPECTED, settledAria, MODE)
  145. expect(tripwire.pageErrors).toEqual([])
  146. expect(tripwire.warnings).toEqual([])
  147. }, 120_000)
  148. it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
  149. await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'session.jsonl', 'settled.expected.md'])
  150. })
  151. })