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');
});
});