// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import { PersonaArtifactInline, resolveInlineBody, updateInlineArtifactBlocks } from "./artifact-inline"; import { componentRegistry } from "./registry"; import type { AgentWidgetArtifactsFeature, AgentWidgetConfig, AgentWidgetMessage, PersonaArtifactRecord, PersonaArtifactStatusLabelContext } from "../types"; import type { ComponentContext } from "./registry"; const ZWSP = "\u200b"; const HTML_RAW = "
node survives the streaming→complete boundary (in-place update).
expect(el.querySelector(".persona-code-pre")).toBe(preBefore);
});
});
describe("PersonaArtifactInline inlineBody flush and sized wrappers", () => {
it("streams source full-bleed in a sized wrapper (pane-style flush code)", () => {
const el = PersonaArtifactInline(
{
artifactId: "f1",
title: "outputs/cat.html",
artifactType: "markdown",
status: "streaming",
markdown: "```html\nhi",
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
},
makeContext(makeConfig())
);
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(true);
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
});
it("keeps rendered markdown padded (no flush, no sized wrapper)", () => {
const el = PersonaArtifactInline(completeProps(), makeContext(makeConfig()));
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(false);
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(false);
expect(bodyEl.classList.contains("persona-artifact-inline-body--cap")).toBe(true);
});
it("sizes the wrapper for the complete iframe (padded, same outer box)", () => {
const el = PersonaArtifactInline(
{
artifactId: "f1",
title: "outputs/cat.html",
artifactType: "markdown",
status: "complete",
markdown: wireFor(HTML_RAW, "html"),
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
},
makeContext(makeConfig())
);
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
expect(el.querySelector("iframe")).toBeTruthy();
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(false);
});
});
describe("PersonaArtifactInline view toggle", () => {
// The toggle is a segmented group (createToggleGroup) matching the pane: two
// always-visible buttons — "Rendered view" (eye) and "Source" (code-xml) — with
// the active one carrying aria-pressed="true". The whole group is hidden via
// persona-hidden when the artifact has no rendered alternative, and omitted
// entirely for showViewToggle: false.
const groupOf = (el: HTMLElement): HTMLElement | null =>
el.querySelector(".persona-artifact-toggle-group");
const renderedBtn = (el: HTMLElement): HTMLElement =>
el.querySelector('button[aria-label="Rendered view"]') as HTMLElement;
const sourceBtn = (el: HTMLElement): HTMLElement =>
el.querySelector('button[aria-label="Source"]') as HTMLElement;
const isHidden = (node: HTMLElement | null): boolean =>
!!node && node.classList.contains("persona-hidden");
const pressed = (btn: HTMLElement): boolean =>
btn.getAttribute("aria-pressed") === "true";
const htmlFileProps = (
status: "streaming" | "complete"
): Record => ({
artifactId: "f1",
title: "outputs/cat.html",
artifactType: "markdown",
status,
markdown: status === "streaming" ? "```html\nhi" : wireFor(HTML_RAW, "html"),
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
});
const htmlCompleteRecord = (): PersonaArtifactRecord => ({
id: "f1",
artifactType: "markdown",
title: "outputs/cat.html",
status: "complete",
markdown: wireFor(HTML_RAW, "html"),
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
});
it("shows the segmented toggle on a complete html file artifact", () => {
const el = PersonaArtifactInline(
htmlFileProps("complete"),
makeContext(makeConfig())
);
const group = groupOf(el);
expect(group).not.toBeNull();
expect(isHidden(group)).toBe(false);
// Both segments present; rendered is the default active segment.
expect(renderedBtn(el)).not.toBeNull();
expect(sourceBtn(el)).not.toBeNull();
expect(pressed(renderedBtn(el))).toBe(true);
expect(pressed(sourceBtn(el))).toBe(false);
// Placed between custom actions and the copy button.
const copy = el.querySelector("[data-copy-artifact]") as HTMLElement;
expect(
group!.compareDocumentPosition(copy) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
it("hides the group while streaming", () => {
const el = PersonaArtifactInline(
htmlFileProps("streaming"),
makeContext(makeConfig({ loadingAnimation: "none" }))
);
expect(isHidden(groupOf(el))).toBe(true);
});
it("hides the group for plain markdown artifacts", () => {
const el = PersonaArtifactInline(completeProps(), makeContext(makeConfig()));
expect(isHidden(groupOf(el))).toBe(true);
});
it("hides the group for component artifacts", () => {
const name = "TestInlineToggleChart";
componentRegistry.register(name, () => {
const node = document.createElement("div");
node.className = "test-toggle-chart";
return node;
});
try {
const el = PersonaArtifactInline(
{
artifactId: "c1",
title: "Chart",
artifactType: "component",
status: "complete",
component: name,
componentProps: {}
},
makeContext(makeConfig())
);
expect(isHidden(groupOf(el))).toBe(true);
} finally {
componentRegistry.unregister(name);
}
});
it("hides the group for a source-only (kind 'other') file artifact", () => {
const el = PersonaArtifactInline(
{
artifactId: "o1",
title: "script.py",
artifactType: "markdown",
status: "complete",
markdown: wireFor("print(1)\n", "python"),
file: { path: "script.py", mimeType: "text/x-python", language: "python" }
},
makeContext(makeConfig())
);
expect(isHidden(groupOf(el))).toBe(true);
});
it("hides the group when filePreview is disabled for an html file", () => {
const el = PersonaArtifactInline(
htmlFileProps("complete"),
makeContext(makeConfig({ filePreview: { enabled: false } }))
);
expect(isHidden(groupOf(el))).toBe(true);
});
it("hides the group when inlineBody.viewMode is 'source'", () => {
const el = PersonaArtifactInline(
htmlFileProps("complete"),
makeContext(makeConfig({ inlineBody: { viewMode: "source" } }))
);
expect(isHidden(groupOf(el))).toBe(true);
});
it("omits the group entirely for inlineChrome { showViewToggle: false }", () => {
const el = PersonaArtifactInline(
htmlFileProps("complete"),
makeContext(makeConfig({ inlineChrome: { showViewToggle: false } }))
);
expect(groupOf(el)).toBeNull();
// Copy + expand remain.
expect(el.querySelector("[data-copy-artifact]")).not.toBeNull();
expect(el.querySelector("[data-expand-artifact-inline]")).not.toBeNull();
});
it("flips iframe -> source and back when clicking the inactive segment", () => {
const el = PersonaArtifactInline(
htmlFileProps("complete"),
makeContext(makeConfig())
);
expect(el.querySelector("iframe")).toBeTruthy();
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
// rendered -> source
sourceBtn(el).click();
expect(el.querySelector("iframe")).toBeNull();
expect(el.querySelector(".persona-code-pre")).toBeTruthy();
expect(pressed(sourceBtn(el))).toBe(true);
expect(pressed(renderedBtn(el))).toBe(false);
// Source view recomputes to full-bleed + sized.
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(true);
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
// source -> rendered
renderedBtn(el).click();
expect(el.querySelector("iframe")).toBeTruthy();
expect(el.querySelector(".persona-code-pre")).toBeNull();
expect(pressed(renderedBtn(el))).toBe(true);
expect(pressed(sourceBtn(el))).toBe(false);
// Preview iframe is padded (not flush) but still sized (same outer box).
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(false);
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
});
it("is a no-op when clicking the already-active segment", () => {
const el = PersonaArtifactInline(
htmlFileProps("complete"),
makeContext(makeConfig())
);
const iframeBefore = el.querySelector("iframe");
expect(iframeBefore).toBeTruthy();
// Clicking the active (rendered) segment does not re-render the body: the
// same iframe node survives and the source view is never built.
renderedBtn(el).click();
expect(el.querySelector("iframe")).toBe(iframeBefore);
expect(el.querySelector(".persona-code-pre")).toBeNull();
expect(pressed(renderedBtn(el))).toBe(true);
expect(pressed(sourceBtn(el))).toBe(false);
});
it("resets to the configured default view when the block restarts streaming", () => {
const container = document.createElement("div");
const el = PersonaArtifactInline(
htmlFileProps("complete"),
makeContext(makeConfig({ loadingAnimation: "none" }))
);
container.appendChild(el);
// Toggle to source, then a streaming restart clears the choice.
sourceBtn(el).click();
expect(el.querySelector(".persona-code-pre")).toBeTruthy();
updateInlineArtifactBlocks(container, [
{ ...htmlCompleteRecord(), status: "streaming", markdown: "```html\nhi" }
]);
updateInlineArtifactBlocks(container, [htmlCompleteRecord()]);
// Back on the rendered default (iframe), the group's selection reset.
expect(el.querySelector("iframe")).toBeTruthy();
expect(pressed(renderedBtn(el))).toBe(true);
expect(pressed(sourceBtn(el))).toBe(false);
});
});
describe("resolveInlineBody completeDisplay", () => {
it("defaults completeDisplay to 'inline'", () => {
const layout = resolveInlineBody({} as AgentWidgetArtifactsFeature);
expect(layout.completeDisplay).toBe("inline");
});
it("resolves completeDisplay 'card' when set", () => {
const layout = resolveInlineBody({
inlineBody: { completeDisplay: "card" }
} as AgentWidgetArtifactsFeature);
expect(layout.completeDisplay).toBe("card");
});
});
describe("PersonaArtifactInline completeDisplay 'card'", () => {
const cardConfig = () => makeConfig({ inlineBody: { completeDisplay: "card" } });
it("keeps the streamed inline body while streaming (no card yet)", () => {
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
// Streaming phase renders the normal inline chrome + body, not the card.
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
expect(el.querySelector(".persona-artifact-inline-body")).not.toBeNull();
expect(el.querySelector(".persona-artifact-card")).toBeNull();
expect(el.classList.contains("persona-artifact-inline--card")).toBe(false);
});
it("collapses chrome + body to the card on the streaming→complete boundary, same root", () => {
const container = document.createElement("div");
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
container.appendChild(el);
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
updateInlineArtifactBlocks(container, [
{
id: "a1",
artifactType: "markdown",
title: "Notes",
status: "complete",
markdown: "# Done"
}
]);
// Same root element, data-artifact-inline + theme zone preserved.
expect(container.firstElementChild).toBe(el);
expect(el.getAttribute("data-artifact-inline")).toBe("a1");
expect(el.getAttribute("data-persona-theme-zone")).toBe("artifact-inline");
// Chrome + body replaced by the reference card.
expect(el.querySelector(".persona-artifact-inline-chrome")).toBeNull();
expect(el.querySelector(".persona-artifact-inline-body")).toBeNull();
const card = el.querySelector(".persona-artifact-card") as HTMLElement | null;
expect(card).not.toBeNull();
expect(card!.getAttribute("data-open-artifact")).toBe("a1");
expect(card!.textContent).toContain("Notes");
// Frame styling neutralized so the nested card is the sole visual.
expect(el.classList.contains("persona-artifact-inline--card")).toBe(true);
});
it("does not run the View Transition on the card-collapse boundary", () => {
type VTStub = {
startViewTransition?: (cb: () => void) => { finished: Promise };
};
const start = vi.fn((cb: () => void) => {
cb();
return { finished: Promise.resolve() };
});
(document as unknown as VTStub).startViewTransition = start;
try {
const container = document.createElement("div");
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
container.appendChild(el);
updateInlineArtifactBlocks(container, [
{
id: "a1",
artifactType: "markdown",
title: "Notes",
status: "complete",
markdown: "# Done"
}
]);
// The plain-CSS collapse replaces the body View Transition.
expect(start).not.toHaveBeenCalled();
expect(el.querySelector(".persona-artifact-card")).not.toBeNull();
} finally {
delete (document as unknown as VTStub).startViewTransition;
}
});
it("hydrates an already-complete block straight to the card (no inline body flash)", () => {
const el = PersonaArtifactInline(completeProps(), makeContext(cardConfig()));
expect(el.querySelector(".persona-artifact-inline-chrome")).toBeNull();
expect(el.querySelector(".persona-artifact-inline-body")).toBeNull();
const card = el.querySelector(".persona-artifact-card") as HTMLElement | null;
expect(card).not.toBeNull();
expect(card!.getAttribute("data-open-artifact")).toBe("a1");
expect(el.classList.contains("persona-artifact-inline--card")).toBe(true);
});
it("keeps routing records to the block after the collapse (card stays in sync)", () => {
const container = document.createElement("div");
const el = PersonaArtifactInline(
completeProps(),
makeContext(cardConfig())
);
container.appendChild(el);
expect(el.querySelector(".persona-artifact-card")?.textContent).toContain(
"Notes"
);
updateInlineArtifactBlocks(container, [
{
id: "a1",
artifactType: "markdown",
title: "Renamed",
status: "complete",
markdown: "# Done"
}
]);
const card = el.querySelector(".persona-artifact-card") as HTMLElement | null;
expect(card).not.toBeNull();
expect(card!.textContent).toContain("Renamed");
});
it("leaves no explicit height on the root in the jsdom instant path", () => {
const container = document.createElement("div");
// Detached container → root is not connected → collapse degrades to an
// instant swap, leaving no inline height/overflow/transition behind.
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
container.appendChild(el);
updateInlineArtifactBlocks(container, [
{
id: "a1",
artifactType: "markdown",
title: "Notes",
status: "complete",
markdown: "# Done"
}
]);
expect(el.style.height).toBe("");
expect(el.style.overflow).toBe("");
expect(el.style.transition).toBe("");
expect(
el.style.getPropertyValue("--persona-artifact-inline-body-height")
).toBe("");
});
it("default 'inline' completeDisplay stays inline through completion (no card)", () => {
const container = document.createElement("div");
const el = PersonaArtifactInline(streamingProps(), makeContext(makeConfig()));
container.appendChild(el);
updateInlineArtifactBlocks(container, [
{
id: "a1",
artifactType: "markdown",
title: "Notes",
status: "complete",
markdown: "# Done"
}
]);
expect(el.querySelector(".persona-artifact-card")).toBeNull();
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
expect(el.querySelector(".persona-artifact-inline-body")).not.toBeNull();
});
});
describe("PersonaArtifactInline clip-mode expand hitbox", () => {
const fileStreamingProps = (): Record => ({
artifactId: "f1",
title: "outputs/cat.html",
artifactType: "markdown",
status: "streaming",
markdown: "```html\nhi",
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
});
const bodyOf = (el: HTMLElement): HTMLElement =>
el.querySelector(".persona-artifact-inline-body") as HTMLElement;
it("marks the body as an expand hitbox in clip mode with expand enabled", () => {
const el = PersonaArtifactInline(
fileStreamingProps(),
makeContext(makeConfig({ inlineBody: { overflow: "clip" } }))
);
const body = bodyOf(el);
expect(body.getAttribute("data-expand-artifact-inline")).toBe("f1");
expect(body.getAttribute("role")).toBe("button");
expect(body.getAttribute("tabindex")).toBe("0");
expect(body.classList.contains("persona-cursor-pointer")).toBe(true);
// File artifacts use the basename as the chrome title.
expect(body.getAttribute("aria-label")).toBe("Open cat.html in panel");
});
it("does not mark the body when overflow is 'scroll' (default)", () => {
const el = PersonaArtifactInline(
fileStreamingProps(),
makeContext(makeConfig({ inlineBody: { overflow: "scroll" } }))
);
expect(
bodyOf(el).hasAttribute("data-expand-artifact-inline")
).toBe(false);
});
it("does not mark the body in clip mode when expand is disabled", () => {
const el = PersonaArtifactInline(
fileStreamingProps(),
makeContext(
makeConfig({
inlineBody: { overflow: "clip" },
inlineChrome: { showExpand: false }
})
)
);
expect(
bodyOf(el).hasAttribute("data-expand-artifact-inline")
).toBe(false);
});
it("does not mark the body in clip mode when chrome is off (no expand control)", () => {
const el = PersonaArtifactInline(
fileStreamingProps(),
makeContext(
makeConfig({ inlineBody: { overflow: "clip" }, inlineChrome: false })
)
);
expect(
bodyOf(el).hasAttribute("data-expand-artifact-inline")
).toBe(false);
});
});
describe("PersonaArtifactInline statusLabel (inline chrome)", () => {
const rec = (markdown: string): PersonaArtifactRecord => ({
id: "a1",
artifactType: "markdown",
title: "Notes",
status: "streaming",
markdown
});
const mount = (artifacts: Record) => {
const el = PersonaArtifactInline(
streamingProps(),
makeContext(makeConfig(artifacts))
);
const container = document.createElement("div");
container.appendChild(el);
return { el, container };
};
const labelEl = (el: HTMLElement): HTMLElement | null =>
el.querySelector(
".persona-artifact-inline-status .persona-artifact-status-label"
);
const detailEl = (el: HTMLElement): HTMLElement | null =>
el.querySelector(
".persona-artifact-inline-status .persona-artifact-status-detail"
);
it("passes the inline-chrome surface and streamed counts; content() is lazy", () => {
const calls: PersonaArtifactStatusLabelContext[] = [];
const { el, container } = mount({
loadingAnimation: "none",
statusLabel: (ctx: PersonaArtifactStatusLabelContext) => {
calls.push(ctx);
return { label: "Writing", detail: `${ctx.lines} lines` };
}
});
updateInlineArtifactBlocks(container, [rec("line one\nline two")]);
const last = calls[calls.length - 1];
expect(last.surface).toBe("inline-chrome");
expect(last.artifactId).toBe("a1");
expect(last.artifactType).toBe("markdown");
expect(last.chars).toBe("line one\nline two".length);
expect(last.lines).toBe(2);
// content() returns the accumulated markdown only when the host calls it.
expect(last.content()).toBe("line one\nline two");
expect(detailEl(el)?.textContent).toBe("2 lines");
});
it("updates the detail per delta while keeping the animated label element stable", () => {
const { el, container } = mount({
loadingAnimation: "none",
statusLabel: (ctx: PersonaArtifactStatusLabelContext) => ({
label: "Writing",
detail: `${ctx.chars}`
})
});
updateInlineArtifactBlocks(container, [rec("Hello")]);
const label1 = labelEl(el);
expect(detailEl(el)?.textContent).toBe("5");
updateInlineArtifactBlocks(container, [rec("Hello world")]);
const label2 = labelEl(el);
expect(detailEl(el)?.textContent).toBe("11");
// Same node: an unchanged label is never re-applied, so the loading
// animation does not restart on a detail-only update.
expect(label1).not.toBeNull();
expect(label1).toBe(label2);
});
it("re-applies the label element when the label text changes", () => {
const { el, container } = mount({
loadingAnimation: "none",
statusLabel: (ctx: PersonaArtifactStatusLabelContext) => ({
label: ctx.chars > 6 ? "Almost done" : "Writing"
})
});
updateInlineArtifactBlocks(container, [rec("short")]);
expect(labelEl(el)?.textContent).toBe("Writing");
updateInlineArtifactBlocks(container, [rec("a longer body")]);
expect(labelEl(el)?.textContent).toBe("Almost done");
});
it("replaces the default status with a plain string", () => {
const { el, container } = mount({
loadingAnimation: "none",
statusLabel: "One moment..."
});
updateInlineArtifactBlocks(container, [rec("Hi")]);
expect(labelEl(el)?.textContent).toBe("One moment...");
expect(
el.querySelector(".persona-artifact-inline-status")?.textContent
).not.toContain("Generating");
});
it("falls back to the default label when the function throws", () => {
const { el, container } = mount({
loadingAnimation: "none",
statusLabel: () => {
throw new Error("boom");
}
});
updateInlineArtifactBlocks(container, [rec("Hi")]);
// Plain markdown artifact (no file) → typeLabel "Document".
expect(labelEl(el)?.textContent).toBe("Generating document...");
});
it("still applies the loading animation to the label span by default", () => {
const { el, container } = mount({
statusLabel: () => ({ label: "Writing", detail: "x" })
});
updateInlineArtifactBlocks(container, [rec("Hi")]);
const label = labelEl(el);
expect(label?.classList.contains("persona-tool-loading-shimmer")).toBe(true);
// The detail span never carries the animation.
expect(detailEl(el)?.querySelector(".persona-tool-char")).toBeNull();
});
});