import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Toaster, toast } from './toast'; import { EXIT_DURATION } from './store'; /** The store is a module singleton, so each test has to leave it empty. */ afterEach(async () => { act(() => toast.dismiss()); await act(async () => { await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION + 10)); }); }); const show = (run: () => void) => act(() => void run()); describe('toast', () => { it('renders nothing until something is published', () => { render(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); it('shows a toast published from anywhere', async () => { render(); show(() => toast('Draft saved')); expect(await screen.findByText('Draft saved')).toBeInTheDocument(); }); it('renders a description alongside the title', async () => { render(); show(() => toast('Message archived', { description: 'It left your inbox.' })); expect(await screen.findByText('Message archived')).toBeInTheDocument(); expect(screen.getByText('It left your inbox.')).toBeInTheDocument(); }); describe('accessibility', () => { it('names the notification region', () => { render(); expect(screen.getByRole('region', { name: 'Notifications' })).toBeInTheDocument(); }); it('announces ordinary toasts politely', async () => { render(); show(() => toast.success('Deployed')); const item = await screen.findByRole('status'); expect(item).toHaveAttribute('aria-live', 'polite'); }); it('interrupts for errors', async () => { render(); show(() => toast.error('Payment failed')); const item = await screen.findByRole('alert'); expect(item).toHaveAttribute('aria-live', 'assertive'); }); it('labels the close button', async () => { render(); show(() => toast('Draft saved')); expect( await screen.findByRole('button', { name: 'Dismiss notification' }) ).toBeInTheDocument(); }); }); describe('lifetime', () => { it('dismisses itself once the duration elapses', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); render(); show(() => toast('Draft saved')); expect(await screen.findByText('Draft saved')).toBeInTheDocument(); await act(async () => { vi.advanceTimersByTime(1000 + EXIT_DURATION + 10); }); expect(screen.queryByText('Draft saved')).not.toBeInTheDocument(); vi.useRealTimers(); }); it('lets a toast override the default duration', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); render(); show(() => toast('Draft saved', { duration: 5000 })); await act(async () => { vi.advanceTimersByTime(1000); }); expect(screen.getByText('Draft saved')).toBeInTheDocument(); vi.useRealTimers(); }); it('pins a loading toast until something replaces it', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); render(); show(() => toast.loading('Publishing…')); await act(async () => { vi.advanceTimersByTime(5000); }); expect(screen.getByText('Publishing…')).toBeInTheDocument(); vi.useRealTimers(); }); it('calls onAutoClose when the timer runs out', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const onAutoClose = vi.fn(); render(); show(() => toast('Draft saved', { onAutoClose })); await act(async () => { vi.advanceTimersByTime(600); }); expect(onAutoClose).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); }); describe('dismissal', () => { it('closes on the close button', async () => { const user = userEvent.setup(); render(); show(() => toast('Draft saved')); await user.click(await screen.findByRole('button', { name: 'Dismiss notification' })); await waitFor(() => expect(screen.queryByText('Draft saved')).not.toBeInTheDocument()); }); it('closes on toast.dismiss(id)', async () => { render(); let id: string | number = ''; show(() => { id = toast('Draft saved'); }); await screen.findByText('Draft saved'); show(() => toast.dismiss(id)); await waitFor(() => expect(screen.queryByText('Draft saved')).not.toBeInTheDocument()); }); it('clears everything when dismiss is called with no id', async () => { render(); show(() => { toast('One'); toast('Two'); }); await screen.findByText('One'); show(() => toast.dismiss()); await waitFor(() => expect(screen.queryByText('One')).not.toBeInTheDocument()); expect(screen.queryByText('Two')).not.toBeInTheDocument(); }); it('reports onDismiss', async () => { const onDismiss = vi.fn(); render(); let id: string | number = ''; show(() => { id = toast('Draft saved', { onDismiss }); }); show(() => toast.dismiss(id)); expect(onDismiss).toHaveBeenCalledTimes(1); }); }); describe('actions', () => { it('runs the action and closes the toast', async () => { const user = userEvent.setup(); const onClick = vi.fn(); render(); show(() => toast('Message archived', { action: { label: 'Undo', onClick } })); await user.click(await screen.findByRole('button', { name: 'Undo' })); expect(onClick).toHaveBeenCalledTimes(1); await waitFor(() => expect(screen.queryByText('Message archived')).not.toBeInTheDocument()); }); }); describe('toast.promise', () => { it('replaces the loading toast in place on success', async () => { render(); let resolvePromise: (value: string) => void = () => {}; const promise = new Promise((resolve) => { resolvePromise = resolve; }); show(() => { toast.promise(promise, { loading: 'Publishing…', success: 'Published' }); }); expect(await screen.findByText('Publishing…')).toBeInTheDocument(); await act(async () => { resolvePromise('ok'); await promise; }); expect(await screen.findByText('Published')).toBeInTheDocument(); expect(screen.queryByText('Publishing…')).not.toBeInTheDocument(); /* Replaced, not appended — one toast, not two. */ expect(screen.getAllByRole('status')).toHaveLength(1); }); it('shows the error message when the promise rejects', async () => { render(); const promise = Promise.reject(new Error('nope')); show(() => { toast.promise(promise, { loading: 'Publishing…', error: 'Could not publish' }); }); await act(async () => { await promise.catch(() => {}); }); expect(await screen.findByText('Could not publish')).toBeInTheDocument(); }); it('derives the message from the resolved value', async () => { render(); const promise = Promise.resolve({ name: 'v2' }); show(() => { toast.promise(promise, { loading: 'Publishing…', success: (value) => `Published ${value.name}`, }); }); await act(async () => { await promise; }); expect(await screen.findByText('Published v2')).toBeInTheDocument(); }); }); describe('stacking', () => { it('shows only the most recent toasts', async () => { render(); show(() => { toast('One'); toast('Two'); toast('Three'); }); await screen.findByText('Three'); expect(screen.queryByText('One')).not.toBeInTheDocument(); expect(screen.getByText('Two')).toBeInTheDocument(); }); it('puts the newest at the top for a top position', async () => { render(); show(() => { toast('One'); toast('Two'); }); await screen.findByText('Two'); const texts = screen.getAllByRole('status').map((node) => node.textContent); expect(texts).toEqual(['Two', 'One']); }); }); it('renders a custom surface', async () => { render(); show(() => toast.custom(() =>
Fully custom
)); expect(await screen.findByText('Fully custom')).toBeInTheDocument(); }); it('applies a forced theme class to the region', () => { render(); expect(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('dark'); }); it('inherits the theme by default', () => { render(); expect(screen.getByRole('region', { name: 'Notifications' })).not.toHaveClass('dark'); }); });