Merge SVG figure style isolation

This commit is contained in:
amosblomqvist
2026-10-26 00:17:25 +02:00
5 changed files with 282 additions and 50 deletions
+37
View File
@@ -0,0 +1,37 @@
/** Two display-only repairs for inputs the old HTML embedding accepted.
* Do not rewrite stored artifacts/downloads or attempt general XML repair.
* Comments, CDATA and processing instructions are copied verbatim. */
export function svgImageSource(svg: string): string {
let foundRoot = false;
return svg.replace(
/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<\?[\s\S]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>|&nbsp;/g,
(token) => {
if (token.startsWith("<!--") || token.startsWith("<![CDATA[") || token.startsWith("<?")) return token;
if (!foundRoot && /^<svg(?=[\s/>])/i.test(token)) {
foundRoot = true;
// Consume complete attribute values, so text mentioning xmlns
// inside a different attribute cannot masquerade as a declaration.
const attrs = token.matchAll(/\s([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g);
const hasNamespace = Array.from(attrs).some((attr) => attr[1] === "xmlns");
if (!hasNamespace) token = token.replace(/^<svg/i, '$& xmlns="http://www.w3.org/2000/svg"');
}
return token.replace(/&nbsp;/g, "&#160;");
},
);
}
/** Keep generated SVG in an image document, outside the app's CSS and ID scope.
* This is a display boundary, not a sanitizer for the original SVG download. */
export function SvgImage({
svg,
className,
width,
}: {
svg: string;
className?: string;
width?: number;
}) {
// The source is already an in-memory vector image; no Next image optimization.
// eslint-disable-next-line @next/next/no-img-element
return <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgImageSource(svg))}`} alt="Figure" className={className} width={width} draggable={false} />;
}
+12 -18
View File
@@ -8,6 +8,7 @@ import { Toggle } from "@/components/settings/controls";
import { useSessionMermaid } from "@/components/session-overlay"; import { useSessionMermaid } from "@/components/session-overlay";
import { downloadFile } from "@/components/artifacts/download"; import { downloadFile } from "@/components/artifacts/download";
import { DispatchCard } from "@/components/artifacts/dispatch-card"; import { DispatchCard } from "@/components/artifacts/dispatch-card";
import { SvgImage } from "@/components/artifacts/svg-image";
import type { ArtifactPartProps, VisualResult } from "@/src/core"; import type { ArtifactPartProps, VisualResult } from "@/src/core";
/** /**
@@ -16,22 +17,10 @@ import type { ArtifactPartProps, VisualResult } from "@/src/core";
* the chat view's jobs feed). The SVG itself renders from the steer message * the chat view's jobs feed). The SVG itself renders from the steer message
* (sub-agent result delivery) via VisualOutputCard. * (sub-agent result delivery) via VisualOutputCard.
* *
* The SVG is model-authored for the learner's own machine (their own teacher * Both display modes use an SVG image document: generated markup and its
* wrote it, verified by looking) — dangerouslySetInnerHTML is acceptable * styles never join the app's DOM. Downloads still contain the original SVG.
* here, with a cheap hygiene pass (sanitizeSvg: strip <script> blocks, on*
* handlers, and javascript:/data:text/html URLs) since it lands in the
* app's DOM.
*/ */
/** Cheap SVG hygiene — not a full sanitizer, just removing the obvious exec vectors. */
export function sanitizeSvg(svg: string): string {
return svg
.replace(/<script[\s\S]*?<\/script\s*>/gi, "")
.replace(/<script[^>]*\/?>/gi, "")
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/(href|xlink:href)\s*=\s*(?:"\s*(?:javascript|data:text\/html)[^"]*"|'\s*(?:javascript|data:text\/html)[^']*'|(?:javascript|data:text\/html)[^\s>]*)/gi, "");
}
/** The svg root's DECLARED width in px, when there is one — the natural /** The svg root's DECLARED width in px, when there is one — the natural
* size the card displays at instead of stretching to full width (owner, * size the card displays at instead of stretching to full width (owner,
* 2026-10-07). %, em, "auto", or a missing attribute → null (the caller * 2026-10-07). %, em, "auto", or a missing attribute → null (the caller
@@ -122,13 +111,18 @@ export function VisualOutputCard({
<div <div
className={ className={
natural !== null natural !== null
? "w-fit max-w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:max-w-full" ? "w-fit max-w-full overflow-hidden rounded-lg bg-white"
: "w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:w-full" : "w-full overflow-hidden rounded-lg bg-white"
} }
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }} style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }}
data-testid="visual-svg" data-testid="visual-svg"
/> >
<SvgImage
svg={result.svg}
width={natural ?? undefined}
className={natural !== null ? "block h-auto max-w-full" : "block h-auto w-full"}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2"> <div className="mt-2 flex items-center justify-between gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div> <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
+19 -16
View File
@@ -7,6 +7,7 @@ import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/comp
import { renderMermaid } from "@/components/mermaid-render"; import { renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error"; import { MermaidError } from "@/components/mermaid-error";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
import { SvgImage } from "@/components/artifacts/svg-image";
/** /**
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen" * SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
@@ -269,22 +270,24 @@ export function MermaidOverlay({
: "transform 180ms ease-out, opacity 160ms ease-out", : "transform 180ms ease-out, opacity 160ms ease-out",
}} }}
> >
{/* data-lh-overlay-chart: globals.css pins the svg to the {/* Figures share the card's image boundary and white backing.
wrapper's width (kills mermaid's inline max-width cap) Mermaid keeps its inline renderer; globals.css removes
so the wrapper IS the natural-size box the fit math its max-width cap so it fills the natural-size box. */}
centers. FIGURE MODE: a white stage (the figure's {rawSvg !== undefined ? (
backing law — models draw for a white page) with the <div
caller's invert filter riding it, exactly like the data-lh-overlay-chart
in-card holder. */} className="bg-white"
<div style={inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined}
data-lh-overlay-chart >
className={cn( <SvgImage svg={svg} className="block h-auto w-full" />
"[&_svg]:h-auto [&_svg]:w-full", </div>
rawSvg !== undefined && "bg-white", ) : (
)} <div
style={rawSvg !== undefined && inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined} data-lh-overlay-chart
dangerouslySetInnerHTML={{ __html: svg }} className="[&_svg]:h-auto [&_svg]:w-full"
/> dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
</div> </div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span> <span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
+7 -16
View File
@@ -6,7 +6,7 @@
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message * 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
* reducer (reduceDemoBridgeState) * reducer (reduceDemoBridgeState)
* 2. card rendering (static markup): sandboxed iframe attrs, error chip, * 2. card rendering (static markup): sandboxed iframe attrs, error chip,
* failure notes, inline sanitized svg * failure notes, SVG image documents
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with * 3. the demo-builder loop with a FAKE model + FAKE headless (success with
* a fix iteration, untested-final verification, never-clean failure, * a fix iteration, untested-final verification, never-clean failure,
* no-Chrome failure) * no-Chrome failure)
@@ -40,7 +40,7 @@ import {
INITIAL_DEMO_BRIDGE_STATE, INITIAL_DEMO_BRIDGE_STATE,
reduceDemoBridgeState, reduceDemoBridgeState,
} from "../components/artifacts/demo-card.tsx"; } from "../components/artifacts/demo-card.tsx";
import { sanitizeSvg, svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx"; import { svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx";
import { slugFilename } from "../components/artifacts/download.ts"; import { slugFilename } from "../components/artifacts/download.ts";
import { chartSize } from "../components/session-overlay.tsx"; import { chartSize } from "../components/session-overlay.tsx";
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx"; import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
@@ -373,19 +373,19 @@ describe("steer card rendering (the delivered artifact)", () => {
assert.ok(!html.includes("<iframe"), "no iframe for a failed build"); assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
}); });
it("renders the verified svg inline (sanitized); the maker's notes are glue and stay hidden", () => { it("renders the verified svg as an image; the maker's notes are glue and stay hidden", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>'; const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
const steer = buildSubagentSteer( const steer = buildSubagentSteer(
{ id: "jv", kind: "visual", toolCallId: "cv" }, { id: "jv", kind: "visual", toolCallId: "cv" },
{ svg, notes: "Looking at the render, the diagram is clean and accurate." }, { svg, notes: "Looking at the render, the diagram is clean and accurate." },
); );
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />); const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.ok(html.includes("circle"), "svg content rendered inline"); assert.ok(html.includes(encodeURIComponent(svg)), "the image URL contains the original SVG");
assert.match(html, /Figure/); assert.match(html, /Figure/);
// Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the // Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the
// holder hugs + centers rather than stretching full-width. // holder hugs + centers rather than stretching full-width.
assert.match(html, /w-fit/, "holder hugs the declared size"); assert.match(html, /w-fit/, "holder hugs the declared size");
assert.match(html, /svg\]:max-w-full/, "svg shrinks to fit only when oversized"); assert.match(html, /class="block h-auto max-w-full"/, "image shrinks to fit only when oversized");
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render"); assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
}); });
@@ -590,17 +590,8 @@ describe("setSteerUiInverted (the persisted invert switch, 2026-10-07)", () => {
}); });
}); });
describe("sanitizeSvg", () => { // Generated-figure image boundaries are covered in svg-image.test.tsx.
it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => { // There is no longer a partial regex sanitizer in the display path.
const dirty = '<svg width="10" height="10" onload="alert(1)"><script>alert(2)</script><circle r="1" onclick="pwn()"/><a href="javascript:alert(3)"><rect width="2" height="2"/></a></svg>';
const clean = sanitizeSvg(dirty);
assert.ok(!/<script/i.test(clean), "script tags removed");
assert.ok(!/\sonload/i.test(clean), "onload removed");
assert.ok(!/\sonclick/i.test(clean), "onclick removed");
assert.ok(!/javascript:/i.test(clean), "javascript: urls removed");
assert.ok(clean.includes("circle") && clean.includes("rect"), "geometry preserved");
});
});
// ─── 3. the demo-builder loop ──────────────────────────────────────────────── // ─── 3. the demo-builder loop ────────────────────────────────────────────────
+207
View File
@@ -0,0 +1,207 @@
import React, { act, useState } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { createRoot } from "react-dom/client";
import { parseHTML } from "linkedom";
import { VisualOutputCard } from "../components/artifacts/visual-card.tsx";
import { svgImageSource } from "../components/artifacts/svg-image.tsx";
import { MermaidOverlay, SessionMermaidContext } from "../components/session-overlay.tsx";
// Structure-only checks: linkedom does not load images or implement CSS cascade.
// No fixture script, link, or external reference is executed by these tests.
const FIGURE = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160" viewBox="0 0 320 160"><style>text{font-family:Arial,Helvetica,sans-serif;fill:#202830}</style><defs><marker id="arrow"><path d="M0 0L5 5"/></marker></defs><path marker-end="url(#arrow)" d="M0 0L20 20"/><text>θ &amp; φ: 50% #1</text></svg>`;
const ACTIVE_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" onload="example()"><script>example()</script><foreignObject><div xmlns="http://www.w3.org/1999/xhtml" onclick="example()">Example</div></foreignObject><a href="javascript:example()"><text>Link</text></a><image href="https://example.invalid/image.png"/></svg>`;
const PREFIX = "data:image/svg+xml;charset=utf-8,";
function assertImageBoundary(holder: Element, source: string) {
const image = holder.querySelector("img");
assert.ok(image, "figure is an image");
assert.equal(holder.children.length, 1, "no figure markup alongside the image");
assert.equal(holder.querySelector("svg, style, script, foreignObject, a, [onload], [onclick]"), null,
"generated elements and CSS never enter the app document");
assert.equal(image.getAttribute("alt"), "Figure");
assert.equal(image.getAttribute("draggable"), "false", "native image dragging must not interfere with panning");
const src = image.getAttribute("src")!;
assert.ok(src.startsWith(PREFIX));
assert.equal(decodeURIComponent(src.slice(PREFIX.length)), source, "image URL contains the expected SVG display source, not a raster");
return image;
}
describe("SVG display compatibility", () => {
it("leaves standalone SVG unchanged, including comments and CDATA", () => {
const source = FIGURE.replace("</svg>", '<!-- &nbsp; <svg> --><![CDATA[&nbsp; <svg>]]></svg>');
assert.equal(svgImageSource(source), source);
});
it("adds only a missing root namespace, ignoring comments and attribute values", () => {
const source = '<?xml version="1.0"?><!-- <svg xmlns="wrong"> --><svg data-note=\' xmlns="not-a-declaration" >\' xmlns:xlink="http://www.w3.org/1999/xlink"><svg/></svg>';
const expected = source.replace('<svg data-note=', '<svg xmlns="http://www.w3.org/2000/svg" data-note=');
assert.equal(svgImageSource(source), expected);
assert.equal(svgImageSource(expected), expected, "normalization is idempotent");
});
it("does not replace an explicit namespace declaration", () => {
for (const declaration of ['xmlns="http://www.w3.org/2000/svg"', "xmlns = 'other'", 'xmlns=""']) {
const source = `<svg ${declaration}><text>Example</text></svg>`;
assert.equal(svgImageSource(source), source);
}
});
it("converts nbsp references in text and attributes, but not comments, CDATA or processing instructions", () => {
const source = '<?example &nbsp;?><svg xmlns="http://www.w3.org/2000/svg" aria-label="A&nbsp;B"><text>A&nbsp;B &amp;nbsp; &#160;</text><!-- &nbsp; --><![CDATA[&nbsp;]]></svg>';
const expected = source.replace('aria-label="A&nbsp;B"', 'aria-label="A&#160;B"').replace('<text>A&nbsp;B', '<text>A&#160;B');
assert.equal(svgImageSource(source), expected);
assert.equal(svgImageSource(expected), expected);
});
});
describe("generated SVG image boundary", () => {
for (const mode of ["inline", "fullscreen"] as const) {
for (const [name, source] of [["reported style rule and fragment IDs", FIGURE], ["active markup", ACTIVE_MARKUP]] as const) {
it(`${mode}: isolates ${name} inside the image URL`, () => {
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={{ svg: source }} />
: <MermaidOverlay svg={source} onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assertImageBoundary(holder, source);
assert.ok(holder.classList.contains("bg-white"), "white backing retained");
});
}
for (const [name, original, display] of [
["missing namespace", '<svg width="320" height="160"><text>Example</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="160"><text>Example</text></svg>'],
["HTML entity", '<svg xmlns="http://www.w3.org/2000/svg"><text>A&nbsp;B</text></svg>', '<svg xmlns="http://www.w3.org/2000/svg"><text>A&#160;B</text></svg>'],
] as const) {
it(`${mode}: repairs ${name} only in the image source`, () => {
const output = { svg: original };
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={output} />
: <MermaidOverlay svg={output.svg} onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assertImageBoundary(holder, display);
assert.equal(output.svg, original, "artifact source was not mutated");
});
}
it(`${mode}: retains inversion on the white backing`, () => {
const html = renderToStaticMarkup(mode === "inline"
? <VisualOutputCard output={{ svg: FIGURE }} initialInverted />
: <MermaidOverlay svg={FIGURE} inverted onClose={() => {}} />);
const { document } = parseHTML(html);
const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!;
assert.match(holder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
assert.ok(holder.classList.contains("bg-white"));
assertImageBoundary(holder, FIGURE);
});
}
it("inline: retains natural width and only shrinks oversized figures", () => {
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: FIGURE }} />));
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, FIGURE);
assert.equal(image.getAttribute("width"), "320");
assert.equal(image.className, "block h-auto max-w-full");
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-fit"));
});
for (const width of ["", ' width="100%"']) {
it(`inline: keeps the full-width fallback for ${width || "viewBox-only"} SVG`, () => {
const source = `<svg xmlns="http://www.w3.org/2000/svg"${width} viewBox="0 0 320 160"><text>Example</text></svg>`;
const { document } = parseHTML(renderToStaticMarkup(<VisualOutputCard output={{ svg: source }} />));
const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, source);
assert.equal(image.getAttribute("width"), null);
assert.equal(image.className, "block h-auto w-full");
assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-full"));
});
}
it("fullscreen: keeps the natural-size box and zoom controls", () => {
const { document } = parseHTML(renderToStaticMarkup(<MermaidOverlay svg={FIGURE} onClose={() => {}} />));
const holder = document.querySelector("[data-lh-overlay-chart]")!;
assert.equal(assertImageBoundary(holder, FIGURE).className, "block h-auto w-full");
assert.match(holder.parentElement!.getAttribute("style")!, /width:320px/);
for (const label of ["Zoom in", "Zoom out", "Fit diagram to view", "Close"]) {
assert.ok(document.querySelector(`button[aria-label="${label}"]`));
}
});
});
it("the fullscreen button opens the same SVG image and inversion; download keeps original bytes", async () => {
const originalSvg = FIGURE.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace("θ &amp; φ", "θ&nbsp;&amp; φ");
const displaySvg = FIGURE.replace("θ &amp; φ", "θ&#160;&amp; φ");
const dom = parseHTML("<html><body></body></html>");
const names = ["window", "document", "Element", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const;
const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
Object.assign(globalThis, {
window: dom.window,
document: dom.document,
Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
const host = dom.document.createElement("div");
dom.document.body.append(host);
const root = createRoot(host);
const originalCreateUrl = URL.createObjectURL;
const originalRevokeUrl = URL.revokeObjectURL;
const originalCreateElement = dom.document.createElement;
let downloaded: Blob | undefined;
let revoked: string | undefined;
let clicked: { name: string; href: string } | undefined;
URL.createObjectURL = (blob) => {
assert.ok(blob instanceof Blob);
downloaded = blob;
return "blob:figure-test";
};
URL.revokeObjectURL = (url) => { revoked = url; };
function Harness() {
const [focus, setFocus] = useState<{ svg: string; inverted: boolean } | null>(null);
return (
<SessionMermaidContext.Provider value={{ openMermaid: () => {}, openFigure: (svg, inverted) => setFocus({ svg, inverted }) }}>
<VisualOutputCard output={{ svg: originalSvg }} initialInverted />
{focus && <MermaidOverlay svg={focus.svg} inverted={focus.inverted} onClose={() => setFocus(null)} />}
</SessionMermaidContext.Provider>
);
}
try {
await act(async () => root.render(<Harness />));
await act(async () => { host.querySelector('[data-testid="visual-open"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
const inline = assertImageBoundary(host.querySelector('[data-testid="visual-svg"]')!, displaySvg);
const overlayHolder = host.querySelector("[data-lh-overlay-chart]")!;
const fullscreen = assertImageBoundary(overlayHolder, displaySvg);
assert.equal(fullscreen.getAttribute("src"), inline.getAttribute("src"));
assert.match(overlayHolder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/);
// Intercept only the download anchor; never navigate or write a real file.
dom.document.createElement = ((...args: Parameters<typeof originalCreateElement>) => {
const element = originalCreateElement.apply(dom.document, args);
if (args[0] === "a") {
element.click = () => { clicked = { name: element.getAttribute("download")!, href: element.getAttribute("href")! }; };
}
return element;
}) as typeof originalCreateElement;
await act(async () => { host.querySelector('[data-testid="visual-download"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); });
assert.ok(downloaded);
assert.equal(await downloaded.text(), originalSvg);
assert.equal(downloaded.type, "image/svg+xml");
assert.deepEqual(clicked, { name: "figure.svg", href: "blob:figure-test" });
assert.equal(revoked, "blob:figure-test");
} finally {
try {
await act(async () => root.unmount());
} finally {
URL.createObjectURL = originalCreateUrl;
URL.revokeObjectURL = originalRevokeUrl;
dom.document.createElement = originalCreateElement;
host.remove();
for (const [i, name] of names.entries()) {
if (originals[i]) Object.defineProperty(globalThis, name, originals[i]!);
else Reflect.deleteProperty(globalThis, name);
}
}
}
});