// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import { renderArtifactPreviewBody, runArtifactBodyTransition, type ArtifactBodyLayout, } from "./artifact-preview"; import * as parsersLoader from "../markdown-parsers-loader"; import type { AgentWidgetConfig, PersonaArtifactRecord, PersonaArtifactStatusLabelContext, } from "../types"; import type { ComponentRenderer } from "./registry"; const layout = (o: Partial = {}): ArtifactBodyLayout => ({ streamingView: "source", viewMode: "rendered", streamingHeight: 320, completeHeight: 320, followOutput: true, overflow: "scroll", fadeTop: true, fadeBottom: false, transition: "auto", completeDisplay: "inline", ...o, }); const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); const ZWSP = "\u200b"; const HTML_RAW = "

hi

\n\n"; const wireFor = (raw: string, lang: string): string => "```" + lang + "\n" + raw.split("```").join("`" + ZWSP + "``") + "\n```"; const makeConfig = (filePreview?: { enabled?: boolean; iframeSandbox?: string; dangerouslyAllowSameOrigin?: boolean; loading?: | boolean | { delayMs?: number; minVisibleMs?: number; timeoutMs?: number; injectReadySignal?: boolean; label?: string | false; labelDelayMs?: number; renderIndicator?: (ctx: { artifactId: string; config: AgentWidgetConfig; }) => HTMLElement | null; }; }): AgentWidgetConfig => ({ sanitize: false, features: { artifacts: { enabled: true, ...(filePreview ? { filePreview } : {}) } }, }) as AgentWidgetConfig; const fileRecord = ( overrides: Partial = {} ): PersonaArtifactRecord => ({ id: "a1", artifactType: "markdown", title: "outputs/cat.html", status: "complete", markdown: wireFor(HTML_RAW, "html"), file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }, ...overrides, }); const markdownRecord = ( overrides: Partial = {} ): PersonaArtifactRecord => ({ id: "m1", artifactType: "markdown", title: "Notes", status: "streaming", markdown: "Hello", ...overrides, }); describe("artifact-preview markdown body", () => { it("renders a markdown bubble and applies streaming deltas via update()", () => { const handle = renderArtifactPreviewBody(markdownRecord(), { config: makeConfig(), }); const bubble = handle.el.querySelector(".persona-markdown-bubble"); expect(bubble).toBeTruthy(); expect(bubble?.textContent).toContain("Hello"); handle.update(markdownRecord({ markdown: "Hello world" })); const updated = handle.el.querySelector(".persona-markdown-bubble"); expect(updated?.textContent).toContain("Hello world"); // Still exactly one body child (updated in place, not appended). expect(handle.el.children.length).toBe(1); }); it("shows raw source when the host resolves 'source' view mode", () => { const handle = renderArtifactPreviewBody(markdownRecord({ markdown: "## Raw" }), { config: makeConfig(), resolveViewMode: () => "source", }); expect(handle.el.querySelector(".persona-markdown-bubble")).toBeNull(); const pre = handle.el.querySelector("pre"); expect(pre?.textContent).toBe("## Raw"); }); }); /** * Regression tests for the parser-ready race on the IIFE/CDN build, where * `marked` + `DOMPurify` load lazily. An artifact upserted right after * `initAgentWidget()` used to render as escaped plain text (literal * `# Welcome`, `**bold**`) and stayed that way until the next update() forced * a re-render — chat messages self-heal via the parser-ready re-render in * `createAgentExperience`, but this body only re-renders on update(). Worse, * the default sanitizer's own degraded fallback is `escapeHtml`, so the old * blanket `sanitize(md(text))` escaped twice and displayed literal entities * (`"`). * * `vitest.setup.ts` eager-provides parsers, so `getMarkdownParsersSync()` is * non-null by default -> that's the "loaded" path. The degraded path is * simulated by spying on the loader module (same approach as * ui.postprocess.test.ts). */ describe("artifact-preview parser-ready self-heal (parsers not loaded at first render)", () => { const RAW_MD = '# Welcome\n\n**bold** "quoted"'; const mdConfig = { markdown: {} } as AgentWidgetConfig; const mdArtifact = () => markdownRecord({ markdown: RAW_MD, status: "complete" }); afterEach(() => { vi.restoreAllMocks(); }); it("renders the escaped fallback exactly once, then re-renders as markdown when the chunk lands", async () => { // The real `onMarkdownParsersReady` no-ops here (vitest.setup.ts eager- // provides parsers into the loader's moduleCache), so spy on the module // namespace and capture the callback instead. artifact-preview.ts calls it // through the module binding, so the spy intercepts — same mechanism as the // getMarkdownParsersSync spy below. let readyCb: (() => void) | null = null; const readySpy = vi .spyOn(parsersLoader, "onMarkdownParsersReady") .mockImplementation((cb: () => void) => { readyCb = cb; return () => {}; }); const syncSpy = vi .spyOn(parsersLoader, "getMarkdownParsersSync") .mockReturnValue(null); const handle = renderArtifactPreviewBody(mdArtifact(), { config: mdConfig }); // Degraded first paint: escaped plain text, no markdown elements. expect(handle.el.querySelector("h1")).toBeNull(); expect(handle.el.textContent).toContain("# Welcome"); expect(handle.el.textContent).toContain("**bold**"); // Escaped exactly once: a double escape would leave a literal `"` // in the visible text instead of the quote character. expect(handle.el.textContent).toContain('"quoted"'); expect(handle.el.textContent).not.toContain("""); // A second render while the chunk is still loading must not re-subscribe. handle.update(mdArtifact()); expect(readySpy).toHaveBeenCalledTimes(1); // Chunk lands: the real getMarkdownParsersSync now returns the parsers // eager-provided by vitest.setup.ts, and the captured ready callback fires. syncSpy.mockRestore(); expect(readyCb).toBeTypeOf("function"); readyCb!(); // Self-healed: real markdown without any user interaction. expect(handle.el.querySelector("h1")?.textContent).toBe("Welcome"); expect(handle.el.querySelector("strong")?.textContent).toBe("bold"); expect(handle.el.textContent).not.toContain("**bold**"); }); it("does not schedule a re-render when parsers are already loaded", () => { const readySpy = vi.spyOn(parsersLoader, "onMarkdownParsersReady"); const handle = renderArtifactPreviewBody(mdArtifact(), { config: mdConfig }); expect(handle.el.querySelector("h1")?.textContent).toBe("Welcome"); expect(readySpy).not.toHaveBeenCalled(); }); }); describe("artifact-preview file body", () => { it("renders a sandboxed iframe (allow-scripts, no allow-same-origin) with srcdoc = raw source", () => { // loading:false → no injected reporter, so srcdoc is exactly the raw source. const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig({ loading: false }), }); const iframe = handle.el.querySelector( "iframe.persona-artifact-iframe" ) as HTMLIFrameElement; expect(iframe).toBeTruthy(); // The iframe is wrapped in a positioned frame that hosts the loading overlay. expect(iframe.parentElement?.classList.contains("persona-artifact-frame")).toBe( true ); expect(iframe.getAttribute("sandbox")).toBe("allow-scripts"); expect(iframe.getAttribute("sandbox")).not.toContain("allow-same-origin"); expect(iframe.getAttribute("data-artifact-id")).toBe("a1"); // srcdoc is the raw, unfenced source assigned as a property. expect(iframe.srcdoc).toBe(HTML_RAW); }); it("honors a custom iframeSandbox override", () => { const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig({ iframeSandbox: "allow-scripts allow-forms" }), }); const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement; expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-forms"); }); it("strips allow-same-origin from iframeSandbox and warns", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig({ iframeSandbox: "allow-scripts allow-same-origin" }), }); const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement; expect(iframe.getAttribute("sandbox")).toBe("allow-scripts"); expect(warn).toHaveBeenCalledOnce(); expect(warn.mock.calls[0][0]).toContain("allow-same-origin"); } finally { warn.mockRestore(); } }); it("keeps allow-same-origin when dangerouslyAllowSameOrigin is set", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig({ iframeSandbox: "allow-scripts allow-same-origin", dangerouslyAllowSameOrigin: true, }), }); const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement; expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); expect(warn).not.toHaveBeenCalled(); } finally { warn.mockRestore(); } }); it("forces source view (no iframe) when filePreview.enabled is false", () => { const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig({ enabled: false }), }); expect(handle.el.querySelector("iframe")).toBeNull(); const pre = handle.el.querySelector("pre"); expect(pre?.textContent).toBe(HTML_RAW); }); it("reuses the iframe node when the artifact and source are unchanged", () => { const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig() }); const first = handle.el.querySelector("iframe"); handle.update(fileRecord()); expect(handle.el.querySelector("iframe")).toBe(first); }); }); describe("artifact-preview status transitions", () => { it("shows source while streaming, then swaps to the iframe on complete", () => { const handle = renderArtifactPreviewBody( fileRecord({ status: "streaming", markdown: "```html\n

hi" }), { config: makeConfig() } ); expect(handle.el.querySelector("iframe")).toBeNull(); expect(handle.el.querySelector("pre")?.textContent).toBe("

hi"); handle.update(fileRecord({ status: "complete" })); expect(handle.el.querySelector("pre")).toBeNull(); const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement; expect(iframe).toBeTruthy(); // Default loading appends the ready reporter, so the raw source is the prefix. expect(iframe.srcdoc.startsWith(HTML_RAW)).toBe(true); }); }); describe("artifact-preview source highlighting", () => { it("renders line spans with token classes for an html file source view", () => { const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig(), resolveViewMode: () => "source", }); const pre = handle.el.querySelector("pre"); expect(pre?.querySelector("code.persona-code")).toBeTruthy(); // One line span per source line (HTML_RAW ends in "\n" → 2 numbered lines). const lines = handle.el.querySelectorAll(".persona-code-line"); expect(lines.length).toBe(2); // Tokenized: at least one tag span for

/