98dca6126f
Lib utilities: format (38), sanitize (12), project-colors (18), csv-export (14). Hooks: useDebounce (8), useTableSort (22), useLocalStorage (18), useColumnConfig (19). Components: BatchActionBar (17), SortableColumnHeader (14), FilterChips (14), ErrorBoundary (16). Web test suite: 63 → 75 files, 343 → 553 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
7.2 KiB
TypeScript
225 lines
7.2 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen } from "~/test-utils.js";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ErrorInfo } from "react";
|
|
import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";
|
|
|
|
// Suppress React's console.error output during error boundary tests
|
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
// A component that unconditionally throws
|
|
function ThrowingChild({ message = "Test error" }: { message?: string }) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
// A component that throws only when `shouldThrow` is true
|
|
function ConditionalThrower({ shouldThrow }: { shouldThrow: boolean }) {
|
|
if (shouldThrow) throw new Error("Conditional error");
|
|
return <div>OK</div>;
|
|
}
|
|
|
|
describe("ErrorBoundary", () => {
|
|
describe("normal rendering", () => {
|
|
it("renders children when no error is thrown", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<div>Hello World</div>
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText("Hello World")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders multiple children normally", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<span>First</span>
|
|
<span>Second</span>
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText("First")).toBeInTheDocument();
|
|
expect(screen.getByText("Second")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("error catching", () => {
|
|
it("catches a thrown error and renders the default fallback UI", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingChild />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
|
});
|
|
|
|
it("displays the error message in the default fallback", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingChild message="Custom error message" />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText("Custom error message")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a custom fallback node instead of the default when provided", () => {
|
|
render(
|
|
<ErrorBoundary fallback={<div>Custom fallback</div>}>
|
|
<ThrowingChild />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText("Custom fallback")).toBeInTheDocument();
|
|
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onError callback when an error is caught", () => {
|
|
const handleError = vi.fn();
|
|
render(
|
|
<ErrorBoundary onError={handleError}>
|
|
<ThrowingChild message="Tracked error" />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(handleError).toHaveBeenCalledOnce();
|
|
const [error, info] = handleError.mock.calls[0] as [Error, ErrorInfo];
|
|
expect(error.message).toBe("Tracked error");
|
|
expect(info).toHaveProperty("componentStack");
|
|
});
|
|
|
|
it("does not call onError when no error is thrown", () => {
|
|
const handleError = vi.fn();
|
|
render(
|
|
<ErrorBoundary onError={handleError}>
|
|
<div>No error here</div>
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(handleError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("default fallback UI", () => {
|
|
it("renders the 'Something went wrong' heading", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingChild />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a 'Try again' button", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingChild />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a 'Go to dashboard' button", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowingChild />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByRole("button", { name: "Go to dashboard" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows a generic message when error.message is empty", () => {
|
|
function ThrowEmptyMessage() {
|
|
const e = new Error("");
|
|
throw e;
|
|
}
|
|
render(
|
|
<ErrorBoundary>
|
|
<ThrowEmptyMessage />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(
|
|
screen.getByText("An unexpected error occurred. The team has been notified."),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("reset / Try again", () => {
|
|
it("clears the error state and re-renders children when Try again is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
// Use a mutable ref-like object to control throwing from outside JSX
|
|
let shouldThrow = true;
|
|
|
|
function ControlledThrower() {
|
|
if (shouldThrow) throw new Error("Controlled error");
|
|
return <div>OK</div>;
|
|
}
|
|
|
|
const { rerender } = render(
|
|
<ErrorBoundary>
|
|
<ControlledThrower />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
|
|
|
// Stop throwing, then click "Try again" so ErrorBoundary resets its state
|
|
shouldThrow = false;
|
|
await user.click(screen.getByRole("button", { name: "Try again" }));
|
|
|
|
// Trigger a re-render so ControlledThrower runs with shouldThrow=false
|
|
rerender(
|
|
<ErrorBoundary>
|
|
<ControlledThrower />
|
|
</ErrorBoundary>,
|
|
);
|
|
|
|
expect(screen.getByText("OK")).toBeInTheDocument();
|
|
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("DefaultErrorFallback", () => {
|
|
it("renders the heading", () => {
|
|
render(<DefaultErrorFallback error={new Error("Boom")} reset={vi.fn()} />);
|
|
expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the error message", () => {
|
|
render(<DefaultErrorFallback error={new Error("Specific error")} reset={vi.fn()} />);
|
|
expect(screen.getByText("Specific error")).toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to generic text when error message is empty", () => {
|
|
render(<DefaultErrorFallback error={new Error("")} reset={vi.fn()} />);
|
|
expect(
|
|
screen.getByText("An unexpected error occurred. The team has been notified."),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls reset when Try again is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const handleReset = vi.fn();
|
|
render(<DefaultErrorFallback error={new Error("Err")} reset={handleReset} />);
|
|
await user.click(screen.getByRole("button", { name: "Try again" }));
|
|
expect(handleReset).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("navigates to /dashboard when Go to dashboard is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
// jsdom does not navigate; spy on the assignment
|
|
const originalHref = window.location.href;
|
|
Object.defineProperty(window, "location", {
|
|
value: { href: originalHref },
|
|
writable: true,
|
|
});
|
|
render(<DefaultErrorFallback error={new Error("Err")} reset={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: "Go to dashboard" }));
|
|
expect(window.location.href).toBe("/dashboard");
|
|
});
|
|
});
|