ソースを参照

test(web): cover trajectory attachment presentation

Refs #4356
sjw1231 3 週間 前
親
コミット
212f5f9ccc

+ 101 - 11
apps/web/tests/file-upload-round.e2e.ts

@@ -15,7 +15,7 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -108,6 +108,93 @@ describe('web e2e: generic file upload through the real assembly', () => {
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
   const sessionEvents: SessionEvent[] = []
+  let liveTrajectory: string | undefined
+
+  /** Inspect one durable mixed message through the real image slot and all message tabs. */
+  async function inspectTrajectoryAttachments(): Promise<string> {
+    const userMessage = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
+    if (userMessage?.type !== 'user/message') throw new Error('the replayed turn recorded no user message')
+    const content = userMessage.data.content
+    const imageName = IMAGE_NAMES[0]!
+    await page.getByRole('tab', { name: 'Chat', exact: true }).click()
+    const chatImage = page.getByRole('img', { name: imageName, exact: true })
+    await expect.poll(() => chatImage.getAttribute('src'), { timeout: 10_000 }).toMatch(/^blob:/)
+    const chatImageUrl = await chatImage.getAttribute('src')
+
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
+    const row = page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true })
+    await row.waitFor({ timeout: 10_000 })
+    const ledger = await captureStableAria(page, '[data-trajectory-row-key][aria-label*="Files ×1"]', scaffold.workspaceCwd)
+    await row.click()
+
+    const panel = page.getByRole('tabpanel')
+    const list = panel.getByRole('list', { name: 'Attachments', exact: true })
+    let summaryAttachments: string | undefined
+    let preview: string | undefined
+    for (const tab of ['Summary', 'Preview']) {
+      await page.getByRole('tab', { name: tab, exact: true }).click()
+      await list.waitFor()
+      expect(await panel.getByText(PROMPT, { exact: true }).count()).toBe(1)
+      const rows = list.getByRole('listitem')
+      expect(await rows.count()).toBe(2)
+      expect(await rows.nth(0).getByTitle(FILE_NAME, { exact: true }).count()).toBe(1)
+      expect(await rows.nth(1).getByTitle(imageName, { exact: true }).count()).toBe(1)
+      expect(await rows.nth(0).textContent()).toContain('TXT · 16B')
+      expect(await rows.nth(1).textContent()).toContain('image/png · 69B · 1 × 1')
+      const thumbnail = list.getByRole('img', { name: imageName, exact: true })
+      await expect.poll(() => thumbnail.getAttribute('src'), { timeout: 10_000 }).toBe(chatImageUrl)
+      expect(await thumbnail.evaluate(element => getComputedStyle(element).objectFit)).toBe('contain')
+
+      const attachments = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd)
+      if (tab === 'Summary') summaryAttachments = attachments
+      else {
+        expect(attachments).toBe(summaryAttachments)
+        preview = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+      }
+      const opener = list.getByRole('button', { name: `${imageName}, click to view original`, exact: true })
+      await opener.focus()
+      await opener.press('Enter')
+      const dialog = page.getByRole('dialog', { name: 'Original image preview', exact: true })
+      await dialog.waitFor()
+      expect(await dialog.getByRole('img', { name: imageName }).getAttribute('src')).toBe(chatImageUrl)
+      await page.keyboard.press('Escape')
+      await dialog.waitFor({ state: 'hidden' })
+      expect(await opener.evaluate(element => element === document.activeElement)).toBe(true)
+    }
+
+    await page.getByRole('tab', { name: 'Raw', exact: true }).click()
+    const disclosures = panel.locator('details')
+    expect(await disclosures.count()).toBe(2)
+    expect(await panel.getByRole('img').count()).toBe(0)
+    for (const disclosure of await disclosures.all()) {
+      expect(await disclosure.getAttribute('open')).toBeNull()
+      expect(await disclosure.locator('pre').isVisible()).toBe(false)
+    }
+    const rawCollapsed = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    const blocks = panel.locator('details, section')
+    expect(await blocks.count()).toBe(content.length)
+    for (const [index, block] of content.entries()) {
+      const renderedBlock = blocks.nth(index)
+      if (block.type === 'text') {
+        expect(await renderedBlock.locator('pre').textContent()).toBe(block.text)
+      } else {
+        expect(['image', 'file']).toContain(block.type)
+        await renderedBlock.locator('summary').click()
+        expect(await renderedBlock.locator('pre').isVisible()).toBe(true)
+        const raw: unknown = JSON.parse((await renderedBlock.locator('pre').textContent())!)
+        expect(raw).toEqual(block)
+      }
+    }
+    const rawExpanded = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    return [
+      '# Ledger', ledger,
+      '# Summary attachments', summaryAttachments,
+      '# Preview', preview,
+      '# Raw (collapsed)', rawCollapsed,
+      '# Raw (expanded)', rawExpanded,
+    ].join('\n\n')
+  }
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({
@@ -283,16 +370,19 @@ describe('web e2e: generic file upload through the real assembly', () => {
     await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE)
   })
 
-  it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => {
-    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
-    await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
-    await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 })
-    const snapshot = await captureStableAria(
-      page,
-      '[data-trajectory-row-key][aria-label*="Files ×1"]',
-      scaffold.workspaceCwd,
-    )
-    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+  it.skipIf(MODE === 'record')('shows both attachment kinds throughout the Trajectory inspector', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory'))
+    liveTrajectory = await inspectTrajectoryAttachments()
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, liveTrajectory, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('restores the same Trajectory attachments from persisted history after reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory-reload'))
+    expect(liveTrajectory).toBeDefined()
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    expect(await inspectTrajectoryAttachments()).toBe(liveTrajectory)
   })
 
   it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {

+ 38 - 2
snapshots/web/file-upload-round/trajectory.expected.md

@@ -1,3 +1,39 @@
-- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.":
+# Ledger
+
+- row "USER, Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.":
   - cell "Turn 1 USER": USER
-  - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop."
+  - cell "Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop."
+
+# Summary attachments
+
+- list "Attachments":
+  - listitem: poem.txt TXT · 16B
+  - listitem:
+    - button "reference-1.png, click to view original":
+      - img "reference-1.png"
+    - text: reference-1.png image/png · 69B · 1 × 1
+
+# Preview
+
+- tabpanel "Preview":
+  - paragraph: Read the attached file with the read tool, reply with exactly the single word it contains, and stop.
+  - list "Attachments":
+    - listitem: poem.txt TXT · 16B
+    - listitem:
+      - button "reference-1.png, click to view original":
+        - img "reference-1.png"
+      - text: reference-1.png image/png · 69B · 1 × 1
+
+# Raw (collapsed)
+
+- tabpanel "Raw":
+  - group: "Block #1 file poem.txt"
+  - group: "Block #2 image reference-1.png"
+  - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop."
+
+# Raw (expanded)
+
+- tabpanel "Raw":
+  - group: "Block #1 file poem.txt { \"type\": \"file\", \"attachment\": { \"attachmentId\": \"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60\", \"name\": \"poem.txt\", \"bytes\": 16 } }"
+  - group: "Block #2 image reference-1.png { \"type\": \"image\", \"attachment\": { \"attachmentId\": \"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\", \"mediaType\": \"image/png\", \"width\": 1, \"height\": 1, \"bytes\": 69, \"name\": \"reference-1.png\" } }"
+  - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop."