@runtypelabs/persona 3.26.0 → 3.28.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 (39) hide show
  1. package/README.md +29 -13
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BZVr1YOV.d.cts → types-CxvHw0X6.d.cts} +551 -1
  5. package/dist/animations/{types-BZVr1YOV.d.ts → types-CxvHw0X6.d.ts} +551 -1
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +80 -0
  9. package/dist/codegen.d.cts +143 -0
  10. package/dist/codegen.d.ts +143 -0
  11. package/dist/codegen.js +80 -0
  12. package/dist/index.cjs +35 -35
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +1230 -3
  15. package/dist/index.d.ts +1230 -3
  16. package/dist/index.global.js +35 -35
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +31 -31
  19. package/dist/index.js.map +1 -1
  20. package/dist/install.global.js +1 -1
  21. package/dist/install.global.js.map +1 -1
  22. package/dist/launcher.global.js +133 -0
  23. package/dist/launcher.global.js.map +1 -0
  24. package/dist/smart-dom-reader.d.cts +551 -1
  25. package/dist/smart-dom-reader.d.ts +551 -1
  26. package/dist/theme-editor.d.cts +551 -1
  27. package/dist/theme-editor.d.ts +551 -1
  28. package/package.json +14 -4
  29. package/src/client.test.ts +9 -9
  30. package/src/codegen.test.ts +39 -0
  31. package/src/codegen.ts +20 -0
  32. package/src/generated/runtype-openapi-contract.ts +1249 -0
  33. package/src/index-core.ts +19 -0
  34. package/src/install.test.ts +442 -0
  35. package/src/install.ts +283 -49
  36. package/src/launcher-global.ts +115 -0
  37. package/src/runtime/init.test.ts +71 -0
  38. package/src/runtime/init.ts +17 -1
  39. package/src/types.ts +14 -8
package/src/index-core.ts CHANGED
@@ -105,6 +105,25 @@ export type {
105
105
  AgentWidgetAskUserQuestionStyles
106
106
  } from "./types";
107
107
 
