live-interactions.e2e.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. // Web e2e scenarios: live-turn interactions — cancellation, error surfacing,
  2. // and transient-retry recovery, all through the real composition and wire.
  3. // The model seam is dsh-llm-replay with override sidecars: `hang` (+ a
  4. // readyFile marker) makes mid-stream cancel deterministic by construction,
  5. // `throw` entries express provider failures by stable code, and `{ patches }`
  6. // augmentation injects a transient throw before the recorded success so
  7. // llm-retry's recovery is proven end-to-end in the browser. Sidecar CONTENT
  8. // is authored here (single-sourced against the fixture via deriveReplayScript
  9. // — no committed copy of recorded chunks); the file is a per-run artifact in
  10. // the temp workspace. One recorded base fixture serves all three scenarios.
  11. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  12. import { existsSync } from 'node:fs'
  13. import { tmpdir } from 'node:os'
  14. import { fileURLToPath } from 'node:url'
  15. import { join } from 'node:path'
  16. import type { Browser, Page } from 'playwright'
  17. import { chromium } from 'playwright'
  18. import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
  19. import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  20. import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  21. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  22. import {
  23. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  24. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  25. } from './scaffold.ts'
  26. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  27. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
  28. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  29. // One golden pins the stable mid-turn loading state; the other three capture
  30. // what the user is left looking at after cancel, after a non-retryable failure,
  31. // and after retry recovery.
  32. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
  33. const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
  34. const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
  35. const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
  36. const MODE = webSnapshotMode()
  37. const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
  38. // The recorded base: one text-only turn whose derived script the sidecars
  39. // patch. Kept deliberately tool-free so the derived script is exactly one
  40. // model call.
  41. const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
  42. /** turn/end reasons observed, in order. */
  43. function turnEndReasons(events: SessionEvent[]): string[] {
  44. return events
  45. .filter(e => e.type === 'turn/end')
  46. .map(e => (e as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind)
  47. }
  48. describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
  49. let scaffold: WebScaffold | undefined
  50. let browser: Browser | undefined
  51. let page: Page
  52. let tripwire: ReturnType<typeof watchConsole>
  53. let sessionEvents: SessionEvent[]
  54. let sidecarDir: string | undefined
  55. afterEach(async () => {
  56. // scaffold.close() failures MUST fail the scenario: assertConsumed() is
  57. // the fixture-drift tripwire and cleanup problems are real defects. Run
  58. // every teardown step regardless, then rethrow what failed.
  59. const failures: unknown[] = []
  60. await browser?.close().catch((error: unknown) => failures.push(error))
  61. browser = undefined
  62. const closing = scaffold
  63. scaffold = undefined
  64. await closing?.close().catch((error: unknown) => failures.push(error))
  65. if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  66. sidecarDir = undefined
  67. if (failures.length === 1) throw failures[0]
  68. if (failures.length > 1) throw new AggregateError(failures, 'live-interactions teardown failed')
  69. })
  70. /** Boot scaffold + page with an optional override doc materialized per run. */
  71. async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise<void> {
  72. sessionEvents = []
  73. let overridePath: string | undefined
  74. if (buildOverride !== undefined) {
  75. // The sidecar CONTENT is authored in this spec; the file is a per-run
  76. // artifact minted in a spec-owned temp dir. It must exist BEFORE the
  77. // scaffold boots — installLlmReplay resolves the script at install.
  78. sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
  79. overridePath = join(sidecarDir, 'replay.override.json')
  80. await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir)))
  81. }
  82. scaffold = await launchWebScaffold({
  83. replayFixture: FIXTURE,
  84. ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
  85. })
  86. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  87. browser = await chromium.launch()
  88. page = await newEnglishPage(browser)
  89. tripwire = watchConsole(page)
  90. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  91. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  92. // Fresh world: connect a Workspace so the composer scenarios start live.
  93. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  94. }
  95. /**
  96. * Type the recorded prompt and send, with the settled barrier pre-armed.
  97. * Returned WRAPPED ({ settled }) — a bare returned promise would be
  98. * flattened by the caller's await, blocking on turn/end before the caller
  99. * can act mid-turn (the cancel scenario's whole point).
  100. */
  101. async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> {
  102. const input = page.locator('textarea').first()
  103. await input.waitFor({ timeout: 10_000 })
  104. const settled = scaffold!.whenTurnSettled(timeoutMs)
  105. await input.fill(PROMPT)
  106. await input.press('Enter')
  107. return { settled }
  108. }
  109. it.skipIf(MODE !== 'record')('records the base fixture live through the composer', async () => {
  110. await launch()
  111. onTestFailed(() => saveFailureShot(page, 'web-e2e-interactions-record'))
  112. const { settled } = await sendPrompt(180_000)
  113. const sessionId = await settled
  114. await recordFixture(scaffold!, sessionId, FIXTURE)
  115. }, 200_000)
  116. it.skipIf(MODE === 'record')('cancels a hung stream deterministically via the readyFile marker', async () => {
  117. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  118. let marker = ''
  119. await launch((sidecarHome) => {
  120. marker = join(sidecarHome, '.hang-ready')
  121. return { patches: [{ at: 0, entry: { kind: 'hang', readyFile: marker } }] }
  122. })
  123. onTestFailed(() => saveFailureShot(page, 'web-e2e-cancel'))
  124. const { settled } = await sendPrompt()
  125. // The marker IS the synchronization: the stream is provably parked in the
  126. // hang (prefix chunks delivered to the loop) before the stop click.
  127. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
  128. await expect.poll(
  129. () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
  130. { timeout: 10_000 },
  131. ).toBe(true)
  132. const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  133. await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
  134. await page.getByRole('button', { name: 'Stop generating' }).click()
  135. await settled
  136. expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
  137. // Composer recovered; no streaming node lingers. The host settled first
  138. // (awaited above), but the abort frame reaches the browser over SSE — the
  139. // frozen-partial swap is eventually consistent, so poll rather than count.
  140. await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  141. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
  142. // Golden of the aborted end-state: the prompt bubble plus the frozen
  143. // partial ('partial' is the hang entry's replayed prefix) and no more.
  144. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  145. await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE)
  146. expect(tripwire.pageErrors).toEqual([])
  147. expect(tripwire.warnings).toEqual([])
  148. }, 120_000)
  149. it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => {
  150. await launch(() => ({
  151. patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
  152. }))
  153. onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth'))
  154. const { settled } = await sendPrompt()
  155. await settled
  156. expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
  157. // AUTH is outside llm-retry's retryable set: no retry record.
  158. expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
  159. await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  160. expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
  161. const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
  162. await errorStatus.waitFor({ timeout: 10_000 })
  163. expect(await errorStatus.textContent()).toContain('API key is invalid')
  164. expect(await errorStatus.textContent()).toContain('AUTH')
  165. expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
  166. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  167. await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE)
  168. await page.getByRole('tab', { name: 'Trajectory' }).click()
  169. const requestMarker = page.locator('tr[data-request-only="true"]').last()
  170. .getByRole('button', { name: /Request #/ })
  171. await requestMarker.click()
  172. await page.getByText('API key is invalid', { exact: true }).waitFor({ timeout: 10_000 })
  173. expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
  174. expect(tripwire.pageErrors).toEqual([])
  175. expect(tripwire.warnings).toEqual([])
  176. }, 120_000)
  177. it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => {
  178. await launch(() => ({
  179. patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
  180. }))
  181. const { settled } = await sendPrompt()
  182. await settled
  183. await page.getByRole('tab', { name: 'Trajectory' }).click()
  184. // The boundary marker row itself is a 0-height hairline except at the
  185. // table tail; the marker button is absolutely positioned and stays
  186. // visible, so wait on it directly.
  187. const tailRequest = page.locator('tr[data-request-only="true"]').last()
  188. const requestMarker = tailRequest.getByRole('button', { name: /Request #/ })
  189. await requestMarker.waitFor({ timeout: 10_000 })
  190. const markerWithinTable = await requestMarker.evaluate((element) => {
  191. const marker = element.getBoundingClientRect()
  192. const table = element.closest('table')?.getBoundingClientRect()
  193. if (table === undefined) throw new Error('request marker has no table')
  194. return marker.bottom <= table.bottom
  195. })
  196. expect(markerWithinTable).toBe(true)
  197. expect(tripwire.pageErrors).toEqual([])
  198. expect(tripwire.warnings).toEqual([])
  199. }, 120_000)
  200. it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => {
  201. const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
  202. expect(derived).toHaveLength(1)
  203. await launch(() => ({
  204. patches: [
  205. { at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } },
  206. // Append the fixture's own success as the retry attempt — single-
  207. // sourced from the recording, never copied into a committed sidecar.
  208. { at: 1, entry: derived[0]! },
  209. ],
  210. }))
  211. onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
  212. // llm-retry backs off ~500ms before the second attempt.
  213. const { settled } = await sendPrompt(60_000)
  214. await settled
  215. expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
  216. // The durable retry record proves the second attempt (request/header logs
  217. // only on change, so attempt count is invisible there).
  218. expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
  219. await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
  220. // Golden of the recovered end-state: the discarded partial stays absent,
  221. // while the settled retry row remains as durable recovery context.
  222. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  223. await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
  224. expect(tripwire.pageErrors).toEqual([])
  225. expect(tripwire.warnings).toEqual([])
  226. }, 120_000)
  227. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  228. await assertFixtureInventory(SNAPSHOT_DIR, [
  229. 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
  230. ])
  231. })
  232. })