import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen } from "~/test-utils.js"; import userEvent from "@testing-library/user-event"; import React, { type ErrorInfo } from "react"; import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js"; // Suppress React's console.error output during error boundary tests let consoleErrorSpy: ReturnType; beforeEach(() => { consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); }); afterEach(() => { consoleErrorSpy.mockRestore(); }); // A component that unconditionally throws function ThrowingChild({ message = "Test error" }: { message?: string }): React.ReactNode { 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
OK
; } describe("ErrorBoundary", () => { describe("normal rendering", () => { it("renders children when no error is thrown", () => { render(
Hello World
, ); expect(screen.getByText("Hello World")).toBeInTheDocument(); }); it("renders multiple children normally", () => { render( First Second , ); 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( , ); expect(screen.getByText("Something went wrong")).toBeInTheDocument(); }); it("displays the error message in the default fallback", () => { render( , ); expect(screen.getByText("Custom error message")).toBeInTheDocument(); }); it("renders a custom fallback node instead of the default when provided", () => { render( Custom fallback}> , ); 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( , ); 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(
No error here
, ); expect(handleError).not.toHaveBeenCalled(); }); }); describe("default fallback UI", () => { it("renders the 'Something went wrong' heading", () => { render( , ); expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument(); }); it("renders a 'Try again' button", () => { render( , ); expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument(); }); it("renders a 'Go to dashboard' button", () => { render( , ); expect(screen.getByRole("button", { name: "Go to dashboard" })).toBeInTheDocument(); }); it("shows a generic message when error.message is empty", () => { function ThrowEmptyMessage(): React.ReactNode { const e = new Error(""); throw e; } render( , ); 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
OK
; } const { rerender } = render( , ); 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( , ); expect(screen.getByText("OK")).toBeInTheDocument(); expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument(); }); }); }); describe("edge cases", () => { it("catches a thrown string (non-Error object)", () => { function ThrowString(): React.ReactNode { throw "string error"; } const spy = vi.spyOn(console, "error").mockImplementation(() => {}); render( , ); expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); spy.mockRestore(); }); }); describe("DefaultErrorFallback", () => { it("renders the heading", () => { render(); expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument(); }); it("renders the error message", () => { render(); expect(screen.getByText("Specific error")).toBeInTheDocument(); }); it("falls back to generic text when error message is empty", () => { render(); 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(); 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(); await user.click(screen.getByRole("button", { name: "Go to dashboard" })); expect(window.location.href).toBe("/dashboard"); }); });