queue-image.e2e.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. // Keyless browser coverage for image attachments submitted while a turn is
  2. // running, through the shipped Web composition and real HTTP/SSE wire. A
  3. // text-plus-image submission queues as one occurrence whose dock row renders
  4. // the durable thumbnail, survives a stop as parked work, and delivers as the
  5. // next turn's user message with its image intact — while the session log holds
  6. // only durable attachment references, never base64.
  7. import { existsSync } from 'node:fs'
  8. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { fileURLToPath } from 'node:url'
  11. import { join } from 'node:path'
  12. import type { Browser, Page } from 'playwright'
  13. import { chromium } from 'playwright'
  14. import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
  15. import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
  16. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  17. import {
  18. captureStableAria, compareOrRefreshGolden,
  19. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  20. } from './scaffold.ts'
  21. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  22. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queued-image', import.meta.url))
  23. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
  24. const PNG = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
  25. const QUEUED_EXPECTED = join(SNAPSHOT_DIR, 'queued.expected.md')
  26. const DELIVERED_EXPECTED = join(SNAPSHOT_DIR, 'delivered.expected.md')
  27. const MODE = webSnapshotMode()
  28. const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
  29. const QUEUED_TEXT = 'Compare with this screenshot'
  30. /** Paste one real PNG into the composer through a genuine clipboard event. */
  31. async function pasteImage(page: Page, bytes: Uint8Array): Promise<void> {
  32. await page.locator('[data-composer-input]').first().evaluate((surface, data) => {
  33. const transfer = new DataTransfer()
  34. transfer.items.add(new File([new Uint8Array(data)], 'queued.png', { type: 'image/png' }))
  35. surface.dispatchEvent(new ClipboardEvent('paste', {
  36. clipboardData: transfer, bubbles: true, cancelable: true,
  37. }))
  38. }, [...bytes])
  39. }
  40. describe('web e2e: queued image submission', () => {
  41. let scaffold: WebScaffold | undefined
  42. let browser: Browser | undefined
  43. let page: Page
  44. let overrideDir: string | undefined
  45. let cleanupRoutes: (() => Promise<void>) | undefined
  46. afterEach(async () => {
  47. const failures: unknown[] = []
  48. await cleanupRoutes?.().catch((error: unknown) => failures.push(error))
  49. cleanupRoutes = undefined
  50. await browser?.close().catch((error: unknown) => failures.push(error))
  51. browser = undefined
  52. const closing = scaffold
  53. scaffold = undefined
  54. await closing?.close().catch((error: unknown) => failures.push(error))
  55. if (overrideDir !== undefined) {
  56. await rm(overrideDir, { recursive: true, force: true })
  57. .catch((error: unknown) => failures.push(error))
  58. }
  59. overrideDir = undefined
  60. if (failures.length === 1) throw failures[0]
  61. if (failures.length > 1) throw new AggregateError(failures, 'queued-image teardown failed')
  62. })
  63. it.skipIf(MODE === 'record')('queues a text-plus-image submission with a thumbnail and delivers it as the next turn', async () => {
  64. overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queued-image-'))
  65. const readyFile = join(overrideDir, '.hang-ready')
  66. const overridePath = join(overrideDir, 'replay.override.json')
  67. const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
  68. expect(recorded).toHaveLength(1)
  69. const replay: ReplayEntry[] = [
  70. { kind: 'hang', readyFile },
  71. recorded[0]!,
  72. recorded[0]!,
  73. ]
  74. await writeFile(overridePath, JSON.stringify(replay))
  75. const sessionEvents: SessionEvent[] = []
  76. scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath, compareReplaySession: false })
  77. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  78. browser = await chromium.launch()
  79. page = await newEnglishPage(browser)
  80. const tripwire = watchConsole(page)
  81. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  82. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  83. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  84. onTestFailed(() => saveFailureShot(page, 'web-e2e-queued-image'))
  85. const input = page.locator('[data-composer-input]').first()
  86. const firstSettled = scaffold.whenTurnSettled()
  87. await input.fill(ACTIVE_PROMPT)
  88. await input.press('Enter')
  89. await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
  90. // A just-submitted composer is read-only for the prompt round-trip.
  91. await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
  92. await pasteImage(page, await readFile(PNG))
  93. await page.getByRole('img', { name: 'queued.png' }).waitFor({ timeout: 10_000 })
  94. const releasePrompt = Promise.withResolvers<undefined>()
  95. const releaseImage = Promise.withResolvers<undefined>()
  96. let cleanupPromise: Promise<void> | undefined
  97. const cleanup = (): Promise<void> => cleanupPromise ??= (async () => {
  98. releasePrompt.resolve(undefined)
  99. releaseImage.resolve(undefined)
  100. await page.unrouteAll({ behavior: 'wait' })
  101. })()
  102. cleanupRoutes = cleanup
  103. let imageRequested = false
  104. await page.route('**/api/session/prompt', async (route) => {
  105. await releasePrompt.promise
  106. await route.continue()
  107. })
  108. await page.route('**/api/session/attachment', async (route) => {
  109. imageRequested = true
  110. await releaseImage.promise
  111. await route.continue()
  112. })
  113. const dockThumb = page.locator('[data-queue-dock] img[alt="Queued message image"]')
  114. try {
  115. await input.fill(QUEUED_TEXT)
  116. await input.press('Enter')
  117. await dockThumb.waitFor({ timeout: 15_000 })
  118. await expect.poll(() => dockThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
  119. expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(1)
  120. releasePrompt.resolve(undefined)
  121. await expect.poll(() => imageRequested, { timeout: 15_000 }).toBe(true)
  122. await page.getByText(QUEUED_TEXT, { exact: true }).waitFor()
  123. await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 15_000 })
  124. expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
  125. expect(await dockThumb.count()).toBe(0)
  126. releaseImage.resolve(undefined)
  127. // Admission replaces the optimistic image; wait for the durable row's own thumbnail.
  128. const durableThumb = page.locator('[data-queue-dock] li:not([data-submission-echo]) img[alt="Queued message image"]')
  129. await durableThumb.waitFor({ timeout: 15_000 })
  130. await expect.poll(() => durableThumb.getAttribute('src'), { timeout: 15_000 }).toMatch(/^blob:/)
  131. await expect.poll(() => durableThumb.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
  132. const queuedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  133. await compareOrRefreshGolden(QUEUED_EXPECTED, queuedSnapshot, MODE)
  134. } finally {
  135. await cleanup()
  136. }
  137. // Stop parks the accepted queue; the next waking send delivers the image
  138. // message first (FIFO), then its own text as the following turn.
  139. await page.getByRole('button', { name: 'Stop generating' }).click()
  140. await firstSettled
  141. await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
  142. await dockThumb.waitFor({ timeout: 10_000 })
  143. const settled = scaffold.whenTurnSettled()
  144. await input.fill('Continue with the queued comparison')
  145. await input.press('Enter')
  146. await settled
  147. // The queued image message and the waking text run as two further turns;
  148. // wait for both to end so the final snapshot never captures a mid-reply
  149. // frame (the aborted first turn precedes them).
  150. await expect.poll(
  151. () => sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : []),
  152. { timeout: 15_000 },
  153. ).toEqual(['aborted', 'completed', 'completed'])
  154. // The delivered user message renders its image in Chat from the durable
  155. // reference, and the dock row is gone.
  156. await expect.poll(
  157. () => page.locator('[data-queue-dock]').count(),
  158. { timeout: 15_000 },
  159. ).toBe(0)
  160. const chatImage = page.locator('[class*="userRow"] img')
  161. await chatImage.first().waitFor({ timeout: 15_000 })
  162. // Host persistence precedes delivery to the browser; require the waking turn's settled tail.
  163. await page.locator('[data-turn-tail="3"]')
  164. .getByRole('button', { name: 'Branch into a new conversation', exact: true })
  165. .waitFor({ timeout: 15_000 })
  166. await expect.poll(
  167. () => page.getByRole('button', { name: /^3 turns 3 steps/ }).count(),
  168. { timeout: 15_000 },
  169. ).toBe(1)
  170. const deliveredSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  171. await compareOrRefreshGolden(DELIVERED_EXPECTED, deliveredSnapshot, MODE)
  172. // Model-visible means logged: the delivered message carries the durable
  173. // reference (never base64), in the composer's canonical images-then-text order.
  174. const delivered = sessionEvents.find(event => event.type === 'user/message'
  175. && event.data.content.some(block => block.type === 'image'))
  176. expect(delivered?.type === 'user/message' && delivered.data.content.map(block => block.type)).toEqual(['image', 'text'])
  177. const imageBlock = delivered?.type === 'user/message'
  178. ? delivered.data.content.find(block => block.type === 'image')
  179. : undefined
  180. expect(imageBlock?.type === 'image' && imageBlock.attachment.name).toBe('queued.png')
  181. expect(JSON.stringify(sessionEvents)).not.toContain('base64')
  182. expect(tripwire.pageErrors).toEqual([])
  183. expect(tripwire.warnings).toEqual([])
  184. }, 120_000)
  185. })