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>
76 lines
3.7 KiB
TypeScript
76 lines
3.7 KiB
TypeScript
import { render, screen } from "@testing-library/react";
|
|
import { http, HttpResponse } from "msw";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { server } from "../test/msw";
|
|
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
|
import type { SessionSummary } from "../api/types";
|
|
|
|
function makeClient() {
|
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
}
|
|
|
|
function wrap(ui: React.ReactElement, client = makeClient()) {
|
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
);
|
|
return render(ui, { wrapper: Wrapper });
|
|
}
|
|
|
|
const base: SessionSummary = {
|
|
id: "s1", status: "open", question: "How many ablations?", summary: null,
|
|
created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null,
|
|
name: null, group: null, archived: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
server.use(http.get("http://localhost:8787/sessions/s1/documents", () =>
|
|
HttpResponse.json([
|
|
{ phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" },
|
|
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
|
|
]),
|
|
));
|
|
});
|
|
|
|
test("renders the document titles from the API", async () => {
|
|
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
|
expect(await screen.findByRole("heading", { name: "Original question" })).toBeInTheDocument();
|
|
// "Final SQL" is both the section heading and the SqlViewer block label — target the heading.
|
|
expect(screen.getByRole("heading", { name: "Final SQL" })).toBeInTheDocument();
|
|
});
|
|
|
|
test("shows Resume for resumable, hides it for finalized", async () => {
|
|
const { rerender } = wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
|
expect(await screen.findByRole("button", { name: /resume/i })).toBeInTheDocument();
|
|
rerender(<SessionDocumentsPanel session={{ ...base, status: "finalized" }} onClose={vi.fn()} onResume={vi.fn()} />);
|
|
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("hides Resume for an archived session", async () => {
|
|
wrap(<SessionDocumentsPanel session={{ ...base, archived: true }} onClose={vi.fn()} onResume={vi.fn()} />);
|
|
await screen.findByText("Original question");
|
|
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("a malformed document renders a fallback without taking down its siblings", async () => {
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
// Valid JSON but missing `candidates` — SchemaLinkingViewer crashes on this shape.
|
|
// Without a per-document boundary this crash blanks the whole panel (React 18
|
|
// unmounts the tree); the good sibling document must still render.
|
|
// resetHandlers(...) replaces the beforeEach handler so this response is used.
|
|
server.resetHandlers(
|
|
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
|
HttpResponse.json([
|
|
{ phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' },
|
|
{ phase: "—", key: "question", title: "Original question", format: "text", content: "SIBLING-SURVIVES" },
|
|
]),
|
|
),
|
|
);
|
|
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
|
// The healthy sibling rendered — the panel did not white-screen.
|
|
expect(await screen.findByText("SIBLING-SURVIVES")).toBeInTheDocument();
|
|
// The broken document degraded to a fallback in place.
|
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
|
|
errorSpy.mockRestore();
|
|
});
|