file-upload-round.e2e.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. // Web e2e scenario: generic file upload round trip. A real chromium picks a
  2. // file through the composer paperclip input; the upload RPC stores the exact
  3. // bytes below the scaffold's isolated DSH_HOME, the prompt cites the staged
  4. // reference, request assembly projects the file block to handle text, and the
  5. // model (replayed or live) reads the saved copy with the REAL read tool. The
  6. // content-addressed store makes the saved path identical across record and
  7. // replay once the workspace cwd is tokenized, so the recorded read arguments
  8. // replay verbatim against a freshly re-uploaded object.
  9. // Record: DSH_SNAPSHOT=record rewrites session.v3.jsonl, then a keyless
  10. // DSH_SNAPSHOT=refresh regenerates ui.expected.md.
  11. import { readFile } from 'node:fs/promises'
  12. import { fileURLToPath } from 'node:url'
  13. import type { Browser, Page } from 'playwright'
  14. import { chromium } from 'playwright'
  15. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  16. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  17. import {
  18. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  19. launchWebScaffold, recordFixture, 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/file-upload-round', import.meta.url))
  23. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v3.jsonl', import.meta.url))
  24. const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url))
  25. const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url))
  26. const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url))
  27. const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url))
  28. const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url))
  29. const IMAGE_FIXTURE = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
  30. const MODE = webSnapshotMode()
  31. /** The uploaded fixture file: constant bytes so record and replay share one content digest. */
  32. const FILE_NAME = 'poem.txt'
  33. const FILE_TEXT = 'UPLOAD_ROUND_OK\n'
  34. const PROMPT = 'Read the attached file with the read tool, reply with exactly the single word it contains, and stop.'
  35. const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${String(index + 1)}.png`)
  36. /** Browser-measured relations for the mixed composer attachment rail. */
  37. interface DraftRailGeometry {
  38. readonly fileIconBox: string
  39. readonly fileIconColor: string
  40. readonly fileIconUsesSolidFill: boolean
  41. readonly order: readonly string[]
  42. readonly oneGroup: boolean
  43. readonly oneRow: boolean
  44. readonly equalHeight: boolean
  45. readonly fileWider: boolean
  46. readonly horizontalOverflow: boolean
  47. readonly noWrap: boolean
  48. }
  49. /** Render stable relations instead of platform-dependent absolute coordinates. */
  50. function renderDraftRailGeometry(geometry: DraftRailGeometry): string {
  51. return [
  52. '# Mixed composer attachment rail',
  53. '',
  54. `- selection order: ${geometry.order.join(' > ')}`,
  55. `- one attachment group: ${String(geometry.oneGroup)}`,
  56. `- file icon dimensions: ${geometry.fileIconBox}`,
  57. `- file icon color: ${geometry.fileIconColor}`,
  58. `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`,
  59. `- all cards share one row: ${String(geometry.oneRow)}`,
  60. `- every card is 64px high: ${String(geometry.equalHeight)}`,
  61. `- the file card is wider than an image: ${String(geometry.fileWider)}`,
  62. `- overflowing cards scroll horizontally: ${String(geometry.horizontalOverflow)}`,
  63. `- the rail does not wrap: ${String(geometry.noWrap)}`,
  64. ].join('\n')
  65. }
  66. /** Browser-measured relations for one durable mixed-attachment message. */
  67. interface HistoryAttachmentGeometry {
  68. readonly fileIconBox: string
  69. readonly fileIconColor: string
  70. readonly fileIconUsesSolidFill: boolean
  71. readonly order: readonly string[]
  72. readonly oneGroup: boolean
  73. readonly oneRow: boolean
  74. readonly equalHeight: boolean
  75. readonly imageIsTile: boolean
  76. readonly fileWider: boolean
  77. readonly wrapsWhenNeeded: boolean
  78. readonly rightAligned: boolean
  79. }
  80. /** Render stable history-layout relations instead of absolute coordinates. */
  81. function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): string {
  82. return [
  83. '# Mixed history attachment flow',
  84. '',
  85. `- source order: ${geometry.order.join(' > ')}`,
  86. `- one attachment group: ${String(geometry.oneGroup)}`,
  87. `- file icon dimensions: ${geometry.fileIconBox}`,
  88. `- file icon color: ${geometry.fileIconColor}`,
  89. `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`,
  90. `- file and image share one row: ${String(geometry.oneRow)}`,
  91. `- both cards are 64px high: ${String(geometry.equalHeight)}`,
  92. `- the image is a 64px tile: ${String(geometry.imageIsTile)}`,
  93. `- the file card is wider than the image: ${String(geometry.fileWider)}`,
  94. `- the group wraps when needed: ${String(geometry.wrapsWhenNeeded)}`,
  95. `- the group is right-aligned: ${String(geometry.rightAligned)}`,
  96. ].join('\n')
  97. }
  98. describe('web e2e: generic file upload through the real assembly', () => {
  99. let scaffold: WebScaffold
  100. let browser: Browser
  101. let page: Page
  102. let tripwire: ReturnType<typeof watchConsole>
  103. const sessionEvents: SessionEvent[] = []
  104. beforeAll(async () => {
  105. scaffold = await launchWebScaffold({
  106. compareReplaySession: true,
  107. // The override rescripts the recorded read arguments with a
  108. // `{{fromRequest:…}}` placeholder: the saved-copy path differs per run,
  109. // and the live handle line in the request carries the current one.
  110. ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE, paceMs: 15 }),
  111. })
  112. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  113. browser = await chromium.launch()
  114. page = await newEnglishPage(browser)
  115. await page.setViewportSize({ width: 900, height: 900 })
  116. tripwire = watchConsole(page)
  117. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  118. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  119. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  120. }, 120_000)
  121. afterAll(async () => {
  122. await browser?.close()
  123. await scaffold?.close()
  124. })
  125. it('uploads on pick, gates send on the staged receipt, and settles the turn (all modes)', async () => {
  126. onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-drive'))
  127. if (MODE !== 'record') {
  128. // Drift guard: the committed fixture must carry exactly the drive prompt.
  129. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  130. }
  131. const input = page.locator('[data-composer-input]').first()
  132. await input.waitFor({ timeout: 10_000 })
  133. const modelTrigger = page.getByRole('button', { name: /^Select model, current/ })
  134. await modelTrigger.click()
  135. await page.getByRole('menuitem', { name: /^Model\b/ }).click()
  136. await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).click()
  137. await expect.poll(() => modelTrigger.getAttribute('aria-label'), { timeout: 10_000 })
  138. .toContain('DeepSeek-V4-Flash-Vision-Exp')
  139. const imageBytes = await readFile(IMAGE_FIXTURE)
  140. // Pick through the composer's hidden file input: the upload RPC runs
  141. // immediately and the pending card appears before any prompt is typed.
  142. await page.locator('input[type="file"]').setInputFiles([
  143. { name: FILE_NAME, mimeType: 'text/plain', buffer: Buffer.from(FILE_TEXT) },
  144. ...IMAGE_NAMES.map(name => ({ name, mimeType: 'image/png', buffer: imageBytes })),
  145. ])
  146. await page.getByTitle(FILE_NAME).waitFor({ timeout: 10_000 })
  147. const rail = page.getByRole('group', { name: 'Pending attachments' })
  148. await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 })
  149. .toBe(IMAGE_NAMES.length + 1)
  150. await rail.locator('svg[viewBox="0 0 28 28"]').waitFor({ timeout: 15_000 })
  151. const geometry = await rail.evaluate((element): DraftRailGeometry => {
  152. const cards = [...element.children] as HTMLElement[]
  153. const boxes = cards.map(card => card.getBoundingClientRect())
  154. const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0
  155. const fileCard = cards.find(card => card.querySelector('[title="poem.txt"]') !== null)!
  156. const fileWidth = fileCard.getBoundingClientRect().width
  157. const fileIcon = fileCard.querySelector('svg[viewBox="0 0 28 28"]')!
  158. return {
  159. fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`,
  160. fileIconColor: getComputedStyle(fileIcon).color,
  161. fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor',
  162. order: cards.map(card => card.querySelector('img')?.getAttribute('alt')
  163. ?? card.querySelector<HTMLElement>('[title]')?.title ?? ''),
  164. oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1,
  165. oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
  166. equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
  167. fileWider: fileWidth > imageWidth,
  168. horizontalOverflow: element.scrollWidth > element.clientWidth,
  169. noWrap: getComputedStyle(element).flexWrap === 'nowrap',
  170. }
  171. })
  172. await compareOrRefreshGolden(DRAFT_EXPECTED, renderDraftRailGeometry(geometry), MODE)
  173. for (const name of IMAGE_NAMES.slice(1)) {
  174. await page.getByRole('button', { name: `Remove image ${name}` }).click({ force: true })
  175. }
  176. await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }).toBe(2)
  177. await input.fill(PROMPT)
  178. // Send unlocks only after the upload receipt lands (the staged file gate).
  179. const send = page.getByRole('button', { name: 'Send message' })
  180. await send.waitFor({ state: 'visible', timeout: 15_000 })
  181. await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true)
  182. const settled = scaffold.whenTurnSettled()
  183. await send.click()
  184. const restoreDeadline = Date.now() + 10_000
  185. let retainedCards = 0
  186. while (retainedCards === 0
  187. && !sessionEvents.some(event => event.type === 'turn/start')
  188. && Date.now() < restoreDeadline) {
  189. await page.waitForTimeout(100)
  190. retainedCards = await rail.locator(':scope > *').count()
  191. }
  192. if (retainedCards !== 0) {
  193. const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents()
  194. throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`)
  195. }
  196. const sessionId = await settled
  197. if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
  198. }, 200_000)
  199. it.skipIf(MODE === 'record')('persists the file block and reads the stored copy with the real read tool', () => {
  200. const userMessage = sessionEvents.find(
  201. (event): event is Extract<SessionEvent, { type: 'user/message' }> =>
  202. event.type === 'user/message' && event.data.source.kind === 'user',
  203. )
  204. if (userMessage === undefined) throw new Error('the replayed turn recorded no user message')
  205. const fileBlock = userMessage.data.content.find(block => block.type === 'file')
  206. if (fileBlock?.type !== 'file') throw new Error('the user message carries no file block')
  207. expect(fileBlock.attachment.name).toBe(FILE_NAME)
  208. expect(fileBlock.attachment.bytes).toBe(Buffer.byteLength(FILE_TEXT))
  209. expect(String(fileBlock.attachment.attachmentId)).toMatch(/^sha256:[0-9a-f]{64}$/)
  210. const readCall = sessionEvents.find(
  211. (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
  212. event.type === 'tool/call' && event.data.name === 'read',
  213. )
  214. if (readCall === undefined) throw new Error('the replayed turn did not call the read tool')
  215. expect(readCall.data.arguments).toContain(FILE_NAME)
  216. const readResult = sessionEvents.find(
  217. (event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
  218. event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId,
  219. )
  220. if (readResult === undefined) throw new Error('the read call produced no durable result')
  221. const content = readResult.data.message.content[0]
  222. expect(content.isError).toBe(false)
  223. expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
  224. .toContain('UPLOAD_ROUND_OK')
  225. const turnEnds = sessionEvents.filter(event => event.type === 'turn/end')
  226. expect(turnEnds.length).toBe(1)
  227. expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
  228. })
  229. it.skipIf(MODE === 'record')('renders the durable file card beside the settled answer', async () => {
  230. onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-aria'))
  231. await expect.poll(() => page.getByText('UPLOAD_ROUND_OK', { exact: false }).count(), { timeout: 15_000 })
  232. .toBeGreaterThanOrEqual(1)
  233. await page.getByTitle(FILE_NAME).first().waitFor({ timeout: 10_000 })
  234. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  235. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  236. })
  237. it.skipIf(MODE === 'record')('keeps a mixed durable message in one ordered wrapping attachment flow', async () => {
  238. const groups = page.locator('[data-message-attachments]')
  239. await expect.poll(() => groups.count(), { timeout: 10_000 }).toBe(1)
  240. const geometry = await groups.first().evaluate((element): HistoryAttachmentGeometry => {
  241. const cards = [...element.children] as HTMLElement[]
  242. const renderedCards = cards.map((card) => {
  243. const box = card.getBoundingClientRect()
  244. return box.width === 0 && box.height === 0 && card.firstElementChild instanceof HTMLElement
  245. ? card.firstElementChild
  246. : card
  247. })
  248. const boxes = renderedCards.map(card => card.getBoundingClientRect())
  249. const imageIndex = cards.findIndex(card => card.querySelector('img') !== null)
  250. const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt')
  251. const imageBox = boxes[imageIndex]
  252. const fileBox = boxes[fileIndex]
  253. const fileIcon = cards[fileIndex]!.querySelector('svg')!
  254. return {
  255. fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`,
  256. fileIconColor: getComputedStyle(fileIcon).color,
  257. fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor',
  258. order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''),
  259. oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1,
  260. oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
  261. equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
  262. imageIsTile: imageBox !== undefined && Math.abs(imageBox.width - 64) < 0.5,
  263. fileWider: fileBox !== undefined && imageBox !== undefined && fileBox.width > imageBox.width,
  264. wrapsWhenNeeded: getComputedStyle(element).flexWrap === 'wrap',
  265. rightAligned: getComputedStyle(element).justifyContent === 'flex-end',
  266. }
  267. })
  268. await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE)
  269. })
  270. it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => {
  271. await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
  272. await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
  273. await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 })
  274. const snapshot = await captureStableAria(
  275. page,
  276. '[data-trajectory-row-key][aria-label*="Files ×1"]',
  277. scaffold.workspaceCwd,
  278. )
  279. await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
  280. })
  281. it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
  282. expect(tripwire.pageErrors).toEqual([])
  283. expect(tripwire.warnings).toEqual([])
  284. await assertFixtureInventory(SNAPSHOT_DIR, [
  285. 'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
  286. ])
  287. })
  288. })