import { describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { Download } from './download' import type { DownloadItem } from './types' import * as exports from './exports' function fakeItem(overrides?: Partial): DownloadItem { return { id: 'csv', label: 'Download as CSV', resolve: () => Promise.resolve({ url: 'blob:fake', filename: 'data.csv', revoke: vi.fn(), }), ...overrides, } } describe('', () => { it('renders a trigger and lists every item when opened', () => { render( , ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) expect(screen.getByText('Download CSV')).toBeTruthy() expect(screen.getByText('Download PNG')).toBeTruthy() }) it('honors custom trigger label', () => { render( , ) expect(screen.getByRole('button', { name: 'Save to disk' })).toBeTruthy() }) it('selecting an item calls its resolve(); the menu then closes', async () => { const resolve = vi.fn(() => Promise.resolve({ url: 'blob:abc', filename: 'x.csv', revoke: vi.fn(), }), ) render() fireEvent.click(screen.getByRole('button', { name: 'Download' })) fireEvent.click(screen.getByText('Download as CSV')) expect(resolve).toHaveBeenCalledTimes(1) // Wait a tick for the resolve promise to settle. await Promise.resolve() }) it('renders a custom icon component when provided', () => { function CustomIcon(props: React.SVGProps) { return } render() expect(screen.getByTestId('custom-dl-icon')).toBeTruthy() }) it('renders a per-item leading icon inside ListItemIcon', () => { render( , }), ]} />, ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) const icon = screen.getByTestId('item-icon') expect(icon.closest('.MuiListItemIcon-root')).not.toBeNull() }) it('disables the menu item when the item is `disabled` and skips resolve()', () => { const resolve = vi.fn(() => Promise.resolve({ url: 'blob:abc', filename: 'x.csv' }), ) render( , ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) const menuItem = screen.getByText('CSV').closest('li') expect(menuItem?.getAttribute('aria-disabled')).toBe('true') fireEvent.click(screen.getByText('CSV')) expect(resolve).not.toHaveBeenCalled() }) it('overrides the resolved filename when `item.filename` is set', async () => { const trigger = vi .spyOn(exports, 'triggerLinkDownload') .mockImplementation(() => undefined) try { render( Promise.resolve({ url: 'blob:fake', filename: 'auto.csv' }), }), ]} />, ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) fireEvent.click(screen.getByText('CSV')) // wait for the resolve() promise to settle await Promise.resolve() await Promise.resolve() expect(trigger).toHaveBeenCalledWith({ url: 'blob:fake', filename: 'override.csv', }) } finally { trigger.mockRestore() } }) it('uses the resolved filename when `item.filename` is not set', async () => { const trigger = vi .spyOn(exports, 'triggerLinkDownload') .mockImplementation(() => undefined) try { render( Promise.resolve({ url: 'blob:fake', filename: 'auto.csv' }), }), ]} />, ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) fireEvent.click(screen.getByText('CSV')) await Promise.resolve() await Promise.resolve() expect(trigger).toHaveBeenCalledWith({ url: 'blob:fake', filename: 'auto.csv', }) } finally { trigger.mockRestore() } }) it('shows a spinner and disables the trigger while resolve() is pending; clears once it settles', async () => { const trigger = vi .spyOn(exports, 'triggerLinkDownload') .mockImplementation(() => undefined) let releaseResolve: (v: { url: string filename: string revoke?: () => void }) => void = () => undefined const pending = new Promise<{ url: string filename: string revoke?: () => void }>((res) => { releaseResolve = res }) try { render( pending }), ]} />, ) const triggerBtn = screen.getByRole('button', { name: 'Download' }) fireEvent.click(triggerBtn) fireEvent.click(screen.getByText('CSV')) // In flight: trigger label and aria-busy switch, button is disabled, // wrapper hover-fade `.active` className is applied, spinner is shown. const busyBtn = screen.getByRole('button', { name: 'Downloading…' }) expect(busyBtn.getAttribute('aria-busy')).toBe('true') expect((busyBtn as HTMLButtonElement).disabled).toBe(true) expect(busyBtn.className).toMatch(/\bactive\b/) expect(busyBtn.querySelector('[role="progressbar"]')).not.toBeNull() // Settle the resolve and wait for the trigger to flip back to idle. releaseResolve({ url: 'blob:fake', filename: 'data.csv' }) const idleBtn = await screen.findByRole('button', { name: 'Download' }) expect(idleBtn.getAttribute('aria-busy')).toBeNull() expect((idleBtn as HTMLButtonElement).disabled).toBe(false) expect(idleBtn.querySelector('[role="progressbar"]')).toBeNull() } finally { trigger.mockRestore() } }) it('clears the spinner when resolve() rejects and fires onError', async () => { const onError = vi.fn() render( Promise.reject(new Error('boom')), }), ]} />, ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) fireEvent.click(screen.getByText('CSV')) // After the rejection the trigger label flips to the error label and // `data-error` is set; the spinner is cleared and the button is enabled. const errBtn = await screen.findByRole('button', { name: 'Download failed', }) expect((errBtn as HTMLButtonElement).disabled).toBe(false) expect(errBtn.querySelector('[role="progressbar"]')).toBeNull() expect(errBtn.getAttribute('data-error')).toBe('true') await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)) const first = onError.mock.calls[0]?.[0] as Error | undefined expect(first).toBeInstanceOf(Error) expect(first?.message).toBe('boom') }) it('normalises non-Error rejections into a real Error before onError', async () => { const onError = vi.fn() render( Promise.reject('non-error-value'), }), ]} />, ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) fireEvent.click(screen.getByText('CSV')) await screen.findByRole('button', { name: 'Download failed' }) const first = onError.mock.calls[0]?.[0] as Error | undefined expect(first).toBeInstanceOf(Error) expect(first?.message).toBe('non-error-value') }) it('clears the error state when the menu is re-opened', async () => { render( Promise.reject(new Error('boom')), }), ]} />, ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) fireEvent.click(screen.getByText('CSV')) const errBtn = await screen.findByRole('button', { name: 'Download failed', }) fireEvent.click(errBtn) // After re-open the trigger flips back to the idle label and data-error // is gone. The menu is now open so we look up the trigger by its // aria-haspopup attribute rather than its role+name (other body nodes // get aria-hidden while the menu is open). await waitFor(() => { const btn = document.querySelector('button[aria-haspopup="true"]') expect(btn).not.toBeNull() expect(btn?.getAttribute('data-error')).toBeNull() expect(btn?.getAttribute('aria-label')).toBe('Download') }) }) it('defers revoke past the current task so the browser can dispatch the download', async () => { const trigger = vi .spyOn(exports, 'triggerLinkDownload') .mockImplementation(() => undefined) const setTimeoutSpy = vi.spyOn(window, 'setTimeout') const revoke = vi.fn() try { render( Promise.resolve({ url: 'blob:abc', filename: 'data.csv', revoke, }), }), ]} />, ) fireEvent.click(screen.getByRole('button', { name: 'Download' })) fireEvent.click(screen.getByText('CSV')) // Trigger fires inside the resolve callback; revoke is scheduled via // setTimeout(_, 0) rather than invoked inline. Wait until the trigger // call has happened — at that point revoke must be scheduled, not run. await waitFor(() => expect(trigger).toHaveBeenCalledTimes(1)) expect(setTimeoutSpy).toHaveBeenCalledWith(revoke, 0) // After the queued timeout drains, revoke actually runs. await waitFor(() => expect(revoke).toHaveBeenCalledTimes(1)) } finally { trigger.mockRestore() setTimeoutSpy.mockRestore() } }) })