// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createArtifactPane } from "./artifact-pane";
import type {
AgentWidgetConfig,
PersonaArtifactCustomAction,
PersonaArtifactRecord,
} from "../types";
beforeAll(() => {
// jsdom does not implement matchMedia; the pane's layout code touches it.
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
});
describe("createArtifactPane toolbar copy", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("uses configurable product-facing toolbar labels", () => {
const pane = createArtifactPane(
{
apiUrl: "/api/chat",
features: {
artifacts: {
enabled: true,
layout: {
toolbarTitle: "Analysis",
closeButtonLabel: "Back to chat",
},
},
},
},
{ onSelect: () => undefined },
);
document.body.appendChild(pane.element);
expect(pane.element.textContent).toContain("Analysis");
expect(pane.element.querySelector('[aria-label="Back to chat"]')).not.toBeNull();
});
});
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;
loading?:
| boolean
| {
delayMs?: number;
minVisibleMs?: number;
timeoutMs?: number;
injectReadySignal?: boolean;
};
}): 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 contentEl = (pane: ReturnType): HTMLElement =>
pane.element.querySelector(".persona-artifact-content") as HTMLElement;
describe("artifact-pane file preview", () => {
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 pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
const iframe = contentEl(pane).querySelector(
"iframe.persona-artifact-iframe"
) as HTMLIFrameElement;
expect(iframe).toBeTruthy();
// Pane path also wraps the iframe in the positioned frame.
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");
// srcdoc is the raw, unfenced source assigned as a property.
expect(iframe.srcdoc).toBe(HTML_RAW);
});
it("honors a custom iframeSandbox override", () => {
const pane = createArtifactPane(makeConfig({ iframeSandbox: "allow-scripts allow-forms" }), {
onSelect: () => {},
});
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
const iframe = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
});
it("shows source (no iframe) while streaming", () => {
const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
pane.update({
artifacts: [fileRecord({ status: "streaming", markdown: "```html\nhi" })],
selectedId: "a1",
});
const content = contentEl(pane);
expect(content.querySelector("iframe")).toBeNull();
const pre = content.querySelector("pre");
expect(pre).toBeTruthy();
expect(pre?.textContent).toBe("hi");
});
it("renders a markdown file through the markdown pipeline (no iframe)", () => {
const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
pane.update({
artifacts: [
fileRecord({
title: "notes.md",
markdown: wireFor("# Hello\n", "md"),
file: { path: "notes.md", mimeType: "text/markdown", language: "md" },
}),
],
selectedId: "a1",
});
const content = contentEl(pane);
expect(content.querySelector("iframe")).toBeNull();
expect(content.querySelector("pre")).toBeNull();
expect(content.querySelector(".persona-markdown-bubble")).toBeTruthy();
});
it("forces source view (no iframe) when filePreview.enabled is false", () => {
const pane = createArtifactPane(makeConfig({ enabled: false }), { onSelect: () => {} });
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
const content = contentEl(pane);
expect(content.querySelector("iframe")).toBeNull();
const pre = content.querySelector("pre");
expect(pre).toBeTruthy();
expect(pre?.textContent).toBe(HTML_RAW);
});
it("renders non-file markdown artifacts unchanged (markdown bubble, no iframe)", () => {
const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
pane.update({
artifacts: [
{
id: "m1",
artifactType: "markdown",
title: "Plain",
status: "complete",
markdown: "## Plain doc",
},
],
selectedId: "m1",
});
const content = contentEl(pane);
expect(content.querySelector("iframe")).toBeNull();
expect(content.querySelector(".persona-markdown-bubble")).toBeTruthy();
});
});
describe("artifact-pane lazy rendering (hidden pane)", () => {
it("does not build the preview iframe while the pane is hidden", () => {
// loading:false → an iframe would be built immediately if we rendered.
const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
pane.setVisible(false);
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
const content = contentEl(pane);
// No preview DOM at all while hidden: no iframe, and not even the source pre.
expect(content.querySelector("iframe")).toBeNull();
expect(content.querySelector("pre")).toBeNull();
});
it("renders the current recorded state on reveal (after lazy-skipped updates)", () => {
const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
pane.setVisible(false);
// Two hidden updates; the second one is the state that must render on reveal.
pane.update({ artifacts: [fileRecord({ id: "a1", title: "outputs/one.html" })], selectedId: "a1" });
pane.update({
artifacts: [
fileRecord({ id: "a1", title: "outputs/one.html" }),
fileRecord({
id: "a2",
title: "outputs/two.html",
file: { path: "outputs/two.html", mimeType: "text/html", language: "html" },
}),
],
selectedId: "a2",
});
expect(contentEl(pane).querySelector("iframe")).toBeNull();
pane.setVisible(true);
const iframe = contentEl(pane).querySelector(
"iframe.persona-artifact-iframe"
) as HTMLIFrameElement;
expect(iframe).toBeTruthy();
// Latest state won: the selected artifact is a2, so its id rode onto the iframe.
expect(iframe.getAttribute("data-artifact-id")).toBe("a2");
});
it("renders eagerly by default (visible path) when constructed directly — panel mode is unaffected", () => {
// No setVisible call: the pane defaults to visible, matching panel display
// mode where ui.ts drives setVisible(true) whenever records exist.
const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
expect(contentEl(pane).querySelector("iframe")).toBeTruthy();
});
it("keeps the already-rendered preview iframe (same node) across hide/show", () => {
const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
pane.setVisible(true);
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
const first = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
expect(first).toBeTruthy();
// Hide (user collapses): the mounted preview must survive so re-open does
// not reload the iframe.
pane.setVisible(false);
expect(contentEl(pane).querySelector("iframe")).toBe(first);
// Reveal again with the same artifact: same node, not a rebuild.
pane.setVisible(true);
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
expect(contentEl(pane).querySelector("iframe")).toBe(first);
});
it("does not build a NEW artifact's iframe while hidden, then renders it on reveal", () => {
const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
pane.setVisible(true);
pane.update({ artifacts: [fileRecord({ id: "a1" })], selectedId: "a1" });
const first = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
expect(first.getAttribute("data-artifact-id")).toBe("a1");
// Hide, then a NEW artifact arrives while hidden: no iframe rebuild yet.
pane.setVisible(false);
pane.update({
artifacts: [
fileRecord({ id: "a1" }),
fileRecord({
id: "a2",
title: "outputs/two.html",
file: { path: "outputs/two.html", mimeType: "text/html", language: "html" },
}),
],
selectedId: "a2",
});
// Still the old node (no render happened while hidden).
expect(contentEl(pane).querySelector("iframe")).toBe(first);
pane.setVisible(true);
const shown = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
expect(shown.getAttribute("data-artifact-id")).toBe("a2");
});
it("renders a streaming artifact live after reveal (source while streaming, iframe on complete)", () => {
const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
pane.setVisible(false);
// Still streaming when the pane opens.
pane.update({
artifacts: [fileRecord({ status: "streaming", markdown: "```html\nhi" })],
selectedId: "a1",
});
pane.setVisible(true);
// Streaming → raw source, no iframe yet.
expect(contentEl(pane).querySelector("iframe")).toBeNull();
expect(contentEl(pane).querySelector("pre")?.textContent).toBe("hi");
// Continues receiving updates while visible; completion swaps to the iframe.
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
expect(contentEl(pane).querySelector("pre")).toBeNull();
expect(contentEl(pane).querySelector("iframe.persona-artifact-iframe")).toBeTruthy();
});
});
const toggleBtn = (
pane: ReturnType,
label: string
): HTMLButtonElement =>
pane.element.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement;
describe("artifact-pane view/source toggle", () => {
it("renders an inline SVG icon in both toggle buttons (icon-registry guard)", () => {
const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
const rendered = toggleBtn(pane, "Rendered view");
const source = toggleBtn(pane, "Source");
expect(rendered).toBeTruthy();
expect(source).toBeTruthy();
// A missing/renamed icon would leave renderLucideIcon returning null and the
// button rendering empty, so guard that both carry an inline