108
+ export type {
109
+ RuntypeAgentSSEEvent,
110
+ RuntypeFlowSSEEvent,
111
+ RuntypeDispatchSSEEvent,
112
+ RuntypeStreamEventOf,
113
+ RuntypeAgentTurnCompleteEvent,
114
+ RuntypeStepCompleteEvent,
115
+ RuntypeStopReasonKind,
116
+ RuntypeClientInitRequest,
117
+ RuntypeClientInitResponse,
118
+ RuntypeClientChatRequest,
119
+ RuntypeClientChatStreamEvent,
120
+ RuntypeClientResumeRequest,
121
+ RuntypeClientResumeStreamEvent,
122
+ RuntypeClientFeedbackRequest,
123
+ RuntypeClientFeedbackResponse,
124
+ RuntypeClientFeedbackType,
125
+ } from "./generated/runtype-openapi-contract";
126
+
108
127
  export {
109
128
  ASK_USER_QUESTION_TOOL_NAME,
110
129
  createAskUserQuestionBubble,
@@ -0,0 +1,442 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Integration tests for the installer IIFE (`install.ts`). These import the real
4
+ // module so the self-executing installer runs in jsdom, then assert behavior by
5
+ // observing the mocked global bundles (`window.AgentWidget` /
6
+ // `window.AgentWidgetLauncher`) and the lifecycle callbacks / DOM events.
7
+ //
8
+ // The installer defers loading to `setTimeout` (via `waitForHydration`, since
9
+ // jsdom has no `requestIdleCallback`), so tests use fake timers and
10
+ // `vi.runAllTimersAsync()` to drive it forward deterministically.
11
+
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ const PERSONA_EVENTS = [
15
+ "persona:script-load",
16
+ "persona:launcher-shown",
17
+ "persona:chat-ready",
18
+ "persona:ready",
19
+ "persona:error",
20
+ ] as const;
21
+
22
+ type FakeHandle = { open: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn> };
23
+
24
+ // Captured across a single installer run.
25
+ let capturedOnOpen: (() => void) | null;
26
+ let launcherMount: ReturnType<typeof vi.fn>;
27
+ let launcherDestroy: ReturnType<typeof vi.fn>;
28
+ let launcherElement: HTMLButtonElement;
29
+ let initAgentWidget: ReturnType<typeof vi.fn>;
30
+ let fakeHandle: FakeHandle;
31
+ let warnSpy: ReturnType<typeof vi.spyOn>;
32
+ let errorSpy: ReturnType<typeof vi.spyOn>;
33
+
34
+ /** Subscribe to every persona:* event and record dispatch order + detail. */
35
+ function recordEvents() {
36
+ const log: Array<{ type: string; detail: any }> = [];
37
+ const handlers: Array<[string, (e: Event) => void]> = [];
38
+ for (const type of PERSONA_EVENTS) {
39
+ const handler = (e: Event) => log.push({ type, detail: (e as CustomEvent).detail });
40
+ window.addEventListener(type, handler);
41
+ handlers.push([type, handler]);
42
+ }
43
+ return {
44
+ log,
45
+ types: () => log.map((entry) => entry.type),
46
+ stop() {
47
+ handlers.forEach(([type, handler]) => window.removeEventListener(type, handler));
48
+ },
49
+ };
50
+ }
51
+
52
+ /** Make `isCssLoaded()` return true so `loadCSS()` resolves without a network. */
53
+ function markCssLoaded() {
54
+ const link = document.createElement("link");
55
+ link.rel = "stylesheet";
56
+ link.setAttribute("data-persona", "true");
57
+ document.head.appendChild(link);
58
+ }
59
+
60
+ /** Provide the full bundle global so `loadJS()` short-circuits to resolved. */
61
+ function provideFullBundle() {
62
+ (window as any).AgentWidget = { initAgentWidget };
63
+ }
64
+
65
+ /** Provide the critical launcher global so `loadLauncher()` short-circuits. */
66
+ function provideLauncherBundle() {
67
+ (window as any).AgentWidgetLauncher = { mount: launcherMount };
68
+ }
69
+
70
+ /**
71
+ * Force the next dynamically-inserted <script> to error. jsdom never fires
72
+ * load/error on injected scripts, so we trigger `onerror` on a fake timer that
73
+ * `runAllTimersAsync()` flushes — deterministic under fake timers.
74
+ */
75
+ function failNextScriptLoad() {
76
+ const head = document.head;
77
+ const original = head.appendChild.bind(head);
78
+ vi.spyOn(head, "appendChild").mockImplementation(((node: any) => {
79
+ const result = original(node);
80
+ if (node && node.tagName === "SCRIPT") {
81
+ setTimeout(() => node.onerror && node.onerror(new Event("error")), 0);
82
+ }
83
+ return result;
84
+ }) as any);
85
+ }
86
+
87
+ async function install(config: any) {
88
+ (window as any).siteAgentConfig = config;
89
+ await import("./install");
90
+ }
91
+
92
+ /** Flush waitForHydration's timer, the deferred init setTimeout(0), prefetch, etc. */
93
+ async function flush() {
94
+ await vi.runAllTimersAsync();
95
+ }
96
+
97
+ beforeEach(() => {
98
+ vi.resetModules();
99
+ vi.useFakeTimers();
100
+
101
+ document.head.innerHTML = "";
102
+ document.body.innerHTML = "";
103
+ try {
104
+ window.sessionStorage.clear();
105
+ window.localStorage.clear();
106
+ } catch {
107
+ /* storage unavailable */
108
+ }
109
+
110
+ delete (window as any).__siteAgentInstallerLoaded;
111
+ delete (window as any).siteAgentConfig;
112
+ delete (window as any).AgentWidget;
113
+ delete (window as any).AgentWidgetLauncher;
114
+ capturedOnOpen = null;
115
+
116
+ launcherElement = document.createElement("button");
117
+ launcherDestroy = vi.fn();
118
+ launcherMount = vi.fn((opts: any) => {
119
+ capturedOnOpen = opts.onOpen;
120
+ return {
121
+ root: document.createElement("div"),
122
+ element: launcherElement,
123
+ update: vi.fn(),
124
+ destroy: launcherDestroy,
125
+ };
126
+ });
127
+
128
+ fakeHandle = { open: vi.fn(), close: vi.fn(), on: vi.fn() };
129
+ initAgentWidget = vi.fn(() => fakeHandle);
130
+
131
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
132
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
133
+ });
134
+
135
+ afterEach(() => {
136
+ vi.useRealTimers();
137
+ vi.restoreAllMocks();
138
+ });
139
+
140
+ describe("install.ts — onScriptLoad beacon", () => {
141
+ it("fires synchronously on script execution, before any loading or gating", async () => {
142
+ const events = recordEvents();
143
+ const onScriptLoad = vi.fn();
144
+
145
+ // No config at all — the beacon must still fire (diagnostics: "did my embed run").
146
+ await install({ onScriptLoad });
147
+
148
+ // Fired during module evaluation — no timer flush needed.
149
+ expect(onScriptLoad).toHaveBeenCalledTimes(1);
150
+ expect(onScriptLoad).toHaveBeenCalledWith({ version: "latest" });
151
+ expect(events.types()[0]).toBe("persona:script-load");
152
+ expect(events.log[0]?.detail).toEqual({ version: "latest" });
153
+ events.stop();
154
+ });
155
+
156
+ it("reports the configured version in the beacon", async () => {
157
+ const onScriptLoad = vi.fn();
158
+ await install({ version: "1.2.3", onScriptLoad });
159
+ expect(onScriptLoad).toHaveBeenCalledWith({ version: "1.2.3" });
160
+ });
161
+ });
162
+
163
+ describe("install.ts — deferred launcher path", () => {
164
+ it("mounts the critical launcher, fires onLauncherShown, and defers onChatReady until open", async () => {
165
+ markCssLoaded();
166
+ provideFullBundle(); // so loadJS resolves instantly when the user opens
167
+ provideLauncherBundle();
168
+ const events = recordEvents();
169
+ const onScriptLoad = vi.fn();
170
+ const onLauncherShown = vi.fn();
171
+ const onChatReady = vi.fn();
172
+
173
+ await install({
174
+ config: { apiUrl: "/api", launcher: { enabled: true } },
175
+ onScriptLoad,
176
+ onLauncherShown,
177
+ onChatReady,
178
+ });
179
+
180
+ // script-load fires before hydration.
181
+ expect(onScriptLoad).toHaveBeenCalledTimes(1);
182
+
183
+ await flush();
184
+
185
+ // The real launcher is painted at page-load time from the critical bundle.
186
+ expect(launcherMount).toHaveBeenCalledTimes(1);
187
+ expect(onLauncherShown).toHaveBeenCalledTimes(1);
188
+ expect(onLauncherShown).toHaveBeenCalledWith({ deferred: true, element: launcherElement });
189
+ // The full widget has NOT been initialized yet — that waits for first open.
190
+ expect(initAgentWidget).not.toHaveBeenCalled();
191
+ expect(onChatReady).not.toHaveBeenCalled();
192
+
193
+ // Simulate the user clicking the launcher.
194
+ expect(capturedOnOpen).toBeTypeOf("function");
195
+ capturedOnOpen!();
196
+ await flush();
197
+
198
+ // Full widget mounts, the click carries through (panel opens), and the
199
+ // critical launcher is removed (handoff).
200
+ expect(initAgentWidget).toHaveBeenCalledTimes(1);
201
+ expect(fakeHandle.open).toHaveBeenCalledTimes(1);
202
+ expect(onChatReady).toHaveBeenCalledTimes(1);
203
+ expect(onChatReady).toHaveBeenCalledWith(fakeHandle);
204
+ expect(launcherDestroy).toHaveBeenCalledTimes(1);
205
+
206
+ // DOM event parity: script-load → launcher-shown (deferred) → chat-ready.
207
+ const types = events.types();
208
+ expect(types).toContain("persona:script-load");
209
+ const launcherShown = events.log.find((e) => e.type === "persona:launcher-shown");
210
+ expect(launcherShown?.detail).toMatchObject({ deferred: true });
211
+ const chatReady = events.log.find((e) => e.type === "persona:chat-ready");
212
+ expect(chatReady?.detail).toBe(fakeHandle);
213
+ // launcher-shown precedes chat-ready.
214
+ expect(types.indexOf("persona:launcher-shown")).toBeLessThan(types.indexOf("persona:chat-ready"));
215
+ events.stop();
216
+ });
217
+
218
+ it("a second click while loading does not initialize the widget twice", async () => {
219
+ markCssLoaded();
220
+ provideFullBundle();
221
+ provideLauncherBundle();
222
+ await install({ config: { apiUrl: "/api", launcher: { enabled: true } } });
223
+ await flush();
224
+
225
+ capturedOnOpen!();
226
+ capturedOnOpen!(); // re-entrant click before the first finishes
227
+ await flush();
228
+
229
+ expect(initAgentWidget).toHaveBeenCalledTimes(1);
230
+ });
231
+ });
232
+
233
+ describe("install.ts — eager path", () => {
234
+ it("non-floating (launcher disabled) eager-loads, fires onChatReady, and does NOT fire onLauncherShown", async () => {
235
+ markCssLoaded();
236
+ provideFullBundle();
237
+ provideLauncherBundle(); // available, but must NOT be used for an inline embed
238
+ const onLauncherShown = vi.fn();
239
+ const onChatReady = vi.fn();
240
+
241
+ await install({
242
+ config: { apiUrl: "/api", launcher: { enabled: false } },
243
+ onLauncherShown,
244
+ onChatReady,
245
+ });
246
+ await flush();
247
+
248
+ expect(launcherMount).not.toHaveBeenCalled();
249
+ expect(initAgentWidget).toHaveBeenCalledTimes(1);
250
+ expect(onChatReady).toHaveBeenCalledWith(fakeHandle);
251
+ // No floating launcher → onLauncherShown must stay silent (name-honest).
252
+ expect(onLauncherShown).not.toHaveBeenCalled();
253
+ });
254
+
255
+ it("eager floating install fires onLauncherShown with deferred:false", async () => {
256
+ markCssLoaded();
257
+ provideFullBundle();
258
+ provideLauncherBundle();
259
+ const onLauncherShown = vi.fn();
260
+
261
+ // autoExpand starts the panel open → deferral is disabled → eager floating.
262
+ await install({
263
+ config: { apiUrl: "/api", launcher: { enabled: true, autoExpand: true } },
264
+ onLauncherShown,
265
+ });
266
+ await flush();
267
+
268
+ expect(launcherMount).not.toHaveBeenCalled(); // deferral off
269
+ expect(initAgentWidget).toHaveBeenCalledTimes(1);
270
+ expect(onLauncherShown).toHaveBeenCalledTimes(1);
271
+ expect(onLauncherShown).toHaveBeenCalledWith({ deferred: false });
272
+ });
273
+ });
274
+
275
+ describe("install.ts — deferral gate", () => {
276
+ async function expectDecision(config: any, { defers }: { defers: boolean }) {
277
+ markCssLoaded();
278
+ provideFullBundle();
279
+ provideLauncherBundle();
280
+ await install({ config });
281
+ await flush();
282
+ if (defers) {
283
+ expect(launcherMount).toHaveBeenCalledTimes(1);
284
+ expect(initAgentWidget).not.toHaveBeenCalled();
285
+ } else {
286
+ expect(launcherMount).not.toHaveBeenCalled();
287
+ expect(initAgentWidget).toHaveBeenCalledTimes(1);
288
+ }
289
+ }
290
+
291
+ it("defers for a plain floating launcher", async () => {
292
+ await expectDecision({ apiUrl: "/api", launcher: { enabled: true } }, { defers: true });
293
+ });
294
+
295
+ it("defers when launcher.mountMode is omitted (defaults to floating)", async () => {
296
+ await expectDecision({ apiUrl: "/api", launcher: { title: "Help" } }, { defers: true });
297
+ });
298
+
299
+ it("does NOT defer for a docked launcher", async () => {
300
+ await expectDecision({ apiUrl: "/api", launcher: { enabled: true, mountMode: "docked" } }, { defers: false });
301
+ });
302
+
303
+ it("does NOT defer when an onStateLoaded hook is present (it may request open)", async () => {
304
+ await expectDecision(
305
+ { apiUrl: "/api", launcher: { enabled: true }, onStateLoaded: () => ({ open: true }) },
306
+ { defers: false }
307
+ );
308
+ });
309
+
310
+ it("does NOT defer when a persisted open state is restored from storage", async () => {
311
+ window.sessionStorage.setItem("persona-widget-open", "true");
312
+ await expectDecision({ apiUrl: "/api", launcher: { enabled: true }, persistState: true }, { defers: false });
313
+ });
314
+
315
+ it("STILL defers when persistState is on but no open state is stored", async () => {
316
+ // Distinguishes the real storage read from a blanket "persistState disables deferral".
317
+ await expectDecision({ apiUrl: "/api", launcher: { enabled: true }, persistState: true }, { defers: true });
318
+ });
319
+
320
+ it("honors a custom keyPrefix + local storage when checking persisted open state", async () => {
321
+ window.localStorage.setItem("shop-widget-open", "true");
322
+ await expectDecision(
323
+ {
324
+ apiUrl: "/api",
325
+ launcher: { enabled: true },
326
+ persistState: { keyPrefix: "shop-", storage: "local" },
327
+ },
328
+ { defers: false }
329
+ );
330
+ });
331
+
332
+ it("does NOT defer when a custom jsUrl override has no derivable launcher URL", async () => {
333
+ markCssLoaded();
334
+ provideFullBundle();
335
+ provideLauncherBundle();
336
+ // Non-standard jsUrl → getCdnBase() yields launcherUrl: null → eager.
337
+ await install({
338
+ cssUrl: "https://cdn.example.com/persona.css",
339
+ jsUrl: "https://cdn.example.com/persona.bundle.js",
340
+ config: { apiUrl: "/api", launcher: { enabled: true } },
341
+ });
342
+ await flush();
343
+ expect(launcherMount).not.toHaveBeenCalled();
344
+ expect(initAgentWidget).toHaveBeenCalledTimes(1);
345
+ });
346
+ });
347
+
348
+ describe("install.ts — onReady deprecation alias", () => {
349
+ it("still calls the deprecated onReady and warns once", async () => {
350
+ markCssLoaded();
351
+ provideFullBundle();
352
+ const onReady = vi.fn();
353
+
354
+ await install({ config: { apiUrl: "/api", launcher: { enabled: false } }, onReady });
355
+ await flush();
356
+
357
+ expect(onReady).toHaveBeenCalledTimes(1);
358
+ expect(onReady).toHaveBeenCalledWith(fakeHandle);
359
+ expect(warnSpy).toHaveBeenCalledTimes(1);
360
+ expect(String(warnSpy.mock.calls[0]?.[0])).toContain("`onReady` is deprecated");
361
+ });
362
+
363
+ it("prefers onChatReady over onReady and does not warn", async () => {
364
+ markCssLoaded();
365
+ provideFullBundle();
366
+ const onReady = vi.fn();
367
+ const onChatReady = vi.fn();
368
+
369
+ await install({ config: { apiUrl: "/api", launcher: { enabled: false } }, onReady, onChatReady });
370
+ await flush();
371
+
372
+ expect(onChatReady).toHaveBeenCalledTimes(1);
373
+ expect(onChatReady).toHaveBeenCalledWith(fakeHandle);
374
+ expect(onReady).not.toHaveBeenCalled();
375
+ expect(warnSpy).not.toHaveBeenCalled();
376
+ });
377
+ });
378
+
379
+ describe("install.ts — onError", () => {
380
+ it("fires onError with phase 'init' and dispatches persona:error when initialization throws", async () => {
381
+ markCssLoaded();
382
+ provideFullBundle();
383
+ const boom = new Error("init boom");
384
+ initAgentWidget.mockImplementation(() => {
385
+ throw boom;
386
+ });
387
+ const events = recordEvents();
388
+ const onError = vi.fn();
389
+
390
+ await install({ config: { apiUrl: "/api", launcher: { enabled: false } }, onError });
391
+ await flush();
392
+
393
+ expect(onError).toHaveBeenCalledWith({ phase: "init", error: boom });
394
+ const errorEvent = events.log.find((e) => e.type === "persona:error");
395
+ expect(errorEvent?.detail).toMatchObject({ phase: "init" });
396
+ events.stop();
397
+ });
398
+
399
+ it("fires onError with phase 'bundle' when the full bundle fails to load on open", async () => {
400
+ markCssLoaded();
401
+ provideLauncherBundle();
402
+ // AgentWidget intentionally NOT provided → loadJS() creates a real <script>...
403
+ failNextScriptLoad(); // ...which we force to error.
404
+ const events = recordEvents();
405
+ const onError = vi.fn();
406
+
407
+ await install({ config: { apiUrl: "/api", launcher: { enabled: true } }, onError });
408
+ await flush();
409
+
410
+ expect(launcherMount).toHaveBeenCalledTimes(1);
411
+ expect(capturedOnOpen).toBeTypeOf("function");
412
+ capturedOnOpen!(); // user clicks → loadJS → <script> error
413
+ await flush();
414
+
415
+ expect(onError).toHaveBeenCalledTimes(1);
416
+ expect(onError.mock.calls[0]?.[0]).toMatchObject({ phase: "bundle" });
417
+ const errorEvent = events.log.find((e) => e.type === "persona:error");
418
+ expect(errorEvent?.detail).toMatchObject({ phase: "bundle" });
419
+ events.stop();
420
+ });
421
+ });
422
+
423
+ describe("install.ts — lifecycle callback safety", () => {
424
+ it("a throwing onScriptLoad callback does not break the rest of installation", async () => {
425
+ markCssLoaded();
426
+ provideFullBundle();
427
+ const onChatReady = vi.fn();
428
+
429
+ await install({
430
+ config: { apiUrl: "/api", launcher: { enabled: false } },
431
+ onScriptLoad: () => {
432
+ throw new Error("user callback blew up");
433
+ },
434
+ onChatReady,
435
+ });
436
+ await flush();
437
+
438
+ // The throw was swallowed (logged) and init still completed.
439
+ expect(onChatReady).toHaveBeenCalledWith(fakeHandle);
440
+ expect(errorSpy).toHaveBeenCalled();
441
+ });
442
+ });