Files
hermes-agent/apps/desktop/src/app/chat/composer/attachments.test.tsx
T
m4 fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
feat: consolidate desktop and provider updates
2026-09-27 17:37:19 +08:00

263 lines
8.6 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { ComposerAttachment } from '@/store/composer'
import { $previewTabs } from '@/store/preview'
import { AttachmentList } from './attachments'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
const THUMBNAIL_URL = 'data:image/png;base64,dGh1bWJuYWls'
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
}
async function renderWithI18n(ui: React.ReactNode) {
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
{ui}
</I18nProvider>
)
})
return result!
}
describe('AttachmentList', () => {
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'hermesDesktop')
vi.restoreAllMocks()
})
it('renders valid attachments', async () => {
const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')]
await renderWithI18n(<AttachmentList attachments={attachments} />)
expect(screen.getByText('doc.pdf')).toBeDefined()
expect(screen.getByText('img.png')).toBeDefined()
})
it('renders empty list without error', async () => {
const { container } = await renderWithI18n(<AttachmentList attachments={[]} />)
const attachmentList = container.querySelector('[data-slot="composer-attachments"]')
expect(attachmentList).toBeDefined()
})
it('does not crash when attachments array contains undefined entries', async () => {
// Repro: session switch can leave stale/undefined entries in the
// attachments array, causing a TypeError at attachment.refText.
const attachments = [
makeAttachment('a', 'good.pdf'),
undefined as unknown as ComposerAttachment,
makeAttachment('b', 'also-good.png')
]
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
// Only valid attachments should render
expect(screen.getByText('good.pdf')).toBeDefined()
expect(screen.getByText('also-good.png')).toBeDefined()
})
it('does not crash when attachments array contains null entries', async () => {
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
expect(screen.getByText('valid.txt')).toBeDefined()
})
it('renders the thumbnail in the pill but opens the full-resolution image in the lightbox', async () => {
$previewTabs.set([])
const image: ComposerAttachment = {
id: 'img',
kind: 'image',
label: 'shot.png',
path: '/tmp/shot.png',
previewUrl: DATA_URL,
thumbnailUrl: THUMBNAIL_URL
}
await renderWithI18n(<AttachmentList attachments={[image]} />)
expect(screen.getByAltText<HTMLImageElement>('shot.png').getAttribute('src')).toBe(THUMBNAIL_URL)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /shot\.png/ }))
})
// The lightbox renders the full-size image in a dialog; the rail stays empty.
const lightbox = await screen.findByRole('dialog')
expect(lightbox.querySelector<HTMLImageElement>('img')?.src).toBe(DATA_URL)
expect($previewTabs.get()).toHaveLength(0)
})
it('loads a path-backed full image only when opened and releases it when closed', async () => {
const readFileDataUrl = vi.fn(async () => DATA_URL)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const image: ComposerAttachment = {
id: 'img-on-demand',
kind: 'image',
label: 'shot.png',
path: '/tmp/shot.png',
thumbnailUrl: THUMBNAIL_URL
}
const { container } = await renderWithI18n(<AttachmentList attachments={[image]} />)
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(screen.getByAltText<HTMLImageElement>('shot.png').getAttribute('src')).toBe(THUMBNAIL_URL)
expect(container.querySelector(`img[src="${DATA_URL}"]`)).toBeNull()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /shot\.png/ }))
})
expect(readFileDataUrl).toHaveBeenCalledOnce()
const lightboxImage = (await screen.findByRole('dialog')).querySelector<HTMLImageElement>('img')
expect(lightboxImage?.getAttribute('src')).toBe(DATA_URL)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
})
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(container.querySelector(`img[src="${DATA_URL}"]`)).toBeNull()
})
it('falls back to the original host path after an image was staged for a different filesystem', async () => {
const stagedPath = '/root/.hermes/attachments/photo.png'
const hostPath = 'C:\\Users\\alice\\Pictures\\photo.png'
const readFileDataUrl = vi.fn(async (path: string) => {
if (path === hostPath) {
return DATA_URL
}
throw new Error(`not readable: ${path}`)
})
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const image: ComposerAttachment = {
attachedSessionId: 'session-1',
detail: hostPath,
id: 'image:photo.png',
kind: 'image',
label: 'photo.png',
path: stagedPath,
thumbnailUrl: THUMBNAIL_URL
}
await renderWithI18n(<AttachmentList attachments={[image]} />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /photo\.png/ }))
})
expect(readFileDataUrl).toHaveBeenCalledWith(stagedPath)
expect(readFileDataUrl).toHaveBeenCalledWith(hostPath)
expect((await screen.findByRole('dialog')).querySelector<HTMLImageElement>('img')?.src).toBe(DATA_URL)
})
it('does not let an old occurrence open a replacement lightbox after a deferred read', async () => {
let resolveOldRead!: (value: string) => void
const oldRead = new Promise<string>(resolve => {
resolveOldRead = resolve
})
const readFileDataUrl = vi.fn((path: string) =>
path === '/tmp/old.png' ? oldRead : Promise.resolve('data:image/png;base64,replacement')
)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const oldOccurrence: ComposerAttachment = {
id: 'image:same-path',
kind: 'image',
label: 'same.png',
occurrenceId: 'occurrence-old',
path: '/tmp/old.png',
thumbnailUrl: THUMBNAIL_URL
}
const replacement: ComposerAttachment = {
...oldOccurrence,
occurrenceId: 'occurrence-replacement',
path: '/tmp/replacement.png'
}
const { rerender } = await renderWithI18n(<AttachmentList attachments={[oldOccurrence]} />)
fireEvent.click(screen.getByRole('button', { name: /same\.png/ }))
expect(readFileDataUrl).toHaveBeenCalledWith('/tmp/old.png')
rerender(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
<AttachmentList attachments={[replacement]} />
</I18nProvider>
)
await act(async () => {
resolveOldRead(DATA_URL)
await oldRead
})
expect(screen.queryByRole('dialog')).toBeNull()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /same\.png/ }))
})
expect(readFileDataUrl).toHaveBeenCalledWith('/tmp/replacement.png')
expect((await screen.findByRole('dialog')).querySelector<HTMLImageElement>('img')?.src).toBe(
'data:image/png;base64,replacement'
)
})
it('removes an attachment from the composer chip', async () => {
const onRemove = vi.fn()
await renderWithI18n(<AttachmentList attachments={[makeAttachment('a', 'doc.pdf')]} onRemove={onRemove} />)
fireEvent.click(screen.getByRole('button', { name: 'Remove doc.pdf' }))
expect(onRemove).toHaveBeenCalledWith('a')
})
it('still routes a non-image attachment to the preview rail', async () => {
$previewTabs.set([])
const file: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.md', path: '/tmp/notes.md' }
await renderWithI18n(<AttachmentList attachments={[file]} />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /notes\.md/ }))
})
expect(screen.queryByRole('dialog')).toBeNull()
expect($previewTabs.get().map(tab => tab.target.path)).toEqual(['/tmp/notes.md'])
})
})