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
263 lines
8.6 KiB
TypeScript
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'])
|
|
})
|
|
})
|