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(); }); });