inspector/clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx
Cliff Hall baf79b843f
feat(web): expand ContentViewer to render PDF, CSV, HTML, XML, JSON, CSS (closes #1329) (#1551)
* feat(web): expand ContentViewer to render PDF, CSV, HTML, XML, JSON, CSS (closes #1329)

Turn ContentViewer into a per-MIME dispatch surface for resource content.
It now accepts raw TextResourceContents | BlobResourceContents (in addition
to the existing ContentBlock path), so ResourcePreviewPanel.toContentBlock
collapses to a passthrough and blob-typed resources render natively instead
of falling back to opaque "[Binary content …]" text.

New renderers (clients/web/src/components/elements/):
- CodeHighlight: lazy react-syntax-highlighter prism-light element. Runtime,
  tomorrow theme, and each grammar dynamic-import on first use (one chunk per
  grammar). Module-level registeredLanguages / failedLoads / loadingPromises
  caches; plain Code block until a grammar resolves.
- PdfFrame: iframe over a Blob URL (#view=FitH), revoked on unmount.
- CsvTable: papaparse -> Mantine Table (first 100 rows), plain-Code fallback.
- HtmlFrame: sandbox="" iframe over a Blob URL with an injected CSP meta
  (no script-src, falls through to default-src 'none').
- contentViewerUtils: getMimeKind, base64 decode helpers, formatXml,
  wrapHtmlWithCsp, SAFE_HREF markdown-anchor allowlist (unsafe href -> span).

ResourcePreviewPanel: extend the URI-suffix MIME inference table
(.csv/.json/.xml/.html/.htm/.css/.pdf alongside .md).

Deps: papaparse + react-syntax-highlighter (+ @types). tsconfig paths stubs
redirect the deep RSH imports for typecheck (the @types ambient declarations
are shadowed by the real .js under bundler resolution) — same pattern the
repo uses for react/pino.

Tests cover PDF blob-URL creation + cleanup, CSV table render + fallback,
HTML sandbox attributes + CSP injection, JSON/XML/CSS highlighted output,
the lazy-load/alias/failed-load/shared-runtime paths, and the URI-suffix
MIME-inference paths. validate, test:coverage (per-file gate), test:storybook,
and integration all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): harden ContentViewer blob/CSV rendering per review (#1551)

Address review feedback on the ContentViewer MIME-dispatch PR:

- useObjectUrl: fix a dev-only StrictMode bug where the committed object
  URL was revoked but never recreated (setup→cleanup→setup with no
  re-render), blanking PDF/HTML previews. Revocation is now deferred to a
  microtask and guarded by a "live URL" ref so a StrictMode re-setup
  cancels it, while a real unmount / blob change still releases the URL.
  Avoids set-state-in-effect; mirrors AppRenderer's deferred-disposal.
- atob: guard base64 decoding with tryDecodeBase64ToUtf8 /
  tryDecodeBase64ToBytes so a malformed blob from an external server
  degrades to the binary-content notice instead of throwing during render
  and taking down the whole preview panel.
- CsvTable: surface the 100-row display cap with a
  "Showing first N of M rows" caption (no silent truncation).
- Extract BinaryNotice into its own module so PdfFrame can reuse it
  without an import cycle.

Tests added for the StrictMode path, malformed-base64 fallbacks (PDF +
textual blob), the safe-decode helpers, and the truncation caption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:18:43 -04:00

172 lines
6.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
renderWithMantine,
screen,
waitFor,
} from "../../../test/renderWithMantine";
// --- Mock the lazily-imported react-syntax-highlighter chunks -----------------
// The prism-light runtime: a component that renders its code into a probeable
// element, plus a `registerLanguage` spy so tests can assert load behavior.
const registerLanguage = vi.fn();
vi.mock("react-syntax-highlighter/dist/esm/prism-light", () => ({
default: Object.assign(
({ language, children }: { language: string; children: string }) => (
<pre data-testid="prism" data-language={language}>
{children}
</pre>
),
{ registerLanguage },
),
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/tomorrow", () => ({
default: {},
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/json", () => ({
default: { __grammar: "json" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markup", () => ({
default: { __grammar: "markup" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/css", () => ({
default: { __grammar: "css" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/yaml", () => ({
default: { __grammar: "yaml" },
}));
// markdown's import rejects, exercising the failed-load path.
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markdown", () => {
throw new Error("grammar load failed");
});
// Module-level caches in CodeHighlight must reset between tests; re-import fresh.
async function loadComponent() {
vi.resetModules();
registerLanguage.mockClear();
return (await import("./CodeHighlight")).CodeHighlight;
}
beforeEach(() => {
vi.resetModules();
});
describe("CodeHighlight", () => {
it("renders plain code initially, then upgrades to the highlighter", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="json" code='{"a":1}' />);
// Plain Mantine Code before the grammar resolves.
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(screen.getByText('{"a":1}')).toBeInTheDocument();
// After the lazy grammar loads, the prism runtime takes over.
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "json");
expect(registerLanguage).toHaveBeenCalledWith("json", {
__grammar: "json",
});
});
it("resolves the xml alias to the markup grammar", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="xml" code="<a/>" />);
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "markup");
expect(registerLanguage).toHaveBeenCalledWith("markup", {
__grammar: "markup",
});
});
it("stays plain for an unknown language and never imports a grammar", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="brainfuck" code="++." />);
// Give the effect a chance to run.
await waitFor(() => expect(screen.getByText("++.")).toBeInTheDocument());
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(registerLanguage).not.toHaveBeenCalled();
});
it("stays plain when a grammar import rejects", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="md" code="# hi" />);
await waitFor(() => expect(screen.getByText("# hi")).toBeInTheDocument());
// markdown's import throws → failed load → never registered, stays plain.
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(registerLanguage).not.toHaveBeenCalledWith(
"markdown",
expect.anything(),
);
});
it("shares one grammar load across concurrent mounts of the same language", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(
<>
<CodeHighlight language="css" code=".a{}" />
<CodeHighlight language="css" code=".b{}" />
</>,
);
await waitFor(() => expect(screen.getAllByTestId("prism")).toHaveLength(2));
// Both mounts shared the in-flight promise → grammar registered once.
expect(registerLanguage).toHaveBeenCalledTimes(1);
expect(registerLanguage).toHaveBeenCalledWith("css", { __grammar: "css" });
});
it("reuses an already-registered grammar without re-importing", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
);
await screen.findByTestId("prism");
expect(registerLanguage).toHaveBeenCalledTimes(1);
unmount();
// A second mount finds json already registered: ready synchronously, no
// second registerLanguage call.
renderWithMantine(<CodeHighlight language="json" code='{"b":2}' />);
expect(await screen.findByTestId("prism")).toBeInTheDocument();
expect(registerLanguage).toHaveBeenCalledTimes(1);
});
it("loads a second language reusing the already-loaded runtime", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
);
await screen.findByTestId("prism");
unmount();
// Runtime is cached now; mounting css registers only the new grammar.
renderWithMantine(<CodeHighlight language="css" code=".a{}" />);
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "css");
expect(registerLanguage).toHaveBeenCalledTimes(2);
expect(registerLanguage).toHaveBeenLastCalledWith("css", {
__grammar: "css",
});
});
it("shares one runtime load across two different languages mounted together", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(
<>
<CodeHighlight language="json" code='{"a":1}' />
<CodeHighlight language="css" code=".a{}" />
</>,
);
await waitFor(() => expect(screen.getAllByTestId("prism")).toHaveLength(2));
// Both grammars registered, but the shared runtime promise loaded once.
expect(registerLanguage).toHaveBeenCalledTimes(2);
});
it("does not retry a language whose grammar previously failed", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="md" code="# a" />,
);
await waitFor(() => expect(screen.getByText("# a")).toBeInTheDocument());
unmount();
// markdown is in failedLoads now; a second mount short-circuits and never
// attempts the import again — still plain, still no registration.
renderWithMantine(<CodeHighlight language="md" code="# b" />);
await waitFor(() => expect(screen.getByText("# b")).toBeInTheDocument());
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(registerLanguage).not.toHaveBeenCalled();
});
});