mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
Compare commits
1 Commits
dependabot
...
fix/artifa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
351001fdca |
@@ -6,9 +6,11 @@ import { Suspense, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { ArtifactRef } from "../../../store";
|
||||
import type { ArtifactClassification } from "../helpers";
|
||||
import { ArtifactErrorBoundary } from "./ArtifactErrorBoundary";
|
||||
import { ArtifactReactPreview } from "./ArtifactReactPreview";
|
||||
import { ArtifactSkeleton } from "./ArtifactSkeleton";
|
||||
import {
|
||||
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
|
||||
TAILWIND_CDN_URL,
|
||||
wrapWithHeadInjection,
|
||||
} from "@/lib/iframe-sandbox-csp";
|
||||
@@ -53,13 +55,18 @@ function ArtifactContentLoader({
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<ArtifactRenderer
|
||||
artifact={artifact}
|
||||
content={content}
|
||||
pdfUrl={pdfUrl}
|
||||
isSourceView={isSourceView}
|
||||
classification={classification}
|
||||
/>
|
||||
<ArtifactErrorBoundary
|
||||
artifactTitle={artifact.title}
|
||||
artifactType={classification.type}
|
||||
>
|
||||
<ArtifactRenderer
|
||||
artifact={artifact}
|
||||
content={content}
|
||||
pdfUrl={pdfUrl}
|
||||
isSourceView={isSourceView}
|
||||
classification={classification}
|
||||
/>
|
||||
</ArtifactErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,7 +207,10 @@ function ArtifactRenderer({
|
||||
if (classification.type === "html") {
|
||||
// Inject Tailwind CDN — no CSP (see iframe-sandbox-csp.ts for why)
|
||||
const tailwindScript = `<script src="${TAILWIND_CDN_URL}"></script>`;
|
||||
const wrapped = wrapWithHeadInjection(content, tailwindScript);
|
||||
const wrapped = wrapWithHeadInjection(
|
||||
content,
|
||||
tailwindScript + FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
|
||||
);
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-scripts"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
artifactTitle: string;
|
||||
artifactType: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ArtifactErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
Sentry.captureException(error, {
|
||||
contexts: {
|
||||
react: { componentStack: errorInfo.componentStack },
|
||||
},
|
||||
tags: { errorBoundary: "true", context: "copilot-artifact" },
|
||||
extra: {
|
||||
artifactTitle: this.props.artifactTitle,
|
||||
artifactType: this.props.artifactType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (
|
||||
this.state.error &&
|
||||
(prevProps.artifactTitle !== this.props.artifactTitle ||
|
||||
prevProps.artifactType !== this.props.artifactType)
|
||||
) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
handleCopy = () => {
|
||||
const { error } = this.state;
|
||||
if (!error) return;
|
||||
const details = [
|
||||
`Artifact: ${this.props.artifactTitle}`,
|
||||
`Type: ${this.props.artifactType}`,
|
||||
`Error: ${error.message}`,
|
||||
error.stack ? `Stack:\n${error.stack}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
navigator.clipboard?.writeText(details).catch(() => {});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const message = error.message || "Unknown rendering error";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center"
|
||||
>
|
||||
<p className="text-sm font-medium text-zinc-700">
|
||||
This artifact couldn't be rendered
|
||||
</p>
|
||||
<p className="max-w-md break-words text-xs text-zinc-500">
|
||||
Something in{" "}
|
||||
<span className="font-mono">{this.props.artifactTitle}</span> threw an
|
||||
error while rendering. The chat and sidebar are still working.
|
||||
</p>
|
||||
<pre className="max-h-32 max-w-md overflow-auto whitespace-pre-wrap break-words rounded-md bg-zinc-100 px-3 py-2 text-left text-xs text-zinc-700">
|
||||
{message}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleCopy}
|
||||
className="rounded-md border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-sm transition-colors hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
>
|
||||
Copy error details
|
||||
</button>
|
||||
<p className="max-w-md text-xs text-zinc-400">
|
||||
Paste this into the chat so the agent can regenerate a working
|
||||
version.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -412,6 +412,41 @@ describe("ArtifactContent", () => {
|
||||
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
});
|
||||
|
||||
it("injects the fragment-link interceptor into HTML artifact iframes (regression)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
'<html><head></head><body><a href="#x">x</a><div id="x">x</div></body></html>',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<ArtifactContent
|
||||
artifact={makeArtifact({
|
||||
id: "html-frag",
|
||||
title: "page.html",
|
||||
mimeType: "text/html",
|
||||
})}
|
||||
isSourceView={false}
|
||||
classification={makeClassification({ type: "html" })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTitle("page.html");
|
||||
const srcdoc = container.querySelector("iframe")?.getAttribute("srcdoc");
|
||||
expect(srcdoc).toBeTruthy();
|
||||
// Markers unique to FRAGMENT_LINK_INTERCEPTOR_SCRIPT — if any of these
|
||||
// disappear, the interceptor is no longer being injected and fragment
|
||||
// links will navigate the parent URL again.
|
||||
expect(srcdoc).toContain("__fragmentLinkInterceptor");
|
||||
expect(srcdoc).toContain('a[href^="#"]');
|
||||
expect(srcdoc).toContain("scrollIntoView");
|
||||
});
|
||||
|
||||
// ── Source view ───────────────────────────────────────────────────
|
||||
|
||||
it("renders source view as pre tag", async () => {
|
||||
@@ -923,6 +958,164 @@ describe("ArtifactContent", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// ── Error boundary ────────────────────────────────────────────────
|
||||
|
||||
it("shows a visible error instead of crashing when the renderer throws", async () => {
|
||||
const consoleErr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const originalImpl = vi
|
||||
.mocked(ArtifactReactPreview)
|
||||
.getMockImplementation();
|
||||
vi.mocked(ArtifactReactPreview).mockImplementation(() => {
|
||||
throw new Error("boom in renderer");
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("source"),
|
||||
}),
|
||||
);
|
||||
|
||||
const artifact = makeArtifact({
|
||||
id: "crash-001",
|
||||
title: "broken.tsx",
|
||||
mimeType: "text/tsx",
|
||||
});
|
||||
const classification = makeClassification({ type: "react" });
|
||||
|
||||
render(
|
||||
<ArtifactContent
|
||||
artifact={artifact}
|
||||
isSourceView={false}
|
||||
classification={classification}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/This artifact couldn't be rendered/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/boom in renderer/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /copy error details/i }),
|
||||
).toBeTruthy();
|
||||
|
||||
if (originalImpl) {
|
||||
vi.mocked(ArtifactReactPreview).mockImplementation(originalImpl);
|
||||
}
|
||||
consoleErr.mockRestore();
|
||||
});
|
||||
|
||||
it("copies artifact title, type, and error to the clipboard", async () => {
|
||||
const consoleErr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const originalImpl = vi
|
||||
.mocked(ArtifactReactPreview)
|
||||
.getMockImplementation();
|
||||
vi.mocked(ArtifactReactPreview).mockImplementation(() => {
|
||||
throw new Error("jsx parse failed at line 42");
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("source"),
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<ArtifactContent
|
||||
artifact={makeArtifact({
|
||||
id: "crash-002",
|
||||
title: "report.tsx",
|
||||
mimeType: "text/tsx",
|
||||
})}
|
||||
isSourceView={false}
|
||||
classification={makeClassification({ type: "react" })}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /copy error details/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
});
|
||||
const payload = writeText.mock.calls[0]![0] as string;
|
||||
expect(payload).toContain("report.tsx");
|
||||
expect(payload).toContain("react");
|
||||
expect(payload).toContain("jsx parse failed at line 42");
|
||||
|
||||
if (originalImpl) {
|
||||
vi.mocked(ArtifactReactPreview).mockImplementation(originalImpl);
|
||||
}
|
||||
consoleErr.mockRestore();
|
||||
});
|
||||
|
||||
it("renders the user-reported plotly HTML artifact into a sandboxed iframe", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AutoGPT Beta Launch Interactive Report</title>
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<style>
|
||||
:root { --bg: #f8f9fa; --primary: #6c5ce7; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>\u{1F4CA} AutoGPT Beta Launch Interactive Report</h1></header>
|
||||
<div class="chart-container" id="globalActivationChart"></div>
|
||||
<script>
|
||||
function showTab(tabId, groupId) {
|
||||
const group = document.getElementById(groupId);
|
||||
group.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
}
|
||||
Plotly.newPlot('globalActivationChart', [{ type: 'pie', values: [1, 2] }], {});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(html),
|
||||
}),
|
||||
);
|
||||
|
||||
const artifact = makeArtifact({
|
||||
id: "html-big-report",
|
||||
title: "report.html",
|
||||
mimeType: "text/html",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ArtifactContent
|
||||
artifact={artifact}
|
||||
isSourceView={false}
|
||||
classification={makeClassification({ type: "html" })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTitle("report.html");
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(screen.queryByText(/couldn't be rendered/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to pre tag when no renderer matches", async () => {
|
||||
const { globalRegistry } = await import(
|
||||
"@/components/contextual/OutputRenderers"
|
||||
|
||||
@@ -116,4 +116,11 @@ describe("buildReactArtifactSrcDoc", () => {
|
||||
expect(doc).toContain("/^[A-Z]/.test(name)");
|
||||
expect(doc).toContain("wrapWithProviders");
|
||||
});
|
||||
|
||||
it("injects the fragment-link interceptor so #anchor clicks stay inside the iframe (regression)", () => {
|
||||
const doc = buildReactArtifactSrcDoc("module.exports = {};", "A", STYLES);
|
||||
expect(doc).toContain("__fragmentLinkInterceptor");
|
||||
expect(doc).toContain('a[href^="#"]');
|
||||
expect(doc).toContain("scrollIntoView");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
* React is loaded from unpkg with pinned version and SRI integrity hashes.
|
||||
*/
|
||||
|
||||
import { TAILWIND_CDN_URL } from "@/lib/iframe-sandbox-csp";
|
||||
import {
|
||||
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
|
||||
TAILWIND_CDN_URL,
|
||||
} from "@/lib/iframe-sandbox-csp";
|
||||
|
||||
export { transpileReactArtifactSource } from "./transpileReactArtifact";
|
||||
|
||||
@@ -95,6 +98,7 @@ export function buildReactArtifactSrcDoc(
|
||||
}
|
||||
</style>
|
||||
<script src="${TAILWIND_CDN_URL}"></script>
|
||||
${FRAGMENT_LINK_INTERCEPTOR_SCRIPT}
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"></script><!-- pragma: allowlist secret -->
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"></script><!-- pragma: allowlist secret -->
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { htmlRenderer } from "./HTMLRenderer";
|
||||
|
||||
describe("HTMLRenderer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders text/html content in a sandboxed iframe", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
{htmlRenderer.render("<h1>Hi</h1>", {
|
||||
mimeType: "text/html",
|
||||
filename: "page.html",
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
});
|
||||
|
||||
it("injects the fragment-link interceptor into the srcDoc (regression)", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
{htmlRenderer.render(
|
||||
'<html><head></head><body><a href="#x">x</a><div id="x">x</div></body></html>',
|
||||
{ mimeType: "text/html", filename: "page.html" },
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
const srcdoc = container.querySelector("iframe")?.getAttribute("srcdoc");
|
||||
expect(srcdoc).toBeTruthy();
|
||||
expect(srcdoc).toContain("__fragmentLinkInterceptor");
|
||||
expect(srcdoc).toContain('a[href^="#"]');
|
||||
expect(srcdoc).toContain("scrollIntoView");
|
||||
});
|
||||
|
||||
it("canRender recognises text/html mime type and .html/.htm filenames", () => {
|
||||
expect(
|
||||
htmlRenderer.canRender("<h1>Hi</h1>", { mimeType: "text/html" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
htmlRenderer.canRender("<h1>Hi</h1>", { filename: "report.html" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
htmlRenderer.canRender("<h1>Hi</h1>", { filename: "report.htm" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
htmlRenderer.canRender("<h1>Hi</h1>", { mimeType: "text/plain" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import {
|
||||
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
|
||||
TAILWIND_CDN_URL,
|
||||
wrapWithHeadInjection,
|
||||
} from "@/lib/iframe-sandbox-csp";
|
||||
@@ -13,7 +14,10 @@ import {
|
||||
function HTMLPreview({ value }: { value: string }) {
|
||||
// Inject Tailwind CDN — no CSP (see iframe-sandbox-csp.ts for why)
|
||||
const tailwindScript = `<script src="${TAILWIND_CDN_URL}"></script>`;
|
||||
const srcDoc = wrapWithHeadInjection(value, tailwindScript);
|
||||
const srcDoc = wrapWithHeadInjection(
|
||||
value,
|
||||
tailwindScript + FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
|
||||
);
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-scripts"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TAILWIND_CDN_URL, wrapWithHeadInjection } from "../iframe-sandbox-csp";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FRAGMENT_LINK_INTERCEPTOR_SCRIPT,
|
||||
TAILWIND_CDN_URL,
|
||||
wrapWithHeadInjection,
|
||||
} from "../iframe-sandbox-csp";
|
||||
|
||||
describe("wrapWithHeadInjection", () => {
|
||||
const injection = '<script src="https://example.com/lib.js"></script>';
|
||||
@@ -45,6 +49,139 @@ describe("TAILWIND_CDN_URL", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FRAGMENT_LINK_INTERCEPTOR_SCRIPT", () => {
|
||||
// Evaluate the script body (without <script> tags) against the current
|
||||
// document. Because sandboxed srcdoc iframes run their scripts in isolation
|
||||
// anyway, the behavior we care about is just "this code, when executed in
|
||||
// a document, intercepts #anchor clicks and calls scrollIntoView".
|
||||
function installInterceptor() {
|
||||
const body = FRAGMENT_LINK_INTERCEPTOR_SCRIPT.replace(
|
||||
/^<script>\s*/,
|
||||
"",
|
||||
).replace(/\s*<\/script>$/, "");
|
||||
new Function(body)();
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (cleanup) cleanup();
|
||||
cleanup = null;
|
||||
document.body.innerHTML = "";
|
||||
const doc = document as Document & {
|
||||
__fragmentLinkInterceptor?: EventListener;
|
||||
};
|
||||
if (doc.__fragmentLinkInterceptor) {
|
||||
document.removeEventListener("click", doc.__fragmentLinkInterceptor);
|
||||
delete doc.__fragmentLinkInterceptor;
|
||||
}
|
||||
});
|
||||
|
||||
it("exports a <script> tag wrapping the interceptor", () => {
|
||||
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT.startsWith("<script>")).toBe(true);
|
||||
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT.endsWith("</script>")).toBe(true);
|
||||
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT).toContain("addEventListener");
|
||||
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT).toContain("scrollIntoView");
|
||||
expect(FRAGMENT_LINK_INTERCEPTOR_SCRIPT).toContain('a[href^="#"]');
|
||||
});
|
||||
|
||||
// Install the interceptor first, then a tail listener that records
|
||||
// defaultPrevented. Listeners fire in registration order, so the tail
|
||||
// sees the post-interceptor state.
|
||||
function installWithObserver() {
|
||||
installInterceptor();
|
||||
const observed = { defaulted: false };
|
||||
const listener = (e: Event) => {
|
||||
observed.defaulted = e.defaultPrevented;
|
||||
};
|
||||
document.addEventListener("click", listener);
|
||||
cleanup = () => document.removeEventListener("click", listener);
|
||||
return observed;
|
||||
}
|
||||
|
||||
it("intercepts fragment-link clicks, calls preventDefault, and scrolls the target into view", () => {
|
||||
document.body.innerHTML = `
|
||||
<nav><a id="nav-link" href="#activation">Activation</a></nav>
|
||||
<section id="activation">Target</section>
|
||||
`;
|
||||
const scrollSpy = vi.fn();
|
||||
document.getElementById("activation")!.scrollIntoView = scrollSpy;
|
||||
|
||||
const observed = installWithObserver();
|
||||
|
||||
document.getElementById("nav-link")!.click();
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
expect(observed.defaulted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not intercept bare '#' links (no target id)", () => {
|
||||
document.body.innerHTML = `<a id="top" href="#">Back to top</a>`;
|
||||
const observed = installWithObserver();
|
||||
|
||||
document.getElementById("top")!.click();
|
||||
|
||||
expect(observed.defaulted).toBe(false);
|
||||
});
|
||||
|
||||
it("does not intercept links with no matching target in the document", () => {
|
||||
document.body.innerHTML = `<a id="dangle" href="#missing">Nowhere</a>`;
|
||||
const observed = installWithObserver();
|
||||
|
||||
document.getElementById("dangle")!.click();
|
||||
|
||||
expect(observed.defaulted).toBe(false);
|
||||
});
|
||||
|
||||
it("does not intercept non-fragment links", () => {
|
||||
document.body.innerHTML = `<a id="ext" href="https://example.com/x">Ext</a>`;
|
||||
installInterceptor();
|
||||
const observed = { defaulted: false };
|
||||
const listener = (e: Event) => {
|
||||
observed.defaulted = e.defaultPrevented;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener("click", listener);
|
||||
cleanup = () => document.removeEventListener("click", listener);
|
||||
|
||||
document.getElementById("ext")!.click();
|
||||
|
||||
expect(observed.defaulted).toBe(false);
|
||||
});
|
||||
|
||||
it("scrolls to target when click originates from a nested child of the anchor", () => {
|
||||
document.body.innerHTML = `
|
||||
<a id="outer" href="#costs"><span id="inner">💰 Costs</span></a>
|
||||
<section id="costs">Target</section>
|
||||
`;
|
||||
const scrollSpy = vi.fn();
|
||||
document.getElementById("costs")!.scrollIntoView = scrollSpy;
|
||||
|
||||
installInterceptor();
|
||||
document.getElementById("inner")!.click();
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles percent-encoded ids", () => {
|
||||
document.body.innerHTML = `
|
||||
<a id="enc" href="#top%20costs">Jump</a>
|
||||
<section id="top costs">Target</section>
|
||||
`;
|
||||
const scrollSpy = vi.fn();
|
||||
document.getElementById("top costs")!.scrollIntoView = scrollSpy;
|
||||
|
||||
installInterceptor();
|
||||
document.getElementById("enc")!.click();
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no CSP is exported", () => {
|
||||
it("does not export ARTIFACT_IFRAME_CSP", async () => {
|
||||
const mod = await import("../iframe-sandbox-csp");
|
||||
|
||||
@@ -32,6 +32,38 @@
|
||||
// changes (SRI is not possible because the JIT runtime is generated on demand).
|
||||
export const TAILWIND_CDN_URL = "https://cdn.tailwindcss.com/3.4.16";
|
||||
|
||||
// Sandboxed srcdoc iframes without `allow-same-origin` resolve `href="#id"` links
|
||||
// against the parent's URL as base. The default click then either navigates the
|
||||
// iframe to `<parent-url>#id` (reloading our app inside the iframe) or updates
|
||||
// the parent window's hash — both of which break the artifact preview.
|
||||
//
|
||||
// This script stays inside the iframe document and handles in-page anchor
|
||||
// navigation locally by scrolling to the element with the matching id.
|
||||
export const FRAGMENT_LINK_INTERCEPTOR_SCRIPT = `<script>
|
||||
(function() {
|
||||
if (document.__fragmentLinkInterceptor) return;
|
||||
function handler(e) {
|
||||
var t = e.target;
|
||||
if (!t || typeof t.closest !== 'function') return;
|
||||
var a = t.closest('a[href^="#"]');
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href');
|
||||
if (!href || href === '#') return;
|
||||
var id;
|
||||
try { id = decodeURIComponent(href.slice(1)); } catch (_) { id = href.slice(1); }
|
||||
if (!id) return;
|
||||
var target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
if (typeof target.scrollIntoView === 'function') {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
document.__fragmentLinkInterceptor = handler;
|
||||
document.addEventListener('click', handler);
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
/**
|
||||
* Inject content into the <head> of an HTML document string.
|
||||
* If the content has no <head> tag, wraps it in a full document skeleton.
|
||||
|
||||
Reference in New Issue
Block a user