1 parent
641253e730
commit
70b4237c00
7 files changed
+135
-15
No files matched your search
@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
@@ -24,6 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-r
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v3.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url))
|
||||
const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url))
|
||||
const TRAJECTORY_STATUS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory-status.expected.md', import.meta.url))
|
||||
const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url))
|
||||
const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url))
|
||||
const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url))
|
||||
@@ -385,11 +386,53 @@ describe('web e2e: generic file upload through the real assembly', () => {
|
||||
expect(await inspectTrajectoryAttachments()).toBe(liveTrajectory)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps thumbnail loading and retry feedback inside the attachment row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-thumbnail-status'))
|
||||
const releaseRead = Promise.withResolvers<undefined>()
|
||||
const read = vi.spyOn(scaffold.ctx.attachments, 'readImage').mockImplementationOnce(async () => {
|
||||
await releaseRead.promise
|
||||
throw new Error('fixture image read failed')
|
||||
})
|
||||
try {
|
||||
// Reload clears the browser image cache so the real image slot observes the delayed read.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
await page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true }).click()
|
||||
await page.getByRole('tab', { name: 'Preview', exact: true }).click()
|
||||
const panel = page.getByRole('tabpanel')
|
||||
const thumbnail = panel.locator('[data-variant="thumbnail"]')
|
||||
await thumbnail.waitFor()
|
||||
await expect.poll(() => read.mock.calls.length).toBe(1)
|
||||
const feedbackFits = () => thumbnail.evaluate(element => element.scrollHeight <= element.clientHeight
|
||||
&& element.scrollWidth <= element.clientWidth)
|
||||
expect(await feedbackFits()).toBe(true)
|
||||
const loading = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd)
|
||||
|
||||
releaseRead.resolve(undefined)
|
||||
const retry = panel.getByRole('button', { name: 'Image failed to load; click to retry', exact: true })
|
||||
await retry.waitFor()
|
||||
expect(await feedbackFits()).toBe(true)
|
||||
const failed = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd)
|
||||
await retry.focus()
|
||||
await retry.press('Enter')
|
||||
await panel.getByRole('img', { name: IMAGE_NAMES[0]!, exact: true }).waitFor()
|
||||
expect(read).toHaveBeenCalledTimes(2)
|
||||
await compareOrRefreshGolden(TRAJECTORY_STATUS_EXPECTED, [
|
||||
'# Loading', loading, '# Failed', failed,
|
||||
].join('\n\n'), MODE)
|
||||
} finally {
|
||||
releaseRead.resolve(undefined)
|
||||
read.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
|
||||
'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', 'trajectory-status.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,8 @@
|
||||
.frame[data-variant='thumbnail'],
|
||||
.error[data-variant='thumbnail'] {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
@@ -84,6 +86,20 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spinner-rotate 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.frame:focus-visible,
|
||||
.error:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { IconLoadingOutline16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
import css from './MessageImage.module.css'
|
||||
@@ -73,6 +74,7 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon
|
||||
* preview. A lone image renders at its `singleFit` size; an image among
|
||||
* several renders as a fixed 64px square tile. The preview arm displays its
|
||||
* local URL directly — no loader round-trip, no failure/retry surface.
|
||||
* List thumbnails use status icons with localized tooltips and accessible names.
|
||||
*
|
||||
* @param props.image - the durable reference to load, or the local preview to display.
|
||||
* @param props.load - session-authorized URL loader for the durable arm.
|
||||
@@ -122,7 +124,21 @@ export function MessageImage({ image, load, variant, labels }: {
|
||||
const src = preview?.url ?? loaded
|
||||
const label = ('attachment' in image ? image.label : undefined)
|
||||
?? preview?.name ?? attachment?.name ?? labels.image
|
||||
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
|
||||
const loadingThumbnail = variant === 'thumbnail' && src === null
|
||||
if (error) return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.error}
|
||||
data-variant={variant}
|
||||
title={variant === 'thumbnail' ? labels.loadFailed : undefined}
|
||||
aria-label={variant === 'thumbnail' ? labels.loadFailed : undefined}
|
||||
onClick={request}
|
||||
>
|
||||
{variant === 'thumbnail'
|
||||
? <span aria-hidden="true"><IconRefreshOutline16 /></span>
|
||||
: labels.loadFailed}
|
||||
</button>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -130,12 +146,17 @@ export function MessageImage({ image, load, variant, labels }: {
|
||||
className={css.frame}
|
||||
data-variant={variant}
|
||||
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
|
||||
title={labels.open}
|
||||
aria-label={labels.openNamed(label)}
|
||||
title={loadingThumbnail ? labels.loading : labels.open}
|
||||
aria-label={loadingThumbnail ? labels.loading : labels.openNamed(label)}
|
||||
aria-busy={loadingThumbnail || undefined}
|
||||
onClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null
|
||||
? <span className={css.loading}>{labels.loading}</span>
|
||||
? (
|
||||
<span className={css.loading} aria-hidden={loadingThumbnail || undefined}>
|
||||
{loadingThumbnail ? <IconLoadingOutline16 className={css.spinner} /> : labels.loading}
|
||||
</span>
|
||||
)
|
||||
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -151,6 +151,33 @@ describe('MessageImage', () => {
|
||||
expect(retry.getAttribute('data-variant')).toBe(variant)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Chinese', labels],
|
||||
['English', { ...labels, loading: 'Loading image…', loadFailed: 'Image failed to load; click to retry' }],
|
||||
] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => {
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry')
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
|
||||
const loading = view.getByRole('button', { name: statusLabels.loading })
|
||||
expect(loading.getAttribute('title')).toBe(statusLabels.loading)
|
||||
expect(loading.getAttribute('aria-busy')).toBe('true')
|
||||
expect(loading.textContent).toBe('')
|
||||
expect(loading.querySelector('svg')).not.toBeNull()
|
||||
|
||||
await act(async () => { pending.reject(new Error('offline')) })
|
||||
const retry = view.getByRole('button', { name: statusLabels.loadFailed })
|
||||
expect(retry.getAttribute('title')).toBe(statusLabels.loadFailed)
|
||||
expect(retry.textContent).toBe('')
|
||||
expect(retry.querySelector('svg')).not.toBeNull()
|
||||
fireEvent.click(retry)
|
||||
await view.findByAltText(attachment.name)
|
||||
const opener = view.getByRole('button', { name: statusLabels.openNamed(attachment.name) })
|
||||
expect(opener.getAttribute('aria-busy')).toBeNull()
|
||||
expect(opener.getAttribute('title')).toBe(statusLabels.open)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
expect(load).toHaveBeenLastCalledWith(attachment)
|
||||
})
|
||||
|
||||
it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => {
|
||||
const { name: _name, ...unnamed } = attachment
|
||||
const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail')
|
||||
|
||||
@@ -122,15 +122,13 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick<
|
||||
| 'timeSeconds'
|
||||
| 'startedAt'
|
||||
> {
|
||||
// An empty text block yields an empty preview; treat it as absent so an
|
||||
// image-bearing record still labels its row instead of rendering blank.
|
||||
const preview = previewContent(node.content)
|
||||
const previewMarkdown = preview === '' ? undefined : preview
|
||||
const images = imageBlockCount(node.content)
|
||||
const files = fileBlockCount(node.content)
|
||||
const attachmentSummary = [
|
||||
images > 0
|
||||
? t('layout.imageOnly', { count: images })
|
||||
? t('layout.imageCount', { count: images })
|
||||
: undefined,
|
||||
files > 0 ? t('layout.fileAttachments', { count: files }) : undefined,
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
@@ -808,7 +806,7 @@ function summarizeAssistantActivity(
|
||||
return t('layout.toolCallOnly')
|
||||
}
|
||||
const images = blocks.filter(block => block.kind === 'image').length
|
||||
if (images > 0) return t('layout.imageOnly', { count: images })
|
||||
if (images > 0) return t('layout.imageCount', { count: images })
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1089,7 +1087,7 @@ function summarizeResult(
|
||||
}
|
||||
}
|
||||
const images = imageBlockCount(node.content)
|
||||
if (images > 0) return { result: t('layout.imageOnly', { count: images }) }
|
||||
if (images > 0) return { result: t('layout.imageCount', { count: images }) }
|
||||
return { result: t('record.noOutput') }
|
||||
}
|
||||
|
||||
@@ -1116,7 +1114,7 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string {
|
||||
.join('\n')
|
||||
if (text !== '') return text
|
||||
const images = imageBlockCount(node.content)
|
||||
if (images > 0) return t('layout.imageOnly', { count: images })
|
||||
if (images > 0) return t('layout.imageCount', { count: images })
|
||||
if (
|
||||
node.content.length === 0
|
||||
|| node.content.every(block =>
|
||||
|
||||
@@ -182,7 +182,7 @@ export const zh = {
|
||||
'layout.toolCallOnly': '仅工具调用',
|
||||
'attachment.list': '附件',
|
||||
'attachment.imageName': '图片 {index}',
|
||||
'layout.imageOnly': '图片 ×{count}',
|
||||
'layout.imageCount': '图片 ×{count}',
|
||||
'layout.fileAttachments': '文件 ×{count}',
|
||||
'layout.initialSystemPrompt': '初始系统提示词',
|
||||
'layout.systemPromptUpdated': '系统提示词已更新',
|
||||
@@ -384,7 +384,7 @@ export const en: Record<TrajectoryKey, string> = {
|
||||
'layout.toolCallOnly': 'Tool call only',
|
||||
'attachment.list': 'Attachments',
|
||||
'attachment.imageName': 'Image {index}',
|
||||
'layout.imageOnly': 'Images ×{count}',
|
||||
'layout.imageCount': 'Images ×{count}',
|
||||
'layout.fileAttachments': 'Files ×{count}',
|
||||
'layout.initialSystemPrompt': 'Initial System Prompt',
|
||||
'layout.systemPromptUpdated': 'System Prompt Updated',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Loading
|
||||
|
||||
- list "Attachments":
|
||||
- listitem: poem.txt TXT · 16B
|
||||
- listitem:
|
||||
- button "Loading image…"
|
||||
- text: reference-1.png image/png · 69B · 1 × 1
|
||||
|
||||
# Failed
|
||||
|
||||
- list "Attachments":
|
||||
- listitem: poem.txt TXT · 16B
|
||||
- listitem:
|
||||
- button "Image failed to load; click to retry"
|
||||
- text: reference-1.png image/png · 69B · 1 × 1
|
||||
Reference in new issue
Block a user