live-interactions.e2e.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. // Web e2e scenarios: live-turn interactions — running-draft submission,
  2. // cancellation, error surfacing, transient-retry recovery, and retry
  3. // exhaustion, all through the real composition and wire. The model adapter is
  4. // dsh-llm-replay with override
  5. // sidecars: `hang` (+ a readyFile marker) makes mid-stream cancel
  6. // deterministic by construction, `throw` entries express provider failures by
  7. // stable code, and `{ patches }` augmentation injects transient throws before
  8. // (or instead of) the recorded success so llm-retry's recovery and exhaustion
  9. // are proven end-to-end in the browser. Sidecar CONTENT is authored here
  10. // (single-sourced against the fixture via deriveReplayScript — no committed
  11. // copy of recorded chunks); the file is a per-run artifact in the temp
  12. // workspace. One recorded base fixture serves every scenario.
  13. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  14. import { existsSync } from 'node:fs'
  15. import { tmpdir } from 'node:os'
  16. import { fileURLToPath } from 'node:url'
  17. import { join } from 'node:path'
  18. import type { Browser, Page } from 'playwright'
  19. import { chromium } from 'playwright'
  20. import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
  21. import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
  22. import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  23. import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  24. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  25. import {
  26. assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
  27. compareOrRefreshGolden, fixtureUserPrompts,
  28. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  29. } from './scaffold.ts'
  30. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  31. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/live-interactions', import.meta.url))
  32. const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
  33. // One golden pins the empty mid-turn loading state, one pins the sendable draft
  34. // state, and the other four capture what remains after cancel, after a
  35. // non-retryable failure, after retry recovery, and after retry exhaustion.
  36. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
  37. const CANCEL_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'cancel-expanded.expected.md')
  38. const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
  39. const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md')
  40. const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
  41. const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
  42. const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md')
  43. const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md')
  44. const MODE = webSnapshotMode()
  45. const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
  46. // The recorded base: one text-only turn whose derived script the sidecars
  47. // patch. Kept deliberately tool-free so the derived script is exactly one
  48. // model call.
  49. const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
  50. const RUNNING_DRAFT = 'Queue this follow-up while the current turn is running.'
  51. /** turn/end reasons observed, in order. */
  52. function turnEndReasons(events: SessionEvent[]): string[] {
  53. return events
  54. .filter(e => e.type === 'turn/end')
  55. .map(e => (e as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind)
  56. }
  57. describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
  58. let scaffold: WebScaffold | undefined
  59. let browser: Browser | undefined
  60. let page: Page
  61. let tripwire: ReturnType<typeof watchConsole>
  62. let sessionEvents: SessionEvent[]
  63. let sidecarDir: string | undefined
  64. afterEach(async () => {
  65. // scaffold.close() failures MUST fail the scenario: assertConsumed() is
  66. // the fixture-drift tripwire and cleanup problems are real defects. Run
  67. // every teardown step regardless, then rethrow what failed.
  68. const failures: unknown[] = []
  69. await browser?.close().catch((error: unknown) => failures.push(error))
  70. browser = undefined
  71. const closing = scaffold
  72. scaffold = undefined
  73. await closing?.close().catch((error: unknown) => failures.push(error))
  74. if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  75. sidecarDir = undefined
  76. if (failures.length === 1) throw failures[0]
  77. if (failures.length > 1) throw new AggregateError(failures, 'live-interactions teardown failed')
  78. })
  79. /** Boot scaffold + page with an optional override doc materialized per run. */
  80. async function launch(
  81. buildOverride?: (sidecarHome: string) => ReplayOverrideDoc,
  82. retryPolicy?: RetryPolicyConfig,
  83. ): Promise<void> {
  84. sessionEvents = []
  85. let overridePath: string | undefined
  86. if (buildOverride !== undefined) {
  87. // The sidecar CONTENT is authored in this spec; the file is a per-run
  88. // artifact minted in a spec-owned temp dir. It must exist BEFORE the
  89. // scaffold boots — installLlmReplay resolves the script at install.
  90. sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
  91. overridePath = join(sidecarDir, 'replay.override.json')
  92. await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir)))
  93. }
  94. scaffold = await launchWebScaffold({
  95. replayFixture: FIXTURE,
  96. // Throughput snapshots need a nonzero interval between replayed chunks.
  97. paceMs: 1,
  98. ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
  99. ...(overridePath === undefined ? {} : { compareReplaySession: false }),
  100. ...(retryPolicy === undefined ? {} : { replayRetryPolicy: retryPolicy }),
  101. })
  102. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  103. browser = await chromium.launch()
  104. page = await newEnglishPage(browser)
  105. tripwire = watchConsole(page)
  106. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  107. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  108. // Fresh world: connect a Workspace so the composer scenarios start live.
  109. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  110. }
  111. /**
  112. * Type the recorded prompt and send, with the settled barrier pre-armed.
  113. * Returned WRAPPED ({ settled }) — a bare returned promise would be
  114. * flattened by the caller's await, blocking on turn/end before the caller
  115. * can act mid-turn (the cancel scenario's whole point).
  116. */
  117. async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> {
  118. const input = page.locator('[data-composer-input]').first()
  119. await input.waitFor({ timeout: 10_000 })
  120. const settled = scaffold!.whenTurnSettled(timeoutMs)
  121. await input.fill(PROMPT)
  122. await input.press('Enter')
  123. return { settled }
  124. }
  125. it.skipIf(MODE !== 'record')('records the base fixture live through the composer', async () => {
  126. await launch()
  127. onTestFailed(() => saveFailureShot(page, 'web-e2e-interactions-record'))
  128. const { settled } = await sendPrompt(180_000)
  129. const sessionId = await settled
  130. await recordFixture(scaffold!, sessionId, FIXTURE)
  131. }, 200_000)
  132. it.skipIf(MODE === 'record')('matches the canonical persisted session', async () => {
  133. await launch()
  134. const { settled } = await sendPrompt(30_000)
  135. await settled
  136. })
  137. it.skipIf(MODE === 'record')('cancels a hung stream deterministically via the readyFile marker', async () => {
  138. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  139. let marker = ''
  140. await launch((sidecarHome) => {
  141. marker = join(sidecarHome, '.hang-ready')
  142. return { patches: [{ at: 0, entry: { kind: 'hang', readyFile: marker } }] }
  143. })
  144. onTestFailed(() => saveFailureShot(page, 'web-e2e-cancel'))
  145. const { settled } = await sendPrompt()
  146. // The marker IS the synchronization: the stream is provably parked in the
  147. // hang (prefix chunks delivered to the loop) before the stop click.
  148. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
  149. await expect.poll(
  150. () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
  151. { timeout: 10_000 },
  152. ).toBe(true)
  153. await page.locator('[data-streaming="true"]')
  154. .getByText('partial', { exact: true })
  155. .waitFor({ timeout: 30_000 })
  156. const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  157. await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
  158. const input = page.locator('[data-composer-input]').first()
  159. await input.fill(RUNNING_DRAFT)
  160. // The running primary names its delivery: the default busy-state
  161. // preference is Queue, so the button reads Queue rather than plain Send.
  162. const send = page.getByRole('button', { name: 'Queue message', exact: true })
  163. await send.waitFor({ timeout: 10_000 })
  164. expect(await page.getByRole('button', { name: 'Stop generating', exact: true }).count()).toBe(0)
  165. expect(await page.getByRole('button', { name: 'Send message', exact: true }).count()).toBe(0)
  166. const runningDraftSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  167. await compareOrRefreshGolden(RUNNING_DRAFT_EXPECTED, runningDraftSnapshot, MODE)
  168. await send.click()
  169. await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
  170. const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: RUNNING_DRAFT })
  171. await queuedRow.waitFor({ timeout: 10_000 })
  172. await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor({ timeout: 10_000 })
  173. await queuedRow.getByRole('button', { name: 'Remove queued message' }).click()
  174. await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0)
  175. const stopButton = page.getByRole('button', { name: 'Stop generating' })
  176. await stopButton.hover()
  177. await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
  178. await stopButton.click()
  179. await settled
  180. expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
  181. // Composer recovered; no streaming node lingers. The host settled first
  182. // (awaited above), but the abort frame reaches the browser over SSE — the
  183. // frozen-partial swap is eventually consistent, so poll rather than count.
  184. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  185. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
  186. await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
  187. // Golden of the aborted end-state: the prompt bubble plus the frozen
  188. // partial ('partial' is the hang entry's replayed prefix) and no more.
  189. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  190. await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE)
  191. const expanded = await captureExpandedTurnProcessAria(
  192. page,
  193. '[class*="centerCol"]',
  194. scaffold!.workspaceCwd,
  195. )
  196. await compareOrRefreshGolden(CANCEL_EXPANDED_EXPECTED, expanded, MODE)
  197. expect(tripwire.pageErrors).toEqual([])
  198. expect(tripwire.warnings).toEqual([])
  199. }, 120_000)
  200. it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => {
  201. await launch(() => ({
  202. patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
  203. }))
  204. onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth'))
  205. const { settled } = await sendPrompt()
  206. await settled
  207. expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
  208. // AUTH is outside llm-retry's retryable set: no retry record.
  209. expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
  210. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  211. expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
  212. const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
  213. await errorStatus.waitFor({ timeout: 10_000 })
  214. expect(await errorStatus.textContent()).toContain('API key is invalid')
  215. expect(await errorStatus.textContent()).toContain('AUTH')
  216. expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
  217. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  218. await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE)
  219. await page.getByRole('tab', { name: 'Trajectory' }).click()
  220. const requestMarker = page.locator('tr[data-request-only="true"]').last()
  221. .getByRole('button', { name: /Request #/ })
  222. await requestMarker.click()
  223. await page.getByText('API key is invalid', { exact: true }).waitFor({ timeout: 10_000 })
  224. expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
  225. expect(tripwire.pageErrors).toEqual([])
  226. expect(tripwire.warnings).toEqual([])
  227. }, 120_000)
  228. it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => {
  229. await launch(() => ({
  230. patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
  231. }))
  232. const { settled } = await sendPrompt()
  233. await settled
  234. await page.getByRole('tab', { name: 'Trajectory' }).click()
  235. // The boundary marker row itself is a 0-height hairline except at the
  236. // table tail; the marker button is absolutely positioned and stays
  237. // visible, so wait on it directly.
  238. const tailRequest = page.locator('tr[data-request-only="true"]').last()
  239. const requestMarker = tailRequest.getByRole('button', { name: /Request #/ })
  240. await requestMarker.waitFor({ timeout: 10_000 })
  241. const markerWithinTable = await requestMarker.evaluate((element) => {
  242. const marker = element.getBoundingClientRect()
  243. const table = element.closest('table')?.getBoundingClientRect()
  244. if (table === undefined) throw new Error('request marker has no table')
  245. return marker.bottom <= table.bottom
  246. })
  247. expect(markerWithinTable).toBe(true)
  248. expect(tripwire.pageErrors).toEqual([])
  249. expect(tripwire.warnings).toEqual([])
  250. }, 120_000)
  251. it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => {
  252. const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
  253. expect(derived).toHaveLength(1)
  254. await launch(() => ({
  255. patches: [
  256. { at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } },
  257. // Append the fixture's own success as the retry attempt — single-
  258. // sourced from the recording, never copied into a committed sidecar.
  259. { at: 1, entry: derived[0]! },
  260. ],
  261. }))
  262. onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
  263. // llm-retry backs off ~500ms before the second attempt.
  264. const { settled } = await sendPrompt(60_000)
  265. await settled
  266. expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
  267. // The durable retry record proves the second attempt (request/header logs
  268. // only on change, so attempt count is invisible there).
  269. expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
  270. await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
  271. // Golden of the recovered end-state: the discarded partial stays absent,
  272. // while the settled retry row remains as durable recovery context.
  273. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  274. await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
  275. const expanded = await captureExpandedTurnProcessAria(
  276. page,
  277. '[class*="centerCol"]',
  278. scaffold!.workspaceCwd,
  279. )
  280. await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE)
  281. expect(tripwire.pageErrors).toEqual([])
  282. expect(tripwire.warnings).toEqual([])
  283. }, 120_000)
  284. it.skipIf(MODE === 'record')('surfaces the terminal turn error after transient retries exhaust', async () => {
  285. // A whole-script replacement: three throw entries cover the first request
  286. // plus both budgeted retries (patches cannot reach past the one-call
  287. // derived script). The scenario-owned policy keeps exhaustion fast and
  288. // jitter-free instead of walking the shared default's five backed-off
  289. // attempts.
  290. const failure: ReplayEntry = { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' }
  291. await launch(
  292. () => [failure, failure, failure],
  293. { mode: 'normal', maxRetries: 2, retryableCodes: ['SERVER'], backoff: { initialDelayMs: 25, maxDelayMs: 50, jitterRatio: 0 } },
  294. )
  295. onTestFailed(() => saveFailureShot(page, 'web-e2e-retry-exhausted'))
  296. const { settled } = await sendPrompt(60_000)
  297. await settled
  298. expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
  299. expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(2)
  300. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
  301. expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
  302. // The terminal error row must render even though the turn owns a retry
  303. // chain: exhausted recovery shares the failing turn, so suppressing the
  304. // row by retry history would leave the failure invisible.
  305. const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
  306. await errorStatus.waitFor({ timeout: 10_000 })
  307. expect(await errorStatus.textContent()).toContain('upstream 503')
  308. expect(await errorStatus.textContent()).toContain('SERVER')
  309. // The settled retry chain stays alongside the terminal row as recovery
  310. // context; the golden pins both.
  311. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  312. await compareOrRefreshGolden(RETRY_EXHAUSTED_EXPECTED, snapshot, MODE)
  313. expect(tripwire.pageErrors).toEqual([])
  314. expect(tripwire.warnings).toEqual([])
  315. }, 120_000)
  316. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  317. await assertFixtureInventory(SNAPSHOT_DIR, [
  318. 'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
  319. 'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md',
  320. 'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md',
  321. ])
  322. })
  323. })