Files
mission-control/tests/components/ErrorBoundary.test.tsx
teernisse bd6d47dd70 feat(bd-3pc): add error boundary and error handling UI
- Add ErrorBoundary class component to catch React render errors
- Show fallback UI with error details (stack in dev mode) and recovery buttons
- Add ErrorDisplay component for showing structured McError messages
- Support all McErrorCode types with contextual messages and install guides
- Implement retry/dismiss actions for recoverable errors
- Add 16 comprehensive tests covering both components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 10:58:24 -05:00

276 lines
7.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ErrorBoundary, ErrorDisplay } from "@/components/ErrorBoundary";
import type { McError, McErrorCode } from "@/lib/types";
/** Helper to create a McError with defaults */
function makeMcError(overrides: Partial<McError> = {}): McError {
return {
code: "INTERNAL_ERROR" as McErrorCode,
message: "Something went wrong",
recoverable: true,
...overrides,
};
}
/** Component that throws on render */
function ThrowingComponent({ error }: { error?: Error }): React.ReactElement {
throw error ?? new Error("Test error");
}
describe("ErrorBoundary", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Suppress console.error during tests (React logs caught errors)
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("renders children when there is no error", () => {
render(
<ErrorBoundary>
<div>Child content</div>
</ErrorBoundary>
);
expect(screen.getByText("Child content")).toBeInTheDocument();
});
it("catches errors and shows fallback UI", () => {
render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /reload/i })
).toBeInTheDocument();
});
it("logs error details to console", () => {
render(
<ErrorBoundary>
<ThrowingComponent error={new Error("Custom test error")} />
</ErrorBoundary>
);
// React's error boundary logs to console.error
expect(consoleSpy).toHaveBeenCalled();
});
it("shows error stack in development mode", () => {
// We're in test mode which acts like development
const testError = new Error("Stack trace test");
testError.stack = "Error: Stack trace test\n at TestComponent";
render(
<ErrorBoundary>
<ThrowingComponent error={testError} />
</ErrorBoundary>
);
// In dev mode, should show stack trace
expect(screen.getByText(/Stack trace test/)).toBeInTheDocument();
});
it("provides Try Again button that calls onRecover", async () => {
const onRecover = vi.fn();
const user = userEvent.setup();
const { rerender } = render(
<ErrorBoundary onRecover={onRecover}>
<ThrowingComponent />
</ErrorBoundary>
);
// Should show error state
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
// Click Try Again
await user.click(screen.getByRole("button", { name: /try again/i }));
expect(onRecover).toHaveBeenCalledOnce();
});
it("resets error state when Try Again is clicked", async () => {
const user = userEvent.setup();
let shouldThrow = true;
function MaybeThrow(): React.ReactElement {
if (shouldThrow) {
throw new Error("Conditional error");
}
return <div>Recovered content</div>;
}
render(
<ErrorBoundary>
<MaybeThrow />
</ErrorBoundary>
);
// Should show error state
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
// Stop throwing
shouldThrow = false;
// Click Try Again to recover
await user.click(screen.getByRole("button", { name: /try again/i }));
// Should show recovered content
expect(screen.getByText("Recovered content")).toBeInTheDocument();
});
});
describe("ErrorDisplay", () => {
it("shows CLI not found error with setup guide", () => {
const error = makeMcError({
code: "LORE_UNAVAILABLE",
message: "lore command not found",
});
render(<ErrorDisplay error={error} />);
expect(screen.getByText(/lore not found/i)).toBeInTheDocument();
expect(screen.getByText(/install with/i)).toBeInTheDocument();
});
it("shows beads CLI not found error with setup guide", () => {
const error = makeMcError({
code: "BEADS_UNAVAILABLE",
message: "br command not found",
});
render(<ErrorDisplay error={error} />);
expect(screen.getByText(/beads.*not found/i)).toBeInTheDocument();
expect(screen.getByText(/install with/i)).toBeInTheDocument();
});
it("shows bv CLI not found error with setup guide", () => {
const error = makeMcError({
code: "BV_UNAVAILABLE",
message: "bv command not found",
});
render(<ErrorDisplay error={error} />);
expect(screen.getByText(/bv.*not found/i)).toBeInTheDocument();
expect(screen.getByText(/install with/i)).toBeInTheDocument();
});
it("shows retry button for recoverable sync errors", async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
const error = makeMcError({
code: "LORE_FETCH_FAILED",
message: "Failed to fetch from GitLab",
recoverable: true,
});
render(<ErrorDisplay error={error} onRetry={onRetry} />);
const retryButton = screen.getByRole("button", { name: /retry/i });
expect(retryButton).toBeInTheDocument();
await user.click(retryButton);
expect(onRetry).toHaveBeenCalledOnce();
});
it("shows no retry button for non-recoverable errors", () => {
const onRetry = vi.fn();
const error = makeMcError({
code: "BRIDGE_MAP_CORRUPTED",
message: "State file corrupted",
recoverable: false,
});
render(<ErrorDisplay error={error} onRetry={onRetry} />);
expect(
screen.queryByRole("button", { name: /retry/i })
).not.toBeInTheDocument();
});
it("shows network error with offline mode hint", () => {
const error = makeMcError({
code: "LORE_FETCH_FAILED",
message: "Network request failed",
recoverable: true,
});
render(<ErrorDisplay error={error} />);
// Should indicate connection issue
expect(screen.getByText(/sync failed/i)).toBeInTheDocument();
});
it("shows dismiss button when onDismiss is provided", async () => {
const onDismiss = vi.fn();
const user = userEvent.setup();
const error = makeMcError({
code: "INTERNAL_ERROR",
message: "Something unexpected happened",
});
render(<ErrorDisplay error={error} onDismiss={onDismiss} />);
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
await user.click(dismissButton);
expect(onDismiss).toHaveBeenCalledOnce();
});
it("shows bridge locked error with wait message", () => {
const error = makeMcError({
code: "BRIDGE_LOCKED",
message: "Another operation in progress",
recoverable: true,
});
render(<ErrorDisplay error={error} />);
expect(screen.getByText(/operation in progress/i)).toBeInTheDocument();
});
it("shows generic error for unknown error codes", () => {
const error = makeMcError({
code: "INTERNAL_ERROR",
message: "Unexpected error occurred",
});
render(<ErrorDisplay error={error} />);
// Check both title and description are present
expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
/unexpected error/i
);
expect(screen.getByText(/an unexpected error occurred/i)).toBeInTheDocument();
});
it("applies destructive variant styling", () => {
const error = makeMcError({
code: "BRIDGE_MAP_CORRUPTED",
message: "Critical error",
recoverable: false,
});
render(<ErrorDisplay error={error} />);
// Check that error styling is applied (has error-related classes)
const errorContainer = screen.getByRole("alert");
expect(errorContainer).toBeInTheDocument();
});
});