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:
259
src/components/ErrorBoundary.tsx
Normal file
259
src/components/ErrorBoundary.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
tests/components/ErrorBoundary.test.tsx
Normal file
275
tests/components/ErrorBoundary.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user