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>
This commit is contained in:
teernisse
2026-02-26 10:56:45 -05:00
parent 044b0024a4
commit bd6d47dd70
2 changed files with 534 additions and 0 deletions

View File

@@ -0,0 +1,259 @@
/**
* ErrorBoundary -- Catches React render errors and shows fallback UI.
*
* Also exports ErrorDisplay for showing structured McError messages
* with appropriate recovery actions.
*/
import { Component, type ReactNode, type ErrorInfo } from "react";
import { motion } from "framer-motion";
import type { McError, McErrorCode } from "@/lib/types";
// -- ErrorBoundary (class component required for getDerivedStateFromError) --
interface ErrorBoundaryProps {
children: ReactNode;
/** Called when user clicks "Try Again" */
onRecover?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log error for debugging (could send to error tracking service)
console.error("React error boundary caught:", error, errorInfo);
}
handleReload = (): void => {
window.location.reload();
};
handleRecover = (): void => {
this.setState({ hasError: false, error: null });
this.props.onRecover?.();
};
render(): ReactNode {
if (this.state.hasError) {
return (
<FallbackUI
error={this.state.error}
onRecover={this.handleRecover}
onReload={this.handleReload}
/>
);
}
return this.props.children;
}
}
// -- Fallback UI for ErrorBoundary --
interface FallbackUIProps {
error: Error | null;
onRecover: () => void;
onReload: () => void;
}
function FallbackUI({
error,
onRecover,
onReload,
}: FallbackUIProps): React.ReactElement {
const isDev = import.meta.env.DEV;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex h-screen flex-col items-center justify-center p-8"
>
{/* Warning icon */}
<div
className="mb-4 text-5xl text-mc-urgent"
aria-hidden="true"
role="img"
>
&#9888;
</div>
<h1 className="mb-2 text-2xl font-bold text-zinc-100">
Something went wrong
</h1>
<p className="mb-4 max-w-md text-center text-zinc-400">
Mission Control encountered an unexpected error.
</p>
{/* Show stack trace in development */}
{isDev && error?.stack && (
<pre className="mb-4 max-w-lg overflow-auto rounded-lg border border-zinc-700 bg-surface p-4 text-xs text-zinc-300">
{error.stack}
</pre>
)}
{/* Recovery actions */}
<div className="flex gap-4">
<button
type="button"
onClick={onRecover}
className="rounded-lg border border-mc-fresh/40 bg-mc-fresh/10 px-5 py-2.5 text-sm font-medium text-mc-fresh transition-colors hover:bg-mc-fresh/20"
>
Try Again
</button>
<button
type="button"
onClick={onReload}
className="rounded-lg border border-zinc-600 bg-surface-raised px-5 py-2.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-surface-overlay hover:text-zinc-100"
>
Reload App
</button>
</div>
</motion.div>
);
}
// -- ErrorDisplay for structured McError --
interface ErrorDisplayProps {
error: McError;
/** Called when user clicks Retry (only shown for recoverable errors) */
onRetry?: () => void;
/** Called when user dismisses the error */
onDismiss?: () => void;
}
interface ErrorMessage {
title: string;
description: string;
showInstall?: string;
}
const ERROR_MESSAGES: Partial<Record<McErrorCode, ErrorMessage>> = {
LORE_UNAVAILABLE: {
title: "lore not found",
description:
"The lore CLI is required to sync GitLab data.",
showInstall: "cargo install gitlore",
},
LORE_UNHEALTHY: {
title: "lore is unhealthy",
description:
"The lore database needs attention. Try running `lore sync` manually.",
},
LORE_FETCH_FAILED: {
title: "Sync failed",
description: "Could not fetch latest data from GitLab.",
},
BEADS_UNAVAILABLE: {
title: "Beads (br) not found",
description:
"The beads CLI is required for task management.",
showInstall: "cargo install beads_rust",
},
BEADS_CREATE_FAILED: {
title: "Failed to create bead",
description: "Could not create a new task in beads.",
},
BEADS_CLOSE_FAILED: {
title: "Failed to close bead",
description: "Could not close the task in beads.",
},
BV_UNAVAILABLE: {
title: "bv not found",
description:
"The bv triage CLI is required for recommendations.",
showInstall: "cargo install bv",
},
BV_TRIAGE_FAILED: {
title: "Triage failed",
description: "Could not get recommendations from bv.",
},
BRIDGE_LOCKED: {
title: "Operation in progress",
description: "Another sync operation is running. Please wait.",
},
BRIDGE_MAP_CORRUPTED: {
title: "State corrupted",
description:
"The bridge mapping file is corrupted. Try clearing state.",
},
BRIDGE_SYNC_FAILED: {
title: "Bridge sync failed",
description: "Could not synchronize GitLab data with beads.",
},
IO_ERROR: {
title: "File system error",
description: "Could not read or write required files.",
},
INTERNAL_ERROR: {
title: "Unexpected error",
description: "An unexpected error occurred.",
},
};
export function ErrorDisplay({
error,
onRetry,
onDismiss,
}: ErrorDisplayProps): React.ReactElement {
const msg = ERROR_MESSAGES[error.code] ?? {
title: "Error",
description: error.message,
};
return (
<div
role="alert"
className="rounded-lg border border-mc-urgent/40 bg-mc-urgent/10 p-4"
>
<h3 className="mb-1 font-semibold text-mc-urgent">{msg.title}</h3>
<p className="text-sm text-zinc-300">{msg.description}</p>
{msg.showInstall && (
<code className="mt-2 block rounded bg-surface px-2 py-1 text-xs text-zinc-400">
Install with: {msg.showInstall}
</code>
)}
{/* Action buttons */}
<div className="mt-3 flex gap-2">
{error.recoverable && onRetry && (
<button
type="button"
onClick={onRetry}
className="rounded border border-zinc-600 bg-surface-raised px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-surface-overlay"
>
Retry
</button>
)}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="rounded border border-zinc-700 bg-transparent px-3 py-1.5 text-xs font-medium text-zinc-400 transition-colors hover:text-zinc-300"
>
Dismiss
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
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();
});
});