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}
)}
+