diff --git a/src/__tests__/ErrorBoundary.test.tsx b/src/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..c05941a --- /dev/null +++ b/src/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,121 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ErrorBoundary from '@/components/ErrorBoundary'; + +describe('ErrorBoundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders children when no error', () => { + render( + +
Test content
+
+ ); + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + + it('renders error UI when error is caught', () => { + const TestComponent = () => { + throw new Error('Test error'); + }; + + const { container } = render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('displays copy button for error details', () => { + const TestComponent = () => { + throw new Error('Test error message'); + }; + + render( + + + + ); + + const copyButton = screen.getByText('Copy error details'); + expect(copyButton).toBeInTheDocument(); + }); + + it('copies error details to clipboard when button is clicked', async () => { + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + Object.assign(navigator, { clipboard: mockClipboard }); + + const TestComponent = () => { + throw new Error('Test error message'); + }; + + render( + + + + ); + + const copyButton = screen.getByText('Copy error details'); + await userEvent.click(copyButton); + + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalled(); + expect(mockClipboard.writeText.mock.calls[0][0]).toContain('Test error message'); + }); + }); + + it('shows copied confirmation for 2 seconds', async () => { + vi.useFakeTimers(); + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + Object.assign(navigator, { clipboard: mockClipboard }); + + const TestComponent = () => { + throw new Error('Test error'); + }; + + render( + + + + ); + + const copyButton = screen.getByText('Copy error details'); + await userEvent.click(copyButton); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + + vi.advanceTimersByTime(2000); + + await waitFor(() => { + expect(screen.getByText('Copy error details')).toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it('renders reload and dashboard buttons', () => { + const TestComponent = () => { + throw new Error('Test error'); + }; + + render( + + + + ); + + expect(screen.getByText('Reload Page')).toBeInTheDocument(); + expect(screen.getByText('Go to Dashboard')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/Skeleton.test.tsx b/src/__tests__/Skeleton.test.tsx new file mode 100644 index 0000000..15fe317 --- /dev/null +++ b/src/__tests__/Skeleton.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Skeleton } from '@/components/Skeleton'; + +describe('Skeleton', () => { + it('renders with skeleton-shimmer class', () => { + const { container } = render(); + const skeletonDiv = container.querySelector('div'); + expect(skeletonDiv).toHaveClass('skeleton-shimmer'); + }); + + it('applies custom className', () => { + const { container } = render(); + const skeletonDiv = container.querySelector('div'); + expect(skeletonDiv).toHaveClass('h-4'); + expect(skeletonDiv).toHaveClass('w-24'); + expect(skeletonDiv).toHaveClass('skeleton-shimmer'); + }); + + it('has proper base styles', () => { + const { container } = render(); + const skeletonDiv = container.querySelector('div'); + expect(skeletonDiv).toHaveClass('bg-gray-200'); + expect(skeletonDiv).toHaveClass('dark:bg-gray-700'); + expect(skeletonDiv).toHaveClass('rounded'); + }); + + it('renders InvoiceCardSkeleton with proper ARIA attributes', () => { + const { render: customRender } = require('@testing-library/react'); + const { InvoiceCardSkeleton } = require('@/components/Skeleton'); + const { container } = customRender(); + const skeleton = container.querySelector('[role="status"]'); + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + expect(skeleton).toHaveAttribute('aria-label', 'Loading invoice data'); + }); +}); diff --git a/src/__tests__/Toast.test.tsx b/src/__tests__/Toast.test.tsx index 08ef967..607ab69 100644 --- a/src/__tests__/Toast.test.tsx +++ b/src/__tests__/Toast.test.tsx @@ -1,8 +1,17 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Toast from '@/components/Toast'; describe('Toast', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('renders the message', () => { render(); expect(screen.getByText('Saved!')).toBeInTheDocument(); @@ -14,12 +23,12 @@ describe('Toast', () => { }); it('renders dismiss button when onDismiss provided', () => { - render(); + render(); expect(screen.getByRole('button', { name: 'Dismiss notification' })).toBeInTheDocument(); }); it('calls onDismiss when dismiss button clicked', async () => { - const onDismiss = jest.fn(); + const onDismiss = vi.fn(); render(); await userEvent.click(screen.getByRole('button', { name: 'Dismiss notification' })); expect(onDismiss).toHaveBeenCalledTimes(1); @@ -44,4 +53,68 @@ describe('Toast', () => { render(); expect(screen.getByRole('alert')).toHaveClass('bg-gray-700'); }); + + it('renders progress bar when duration is provided', () => { + const { container } = render( + + ); + const progressBar = container.querySelector('div[aria-hidden="true"]'); + expect(progressBar).toBeInTheDocument(); + }); + + it('does not render progress bar when duration is not provided', () => { + const { container } = render(); + const progressBars = container.querySelectorAll('div[aria-hidden="true"]'); + expect(progressBars.length).toBe(0); + }); + + it('progress bar decreases over time', () => { + const { container } = render( + + ); + const progressBar = container.querySelector('div[aria-hidden="true"]'); + const initialWidth = progressBar?.parentElement?.querySelector('div')?.style.width; + + vi.advanceTimersByTime(2000); + + const updatedWidth = progressBar?.parentElement?.querySelector('div')?.style.width; + expect(initialWidth).not.toBe(updatedWidth); + }); + + it('pauses progress when hovering', () => { + const onDismiss = vi.fn(); + const { container } = render( + + ); + const alert = screen.getByRole('alert'); + + userEvent.hover(alert); + vi.advanceTimersByTime(2000); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('resumes progress when hover ends', async () => { + const onDismiss = vi.fn(); + const { container } = render( + + ); + const alert = screen.getByRole('alert'); + + await userEvent.hover(alert); + vi.advanceTimersByTime(500); + await userEvent.unhover(alert); + vi.advanceTimersByTime(1000); + + expect(onDismiss).toHaveBeenCalled(); + }); + + it('calls onDismiss when progress completes', () => { + const onDismiss = vi.fn(); + render(); + + vi.advanceTimersByTime(1000); + + expect(onDismiss).toHaveBeenCalled(); + }); }); diff --git a/src/app/globals.css b/src/app/globals.css index c992527..cc6074e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -126,6 +126,26 @@ html[data-contrast="high"] .text-indigo-500 { } /* ── Custom animations ─────────────────────────────────────────── */ +@keyframes shimmer { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.skeleton-shimmer { + animation: shimmer 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-shimmer { + animation: none; + opacity: 0.6; + } +} + @keyframes slide-up { from { opacity: 0; diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 7f7cf42..fe4dfc8 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -51,7 +51,7 @@ export default function Breadcrumb({ items }: Props) { )} {isLast ? ( - {item.label} + {item.label} ) : ( <> {i === 0 && !showEllipsis && ( diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 640615e..31fac81 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -12,11 +12,12 @@ interface Props { interface State { error: Error | null; sentryEventId: string | null; + copied: boolean; } /** Root error boundary — wraps the entire app. Captures exceptions to Sentry when configured. */ export default class ErrorBoundary extends Component { - state: State = { error: null, sentryEventId: null }; + state: State = { error: null, sentryEventId: null, copied: false }; static getDerivedStateFromError(error: Error): Partial { return { error }; @@ -36,10 +37,26 @@ export default class ErrorBoundary extends Component { } private handleRetry = () => { - this.setState({ error: null, sentryEventId: null }); + this.setState({ error: null, sentryEventId: null, copied: false }); window.location.reload(); }; + private handleCopyErrorDetails = async () => { + const { error } = this.state; + if (!error) return; + + const errorText = `${error.message}\n${error.stack}`; + try { + await navigator.clipboard.writeText(errorText); + this.setState({ copied: true }); + setTimeout(() => { + this.setState({ copied: false }); + }, 2000); + } catch { + console.error('Failed to copy error details'); + } + }; + render() { const { error, sentryEventId } = this.state; if (!error) { @@ -95,6 +112,13 @@ export default class ErrorBoundary extends Component { Error ID: {sentryEventId}

)} +
} +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+ {message} + {onDismiss && } +
+ {duration && ( +
+ + )}
); } diff --git a/src/components/__tests__/Breadcrumb.test.tsx b/src/components/__tests__/Breadcrumb.test.tsx index d96fbe1..b28be74 100644 --- a/src/components/__tests__/Breadcrumb.test.tsx +++ b/src/components/__tests__/Breadcrumb.test.tsx @@ -48,4 +48,25 @@ describe("Breadcrumb", () => { const list = container.querySelector("ol"); expect(list).toBeInTheDocument(); }); + + it("should mark the last item with aria-current='page'", () => { + const items: BreadcrumbItem[] = [ + { label: "Invoices", href: "/dashboard" }, + { label: "Invoice #123" }, + ]; + const { container } = render(); + const lastItem = container.querySelector("span[aria-current='page']"); + expect(lastItem).toBeInTheDocument(); + expect(lastItem).toHaveTextContent("Invoice #123"); + }); + + it("should not mark non-last items with aria-current", () => { + const items: BreadcrumbItem[] = [ + { label: "Invoices", href: "/dashboard" }, + { label: "Invoice #123" }, + ]; + const { container } = render(); + const invoicesLink = screen.getByText("Invoices").closest("a"); + expect(invoicesLink).not.toHaveAttribute("aria-current"); + }); });