@runtypelabs/persona 4.6.1 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +918 -174
  16. package/dist/index.d.ts +918 -174
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +89 -66
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +500 -1
  56. package/src/client.ts +285 -93
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/runtime/persist-state.test.ts +118 -0
  80. package/src/session.ts +76 -22
  81. package/src/styles/widget.css +773 -26
  82. package/src/theme-editor/preview.ts +2 -0
  83. package/src/theme-editor/sections.test.ts +26 -1
  84. package/src/theme-editor/sections.ts +10 -2
  85. package/src/theme-reference.ts +2 -2
  86. package/src/tool-call-display-defaults.test.ts +1 -0
  87. package/src/types/theme.ts +77 -0
  88. package/src/types.ts +516 -17
  89. package/src/ui.artifact-pane-gating.test.ts +636 -0
  90. package/src/ui.component-directive.test.ts +104 -0
  91. package/src/ui.composer-bar.test.ts +60 -2
  92. package/src/ui.detached-panel.test.ts +1049 -0
  93. package/src/ui.scroll-additive.test.ts +1 -1
  94. package/src/ui.streaming-coalescing.test.ts +312 -0
  95. package/src/ui.tool-display.test.ts +51 -0
  96. package/src/ui.ts +933 -214
  97. package/src/utils/artifact-custom-actions.ts +128 -0
  98. package/src/utils/artifact-display.test.ts +42 -0
  99. package/src/utils/artifact-display.ts +84 -0
  100. package/src/utils/artifact-file.test.ts +116 -0
  101. package/src/utils/artifact-file.ts +117 -0
  102. package/src/utils/artifact-gate.test.ts +112 -5
  103. package/src/utils/artifact-gate.ts +39 -14
  104. package/src/utils/artifact-loading-status.ts +55 -0
  105. package/src/utils/artifact-status-label.ts +190 -0
  106. package/src/utils/buttons.ts +7 -1
  107. package/src/utils/code-highlight.test.ts +186 -0
  108. package/src/utils/code-highlight.ts +400 -0
  109. package/src/utils/icons.ts +2 -0
  110. package/src/utils/roving-tablist.test.ts +152 -0
  111. package/src/utils/roving-tablist.ts +111 -0
  112. package/src/utils/spinner.ts +45 -0
  113. package/src/utils/theme.test.ts +48 -0
  114. package/src/utils/theme.ts +7 -0
  115. package/src/utils/tokens.ts +91 -0
  116. package/src/utils/tool-loading-animation.test.ts +32 -0
  117. package/src/utils/tool-loading-animation.ts +24 -0
  118. package/dist/chunk-DFBSCFYN.js +0 -1
  119. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  120. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,1155 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ renderArtifactPreviewBody,
