import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithProviders, screen, fireEvent } from '../../test/utils' import { triggerDownload } from './_shared/triggerDownload' import MessageAttachment, { bubbleGroupPositionFromStream } from '.' vi.mock('./_shared/triggerDownload', () => ({ triggerDownload: vi.fn(() => Promise.resolve()), })) const mockTriggerDownload = vi.mocked(triggerDownload) beforeEach(() => { mockTriggerDownload.mockClear() }) // The viewer's `` stays mounted across open/close so the // platform open / close transitions (via `@starting-style` + // `allow-discrete`) can play. That means we can't gate openness on // "is the dialog in the DOM" — we have to look at the `[open]` // attribute the platform sets on a `` while it's visible. const expectViewerClosed = (testId: string) => { const dialog = screen.queryByTestId(testId) if (dialog) expect(dialog).not.toHaveAttribute('open') } const expectViewerOpen = (testId: string) => { const dialog = screen.getByTestId(testId) expect(dialog).toHaveAttribute('open') return dialog } describe('MessageAttachment.Image', () => { it('optimizes Linktree-hosted composer previews without changing the viewer source', () => { renderWithProviders( ) expect(screen.getByRole('img', { name: 'Photo' })).toHaveAttribute( 'src', 'https://assets.linktr.ee/photo.jpg?io=true&size=messaging-attachment-v1_0' ) fireEvent.click(screen.getByLabelText('Open image')) const viewer = expectViewerOpen('image-viewer') expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://assets.linktr.ee/photo.jpg' ) }) it('renders an image bubble with caption', () => { renderWithProviders( ) const bubble = screen.getByTestId('image-attachment') expect(bubble).toBeInTheDocument() expect(screen.getByText('Here is the image')).toBeInTheDocument() expect(bubble.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/photo.jpg' ) }) it('opens the ImageViewer lightbox when clicked', () => { renderWithProviders( ) expectViewerClosed('image-viewer') fireEvent.click(screen.getByLabelText('Open image 1 of 1')) expectViewerOpen('image-viewer') }) it('renders the dismiss button only on Composer state', () => { const onDismiss = vi.fn() const { rerender } = renderWithProviders( ) expect(screen.getByLabelText('Dismiss attachment')).toBeInTheDocument() rerender( ) expect(screen.queryByLabelText('Dismiss attachment')).toBeNull() }) it('renders a stacked grid for multiple items', () => { renderWithProviders( ) expect(screen.getByLabelText('Open image 1 of 3')).toBeInTheDocument() expect(screen.getByLabelText('Open image 2 of 3')).toBeInTheDocument() expect(screen.getByLabelText('Open image 3 of 3')).toBeInTheDocument() }) it('renders a download button inside the image viewer', () => { renderWithProviders( ) fireEvent.click(screen.getByLabelText('Open image 1 of 1')) const viewer = expectViewerOpen('image-viewer') // The viewer chrome carries its own download action — separate // from any sibling download button on the bubble surface — so the // user can grab the image without leaving the lightbox. expect( viewer.querySelector('button[aria-label="Download photo.jpg"]') ).not.toBeNull() }) it('does not render carousel nav controls for a single image', () => { renderWithProviders( ) fireEvent.click(screen.getByLabelText('Open image 1 of 1')) expectViewerOpen('image-viewer') expect(screen.queryByLabelText('Next image')).toBeNull() expect(screen.queryByLabelText('Previous image')).toBeNull() }) it('renders the stacked images as a carousel inside the viewer', () => { renderWithProviders( ) // Open the second tile — the viewer should land on image 2. fireEvent.click(screen.getByLabelText('Open image 2 of 3')) const viewer = expectViewerOpen('image-viewer') expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/b.jpg' ) // Counter + nav chrome only appear on multi-item viewers. expect(viewer.textContent).toContain('2 / 3') // Next button advances by one. fireEvent.click(screen.getByLabelText('Next image')) expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/c.jpg' ) expect(viewer.textContent).toContain('3 / 3') // Next wraps from the last item back to the first. fireEvent.click(screen.getByLabelText('Next image')) expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/a.jpg' ) // Previous wraps from the first item to the last. fireEvent.click(screen.getByLabelText('Previous image')) expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/c.jpg' ) }) it('uses ArrowRight / ArrowLeft to navigate stacked images', () => { renderWithProviders( ) fireEvent.click(screen.getByLabelText('Open image 1 of 2')) const viewer = expectViewerOpen('image-viewer') expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/a.jpg' ) fireEvent.keyDown(window, { key: 'ArrowRight' }) expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/b.jpg' ) fireEvent.keyDown(window, { key: 'ArrowLeft' }) expect(viewer.querySelector('img')?.getAttribute('src')).toBe( 'https://cdn.example.com/a.jpg' ) }) it('shows a +N overflow tile for 5+ items', () => { renderWithProviders( ({ src: `https://cdn.example.com/${i}.jpg`, }))} /> ) expect(screen.getByText('+2')).toBeInTheDocument() }) }) describe('MessageAttachment.Image single-image aspect ratio', () => { // A single image used to be force-cropped to 1:1. It now fits inside // a max 326x436 content box on web and 309x436 on mobile, preserving // its own ratio, and the card adopts the fitted box so `object-cover` // performs no crop. The width caps differ per breakpoint; the 436px // height cap deliberately does not, which is why Figma's 9:16 // portrait renders 246x436 on web *and* mobile. const WIDTH_CAP_MOBILE_PX = 309 const WIDTH_CAP_WEB_PX = 326 const mediaBox = (label: string) => screen.getByLabelText(label).parentElement as HTMLElement /** The `min(var(--…), Npx)` term the height cap contributes. */ const heightBoundWidthPx = (box: HTMLElement) => Number(/([\d.]+)px\)\s*$/.exec(box.style.width)?.[1]) it('adopts the source ratio instead of forcing a square', () => { renderWithProviders( ) const box = mediaBox('Open image 1 of 1') expect(box).not.toHaveClass('aspect-square') expect(Number(box.style.aspectRatio)).toBeCloseTo(0.5625, 4) expect(box).toHaveClass( '[--mes-single-width-cap:309px]', 'sm:[--mes-single-width-cap:326px]' ) }) it('resolves a portrait source against the height cap on both breakpoints', () => { renderWithProviders( ) // 436 x 0.5625 = 245.25, under both width caps — so the height cap // governs everywhere and the card resolves to 249 x 440. const bound = heightBoundWidthPx(mediaBox('Open image 1 of 1')) expect(bound).toBeCloseTo(245.25, 2) expect(bound).toBeLessThan(WIDTH_CAP_MOBILE_PX) }) it('resolves a landscape source against the breakpoint width cap', () => { renderWithProviders( ) // 436 x 1.7778 = 775.11, over both width caps — so 326px (309px on // mobile) governs and the card resolves to 330 x 187. expect(heightBoundWidthPx(mediaBox('Open image 1 of 1'))).toBeGreaterThan( WIDTH_CAP_WEB_PX ) }) it('reserves the 4:3 landscape box until the image decodes, then adopts its real ratio', () => { renderWithProviders( ) expect(Number(mediaBox('Open image 1 of 1').style.aspectRatio)).toBeCloseTo( 326 / 246, 4 ) // Scoped to the tile: the always-mounted `ImageViewer` renders a // second `` for the same source. const img = mediaBox('Open image 1 of 1').querySelector( 'img' ) as HTMLImageElement Object.defineProperty(img, 'naturalWidth', { value: 1080, configurable: true, }) Object.defineProperty(img, 'naturalHeight', { value: 1920, configurable: true, }) fireEvent.load(img) expect(Number(mediaBox('Open image 1 of 1').style.aspectRatio)).toBeCloseTo( 0.5625, 4 ) }) it('leaves a stacked grid on its fixed aspect', () => { renderWithProviders( ({ src: `https://cdn.example.com/${i}.jpg`, width: 1080, height: 1920, }))} /> ) // 379x286 is the content box of Figma's 383x290 stacked card. const grid = mediaBox('Open image 1 of 4') expect(grid).toHaveClass('aspect-[379/286]') expect(grid.style.width).toBe('') expect(grid.style.aspectRatio).toBe('') }) }) describe('MessageAttachment.Bubble geometry overrides', () => { // `widthClassName` / `paddingClassName` / `radiusClassName` REPLACE // their defaults outright rather than merging: an appended radius and // the corner table have identical specificity, so Tailwind's emitted // source order — not argument order — would pick the winner. Document // and audio rows keep the 280px / 8px defaults; media cards override // all three. it('keeps the default width, padding and corner utilities when nothing is overridden', () => { renderWithProviders( ) expect(screen.getByTestId('file-attachment')).toHaveClass( 'w-[280px]', 'px-2', 'py-2', 'rounded-tl-[18px]' ) }) it('replaces every default on a media card instead of merging', () => { renderWithProviders( ) const bubble = screen.getByTestId('image-attachment') expect(bubble).toHaveClass('w-fit', 'p-0.5', 'rounded-[20px]') expect(bubble.className).not.toMatch( /w-\[280px\]|px-2|py-2|rounded-tl-\[18px\]/ ) }) }) describe('MessageAttachment.Pdf', () => { it('renders the compact row with filename + meta', () => { renderWithProviders( ) expect(screen.getByText('ESOP-summary.pdf')).toBeInTheDocument() expect(screen.getByText('PDF · 379.5 KB')).toBeInTheDocument() }) it('opens the PdfViewer when clicked', () => { renderWithProviders( ) expectViewerClosed('pdf-viewer') fireEvent.click(screen.getByLabelText('Open doc.pdf')) expectViewerOpen('pdf-viewer') }) it('forwards the onClick handler before opening the viewer', () => { const onClick = vi.fn() renderWithProviders( ) fireEvent.click(screen.getByLabelText('Open doc.pdf')) expect(onClick).toHaveBeenCalledTimes(1) }) it('renders caption inside the same bubble', () => { renderWithProviders( ) const bubble = screen.getByTestId('pdf-attachment') expect(bubble.textContent).toContain('Here is the file') }) it('renders a row per item for stacked PDFs', () => { renderWithProviders( ) expect(screen.getByLabelText('Open a.pdf')).toBeInTheDocument() expect(screen.getByLabelText('Open b.pdf')).toBeInTheDocument() expect(screen.getByLabelText('Open c.pdf')).toBeInTheDocument() }) it('opens the viewer on the activated row in a stacked PDF bubble', () => { renderWithProviders( ) expectViewerClosed('pdf-viewer') fireEvent.click(screen.getByLabelText('Open b.pdf')) const viewer = expectViewerOpen('pdf-viewer') // The active filename surfaces as the dialog's accessible name — // the lightbox chrome is intentionally just close + carousel chrome // so the filename isn't rendered as visible text inside the modal. expect(viewer.getAttribute('aria-label')).toBe('b.pdf') // Carousel chrome is present because items.length > 1. expect(screen.getByLabelText('Next document')).toBeInTheDocument() expect(screen.getByLabelText('Previous document')).toBeInTheDocument() expect(viewer.textContent).toContain('2 / 2') }) it('navigates between stacked PDFs via the carousel controls', () => { renderWithProviders( ) fireEvent.click(screen.getByLabelText('Open a.pdf')) const viewer = expectViewerOpen('pdf-viewer') expect(viewer.getAttribute('aria-label')).toBe('a.pdf') fireEvent.click(screen.getByLabelText('Next document')) expect(viewer.getAttribute('aria-label')).toBe('b.pdf') fireEvent.click(screen.getByLabelText('Next document')) expect(viewer.getAttribute('aria-label')).toBe('c.pdf') // Wraps back to the start. fireEvent.click(screen.getByLabelText('Next document')) expect(viewer.getAttribute('aria-label')).toBe('a.pdf') }) it('renders a sibling Download button on Sent / Received rows', () => { renderWithProviders( ) const open = screen.getByLabelText('Open notes.pdf') const download = screen.getByLabelText('Download notes.pdf') expect(open).toBeInTheDocument() expect(download).toBeInTheDocument() // The download button must be a real sibling button — not nested // inside the open button — so the two activations are independent. expect(open.contains(download)).toBe(false) expect(download.contains(open)).toBe(false) }) it('renders one Download button per row for stacked PDFs', () => { renderWithProviders( ) expect(screen.getByLabelText('Download a.pdf')).toBeInTheDocument() expect(screen.getByLabelText('Download b.pdf')).toBeInTheDocument() expect(screen.getByLabelText('Download c.pdf')).toBeInTheDocument() }) it('does not open the viewer when the Download button is clicked', () => { renderWithProviders( ) expectViewerClosed('pdf-viewer') fireEvent.click(screen.getByLabelText('Download notes.pdf')) // Download should fire on its own without bubbling up to the row // activation that opens the viewer. expectViewerClosed('pdf-viewer') }) it('hides the Download button on the Composer state (dismiss takes its place)', () => { renderWithProviders( undefined} /> ) expect(screen.queryByLabelText('Download notes.pdf')).toBeNull() expect(screen.getByLabelText('Dismiss attachment')).toBeInTheDocument() }) it('does not crash when items shrinks after the viewer opens (P1 regression)', () => { // Regression test for the PR #151 P1 reviewer finding: // `viewerIndex` was dereferenced directly against `resolvedItems`, // so a re-render with fewer items than the open index would crash // with "Cannot read properties of undefined". The component now // clamps the index defensively. const { rerender } = renderWithProviders( ) fireEvent.click(screen.getByLabelText('Open c.pdf')) expectViewerOpen('pdf-viewer') expect(() => rerender( ) ).not.toThrow() // Bubble still renders the surviving row instead of blowing up. expect(screen.getByLabelText('Open a.pdf')).toBeInTheDocument() // Viewer falls back to the last available item (the surviving one) // — surfaced as the dialog's aria-label since the chrome no longer // renders the filename as visible text. expect(screen.getByTestId('pdf-viewer').getAttribute('aria-label')).toBe( 'a.pdf' ) }) it('does not open the viewer when onClick returns false', () => { const onClick = vi.fn(() => false as const) renderWithProviders( ) fireEvent.click(screen.getByLabelText('Open doc.pdf')) expect(onClick).toHaveBeenCalledTimes(1) expectViewerClosed('pdf-viewer') }) }) describe('MessageAttachment.File', () => { it('renders the row with the trailing download icon', () => { renderWithProviders( ) expect(screen.getByText('workout-plan.zip')).toBeInTheDocument() expect(screen.getByText('ZIP · 2.34 MB')).toBeInTheDocument() expect( screen.getByLabelText('Download workout-plan.zip') ).toBeInTheDocument() }) it('falls back to the URL filename when none is provided', () => { renderWithProviders( ) expect(screen.getByText('notes.pdf')).toBeInTheDocument() }) it('renders one row per item for stacked files', () => { renderWithProviders( ) expect(screen.getByLabelText('Download a.zip')).toBeInTheDocument() expect(screen.getByLabelText('Download b.docx')).toBeInTheDocument() expect(screen.getByLabelText('Download c.pdf')).toBeInTheDocument() }) it('downloads the file when the row is clicked and onClick allows it', () => { const onClick = vi.fn() renderWithProviders( ) fireEvent.click(screen.getByLabelText('Download workout-plan.zip')) expect(onClick).toHaveBeenCalledWith(0) expect(mockTriggerDownload).toHaveBeenCalledTimes(1) expect(mockTriggerDownload).toHaveBeenCalledWith( 'https://cdn.example.com/file.zip', 'workout-plan.zip' ) }) it('lets onClick cancel the automatic download by returning false (P2 regression)', () => { // Regression test for the PR #151 P2 reviewer finding: // the docs advertised `onClick` as a hook for intercepting the // download (e.g. confirmation modal), but the handler used to fire // the default download unconditionally. Returning `false` from // `onClick` now suppresses the built-in trigger. const onClick = vi.fn(() => false as const) renderWithProviders( ) fireEvent.click(screen.getByLabelText('Download workout-plan.zip')) expect(onClick).toHaveBeenCalledTimes(1) expect(mockTriggerDownload).not.toHaveBeenCalled() }) it('still downloads when onClick returns void', () => { // Belt-and-braces: void / undefined returns must not be confused // with `false` (the only cancellation sentinel). const onClick = vi.fn(() => undefined) renderWithProviders( ) fireEvent.click(screen.getByLabelText('Download workout-plan.zip')) expect(mockTriggerDownload).toHaveBeenCalledTimes(1) }) }) describe('MessageAttachment.Audio', () => { it('renders the native audio player only (no title, no extra download)', () => { renderWithProviders( ) const bubble = screen.getByTestId('audio-attachment') expect(bubble.querySelector('audio')).not.toBeNull() // No filename title — the native `