Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/__tests__/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
);
expect(screen.getByText('Test content')).toBeInTheDocument();
});

it('renders error UI when error is caught', () => {
const TestComponent = () => {
throw new Error('Test error');
};

const { container } = render(
<ErrorBoundary>
<TestComponent />
</ErrorBoundary>
);

expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});

it('displays copy button for error details', () => {
const TestComponent = () => {
throw new Error('Test error message');
};

render(
<ErrorBoundary>
<TestComponent />
</ErrorBoundary>
);

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(
<ErrorBoundary>
<TestComponent />
</ErrorBoundary>
);

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(
<ErrorBoundary>
<TestComponent />
</ErrorBoundary>
);

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(
<ErrorBoundary>
<TestComponent />
</ErrorBoundary>
);

expect(screen.getByText('Reload Page')).toBeInTheDocument();
expect(screen.getByText('Go to Dashboard')).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions src/__tests__/Skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Skeleton />);
const skeletonDiv = container.querySelector('div');
expect(skeletonDiv).toHaveClass('skeleton-shimmer');
});

it('applies custom className', () => {
const { container } = render(<Skeleton className="h-4 w-24" />);
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(<Skeleton />);
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(<InvoiceCardSkeleton />);
const skeleton = container.querySelector('[role="status"]');
expect(skeleton).toHaveAttribute('aria-busy', 'true');
expect(skeleton).toHaveAttribute('aria-label', 'Loading invoice data');
});
});
77 changes: 75 additions & 2 deletions src/__tests__/Toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Toast message="Saved!" />);
expect(screen.getByText('Saved!')).toBeInTheDocument();
Expand All @@ -14,12 +23,12 @@ describe('Toast', () => {
});

it('renders dismiss button when onDismiss provided', () => {
render(<Toast message="Saved!" onDismiss={jest.fn()} />);
render(<Toast message="Saved!" onDismiss={vi.fn()} />);
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(<Toast message="Saved!" onDismiss={onDismiss} />);
await userEvent.click(screen.getByRole('button', { name: 'Dismiss notification' }));
expect(onDismiss).toHaveBeenCalledTimes(1);
Expand All @@ -44,4 +53,68 @@ describe('Toast', () => {
render(<Toast message="Note" />);
expect(screen.getByRole('alert')).toHaveClass('bg-gray-700');
});

it('renders progress bar when duration is provided', () => {
const { container } = render(
<Toast message="Loading..." duration={4000} />
);
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(<Toast message="Loading..." />);
const progressBars = container.querySelectorAll('div[aria-hidden="true"]');
expect(progressBars.length).toBe(0);
});

it('progress bar decreases over time', () => {
const { container } = render(
<Toast message="Loading..." duration={4000} />
);
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(
<Toast message="Loading..." duration={4000} onDismiss={onDismiss} />
);
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(
<Toast message="Loading..." duration={1000} onDismiss={onDismiss} />
);
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(<Toast message="Loading..." duration={1000} onDismiss={onDismiss} />);

vi.advanceTimersByTime(1000);

expect(onDismiss).toHaveBeenCalled();
});
});
20 changes: 20 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function Breadcrumb({ items }: Props) {
</span>
)}
{isLast ? (
<span className="text-gray-300 font-medium">{item.label}</span>
<span className="text-gray-300 font-medium" aria-current="page">{item.label}</span>
) : (
<>
{i === 0 && !showEllipsis && (
Expand Down
28 changes: 26 additions & 2 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props, State> {
state: State = { error: null, sentryEventId: null };
state: State = { error: null, sentryEventId: null, copied: false };

static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
Expand All @@ -36,10 +37,26 @@ export default class ErrorBoundary extends Component<Props, State> {
}

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) {
Expand Down Expand Up @@ -95,6 +112,13 @@ export default class ErrorBoundary extends Component<Props, State> {
Error ID: {sentryEventId}
</p>
)}
<button
type="button"
onClick={this.handleCopyErrorDetails}
className="min-h-10 px-4 py-2 text-sm rounded-lg bg-gray-800 hover:bg-gray-700 font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
{this.state.copied ? "Copied!" : "Copy error details"}
</button>
<div className="flex gap-3 flex-wrap justify-center">
<button
type="button"
Expand Down
2 changes: 1 addition & 1 deletion src/components/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useEffect, useState } from "react";

/** Shared animated shimmer base */
const shimmer = "animate-pulse bg-gray-200 dark:bg-gray-700 rounded";
const shimmer = "skeleton-shimmer bg-gray-200 dark:bg-gray-700 rounded";

/** Base pulsing rectangle primitive — every skeleton in the app builds on this. */
export function Skeleton({ className = "" }: { className?: string }) {
Expand Down
Loading