// @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; const navigateMock = vi.hoisted(() => vi.fn()); const routerLocation = vi.hoisted(() => ({ current: { pathname: "/co/agents/new", search: "?adapterType=claude_local" } })); const captureBrowserExceptionMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ useLocation: () => routerLocation.current, useNavigate: () => navigateMock, })); vi.mock("@/lib/sentry", () => ({ captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args), })); function Boom(): never { throw new Error("Maximum update depth exceeded"); } describe("RouteErrorBoundary", () => { let container: HTMLDivElement; let consoleErrorSpy: ReturnType; beforeEach(() => { navigateMock.mockReset(); routerLocation.current = { pathname: "/co/agents/new", search: "?adapterType=claude_local" }; container = document.createElement("div"); document.body.appendChild(container); // React logs caught render errors to console.error; silence the expected noise. consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { consoleErrorSpy.mockRestore(); container.remove(); captureBrowserExceptionMock.mockClear(); }); it("reports one captured error and keeps the recovery button", () => { const root = createRoot(container); act(() => { root.render( , ); }); expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), { boundary: "route", componentStack: expect.stringContaining("Boom"), }); expect( Array.from(container.querySelectorAll("button")).some( (button) => button.textContent === "Go back", ), ).toBe(true); act(() => { root.unmount(); }); }); it("renders a recoverable error card instead of a blank page when a child throws", () => { const root = createRoot(container); act(() => { root.render( , ); }); expect(container.textContent).toContain("This page hit an error"); expect(container.textContent).toContain("Maximum update depth exceeded"); expect(container.querySelector("pre")).not.toBeNull(); act(() => { root.unmount(); }); }); it("navigates back when the user clicks Go back", () => { const root = createRoot(container); act(() => { root.render( , ); }); const goBack = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Go back", ); expect(goBack).toBeDefined(); act(() => { goBack?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(navigateMock).toHaveBeenCalledWith(-1); act(() => { root.unmount(); }); }); it("recovers and renders children again after the route changes", () => { const root = createRoot(container); act(() => { root.render( , ); }); expect(container.textContent).toContain("This page hit an error"); // Simulate back-navigation to a different route, then re-render with a // healthy child — the boundary should reset off the new resetKey. routerLocation.current = { pathname: "/co/agents", search: "" }; act(() => { root.render(
Agents list
, ); }); expect(container.textContent).toContain("Agents list"); expect(container.textContent).not.toContain("This page hit an error"); act(() => { root.unmount(); }); }); });