+ {currentPreview?.error + ? t.artifactPreview.previewFailed + : currentPreview?.failedResources + ? t.artifactPreview.resourcesUnavailable + : t.artifactPreview.loadingPreview} +
+ )} + +
`;
+ mockLangGraphAPI(page, {
+ threads: [
+ {
+ thread_id: threadId,
+ messages: presentFilesMessages(filepath),
+ artifacts: [filepath],
+ },
+ ],
+ });
+ await context.addCookies([
+ {
+ name: "access_token",
+ value: "preview-session",
+ url: baseURL!,
+ httpOnly: true,
+ sameSite: "Lax",
+ },
+ ]);
+ const resources: Array<{ filename: string; authenticated: boolean }> = [];
+ await page.route(
+ `**/api/threads/${threadId}/artifacts/mnt/user-data/outputs/*`,
+ async (route) => {
+ const filename = new URL(route.request().url()).pathname
+ .split("/")
+ .pop()!;
+ const headers = await route.request().allHeaders();
+ const authenticated =
+ headers.cookie?.includes("access_token=preview-session") ?? false;
+ if (filename !== "partial-report.html")
+ resources.push({ filename, authenticated });
+ if (!authenticated || filename === "expired.png")
+ return route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: '{"detail":"Unauthorized"}',
+ });
+ if (filename === "partial-report.html")
+ return route.fulfill({ contentType: "text/html", body: content });
+ await route.fulfill({
+ contentType: "image/svg+xml",
+ body: '',
+ });
+ },
+ );
+ await page.goto(`/workspace/chats/${threadId}`);
+ await page
+ .getByText("partial-report.html", { exact: true })
+ .first()
+ .click();
+ const panel = page.locator("#artifacts");
+ await expect(panel.getByRole("status")).toContainText(
+ "Some preview resources could not be loaded",
+ );
+ const iframe = panel.locator('iframe[title="Artifact preview"]');
+ await expect(iframe).toHaveAttribute(
+ "sandbox",
+ "allow-scripts allow-forms",
+ );
+ const frame = iframe.contentFrame();
+ await expect(frame.locator("h1")).toHaveText("Partial report");
+ const result = await frame.locator("body").evaluate(() => ({
+ mode: document.compatMode,
+ doctype: document.doctype
+ ? {
+ publicId: document.doctype.publicId,
+ systemId: document.doctype.systemId,
+ }
+ : null,
+ stylesheet: document.querySelector("svg style")?.textContent,
+ inline: document.querySelector("svg rect")?.getAttribute("style"),
+ comments: Array.from(document.childNodes)
+ .filter((node) => node.nodeType === Node.COMMENT_NODE)
+ .map((node) => node.textContent),
+ }));
+ expect(result.mode).toBe(fixture.mode);
+ expect(result.doctype).toEqual(
+ fixture.declaration
+ ? { publicId: fixture.publicId, systemId: fixture.systemId }
+ : null,
+ );
+ expect(result.stylesheet).toContain("data:image/svg+xml;base64,");
+ expect(result.inline).toContain("data:image/svg+xml;base64,");
+ expect(result.stylesheet).toContain("#blur");
+ expect(result.inline).toContain("#paint");
+ expect(result.comments).toEqual(["before", "after"]);
+ expect(resources).toHaveLength(3);
+ expect(resources.every((resource) => resource.authenticated)).toBe(true);
+ await expect(page).toHaveURL(new RegExp(`/workspace/chats/${threadId}$`));
+ });
+ }
+
+ test("loads protected HTML sibling resources while retaining an opaque sandbox", async ({
+ page,
+ context,
+ baseURL,
+ }, testInfo) => {
+ const threadId = "00000000-0000-0000-0000-000000003140";
+ const filepath = "/mnt/user-data/outputs/authenticated-report.html";
+ const stylesheet =
+ '@import "nested.css"; h1{background-image:url(chart.png)}';
+ const integrity = `sha256-${createHash("sha256").update(stylesheet).digest("base64")}`;
+ const content = `
`;
+ mockLangGraphAPI(page, {
+ threads: [
+ {
+ thread_id: threadId,
+ messages: presentFilesMessages(filepath),
+ artifacts: [filepath],
+ },
+ ],
+ });
+ await context.addCookies([
+ {
+ name: "access_token",
+ value: "preview-session",
+ url: baseURL!,
+ httpOnly: true,
+ sameSite: "Lax",
+ },
+ ]);
+ const resources: Array<{ filename: string; authenticated: boolean }> = [];
+ await page.route(
+ `**/api/threads/${threadId}/artifacts/mnt/user-data/outputs/*`,
+ async (route) => {
+ const filename = new URL(route.request().url()).pathname
+ .split("/")
+ .pop()!;
+ const headers = await route.request().allHeaders();
+ const authenticated =
+ headers.cookie?.includes("access_token=preview-session") ?? false;
+ if (filename !== "authenticated-report.html")
+ resources.push({ filename, authenticated });
+ if (!authenticated)
+ return route.fulfill({ status: 401, body: "unauthorized" });
+ const bodies: Record<
+ string,
+ { contentType: string; body: string | Buffer }
+ > = {
+ "authenticated-report.html": {
+ contentType: "text/html",
+ body: content,
+ },
+ "style.css": {
+ contentType: "text/css",
+ body: stylesheet,
+ },
+ "nested.css": {
+ contentType: "text/css",
+ body: "h1{color:rgb(1,2,3)}",
+ },
+ "app.js": {
+ contentType: "application/javascript",
+ body: "window.addEventListener('DOMContentLoaded',()=>{document.body.dataset.scriptLoaded='true'})",
+ },
+ "chart.png": {
+ contentType: "image/png",
+ body: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yA1kAAAAASUVORK5CYII=",
+ "base64",
+ ),
+ },
+ };
+ const response = bodies[filename];
+ if (!response) return route.fulfill({ status: 404 });
+ await route.fulfill(response);
+ },
+ );
+ await page.goto(`/workspace/chats/${threadId}`);
+ await page
+ .getByText("authenticated-report.html", { exact: true })
+ .first()
+ .click();
+ const iframe = page.locator('#artifacts iframe[title="Artifact preview"]');
+ await expect(iframe).toHaveAttribute(
+ "sandbox",
+ "allow-scripts allow-forms",
+ );
+ const frame = iframe.contentFrame();
+ await expect(frame.locator("body")).toHaveAttribute(
+ "data-script-loaded",
+ "true",
+ );
+ await expect(frame.locator("h1")).toHaveCSS("color", "rgb(1, 2, 3)");
+ await expect
+ .poll(() =>
+ frame
+ .locator("#chart")
+ .evaluate((image) => (image as HTMLImageElement).naturalWidth),
+ )
+ .toBe(1);
+ expect(resources).toHaveLength(4);
+ expect(resources.every((resource) => resource.authenticated)).toBe(true);
+ expect(
+ await frame.locator("body").evaluate(() => {
+ try {
+ return !!window.parent.document;
+ } catch {
+ return false;
+ }
+ }),
+ ).toBe(false);
+ expect(
+ await frame.locator("body").evaluate(() => {
+ try {
+ return document.cookie;
+ } catch {
+ return "blocked";
+ }
+ }),
+ ).toBe("blocked");
+ await iframe.screenshot({ path: testInfo.outputPath("html-preview.png") });
+ });
+
for (const [index, filename] of ["constructor", "data.__proto__"].entries()) {
test(`keeps ${filename} artifacts on the download fallback`, async ({
page,
diff --git a/frontend/tests/unit/components/workspace/artifacts/artifact-file-preview.dom.test.tsx b/frontend/tests/unit/components/workspace/artifacts/artifact-file-preview.dom.test.tsx
new file mode 100644
index 0000000000..a7aead41c7
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/artifacts/artifact-file-preview.dom.test.tsx
@@ -0,0 +1,81 @@
+import { afterEach, expect, rs, test } from "@rstest/core";
+import { act, cleanup, render, waitFor } from "@testing-library/react";
+
+import { ArtifactFilePreview } from "@/components/workspace/artifacts/artifact-file-preview";
+import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
+import { I18nContext } from "@/core/i18n/context";
+import { enUS } from "@/core/i18n/locales/en-US";
+
+rs.mock("@/core/artifacts/html-assets", () => ({
+ prepareHtmlPreview: rs.fn(),
+}));
+rs.mock("@/core/streamdown/components", () => ({
+ SafeStreamdown: () => null,
+ toStreamdownComponents: () => ({}),
+}));
+
+afterEach(() => {
+ cleanup();
+ rs.restoreAllMocks();
+ rs.mocked(prepareHtmlPreview).mockReset();
+});
+
+test("cancels obsolete preparation, hides previous content and releases document URLs", async () => {
+ let resolveOld!: (
+ result: Awaited
',
+ url,
+ );
+ expect(result.failedResources).toBe(1);
+ expect(result.content).toContain("Report");
+ expect(result.content).toContain('src="data:,"');
+ expect(navigate).not.toHaveBeenCalled();
+ expect(request).toHaveBeenCalledWith(
+ url.replace("report.html", "chart.png"),
+ expect.objectContaining({ credentials: "include", redirect: "error" }),
+ );
+});
diff --git a/frontend/tests/unit/core/artifacts/html-assets.dom.test.ts b/frontend/tests/unit/core/artifacts/html-assets.dom.test.ts
new file mode 100644
index 0000000000..4a926abba1
--- /dev/null
+++ b/frontend/tests/unit/core/artifacts/html-assets.dom.test.ts
@@ -0,0 +1,395 @@
+import { afterEach, expect, rs, test } from "@rstest/core";
+
+import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
+
+const url =
+ "https://app.example/api/threads/chat/artifacts/mnt/user-data/outputs/report.html";
+const request = rs.spyOn(globalThis, "fetch");
+
+afterEach(() => {
+ request.mockReset();
+});
+
+test("preserves missing and legacy doctypes and comments outside the HTML element", async () => {
+ for (const declaration of [
+ "",
+ "",
+ '',
+ '',
+ ]) {
+ const content = `${declaration}
',
+ url,
+ );
+ expect(result.failedResources).toBe(0);
+ expect(
+ request.mock.calls.map(([input]) =>
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url,
+ ),
+ ).toEqual([
+ url.replace("report.html", "main.css"),
+ url.replace("report.html", "nested.css"),
+ url.replace("outputs/report.html", "uploads/icon.png"),
+ url.replace("report.html", "chart%2520.png"),
+ url.replace("report.html", "app.js"),
+ ]);
+ expect(result.content).not.toContain('src="chart%2520.png"');
+ const document = new DOMParser().parseFromString(result.content, "text/html");
+ const css = atob(
+ document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
+ );
+ expect(css).toContain("data:text/css");
+ expect(
+ request.mock.calls.every(([, init]) => init?.redirect === "error"),
+ ).toBe(true);
+});
+
+test("never fetches another thread, external origin, non-artifact route or encoded separators", async () => {
+ const result = await prepareHtmlPreview(
+ `


',
+ url,
+ );
+ expect(request).toHaveBeenCalledTimes(3);
+ expect(result.failedResources).toBe(2);
+ expect(result.content).toContain("Report");
+ expect(result.content).not.toContain('src="missing.png"');
+});
+
+test("rewrites inline CSS and ignores commented URL text", async () => {
+ request.mockResolvedValue(new Response("image"));
+ const result = await prepareHtmlPreview(
+ '',
+ url,
+ );
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(result.content).toContain("/*url(no.png)*/");
+ expect(result.content).toContain("data:");
+});
+
+test("leaves public demos and previews without a resource URL on their existing path", async () => {
+ const content = '
';
+ for (const value of [
+ undefined,
+ "https://app.example/demo/threads/chat/report.html",
+ ]) {
+ const result = await prepareHtmlPreview(content, value);
+ expect(result.content).toContain(content);
+ expect(request).not.toHaveBeenCalled();
+ }
+});
+
+test("preserves external bases and escaped external CSS references", async () => {
+ const result = await prepareHtmlPreview(
+ '
',
+ url,
+ );
+ expect(request).not.toHaveBeenCalled();
+ expect(result.content).toContain('src="image.png"');
+ expect(result.content).toContain("a\\)b.png");
+});
+
+test("supports responsive pictures, SVG fragments and CSS escaped filenames", async () => {
+ request.mockImplementation(async () => new Response("image"));
+ const result = await prepareHtmlPreview(
+ '
`).join(""),
+ url,
+ );
+ expect(request).toHaveBeenCalledTimes(32);
+ expect(result.failedResources).toBe(8);
+ request.mockClear();
+ request.mockImplementation(async (input) => {
+ const name = new URL(
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url,
+ ).pathname
+ .split("/")
+ .pop()!;
+ return new Response(`@import "${Number.parseInt(name) + 1}.css";`);
+ });
+ const nested = await prepareHtmlPreview(
+ '',
+ url,
+ );
+ expect(request).toHaveBeenCalledTimes(9);
+ expect(nested.failedResources).toBe(1);
+});
+
+test("caps streamed resource bytes and the aggregate budget", async () => {
+ request.mockImplementation(
+ async () => new Response(new Uint8Array(4 * 1024 * 1024 + 1)),
+ );
+ const oversized = await prepareHtmlPreview('
', url);
+ expect(oversized.failedResources).toBe(1);
+ expect(oversized.content).toContain('src="data:,"');
+ request.mockClear();
+ request.mockImplementation(
+ async () => new Response(new Uint8Array(4 * 1024 * 1024)),
+ );
+ const aggregate = await prepareHtmlPreview(
+ Array.from({ length: 5 }, (_, i) => `
`).join(""),
+ url,
+ );
+ expect(aggregate.failedResources).toBe(1);
+ expect(request).toHaveBeenCalledTimes(4);
+});
+
+test("bounds repeated data URLs and preserves out-of-route CSS resolution", async () => {
+ request.mockImplementation(
+ async () => new Response(new Uint8Array(4 * 1024 * 1024)),
+ );
+ const repeated = await prepareHtmlPreview(
+ Array.from({ length: 8 }, () => '
').join(""),
+ url,
+ );
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(repeated.failedResources).toBeGreaterThan(0);
+ expect(repeated.content.length).toBeLessThan(24 * 1024 * 1024);
+ request.mockClear();
+ request.mockResolvedValue(
+ new Response(".x{background:url(/public/chart.png)}"),
+ );
+ const css = await prepareHtmlPreview(
+ '',
+ url,
+ );
+ const document = new DOMParser().parseFromString(css.content, "text/html");
+ const stylesheet = atob(
+ document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
+ );
+ expect(stylesheet).toContain("https://app.example/public/chart.png");
+ expect(request).toHaveBeenCalledTimes(1);
+});
+
+test("quotes absolute CSS URLs that replace escaped unquoted references", async () => {
+ request.mockResolvedValue(
+ new Response(".chart{background:url(//cdn.example/chart\\(1\\).png)}"),
+ );
+ const result = await prepareHtmlPreview(
+ '',
+ url,
+ );
+ const document = new DOMParser().parseFromString(result.content, "text/html");
+ const stylesheet = atob(
+ document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
+ );
+ expect(stylesheet).toContain('url("https://cdn.example/chart(1).png")');
+ expect(result.failedResources).toBe(0);
+});
+
+test("aborts late responses before exposing prepared HTML", async () => {
+ const controller = new AbortController();
+ request.mockImplementation(async (input) => {
+ if (
+ (typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url
+ ).endsWith("late.png")
+ )
+ controller.abort();
+ return new Response("image");
+ });
+ await expect(
+ prepareHtmlPreview(
+ '
',
+ url,
+ controller.signal,
+ ),
+ ).rejects.toThrow();
+});
+
+test("validates original integrity before rewriting resources", async () => {
+ request.mockImplementation(async () => new Response("body"));
+ const result = await prepareHtmlPreview(
+ '',
+ url,
+ );
+ expect(request.mock.calls.map(([, init]) => init?.integrity)).toEqual([
+ "sha256-original",
+ "sha256-script",
+ ]);
+ expect(result.content).not.toContain("sha256-original");
+ expect(result.content).not.toContain("sha256-script");
+ expect(result.content).toContain("sha256-external");
+});
+
+test("never reuses an unverified resource for a distinct integrity constraint", async () => {
+ request.mockImplementation(async (_input, init) => {
+ if (init?.integrity === "sha256-wrong")
+ throw new TypeError("Integrity mismatch");
+ return new Response("image");
+ });
+ const result = await prepareHtmlPreview(
+ '

',
+ url,
+ );
+ expect(request.mock.calls.map(([, init]) => init?.integrity)).toEqual([
+ undefined,
+ "sha256-valid",
+ "sha256-wrong",
+ ]);
+ expect(result.failedResources).toBe(1);
+ expect(result.content).toContain('src="data:,"');
+});
+
+test("returns a partial preview when the preparation deadline expires", async () => {
+ rs.useFakeTimers();
+ try {
+ request.mockImplementation(
+ (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener(
+ "abort",
+ () => reject(new DOMException("Aborted", "AbortError")),
+ { once: true },
+ );
+ }),
+ );
+ const pending = prepareHtmlPreview(
+ '
',
+ url,
+ );
+ await rs.advanceTimersByTimeAsync(10_000);
+ const result = await pending;
+ expect(result.failedResources).toBe(2);
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(result.content).toContain("Report");
+ } finally {
+ rs.useRealTimers();
+ }
+});