inspector/clients/web/src/components/elements/ContentViewer/useObjectUrl.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

62 lines
2.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { StrictMode } from "react";
import { renderHook } from "@testing-library/react";
import { useObjectUrl } from "./useObjectUrl";
// Revocation is deferred to a microtask; let the queue drain before asserting.
const flushMicrotasks = () => Promise.resolve();
describe("useObjectUrl", () => {
beforeEach(() => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates an object URL for a blob and revokes it on unmount", async () => {
const blob = new Blob(["x"], { type: "text/plain" });
const { result, unmount } = renderHook(() => useObjectUrl(blob));
expect(result.current).toBe("blob:mock-url");
expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
unmount();
await flushMicrotasks();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
});
it("revokes the previous URL when the blob changes", async () => {
// Unique URLs per call so the cleanup effect's [url] dep actually changes.
let n = 0;
vi.mocked(URL.createObjectURL).mockImplementation(() => `blob:url-${n++}`);
const first = new Blob(["a"]);
const second = new Blob(["b"]);
const { rerender } = renderHook(({ blob }) => useObjectUrl(blob), {
initialProps: { blob: first },
});
rerender({ blob: second });
await flushMicrotasks();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:url-0");
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
});
it("keeps the committed URL live under StrictMode double-invoke", async () => {
// StrictMode runs effects setup → cleanup → setup with no re-render. A
// synchronous revoke in cleanup would kill the committed URL; the deferred,
// ref-guarded revoke must leave the URL consumers receive untouched.
let n = 0;
const revoked: string[] = [];
vi.mocked(URL.createObjectURL).mockImplementation(() => `blob:url-${n++}`);
vi.mocked(URL.revokeObjectURL).mockImplementation((url) => {
revoked.push(url as string);
});
const blob = new Blob(["x"]);
const { result } = renderHook(() => useObjectUrl(blob), {
wrapper: StrictMode,
});
await flushMicrotasks();
expect(result.current).toBeDefined();
expect(revoked).not.toContain(result.current);
});
});