A render error in one viewer or gate widget unmounted the whole React root (white screen). Add a reusable ErrorBoundary (class component, no new dep) with resetKeys + an on-brand fallback, and wire it at two surfaces: - WidgetHost: isolates the gate widget (reset on descriptor id) so a malformed gate payload no longer blanks the conversation - SessionDocumentsPanel: wraps each document (reset on doc key/content) so one crashing viewer degrades only its section; siblings and the panel survive The observed crash: a schema-linking doc that parses but lacks `candidates` makes SchemaLinkingViewer throw. Verified live via Playwright against the mock backend. TDD throughout; tsc clean, 123/123 vitest (+8 new tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.3 KiB
TypeScript
102 lines
3.3 KiB
TypeScript
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { useState } from "react";
|
|
import { ErrorBoundary } from "./ErrorBoundary";
|
|
|
|
/** A child that throws on demand. Toggle `boom` to control whether it crashes. */
|
|
function Boom({ boom, label = "kaboom" }: { boom: boolean; label?: string }) {
|
|
if (boom) throw new Error(label);
|
|
return <div>child ok</div>;
|
|
}
|
|
|
|
// React logs caught render errors to console.error; silence it so the suite output
|
|
// stays readable while still exercising the real error path.
|
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
beforeEach(() => {
|
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
afterEach(() => {
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
test("renders children when nothing throws", () => {
|
|
render(
|
|
<ErrorBoundary>
|
|
<Boom boom={false} />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText("child ok")).toBeInTheDocument();
|
|
});
|
|
|
|
test("renders the default fallback when a child throws", () => {
|
|
render(
|
|
<ErrorBoundary label="step">
|
|
<Boom boom />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.queryByText("child ok")).not.toBeInTheDocument();
|
|
// Default fallback names the labelled surface.
|
|
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/step/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("uses a custom fallback render prop with the error", () => {
|
|
render(
|
|
<ErrorBoundary fallback={(err) => <p>custom: {err.message}</p>}>
|
|
<Boom boom label="explode" />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText("custom: explode")).toBeInTheDocument();
|
|
});
|
|
|
|
test("calls onError when a child throws", () => {
|
|
const onError = vi.fn();
|
|
render(
|
|
<ErrorBoundary onError={onError}>
|
|
<Boom boom label="reported" />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
|
|
expect((onError.mock.calls[0][0] as Error).message).toBe("reported");
|
|
});
|
|
|
|
test("recovers when resetKeys change to non-crashing content", async () => {
|
|
function Harness() {
|
|
const [boom, setBoom] = useState(true);
|
|
return (
|
|
<>
|
|
<button onClick={() => setBoom(false)}>fix</button>
|
|
<ErrorBoundary resetKeys={[boom]}>
|
|
<Boom boom={boom} />
|
|
</ErrorBoundary>
|
|
</>
|
|
);
|
|
}
|
|
render(<Harness />);
|
|
// Starts crashed → fallback visible.
|
|
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
|
|
// Flip the data (and thus resetKeys); the boundary clears and re-renders children.
|
|
await userEvent.click(screen.getByRole("button", { name: "fix" }));
|
|
expect(screen.getByText("child ok")).toBeInTheDocument();
|
|
expect(screen.queryByText(/couldn't be displayed/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("Try again resets the boundary", async () => {
|
|
let boom = true;
|
|
function Flaky() {
|
|
if (boom) throw new Error("transient");
|
|
return <div>recovered</div>;
|
|
}
|
|
render(
|
|
<ErrorBoundary>
|
|
<Flaky />
|
|
</ErrorBoundary>,
|
|
);
|
|
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
|
|
// Simulate the underlying condition clearing, then retry.
|
|
boom = false;
|
|
await userEvent.click(screen.getByRole("button", { name: /try again/i }));
|
|
expect(screen.getByText("recovered")).toBeInTheDocument();
|
|
});
|