6
+ runArtifactBodyTransition,
7
+ type ArtifactBodyLayout,
8
+ } from "./artifact-preview";
9
+ import * as parsersLoader from "../markdown-parsers-loader";
10
+ import type {
11
+ AgentWidgetConfig,
12
+ PersonaArtifactRecord,
13
+ PersonaArtifactStatusLabelContext,
14
+ } from "../types";
15
+ import type { ComponentRenderer } from "./registry";
16
+
17
+ const layout = (o: Partial<ArtifactBodyLayout> = {}): ArtifactBodyLayout => ({
18
+ streamingView: "source",
19
+ viewMode: "rendered",
20
+ streamingHeight: 320,
21
+ completeHeight: 320,
22
+ followOutput: true,
23
+ overflow: "scroll",
24
+ fadeTop: true,
25
+ fadeBottom: false,
26
+ transition: "auto",
27
+ completeDisplay: "inline",
28
+ ...o,
29
+ });
30
+
31
+ const nextFrame = () =>
32
+ new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
33
+
34
+ const ZWSP = "\u200b";
35
+ const HTML_RAW = "<h1>hi</h1>\n<script>window.x = 1;</script>\n";
36
+ const wireFor = (raw: string, lang: string): string =>
37
+ "```" + lang + "\n" + raw.split("```").join("`" + ZWSP + "``") + "\n```";
38
+
39
+ const makeConfig = (filePreview?: {
40
+ enabled?: boolean;
41
+ iframeSandbox?: string;
42
+ dangerouslyAllowSameOrigin?: boolean;
43
+ loading?:
44
+ | boolean
45
+ | {
46
+ delayMs?: number;
47
+ minVisibleMs?: number;
48
+ timeoutMs?: number;
49
+ injectReadySignal?: boolean;
50
+ label?: string | false;
51
+ labelDelayMs?: number;
52
+ renderIndicator?: (ctx: {
53
+ artifactId: string;
54
+ config: AgentWidgetConfig;
55
+ }) => HTMLElement | null;
56
+ };
57
+ }): AgentWidgetConfig =>
58
+ ({
59
+ sanitize: false,
60
+ features: { artifacts: { enabled: true, ...(filePreview ? { filePreview } : {}) } },
61
+ }) as AgentWidgetConfig;
62
+
63
+ const fileRecord = (
64
+ overrides: Partial<PersonaArtifactRecord> = {}
65
+ ): PersonaArtifactRecord => ({
66
+ id: "a1",
67
+ artifactType: "markdown",
68
+ title: "outputs/cat.html",
69
+ status: "complete",
70
+ markdown: wireFor(HTML_RAW, "html"),
71
+ file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" },
72
+ ...overrides,
73
+ });
74
+
75
+ const markdownRecord = (
76
+ overrides: Partial<PersonaArtifactRecord> = {}
77
+ ): PersonaArtifactRecord => ({
78
+ id: "m1",
79
+ artifactType: "markdown",
80
+ title: "Notes",
81
+ status: "streaming",
82
+ markdown: "Hello",
83
+ ...overrides,
84
+ });
85
+
86
+ describe("artifact-preview markdown body", () => {
87
+ it("renders a markdown bubble and applies streaming deltas via update()", () => {
88
+ const handle = renderArtifactPreviewBody(markdownRecord(), {
89
+ config: makeConfig(),
90
+ });
91
+ const bubble = handle.el.querySelector(".persona-markdown-bubble");
92
+ expect(bubble).toBeTruthy();
93
+ expect(bubble?.textContent).toContain("Hello");
94
+
95
+ handle.update(markdownRecord({ markdown: "Hello world" }));
96
+ const updated = handle.el.querySelector(".persona-markdown-bubble");
97
+ expect(updated?.textContent).toContain("Hello world");
98
+ // Still exactly one body child (updated in place, not appended).
99
+ expect(handle.el.children.length).toBe(1);
100
+ });
101
+
102
+ it("shows raw source when the host resolves 'source' view mode", () => {
103
+ const handle = renderArtifactPreviewBody(markdownRecord({ markdown: "## Raw" }), {
104
+ config: makeConfig(),
105
+ resolveViewMode: () => "source",
106
+ });
107
+ expect(handle.el.querySelector(".persona-markdown-bubble")).toBeNull();
108
+ const pre = handle.el.querySelector("pre");
109
+ expect(pre?.textContent).toBe("## Raw");
110
+ });
111
+ });
112
+
113
+ /**
114
+ * Regression tests for the parser-ready race on the IIFE/CDN build, where
115
+ * `marked` + `DOMPurify` load lazily. An artifact upserted right after
116
+ * `initAgentWidget()` used to render as escaped plain text (literal
117
+ * `# Welcome`, `**bold**`) and stayed that way until the next update() forced
118
+ * a re-render — chat messages self-heal via the parser-ready re-render in
119
+ * `createAgentExperience`, but this body only re-renders on update(). Worse,
120
+ * the default sanitizer's own degraded fallback is `escapeHtml`, so the old
121
+ * blanket `sanitize(md(text))` escaped twice and displayed literal entities
122
+ * (`&quot;`).
123
+ *
124
+ * `vitest.setup.ts` eager-provides parsers, so `getMarkdownParsersSync()` is
125
+ * non-null by default -> that's the "loaded" path. The degraded path is
126
+ * simulated by spying on the loader module (same approach as
127
+ * ui.postprocess.test.ts).
128
+ */
129
+ describe("artifact-preview parser-ready self-heal (parsers not loaded at first render)", () => {
130
+ const RAW_MD = '# Welcome\n\n**bold** "quoted"';
131
+ const mdConfig = { markdown: {} } as AgentWidgetConfig;
132
+ const mdArtifact = () =>
133
+ markdownRecord({ markdown: RAW_MD, status: "complete" });
134
+
135
+ afterEach(() => {
136
+ vi.restoreAllMocks();
137
+ });
138
+
139
+ it("renders the escaped fallback exactly once, then re-renders as markdown when the chunk lands", async () => {
140
+ // The real `onMarkdownParsersReady` no-ops here (vitest.setup.ts eager-
141
+ // provides parsers into the loader's moduleCache), so spy on the module
142
+ // namespace and capture the callback instead. artifact-preview.ts calls it
143
+ // through the module binding, so the spy intercepts — same mechanism as the
144
+ // getMarkdownParsersSync spy below.
145
+ let readyCb: (() => void) | null = null;
146
+ const readySpy = vi
147
+ .spyOn(parsersLoader, "onMarkdownParsersReady")
148
+ .mockImplementation((cb: () => void) => {
149
+ readyCb = cb;
150
+ return () => {};
151
+ });
152
+ const syncSpy = vi
153
+ .spyOn(parsersLoader, "getMarkdownParsersSync")
154
+ .mockReturnValue(null);
155
+
156
+ const handle = renderArtifactPreviewBody(mdArtifact(), { config: mdConfig });
157
+
158
+ // Degraded first paint: escaped plain text, no markdown elements.
159
+ expect(handle.el.querySelector("h1")).toBeNull();
160
+ expect(handle.el.textContent).toContain("# Welcome");
161
+ expect(handle.el.textContent).toContain("**bold**");
162
+ // Escaped exactly once: a double escape would leave a literal `&quot;`
163
+ // in the visible text instead of the quote character.
164
+ expect(handle.el.textContent).toContain('"quoted"');
165
+ expect(handle.el.textContent).not.toContain("&quot;");
166
+
167
+ // A second render while the chunk is still loading must not re-subscribe.
168
+ handle.update(mdArtifact());
169
+ expect(readySpy).toHaveBeenCalledTimes(1);
170
+
171
+ // Chunk lands: the real getMarkdownParsersSync now returns the parsers
172
+ // eager-provided by vitest.setup.ts, and the captured ready callback fires.
173
+ syncSpy.mockRestore();
174
+ expect(readyCb).toBeTypeOf("function");
175
+ readyCb!();
176
+
177
+ // Self-healed: real markdown without any user interaction.
178
+ expect(handle.el.querySelector("h1")?.textContent).toBe("Welcome");
179
+ expect(handle.el.querySelector("strong")?.textContent).toBe("bold");
180
+ expect(handle.el.textContent).not.toContain("**bold**");
181
+ });
182
+
183
+ it("does not schedule a re-render when parsers are already loaded", () => {
184
+ const readySpy = vi.spyOn(parsersLoader, "onMarkdownParsersReady");
185
+
186
+ const handle = renderArtifactPreviewBody(mdArtifact(), { config: mdConfig });
187
+
188
+ expect(handle.el.querySelector("h1")?.textContent).toBe("Welcome");
189
+ expect(readySpy).not.toHaveBeenCalled();
190
+ });
191
+ });
192
+
193
+ describe("artifact-preview file body", () => {
194
+ it("renders a sandboxed iframe (allow-scripts, no allow-same-origin) with srcdoc = raw source", () => {
195
+ // loading:false → no injected reporter, so srcdoc is exactly the raw source.
196
+ const handle = renderArtifactPreviewBody(fileRecord(), {
197
+ config: makeConfig({ loading: false }),
198
+ });
199
+ const iframe = handle.el.querySelector(
200
+ "iframe.persona-artifact-iframe"
201
+ ) as HTMLIFrameElement;
202
+ expect(iframe).toBeTruthy();
203
+ // The iframe is wrapped in a positioned frame that hosts the loading overlay.
204
+ expect(iframe.parentElement?.classList.contains("persona-artifact-frame")).toBe(
205
+ true
206
+ );
207
+ expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
208
+ expect(iframe.getAttribute("sandbox")).not.toContain("allow-same-origin");
209
+ expect(iframe.getAttribute("data-artifact-id")).toBe("a1");
210
+ // srcdoc is the raw, unfenced source assigned as a property.
211
+ expect(iframe.srcdoc).toBe(HTML_RAW);
212
+ });
213
+
214
+ it("honors a custom iframeSandbox override", () => {
215
+ const handle = renderArtifactPreviewBody(fileRecord(), {
216
+ config: makeConfig({ iframeSandbox: "allow-scripts allow-forms" }),
217
+ });
218
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
219
+ expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
220
+ });
221
+
222
+ it("strips allow-same-origin from iframeSandbox and warns", () => {
223
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
224
+ try {
225
+ const handle = renderArtifactPreviewBody(fileRecord(), {
226
+ config: makeConfig({ iframeSandbox: "allow-scripts allow-same-origin" }),
227
+ });
228
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
229
+ expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
230
+ expect(warn).toHaveBeenCalledOnce();
231
+ expect(warn.mock.calls[0][0]).toContain("allow-same-origin");
232
+ } finally {
233
+ warn.mockRestore();
234
+ }
235
+ });
236
+
237
+ it("keeps allow-same-origin when dangerouslyAllowSameOrigin is set", () => {
238
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
239
+ try {
240
+ const handle = renderArtifactPreviewBody(fileRecord(), {
241
+ config: makeConfig({
242
+ iframeSandbox: "allow-scripts allow-same-origin",
243
+ dangerouslyAllowSameOrigin: true,
244
+ }),
245
+ });
246
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
247
+ expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin");
248
+ expect(warn).not.toHaveBeenCalled();
249
+ } finally {
250
+ warn.mockRestore();
251
+ }
252
+ });
253
+
254
+ it("forces source view (no iframe) when filePreview.enabled is false", () => {
255
+ const handle = renderArtifactPreviewBody(fileRecord(), {
256
+ config: makeConfig({ enabled: false }),
257
+ });
258
+ expect(handle.el.querySelector("iframe")).toBeNull();
259
+ const pre = handle.el.querySelector("pre");
260
+ expect(pre?.textContent).toBe(HTML_RAW);
261
+ });
262
+
263
+ it("reuses the iframe node when the artifact and source are unchanged", () => {
264
+ const handle = renderArtifactPreviewBody(fileRecord(), { config: makeConfig() });
265
+ const first = handle.el.querySelector("iframe");
266
+ handle.update(fileRecord());
267
+ expect(handle.el.querySelector("iframe")).toBe(first);
268
+ });
269
+ });
270
+
271
+ describe("artifact-preview status transitions", () => {
272
+ it("shows source while streaming, then swaps to the iframe on complete", () => {
273
+ const handle = renderArtifactPreviewBody(
274
+ fileRecord({ status: "streaming", markdown: "```html\n<h1>hi" }),
275
+ { config: makeConfig() }
276
+ );
277
+ expect(handle.el.querySelector("iframe")).toBeNull();
278
+ expect(handle.el.querySelector("pre")?.textContent).toBe("<h1>hi");
279
+
280
+ handle.update(fileRecord({ status: "complete" }));
281
+ expect(handle.el.querySelector("pre")).toBeNull();
282
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
283
+ expect(iframe).toBeTruthy();
284
+ // Default loading appends the ready reporter, so the raw source is the prefix.
285
+ expect(iframe.srcdoc.startsWith(HTML_RAW)).toBe(true);
286
+ });
287
+ });
288
+
289
+ describe("artifact-preview source highlighting", () => {
290
+ it("renders line spans with token classes for an html file source view", () => {
291
+ const handle = renderArtifactPreviewBody(fileRecord(), {
292
+ config: makeConfig(),
293
+ resolveViewMode: () => "source",
294
+ });
295
+ const pre = handle.el.querySelector("pre");
296
+ expect(pre?.querySelector("code.persona-code")).toBeTruthy();
297
+ // One line span per source line (HTML_RAW ends in "\n" → 2 numbered lines).
298
+ const lines = handle.el.querySelectorAll(".persona-code-line");
299
+ expect(lines.length).toBe(2);
300
+ // Tokenized: at least one tag span for <h1>/<script>.
301
+ expect(handle.el.querySelector(".persona-code-token-tag")).toBeTruthy();
302
+ // Verbatim reconstruction invariant: pre text === original source.
303
+ expect(pre?.textContent).toBe(HTML_RAW);
304
+ });
305
+
306
+ it("line-numbers streaming file source too", () => {
307
+ const handle = renderArtifactPreviewBody(
308
+ fileRecord({ status: "streaming", markdown: "```html\n<h1>hi" }),
309
+ { config: makeConfig() }
310
+ );
311
+ expect(handle.el.querySelector(".persona-code-line")).toBeTruthy();
312
+ expect(handle.el.querySelector("pre")?.textContent).toBe("<h1>hi");
313
+ });
314
+ });
315
+
316
+ describe("artifact-preview inline bodyLayout", () => {
317
+ const streamingFile = (markdown = "```html\n<h1>hi") =>
318
+ fileRecord({ status: "streaming", markdown });
319
+
320
+ it("reserves a fixed-height scroll window for streaming source (numeric height)", () => {
321
+ const handle = renderArtifactPreviewBody(streamingFile(), {
322
+ config: makeConfig(),
323
+ bodyLayout: layout(),
324
+ });
325
+ const win = handle.el.querySelector(".persona-artifact-source-window");
326
+ expect(win).toBeTruthy();
327
+ expect(win!.classList.contains("persona-artifact-source-window--fixed")).toBe(
328
+ true
329
+ );
330
+ expect(win!.querySelector("pre")).toBeTruthy();
331
+ });
332
+
333
+ it("grows (no fixed window) for streamingHeight 'auto'", () => {
334
+ const handle = renderArtifactPreviewBody(streamingFile(), {
335
+ config: makeConfig(),
336
+ bodyLayout: layout({ streamingHeight: "auto" }),
337
+ });
338
+ const win = handle.el.querySelector(".persona-artifact-source-window");
339
+ expect(win).toBeTruthy();
340
+ expect(win!.classList.contains("persona-artifact-source-window--fixed")).toBe(
341
+ false
342
+ );
343
+ });
344
+
345
+ it("pane path (no bodyLayout) renders a bare pre with no scroll window", () => {
346
+ const handle = renderArtifactPreviewBody(streamingFile(), {
347
+ config: makeConfig(),
348
+ });
349
+ expect(handle.el.querySelector(".persona-artifact-source-window")).toBeNull();
350
+ expect(handle.el.firstElementChild?.tagName.toLowerCase()).toBe("pre");
351
+ });
352
+
353
+ it("keeps the same <pre>/window across streaming deltas (in-place update)", () => {
354
+ // Streaming source recovery drops the still-open last line (assumed fence),
355
+ // so a trailing sentinel line keeps the newest visible line stable.
356
+ const handle = renderArtifactPreviewBody(streamingFile("```html\n<h1>hi\nX"), {
357
+ config: makeConfig(),
358
+ bodyLayout: layout(),
359
+ });
360
+ const pre = handle.el.querySelector("pre");
361
+ const win = handle.el.querySelector(".persona-artifact-source-window");
362
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
363
+ expect(handle.el.querySelector("pre")).toBe(pre);
364
+ expect(handle.el.querySelector(".persona-artifact-source-window")).toBe(win);
365
+ expect(handle.el.querySelector("pre")?.textContent).toContain("more");
366
+ });
367
+
368
+ // Stub live scroll metrics jsdom doesn't compute, with a settable scrollTop.
369
+ const stubScroll = (win: HTMLElement, top: number) => {
370
+ let cur = top;
371
+ Object.defineProperty(win, "scrollHeight", { configurable: true, value: 1000 });
372
+ Object.defineProperty(win, "clientHeight", { configurable: true, value: 320 });
373
+ Object.defineProperty(win, "scrollTop", {
374
+ configurable: true,
375
+ get: () => cur,
376
+ set: (v: number) => {
377
+ cur = v;
378
+ },
379
+ });
380
+ };
381
+
382
+ it("tail-follows when the viewport is at the bottom", async () => {
383
+ const handle = renderArtifactPreviewBody(streamingFile(), {
384
+ config: makeConfig(),
385
+ bodyLayout: layout(),
386
+ });
387
+ // Drain the construction-time follow before stubbing real metrics.
388
+ await nextFrame();
389
+ const win = handle.el.querySelector(
390
+ ".persona-artifact-source-window"
391
+ ) as HTMLElement;
392
+ stubScroll(win, 680); // dist = 1000 - 320 - 680 = 0 → at the bottom.
393
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
394
+ await nextFrame();
395
+ expect(win.scrollTop).toBe(1000);
396
+ });
397
+
398
+ it("does not fight a reader who scrolled up", async () => {
399
+ const handle = renderArtifactPreviewBody(streamingFile(), {
400
+ config: makeConfig(),
401
+ bodyLayout: layout(),
402
+ });
403
+ await nextFrame();
404
+ const win = handle.el.querySelector(
405
+ ".persona-artifact-source-window"
406
+ ) as HTMLElement;
407
+ stubScroll(win, 100); // dist = 1000 - 320 - 100 = 580 → scrolled up.
408
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
409
+ await nextFrame();
410
+ expect(win.scrollTop).toBe(100);
411
+ });
412
+
413
+ // Mutable-metric variant: lets a test grow the window and move scrollTop
414
+ // between updates (the plain stubScroll above pins scrollHeight/clientHeight).
415
+ const stubScrollLive = (
416
+ win: HTMLElement,
417
+ init: { scrollHeight: number; clientHeight: number; scrollTop: number }
418
+ ) => {
419
+ const s = { ...init };
420
+ Object.defineProperty(win, "scrollHeight", {
421
+ configurable: true,
422
+ get: () => s.scrollHeight,
423
+ });
424
+ Object.defineProperty(win, "clientHeight", {
425
+ configurable: true,
426
+ get: () => s.clientHeight,
427
+ });
428
+ Object.defineProperty(win, "scrollTop", {
429
+ configurable: true,
430
+ get: () => s.scrollTop,
431
+ set: (v: number) => {
432
+ s.scrollTop = v;
433
+ },
434
+ });
435
+ return s;
436
+ };
437
+
438
+ it("stops tail-follow after an upward wheel gesture while overflowing", async () => {
439
+ const handle = renderArtifactPreviewBody(streamingFile(), {
440
+ config: makeConfig(),
441
+ bodyLayout: layout(),
442
+ });
443
+ await nextFrame();
444
+ const win = handle.el.querySelector(
445
+ ".persona-artifact-source-window"
446
+ ) as HTMLElement;
447
+ stubScroll(win, 680); // dist = 0 → at the bottom (would normally pin).
448
+ // Upward wheel over an overflowing window latches "escaped".
449
+ win.dispatchEvent(new WheelEvent("wheel", { deltaY: -20 }));
450
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
451
+ await nextFrame();
452
+ // Latched: the pin is suppressed even though the reader was at the bottom.
453
+ expect(win.scrollTop).toBe(680);
454
+ // A further delta must not re-pin while still latched.
455
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\n<p>again\nX"));
456
+ await nextFrame();
457
+ expect(win.scrollTop).toBe(680);
458
+ });
459
+
460
+ it("re-engages tail-follow when the reader returns within 40px of the bottom", async () => {
461
+ const handle = renderArtifactPreviewBody(streamingFile(), {
462
+ config: makeConfig(),
463
+ bodyLayout: layout(),
464
+ });
465
+ await nextFrame();
466
+ const win = handle.el.querySelector(
467
+ ".persona-artifact-source-window"
468
+ ) as HTMLElement;
469
+ stubScroll(win, 680);
470
+ win.dispatchEvent(new WheelEvent("wheel", { deltaY: -20 })); // escape
471
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
472
+ await nextFrame();
473
+ expect(win.scrollTop).toBe(680); // still latched, no pin.
474
+
475
+ // Reader flicks back to the bottom → scroll event clears the latch.
476
+ win.dispatchEvent(new Event("scroll"));
477
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\n<p>again\nX"));
478
+ await nextFrame();
479
+ expect(win.scrollTop).toBe(1000); // follow resumed.
480
+ });
481
+
482
+ it("clears the escaped latch on completion so a reused window follows again", async () => {
483
+ // viewMode "source" keeps the completed record in the source window (no
484
+ // iframe swap), so the same window is reused and we can observe the reset.
485
+ const handle = renderArtifactPreviewBody(streamingFile(), {
486
+ config: makeConfig(),
487
+ bodyLayout: layout({ viewMode: "source" }),
488
+ });
489
+ await nextFrame();
490
+ const win = handle.el.querySelector(
491
+ ".persona-artifact-source-window"
492
+ ) as HTMLElement;
493
+ stubScroll(win, 680);
494
+ win.dispatchEvent(new WheelEvent("wheel", { deltaY: -20 })); // escape
495
+
496
+ // Completion clears the latch.
497
+ handle.update(fileRecord({ status: "complete" }));
498
+ await nextFrame();
499
+ expect(handle.el.querySelector(".persona-artifact-source-window")).toBe(win);
500
+
501
+ // A later streaming delta at the bottom pins again — the latch was cleared.
502
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
503
+ await nextFrame();
504
+ expect(win.scrollTop).toBe(1000);
505
+ });
506
+
507
+ it("does not latch on an upward wheel when the window does not overflow", async () => {
508
+ const handle = renderArtifactPreviewBody(streamingFile(), {
509
+ config: makeConfig(),
510
+ bodyLayout: layout(),
511
+ });
512
+ await nextFrame();
513
+ const win = handle.el.querySelector(
514
+ ".persona-artifact-source-window"
515
+ ) as HTMLElement;
516
+ // No overflow yet (scrollHeight === clientHeight): the gesture is ignored.
517
+ const metrics = stubScrollLive(win, {
518
+ scrollHeight: 320,
519
+ clientHeight: 320,
520
+ scrollTop: 0,
521
+ });
522
+ win.dispatchEvent(new WheelEvent("wheel", { deltaY: -20 }));
523
+
524
+ // Content grows to overflow and the reader is at the bottom; because the
525
+ // earlier wheel never latched, follow still pins.
526
+ metrics.scrollHeight = 1000;
527
+ metrics.scrollTop = 680; // dist = 1000 - 320 - 680 = 0.
528
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
529
+ await nextFrame();
530
+ expect(win.scrollTop).toBe(1000);
531
+ });
532
+
533
+ it("renders a status placeholder while streaming and the real body on complete", () => {
534
+ const handle = renderArtifactPreviewBody(streamingFile(), {
535
+ config: makeConfig(),
536
+ bodyLayout: layout({ streamingView: "status" }),
537
+ });
538
+ const status = handle.el.querySelector(".persona-artifact-status-view");
539
+ expect(status).toBeTruthy();
540
+ expect(status!.textContent).toContain("Generating");
541
+ expect(handle.el.querySelector(".persona-artifact-source-window")).toBeNull();
542
+
543
+ handle.update(fileRecord({ status: "complete" }));
544
+ expect(handle.el.querySelector(".persona-artifact-status-view")).toBeNull();
545
+ expect(handle.el.querySelector("iframe")).toBeTruthy();
546
+ });
547
+
548
+ it("adds the --clip class (plus --fixed) for overflow 'clip'", () => {
549
+ const handle = renderArtifactPreviewBody(streamingFile(), {
550
+ config: makeConfig(),
551
+ bodyLayout: layout({ overflow: "clip", followOutput: false, fadeBottom: true }),
552
+ });
553
+ const win = handle.el.querySelector(".persona-artifact-source-window")!;
554
+ expect(win.classList.contains("persona-artifact-source-window--fixed")).toBe(true);
555
+ expect(win.classList.contains("persona-artifact-source-window--clip")).toBe(true);
556
+ });
557
+
558
+ it("does not add the --clip class for overflow 'scroll'", () => {
559
+ const handle = renderArtifactPreviewBody(streamingFile(), {
560
+ config: makeConfig(),
561
+ bodyLayout: layout(),
562
+ });
563
+ const win = handle.el.querySelector(".persona-artifact-source-window")!;
564
+ expect(win.classList.contains("persona-artifact-source-window--clip")).toBe(false);
565
+ });
566
+
567
+ it("never tail-follows in clip mode even when the reader is at the bottom", async () => {
568
+ const handle = renderArtifactPreviewBody(streamingFile(), {
569
+ config: makeConfig(),
570
+ bodyLayout: layout({ overflow: "clip", followOutput: false, fadeBottom: true }),
571
+ });
572
+ await nextFrame();
573
+ const win = handle.el.querySelector(
574
+ ".persona-artifact-source-window"
575
+ ) as HTMLElement;
576
+ stubScroll(win, 0); // top of the document; dist to bottom = 680.
577
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
578
+ await nextFrame();
579
+ // Clip windows never pin the tail: scrollTop stays at the top.
580
+ expect(win.scrollTop).toBe(0);
581
+ });
582
+
583
+ it("shows the bottom fade class when clipped content overflows, never the top fade", () => {
584
+ const handle = renderArtifactPreviewBody(streamingFile(), {
585
+ config: makeConfig(),
586
+ bodyLayout: layout({ overflow: "clip", followOutput: false, fadeBottom: true }),
587
+ });
588
+ const win = handle.el.querySelector(
589
+ ".persona-artifact-source-window"
590
+ ) as HTMLElement;
591
+ stubScroll(win, 0); // overflowing (scrollHeight 1000 > clientHeight 320).
592
+ handle.update(streamingFile("```html\n<h1>hi\n<p>more\nX"));
593
+ expect(win.classList.contains("persona-artifact-fade-bottom")).toBe(true);
594
+ expect(win.classList.contains("persona-artifact-fade-top")).toBe(false);
595
+ });
596
+ });
597
+
598
+ describe("runArtifactBodyTransition", () => {
599
+ it("falls back to an instant swap when startViewTransition is absent", () => {
600
+ const swap = vi.fn();
601
+ runArtifactBodyTransition(document.createElement("div"), "auto", "a/1", swap);
602
+ expect(swap).toHaveBeenCalledTimes(1);
603
+ });
604
+
605
+ it("swaps instantly for transition 'none'", () => {
606
+ const swap = vi.fn();
607
+ runArtifactBodyTransition(document.createElement("div"), "none", "a1", swap);
608
+ expect(swap).toHaveBeenCalledTimes(1);
609
+ });
610
+ });
611
+
612
+ describe("artifact-preview loading overlay", () => {
613
+ const DOCTYPE_SRC = "<!doctype html>\n<h1>hi</h1>\n";
614
+ const doctypeFile = (o: Partial<PersonaArtifactRecord> = {}) =>
615
+ fileRecord({ markdown: wireFor(DOCTYPE_SRC, "html"), ...o });
616
+ const frameOf = (h: { el: HTMLElement }) =>
617
+ h.el.querySelector(".persona-artifact-frame") as HTMLElement;
618
+ const overlayOf = (h: { el: HTMLElement }) =>
619
+ frameOf(h).querySelector(".persona-artifact-frame-loading");
620
+ const tokenFromSrcdoc = (srcdoc: string): string => {
621
+ const m = srcdoc.match(/var t=("(?:[^"\\]|\\.)*")/);
622
+ return m ? (JSON.parse(m[1]) as string) : "";
623
+ };
624
+
625
+ it("appends the ready reporter by default, keeping the doctype first", () => {
626
+ const handle = renderArtifactPreviewBody(doctypeFile(), { config: makeConfig() });
627
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
628
+ expect(iframe.srcdoc.startsWith("<!doctype html>")).toBe(true);
629
+ expect(iframe.srcdoc).toContain("artifact-preview-ready");
630
+ // APPENDED, never prepended: the reporter follows the document body.
631
+ expect(iframe.srcdoc.indexOf("artifact-preview-ready")).toBeGreaterThan(
632
+ iframe.srcdoc.indexOf("<h1>")
633
+ );
634
+ });
635
+
636
+ it("omits the reporter entirely for loading:false (raw srcdoc, no overlay)", () => {
637
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
638
+ config: makeConfig({ loading: false }),
639
+ });
640
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
641
+ expect(iframe.srcdoc).toBe(DOCTYPE_SRC);
642
+ expect(iframe.srcdoc).not.toContain("artifact-preview-ready");
643
+ });
644
+
645
+ it("omits the reporter for injectReadySignal:false but still overlays", () => {
646
+ vi.useFakeTimers();
647
+ try {
648
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
649
+ config: makeConfig({ loading: { injectReadySignal: false, delayMs: 100 } }),
650
+ });
651
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
652
+ expect(iframe.srcdoc).toBe(DOCTYPE_SRC);
653
+ expect(overlayOf(handle)).toBeNull();
654
+ vi.advanceTimersByTime(100);
655
+ expect(overlayOf(handle)).toBeTruthy();
656
+ } finally {
657
+ vi.useRealTimers();
658
+ }
659
+ });
660
+
661
+ it("shows the icon spinner (no text) after delayMs, not before", () => {
662
+ vi.useFakeTimers();
663
+ try {
664
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
665
+ config: makeConfig({ loading: { delayMs: 200 } }),
666
+ });
667
+ expect(overlayOf(handle)).toBeNull();
668
+ vi.advanceTimersByTime(199);
669
+ expect(overlayOf(handle)).toBeNull();
670
+ vi.advanceTimersByTime(1);
671
+ const overlay = overlayOf(handle);
672
+ expect(overlay).toBeTruthy();
673
+ // Icon-first: the default indicator is a pure-CSS spinner...
674
+ expect(overlay!.querySelector(".persona-spinner")).toBeTruthy();
675
+ // ...and the escalation label, though present in the DOM, is hidden (no
676
+ // --visible modifier → opacity: 0) until it fades in after labelDelayMs.
677
+ const label = overlay!.querySelector(".persona-artifact-frame-loading-text");
678
+ expect(
679
+ label!.classList.contains("persona-artifact-frame-loading-text--visible")
680
+ ).toBe(false);
681
+ } finally {
682
+ vi.useRealTimers();
683
+ }
684
+ });
685
+
686
+ it("fades in the default escalation label after labelDelayMs (from overlay-visible)", () => {
687
+ vi.useFakeTimers();
688
+ try {
689
+ // delayMs=100 (overlay show), labelDelayMs=200 → label reveals ~300 from
690
+ // start, i.e. 200 AFTER the overlay becomes visible, not from iframe create.
691
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
692
+ config: makeConfig({
693
+ loading: { delayMs: 100, labelDelayMs: 200, timeoutMs: 100000 },
694
+ }),
695
+ });
696
+ vi.advanceTimersByTime(100);
697
+ const overlay = overlayOf(handle)!;
698
+ const label = overlay.querySelector(".persona-artifact-frame-loading-text")!;
699
+ expect(label.textContent).toBe("Starting preview...");
700
+ expect(
701
+ label.classList.contains("persona-artifact-frame-loading-text--visible")
702
+ ).toBe(false);
703
+ // 199ms after the overlay showed (299 from start): still hidden.
704
+ vi.advanceTimersByTime(199);
705
+ expect(
706
+ label.classList.contains("persona-artifact-frame-loading-text--visible")
707
+ ).toBe(false);
708
+ // 200ms after the overlay showed (300 from start): revealed.
709
+ vi.advanceTimersByTime(1);
710
+ expect(
711
+ label.classList.contains("persona-artifact-frame-loading-text--visible")
712
+ ).toBe(true);
713
+ } finally {
714
+ vi.useRealTimers();
715
+ }
716
+ });
717
+
718
+ it("uses a custom label string in place of the default", () => {
719
+ vi.useFakeTimers();
720
+ try {
721
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
722
+ config: makeConfig({
723
+ loading: { delayMs: 10, labelDelayMs: 20, label: "Compiling…", timeoutMs: 100000 },
724
+ }),
725
+ });
726
+ vi.advanceTimersByTime(10 + 20);
727
+ const label = overlayOf(handle)!.querySelector(
728
+ ".persona-artifact-frame-loading-text"
729
+ )!;
730
+ expect(label.textContent).toBe("Compiling…");
731
+ expect(
732
+ label.classList.contains("persona-artifact-frame-loading-text--visible")
733
+ ).toBe(true);
734
+ } finally {
735
+ vi.useRealTimers();
736
+ }
737
+ });
738
+
739
+ it("never renders any text for label:false (icon-only forever)", () => {
740
+ vi.useFakeTimers();
741
+ try {
742
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
743
+ config: makeConfig({
744
+ loading: { delayMs: 10, labelDelayMs: 20, label: false, timeoutMs: 100000 },
745
+ }),
746
+ });
747
+ vi.advanceTimersByTime(10);
748
+ const overlay = overlayOf(handle)!;
749
+ expect(overlay.querySelector(".persona-spinner")).toBeTruthy();
750
+ expect(
751
+ overlay.querySelector(".persona-artifact-frame-loading-text")
752
+ ).toBeNull();
753
+ // Even long past labelDelayMs, no text node ever appears.
754
+ vi.advanceTimersByTime(1000);
755
+ expect(
756
+ overlay.querySelector(".persona-artifact-frame-loading-text")
757
+ ).toBeNull();
758
+ expect(overlay.textContent!.trim()).toBe("");
759
+ } finally {
760
+ vi.useRealTimers();
761
+ }
762
+ });
763
+
764
+ it("uses a renderIndicator override and suppresses the default spinner/label", () => {
765
+ vi.useFakeTimers();
766
+ try {
767
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
768
+ config: makeConfig({
769
+ loading: {
770
+ delayMs: 10,
771
+ labelDelayMs: 20,
772
+ renderIndicator: ({ artifactId }) => {
773
+ const el = document.createElement("div");
774
+ el.className = "my-brand-loader";
775
+ el.textContent = artifactId;
776
+ return el;
777
+ },
778
+ timeoutMs: 100000,
779
+ },
780
+ }),
781
+ });
782
+ vi.advanceTimersByTime(10);
783
+ const overlay = overlayOf(handle)!;
784
+ const custom = overlay.querySelector(".my-brand-loader");
785
+ expect(custom).toBeTruthy();
786
+ expect(custom!.textContent).toBe("a1"); // artifactId threaded through
787
+ // Default spinner + label are fully suppressed.
788
+ expect(overlay.querySelector(".persona-spinner")).toBeNull();
789
+ expect(
790
+ overlay.querySelector(".persona-artifact-frame-loading-text")
791
+ ).toBeNull();
792
+ // No escalation label ever appears past labelDelayMs (host owns content).
793
+ vi.advanceTimersByTime(1000);
794
+ expect(
795
+ overlay.querySelector(".persona-artifact-frame-loading-text")
796
+ ).toBeNull();
797
+ } finally {
798
+ vi.useRealTimers();
799
+ }
800
+ });
801
+
802
+ it("falls back to the default spinner when renderIndicator returns null", () => {
803
+ vi.useFakeTimers();
804
+ try {
805
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
806
+ config: makeConfig({
807
+ loading: { delayMs: 10, renderIndicator: () => null },
808
+ }),
809
+ });
810
+ vi.advanceTimersByTime(10);
811
+ expect(overlayOf(handle)!.querySelector(".persona-spinner")).toBeTruthy();
812
+ } finally {
813
+ vi.useRealTimers();
814
+ }
815
+ });
816
+
817
+ it("falls back to the default spinner when renderIndicator throws", () => {
818
+ vi.useFakeTimers();
819
+ try {
820
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
821
+ config: makeConfig({
822
+ loading: {
823
+ delayMs: 10,
824
+ renderIndicator: () => {
825
+ throw new Error("boom");
826
+ },
827
+ },
828
+ }),
829
+ });
830
+ vi.advanceTimersByTime(10);
831
+ expect(overlayOf(handle)!.querySelector(".persona-spinner")).toBeTruthy();
832
+ } finally {
833
+ vi.useRealTimers();
834
+ }
835
+ });
836
+
837
+ it("clears the label timer on teardown (no post-teardown DOM mutation)", () => {
838
+ vi.useFakeTimers();
839
+ try {
840
+ let mode: "rendered" | "source" = "rendered";
841
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
842
+ config: makeConfig({
843
+ loading: { delayMs: 10, labelDelayMs: 200, timeoutMs: 100000 },
844
+ }),
845
+ resolveViewMode: () => mode,
846
+ });
847
+ vi.advanceTimersByTime(10);
848
+ const overlay = overlayOf(handle)!;
849
+ const label = overlay.querySelector(".persona-artifact-frame-loading-text")!;
850
+ // Tear the iframe down (switch to source) BEFORE the label timer fires.
851
+ mode = "source";
852
+ handle.update(doctypeFile());
853
+ // The pending label timer must have been cleared: advancing past it must
854
+ // not add the --visible class to the now-detached label.
855
+ vi.advanceTimersByTime(1000);
856
+ expect(
857
+ label.classList.contains("persona-artifact-frame-loading-text--visible")
858
+ ).toBe(false);
859
+ } finally {
860
+ vi.useRealTimers();
861
+ }
862
+ });
863
+
864
+ it("dismisses on a matched postMessage (token + source window)", () => {
865
+ vi.useFakeTimers();
866
+ try {
867
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
868
+ config: makeConfig({ loading: { delayMs: 10, minVisibleMs: 0 } }),
869
+ });
870
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
871
+ const fakeWin = {} as Window;
872
+ Object.defineProperty(iframe, "contentWindow", {
873
+ configurable: true,
874
+ value: fakeWin,
875
+ });
876
+ vi.advanceTimersByTime(10);
877
+ expect(overlayOf(handle)).toBeTruthy();
878
+
879
+ const token = tokenFromSrcdoc(iframe.srcdoc);
880
+ window.dispatchEvent(
881
+ new MessageEvent("message", {
882
+ source: fakeWin,
883
+ data: { persona: "artifact-preview-ready", token },
884
+ })
885
+ );
886
+ // minVisible 0 → fade immediately; overlay removed after the fade window.
887
+ vi.advanceTimersByTime(300);
888
+ expect(overlayOf(handle)).toBeNull();
889
+ } finally {
890
+ vi.useRealTimers();
891
+ }
892
+ });
893
+
894
+ it("ignores a postMessage with the wrong token", () => {
895
+ vi.useFakeTimers();
896
+ try {
897
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
898
+ config: makeConfig({ loading: { delayMs: 10, timeoutMs: 100000 } }),
899
+ });
900
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
901
+ const fakeWin = {} as Window;
902
+ Object.defineProperty(iframe, "contentWindow", {
903
+ configurable: true,
904
+ value: fakeWin,
905
+ });
906
+ vi.advanceTimersByTime(10);
907
+ window.dispatchEvent(
908
+ new MessageEvent("message", {
909
+ source: fakeWin,
910
+ data: { persona: "artifact-preview-ready", token: "not-the-token" },
911
+ })
912
+ );
913
+ vi.advanceTimersByTime(1000);
914
+ expect(overlayOf(handle)).toBeTruthy();
915
+ } finally {
916
+ vi.useRealTimers();
917
+ }
918
+ });
919
+
920
+ it("ignores a matched token from the wrong source window", () => {
921
+ vi.useFakeTimers();
922
+ try {
923
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
924
+ config: makeConfig({ loading: { delayMs: 10, timeoutMs: 100000 } }),
925
+ });
926
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
927
+ Object.defineProperty(iframe, "contentWindow", {
928
+ configurable: true,
929
+ value: {} as Window,
930
+ });
931
+ vi.advanceTimersByTime(10);
932
+ const token = tokenFromSrcdoc(iframe.srcdoc);
933
+ window.dispatchEvent(
934
+ new MessageEvent("message", {
935
+ source: {} as Window, // different object identity
936
+ data: { persona: "artifact-preview-ready", token },
937
+ })
938
+ );
939
+ vi.advanceTimersByTime(1000);
940
+ expect(overlayOf(handle)).toBeTruthy();
941
+ } finally {
942
+ vi.useRealTimers();
943
+ }
944
+ });
945
+
946
+ it("reveals on timeout when no ready signal arrives", () => {
947
+ vi.useFakeTimers();
948
+ try {
949
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
950
+ config: makeConfig({
951
+ loading: { delayMs: 10, minVisibleMs: 0, timeoutMs: 500 },
952
+ }),
953
+ });
954
+ vi.advanceTimersByTime(10);
955
+ expect(overlayOf(handle)).toBeTruthy();
956
+ vi.advanceTimersByTime(490); // hard timeout
957
+ vi.advanceTimersByTime(300); // fade window
958
+ expect(overlayOf(handle)).toBeNull();
959
+ } finally {
960
+ vi.useRealTimers();
961
+ }
962
+ });
963
+
964
+ it("keeps the overlay at least minVisibleMs after a fast ready", () => {
965
+ vi.useFakeTimers();
966
+ try {
967
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
968
+ config: makeConfig({ loading: { delayMs: 10, minVisibleMs: 300 } }),
969
+ });
970
+ const iframe = handle.el.querySelector("iframe") as HTMLIFrameElement;
971
+ const fakeWin = {} as Window;
972
+ Object.defineProperty(iframe, "contentWindow", {
973
+ configurable: true,
974
+ value: fakeWin,
975
+ });
976
+ vi.advanceTimersByTime(10);
977
+ const token = tokenFromSrcdoc(iframe.srcdoc);
978
+ window.dispatchEvent(
979
+ new MessageEvent("message", {
980
+ source: fakeWin,
981
+ data: { persona: "artifact-preview-ready", token },
982
+ })
983
+ );
984
+ // Ready arrived at shownAt, so the fade waits the full minVisibleMs.
985
+ vi.advanceTimersByTime(299);
986
+ expect(overlayOf(handle)).toBeTruthy();
987
+ vi.advanceTimersByTime(1 + 300);
988
+ expect(overlayOf(handle)).toBeNull();
989
+ } finally {
990
+ vi.useRealTimers();
991
+ }
992
+ });
993
+
994
+ it("does not stack message listeners across idle re-renders (reuse path)", () => {
995
+ const addSpy = vi.spyOn(window, "addEventListener");
996
+ try {
997
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
998
+ config: makeConfig(),
999
+ });
1000
+ const msgCalls = () =>
1001
+ addSpy.mock.calls.filter((c) => c[0] === "message").length;
1002
+ expect(msgCalls()).toBe(1);
1003
+ handle.update(doctypeFile()); // identical id + source → reuse, no rebuild
1004
+ expect(msgCalls()).toBe(1);
1005
+ } finally {
1006
+ addSpy.mockRestore();
1007
+ }
1008
+ });
1009
+
1010
+ it("tears down the listener when the iframe is replaced (view switches to source)", () => {
1011
+ const removeSpy = vi.spyOn(window, "removeEventListener");
1012
+ try {
1013
+ let mode: "rendered" | "source" = "rendered";
1014
+ const handle = renderArtifactPreviewBody(doctypeFile(), {
1015
+ config: makeConfig(),
1016
+ resolveViewMode: () => mode,
1017
+ });
1018
+ expect(handle.el.querySelector("iframe")).toBeTruthy();
1019
+ mode = "source";
1020
+ handle.update(doctypeFile());
1021
+ // iframe gone → its loading machine was torn down (listener removed).
1022
+ expect(handle.el.querySelector("iframe")).toBeNull();
1023
+ expect(
1024
+ removeSpy.mock.calls.some((c) => c[0] === "message")
1025
+ ).toBe(true);
1026
+ } finally {
1027
+ removeSpy.mockRestore();
1028
+ }
1029
+ });
1030
+ });
1031
+
1032
+ describe("artifact-preview component body", () => {
1033
+ const componentRecord = (
1034
+ overrides: Partial<PersonaArtifactRecord> = {}
1035
+ ): PersonaArtifactRecord => ({
1036
+ id: "c1",
1037
+ artifactType: "component",
1038
+ title: "Chart",
1039
+ status: "complete",
1040
+ component: "MyChart",
1041
+ props: { series: [1, 2, 3] },
1042
+ ...overrides,
1043
+ });
1044
+
1045
+ it("renders through the provided registry", () => {
1046
+ const seen: Array<Record<string, unknown>> = [];
1047
+ const renderer: ComponentRenderer = (props) => {
1048
+ seen.push(props);
1049
+ const el = document.createElement("div");
1050
+ el.className = "my-chart";
1051
+ el.textContent = "chart";
1052
+ return el;
1053
+ };
1054
+ const handle = renderArtifactPreviewBody(componentRecord(), {
1055
+ config: makeConfig(),
1056
+ registry: { get: (name) => (name === "MyChart" ? renderer : undefined) },
1057
+ });
1058
+ expect(handle.el.querySelector(".my-chart")?.textContent).toBe("chart");
1059
+ expect(seen).toEqual([{ series: [1, 2, 3] }]);
1060
+ });
1061
+
1062
+ it("falls back to the inspector card for unknown components", () => {
1063
+ const handle = renderArtifactPreviewBody(componentRecord({ component: "Nope" }), {
1064
+ config: makeConfig(),
1065
+ registry: { get: () => undefined },
1066
+ });
1067
+ expect(handle.el.textContent).toContain("Component: Nope");
1068
+ const pre = handle.el.querySelector("pre");
1069
+ expect(pre?.textContent).toContain('"series"');
1070
+ });
1071
+
1072
+ it("falls back to the inspector card when the renderer throws", () => {
1073
+ const handle = renderArtifactPreviewBody(componentRecord(), {
1074
+ config: makeConfig(),
1075
+ registry: {
1076
+ get: () => () => {
1077
+ throw new Error("boom");
1078
+ },
1079
+ },
1080
+ });
1081
+ expect(handle.el.textContent).toContain("Component: MyChart");
1082
+ });
1083
+ });
1084
+
1085
+ describe("artifact-preview status body statusLabel", () => {
1086
+ const cfg = (artifacts: Record<string, unknown>): AgentWidgetConfig =>
1087
+ ({
1088
+ sanitize: false,
1089
+ features: { artifacts: { enabled: true, ...artifacts } },
1090
+ }) as AgentWidgetConfig;
1091
+ const statusLayout = () => layout({ streamingView: "status" });
1092
+ const streamingMd = (markdown: string): PersonaArtifactRecord => ({
1093
+ id: "s1",
1094
+ artifactType: "markdown",
1095
+ title: "Notes",
1096
+ status: "streaming",
1097
+ markdown,
1098
+ });
1099
+ const labelEl = (el: HTMLElement): HTMLElement | null =>
1100
+ el.querySelector(
1101
+ ".persona-artifact-status-view-text .persona-artifact-status-label"
1102
+ );
1103
+ const detailEl = (el: HTMLElement): HTMLElement | null =>
1104
+ el.querySelector(
1105
+ ".persona-artifact-status-view-text .persona-artifact-status-detail"
1106
+ );
1107
+
1108
+ it("replaces the default status with a plain string", () => {
1109
+ const handle = renderArtifactPreviewBody(streamingMd("Hi"), {
1110
+ config: cfg({ loadingAnimation: "none", statusLabel: "Generating your notes" }),
1111
+ bodyLayout: statusLayout(),
1112
+ });
1113
+ expect(labelEl(handle.el)?.textContent).toBe("Generating your notes");
1114
+ expect(handle.el.textContent).not.toContain("Generating document");
1115
+ });
1116
+
1117
+ it("passes the status-body surface and re-resolves the detail per delta", () => {
1118
+ const calls: PersonaArtifactStatusLabelContext[] = [];
1119
+ const handle = renderArtifactPreviewBody(streamingMd("Hello"), {
1120
+ config: cfg({
1121
+ loadingAnimation: "none",
1122
+ statusLabel: (ctx: PersonaArtifactStatusLabelContext) => {
1123
+ calls.push(ctx);
1124
+ return { label: "Writing", detail: `${ctx.chars} chars` };
1125
+ },
1126
+ }),
1127
+ bodyLayout: statusLayout(),
1128
+ });
1129
+ expect(calls[calls.length - 1].surface).toBe("status-body");
1130
+ const label1 = labelEl(handle.el);
1131
+ expect(detailEl(handle.el)?.textContent).toBe("5 chars");
1132
+
1133
+ handle.update(streamingMd("Hello world"));
1134
+ const label2 = labelEl(handle.el);
1135
+ expect(detailEl(handle.el)?.textContent).toBe("11 chars");
1136
+ // The status view and its animated label element are reused across deltas
1137
+ // (rebuilding them would restart the animation).
1138
+ expect(label1).not.toBeNull();
1139
+ expect(label1).toBe(label2);
1140
+ });
1141
+
1142
+ it("falls back to the default label when the function throws", () => {
1143
+ const handle = renderArtifactPreviewBody(streamingMd("Hi"), {
1144
+ config: cfg({
1145
+ loadingAnimation: "none",
1146
+ statusLabel: () => {
1147
+ throw new Error("boom");
1148
+ },
1149
+ }),
1150
+ bodyLayout: statusLayout(),
1151
+ });
1152
+ // Plain markdown artifact (no file) → typeLabel "Document".
1153
+ expect(labelEl(handle.el)?.textContent).toBe("Generating document...");
1154
+ });
1155
+ });