Files
stackpilot/frontend/src/components/ui/ErrorBoundary.test.tsx
T
menzeljandClaude Opus 5 a25741f579
CI / check (push) Successful in 7m23s
CI / build-and-push (push) Successful in 1m56s
Add an error boundary and split the bundle (0.50.0)
F15 — Two unrelated frontend weaknesses.

A render error unmounted the whole React tree: a white window, no navigation,
no indication of what happened, and the only way out was knowing to reload.
ErrorBoundary now shows the message with a retry and a reload, and clears itself
when resetKey (the route) changes, so navigating to a working page just works
instead of staying stuck. There are two: one inside AppShell around the routed
pages, one at the root for the shell itself and the login screen, which sit
outside it.

The bundle was one 841 kB file (234 kB gzipped) every visitor downloaded in
full, with Vite warning about it on every build. Routes are lazy now and the
entry chunk is 377 kB (120 kB gzipped) — a 55% cut, warning gone.

Measuring first changed what to split. Monaco turned out not to be in the bundle
at all: @monaco-editor/react loads it from cdn.jsdelivr.net, so only the small
wrapper ships. xterm.js *is* bundled, all 294 kB of it, and it was reachable
from ContainerCard — which renders on every stack detail page — so every visitor
paid for a terminal most never open. It is lazy now and lands in its own chunk.

(Worth knowing separately: the compose editor therefore needs jsdelivr.net
reachable. For a self-hosted tool on an air-gapped network that is a real
limitation, but vendoring Monaco means +3 MB and is its own change.)

Adding a boundary whose behaviour I could only reason about was not good enough,
and the missing frontend test runner was already flagged as the gap from 0.49.0.
So this also sets up vitest + jsdom + testing-library and covers the boundary:
that it renders the error rather than a blank page, offers a way out, clears on
navigation, and stays put on an unrelated re-render. CI runs `npm test` next to
pytest.

One snag worth recording: installing the dev dependencies triggered npm's
optional-dependency pruning and dropped @rollup/rollup-linux-x64-gnu, which
broke the build. Reinstalling it directly put a linux-x64-glibc binary in
package.json, which would have broken `npm ci` on every other platform — so that
was backed out and the lockfile now carries the bindings as rollup's optional
deps, where they belong. Verified with a clean `npm ci` in a scratch copy:
install, typecheck, build and test all pass from the committed lockfile.

758 backend tests, 7 frontend tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
2026-08-31 15:38:14 +02:00

122 lines
3.6 KiB
TypeScript

import { useState } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { ErrorBoundary } from "./ErrorBoundary";
/**
* Before this component existed, any render error unmounted the whole React
* tree: a white window, no navigation, no clue what happened. That failure mode
* is invisible to the typechecker, which is what these cover.
*/
function Boom({ shouldThrow }: { shouldThrow: boolean }): React.ReactElement {
if (shouldThrow) throw new Error("the sky is falling");
return <p>all good</p>;
}
beforeEach(() => {
// React logs caught errors itself; silence it so a passing run stays readable.
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe("ErrorBoundary", () => {
it("renders its children when nothing goes wrong", () => {
render(
<ErrorBoundary>
<Boom shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText("all good")).toBeDefined();
});
it("shows the error instead of a blank page", () => {
render(
<ErrorBoundary>
<Boom shouldThrow />
</ErrorBoundary>
);
expect(screen.getByText("This page hit an error")).toBeDefined();
// The message is surfaced, not swallowed — otherwise there is nothing to
// report and no way to tell what broke.
expect(screen.getByText("the sky is falling")).toBeDefined();
});
it("offers a way out", () => {
render(
<ErrorBoundary>
<Boom shouldThrow />
</ErrorBoundary>
);
expect(screen.getByRole("button", { name: /try again/i })).toBeDefined();
expect(screen.getByRole("button", { name: /reload/i })).toBeDefined();
});
it("says the rest of the app is unaffected", () => {
render(
<ErrorBoundary>
<Boom shouldThrow />
</ErrorBoundary>
);
expect(screen.getByText(/your stacks are unaffected/i)).toBeDefined();
});
it("clears the error when resetKey changes", () => {
// This is what makes navigating away from a broken page work: without it
// the boundary keeps showing the error on every subsequent route.
function Harness() {
const [route, setRoute] = useState("/broken");
return (
<>
<button onClick={() => setRoute("/fine")}>navigate</button>
<ErrorBoundary resetKey={route}>
<Boom shouldThrow={route === "/broken"} />
</ErrorBoundary>
</>
);
}
render(<Harness />);
expect(screen.getByText("This page hit an error")).toBeDefined();
fireEvent.click(screen.getByText("navigate"));
expect(screen.queryByText("This page hit an error")).toBeNull();
expect(screen.getByText("all good")).toBeDefined();
});
it("stays on the error screen while the route is unchanged", () => {
function Harness() {
const [, force] = useState(0);
return (
<>
<button onClick={() => force((n) => n + 1)}>rerender</button>
<ErrorBoundary resetKey="/same">
<Boom shouldThrow />
</ErrorBoundary>
</>
);
}
render(<Harness />);
fireEvent.click(screen.getByText("rerender"));
expect(screen.getByText("This page hit an error")).toBeDefined();
});
it("logs the error so it is recoverable from the console", () => {
render(
<ErrorBoundary>
<Boom shouldThrow />
</ErrorBoundary>
);
const logged = vi
.mocked(console.error)
.mock.calls.some((args) => String(args[0]).includes("Render error:"));
expect(logged).toBe(true);
});
});