- 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>
276 lines
7.6 KiB
TypeScript
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();
|
|
});
|
|
});
|