From bd6d47dd703ad3b5aa2c39b3a95393ecd6570278 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 10:56:45 -0500 Subject: [PATCH] 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 --- src/components/ErrorBoundary.tsx | 259 ++++++++++++++++++++++ tests/components/ErrorBoundary.test.tsx | 275 ++++++++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 tests/components/ErrorBoundary.test.tsx diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..2dff211 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 ( + + ); + } + + 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 ( + + {/* Warning icon */} + + +

+ Something went wrong +

+ +

+ Mission Control encountered an unexpected error. +

+ + {/* Show stack trace in development */} + {isDev && error?.stack && ( +
+          {error.stack}
+        
+ )} + + {/* Recovery actions */} +
+ + +
+
+ ); +} + +// -- 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> = { + 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 ( +
+

{msg.title}

+

{msg.description}

+ + {msg.showInstall && ( + + Install with: {msg.showInstall} + + )} + + {/* Action buttons */} +
+ {error.recoverable && onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+ ); +} diff --git a/tests/components/ErrorBoundary.test.tsx b/tests/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..b5a98d0 --- /dev/null +++ b/tests/components/ErrorBoundary.test.tsx @@ -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 { + 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; + + 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( + +
Child content
+
+ ); + + expect(screen.getByText("Child content")).toBeInTheDocument(); + }); + + it("catches errors and shows fallback UI", () => { + render( + + + + ); + + expect(screen.getByText(/Something went wrong/)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /reload/i }) + ).toBeInTheDocument(); + }); + + it("logs error details to console", () => { + render( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + // 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
Recovered content
; + } + + render( + + + + ); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + // 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(); + + // Check that error styling is applied (has error-related classes) + const errorContainer = screen.getByRole("alert"); + expect(errorContainer).toBeInTheDocument(); + }); +});