@runtypelabs/persona 4.17.0 → 4.18.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 (165) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,456 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Controller-level history lifecycle (`docs/visitor-history-implementation-plan.md`
5
+ * D4/D6): the `update()` visitor-store re-key matrix, `destroy()` releasing the
6
+ * storage listener and any held first-init lease, and provider rebuilds
7
+ * rebinding their subscriptions without leaving a stale one attached.
8
+ *
9
+ * The store itself is covered by `utils/visitor-store.test.ts`; everything here
10
+ * is about who owns it and when the controller swaps it.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ /** Records every store the controller builds, with its construction tuple. */
16
+ const recorder = vi.hoisted(() => ({
17
+ instances: [] as Array<{
18
+ args: [string, string, boolean];
19
+ store: import("./utils/visitor-store").VisitorStore;
20
+ destroy: ReturnType<typeof vi.fn>;
21
+ clear: ReturnType<typeof vi.fn>;
22
+ }>,
23
+ }));
24
+
25
+ vi.mock("./utils/visitor-store", async (importOriginal) => {
26
+ const actual =
27
+ await importOriginal<typeof import("./utils/visitor-store")>();
28
+ return {
29
+ ...actual,
30
+ createVisitorStore: (
31
+ clientToken: string,
32
+ keyPrefix: string,
33
+ persistDisabled: boolean
34
+ ) => {
35
+ const store = actual.createVisitorStore(
36
+ clientToken,
37
+ keyPrefix,
38
+ persistDisabled
39
+ );
40
+ const destroy = vi.fn(() => store.destroy());
41
+ const clear = vi.fn(() => store.clear());
42
+ recorder.instances.push({
43
+ args: [clientToken, keyPrefix, persistDisabled],
44
+ store,
45
+ destroy,
46
+ clear,
47
+ });
48
+ return { ...store, destroy, clear };
49
+ },
50
+ };
51
+ });
52
+
53
+ import { createAgentExperience } from "./ui";
54
+ import { createHistoryView } from "./components/history-view";
55
+ import { setHistoryViewLoader } from "./history-view-loader";
56
+ import { setHistoryProviderFactory } from "./internal/history-provider-registry";
57
+ import { createDemoHistoryProvider } from "./internal/demo-history-provider";
58
+ import { visitorStoreKeys } from "./utils/visitor-store";
59
+ import type { HistoryProvider } from "./internal/history-provider";
60
+ import type { HistoryIdentityStatus } from "./types";
61
+
62
+ const TOKEN_A = "ct_live_alpha";
63
+ const TOKEN_B = "ct_live_beta";
64
+ const KEY_PREFIX = "persona-lifecycle-";
65
+ const API_URL = "https://api.runtype.com";
66
+
67
+ const flush = async (times = 12) => {
68
+ for (let i = 0; i < times; i += 1) {
69
+ // eslint-disable-next-line no-await-in-loop
70
+ await Promise.resolve();
71
+ }
72
+ };
73
+
74
+ const mounts: HTMLElement[] = [];
75
+ const controllers: Array<{ destroy: () => void }> = [];
76
+
77
+ /** Permissive init stub: every client rebuild re-inits, and none of it matters. */
78
+ const installFetch = () => {
79
+ global.fetch = vi.fn(async () => ({
80
+ ok: true,
81
+ status: 200,
82
+ json: async () => ({
83
+ sessionId: `sess_${Math.random().toString(36).slice(2, 8)}`,
84
+ expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
85
+ flow: { id: "flow_1", name: "Flow", description: null },
86
+ conversationId: "conv_1",
87
+ targetId: "flow_1",
88
+ conversationRevision: "rev_1",
89
+ config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
90
+ }),
91
+ })) as unknown as typeof fetch;
92
+ };
93
+
94
+ const mount = (config: Record<string, unknown>) => {
95
+ const host = document.createElement("div");
96
+ document.body.appendChild(host);
97
+ mounts.push(host);
98
+ const controller = createAgentExperience(host, {
99
+ apiUrl: API_URL,
100
+ launcher: { enabled: false },
101
+ suggestionChips: [],
102
+ features: { history: { enabled: true } },
103
+ ...config,
104
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
105
+ controllers.push(controller);
106
+ return { host, controller };
107
+ };
108
+
109
+ describe("history controller lifecycle", () => {
110
+ beforeEach(() => {
111
+ window.scrollTo = vi.fn();
112
+ setHistoryViewLoader(async () => ({ createHistoryView }));
113
+ installFetch();
114
+ });
115
+
116
+ afterEach(() => {
117
+ setHistoryProviderFactory(null);
118
+ controllers.splice(0).forEach((controller) => {
119
+ try {
120
+ controller.destroy();
121
+ } catch {
122
+ /* already destroyed */
123
+ }
124
+ });
125
+ mounts.splice(0).forEach((host) => host.remove());
126
+ recorder.instances.length = 0;
127
+ document.body.innerHTML = "";
128
+ window.localStorage.clear();
129
+ Reflect.deleteProperty(globalThis.navigator, "locks");
130
+ vi.restoreAllMocks();
131
+ });
132
+
133
+ describe("update() store re-key matrix", () => {
134
+ const seed = async () => {
135
+ const keysA = await visitorStoreKeys(TOKEN_A, KEY_PREFIX);
136
+ const keysB = await visitorStoreKeys(TOKEN_B, KEY_PREFIX);
137
+ window.localStorage.setItem(keysA.storageKey, "cvt_alpha");
138
+ window.localStorage.setItem(keysB.storageKey, "cvt_beta");
139
+ return { keysA, keysB };
140
+ };
141
+
142
+ it("re-keys on a client-token change without clearing the old namespace", async () => {
143
+ const { keysA, keysB } = await seed();
144
+ const { controller } = mount({
145
+ clientToken: TOKEN_A,
146
+ persistState: { keyPrefix: KEY_PREFIX },
147
+ });
148
+
149
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
150
+ const first = recorder.instances[0]!;
151
+ expect(first.args).toEqual([TOKEN_A, KEY_PREFIX, false]);
152
+ expect(await first.store.get()).toBe("cvt_alpha");
153
+
154
+ controller.update({ clientToken: TOKEN_B });
155
+ await flush(20);
156
+
157
+ expect(recorder.instances.length).toBe(2);
158
+ const second = recorder.instances[1]!;
159
+ expect(second.args).toEqual([TOKEN_B, KEY_PREFIX, false]);
160
+ // Correctly keyed: it reads the beta namespace, never alpha's secret.
161
+ expect(await second.store.get()).toBe("cvt_beta");
162
+
163
+ // The prior surface keeps its credential: re-keying is not a logout.
164
+ expect(first.destroy).toHaveBeenCalledTimes(1);
165
+ expect(first.clear).not.toHaveBeenCalled();
166
+ expect(window.localStorage.getItem(keysA.storageKey)).toBe("cvt_alpha");
167
+ expect(window.localStorage.getItem(keysB.storageKey)).toBe("cvt_beta");
168
+ });
169
+
170
+ it("keeps the same store across an unrelated connection rebuild", async () => {
171
+ await seed();
172
+ const { controller } = mount({
173
+ clientToken: TOKEN_A,
174
+ persistState: { keyPrefix: KEY_PREFIX },
175
+ });
176
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
177
+
178
+ controller.update({ apiUrl: `${API_URL}/edge` });
179
+ controller.update({ headers: { "x-demo": "1" } });
180
+ await flush(20);
181
+
182
+ // The identity tuple did not move, so the credential store survives the
183
+ // client rebuild underneath it.
184
+ expect(recorder.instances.length).toBe(1);
185
+ expect(recorder.instances[0]!.destroy).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it("rebuilds across a persistState on/off/on transition", async () => {
189
+ const { keysA } = await seed();
190
+ const { controller } = mount({
191
+ clientToken: TOKEN_A,
192
+ persistState: { keyPrefix: KEY_PREFIX },
193
+ });
194
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
195
+
196
+ controller.update({ persistState: false });
197
+ await flush(20);
198
+ expect(recorder.instances.length).toBe(2);
199
+ // Persistence off also drops the custom prefix, so both tuple fields move.
200
+ expect(recorder.instances[1]!.args).toEqual([TOKEN_A, "persona-", true]);
201
+ // Memory-only: the durable credential is not readable through it.
202
+ expect(await recorder.instances[1]!.store.get()).toBeNull();
203
+
204
+ controller.update({ persistState: { keyPrefix: KEY_PREFIX } });
205
+ await flush(20);
206
+ expect(recorder.instances.length).toBe(3);
207
+ expect(recorder.instances[2]!.args).toEqual([TOKEN_A, KEY_PREFIX, false]);
208
+ expect(await recorder.instances[2]!.store.get()).toBe("cvt_alpha");
209
+
210
+ expect(recorder.instances[0]!.clear).not.toHaveBeenCalled();
211
+ expect(recorder.instances[1]!.clear).not.toHaveBeenCalled();
212
+ expect(window.localStorage.getItem(keysA.storageKey)).toBe("cvt_alpha");
213
+ });
214
+
215
+ it("drops the store entirely when history is disabled", async () => {
216
+ await seed();
217
+ const { controller } = mount({
218
+ clientToken: TOKEN_A,
219
+ persistState: { keyPrefix: KEY_PREFIX },
220
+ });
221
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
222
+
223
+ controller.update({ features: { history: { enabled: false } } });
224
+ await flush(20);
225
+
226
+ expect(recorder.instances.length).toBe(1);
227
+ expect(recorder.instances[0]!.destroy).toHaveBeenCalledTimes(1);
228
+ expect(recorder.instances[0]!.clear).not.toHaveBeenCalled();
229
+ });
230
+ });
231
+
232
+ describe("external credential change (D3)", () => {
233
+ /** Exactly what a sibling tab's write looks like: jsdom fires no event. */
234
+ const externalClear = (storageKey: string) => {
235
+ window.localStorage.removeItem(storageKey);
236
+ window.dispatchEvent(
237
+ new StorageEvent("storage", { key: storageKey, newValue: null })
238
+ );
239
+ };
240
+
241
+ it("wipes the mounted transcript when a sibling revokes the credential", async () => {
242
+ const keys = await visitorStoreKeys(TOKEN_A, KEY_PREFIX);
243
+ window.localStorage.setItem(keys.storageKey, "cvt_alpha");
244
+ const { controller } = mount({
245
+ clientToken: TOKEN_A,
246
+ persistState: { keyPrefix: KEY_PREFIX },
247
+ });
248
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
249
+ await recorder.instances[0]!.store.ready;
250
+ await flush(20);
251
+
252
+ controller.injectAssistantMessage({ content: "before the reset" });
253
+ expect(controller.getMessages()).toHaveLength(1);
254
+
255
+ externalClear(keys.storageKey);
256
+ await flush(20);
257
+
258
+ expect(controller.getMessages()).toHaveLength(0);
259
+ });
260
+
261
+ it("follows the store across an update() re-key", async () => {
262
+ const keysA = await visitorStoreKeys(TOKEN_A, KEY_PREFIX);
263
+ const keysB = await visitorStoreKeys(TOKEN_B, KEY_PREFIX);
264
+ window.localStorage.setItem(keysA.storageKey, "cvt_alpha");
265
+ window.localStorage.setItem(keysB.storageKey, "cvt_beta");
266
+ const { controller } = mount({
267
+ clientToken: TOKEN_A,
268
+ persistState: { keyPrefix: KEY_PREFIX },
269
+ });
270
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
271
+
272
+ controller.update({ clientToken: TOKEN_B });
273
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(2));
274
+ await recorder.instances[1]!.store.ready;
275
+ await flush(20);
276
+
277
+ controller.injectAssistantMessage({ content: "still here" });
278
+ // The detached alpha store no longer reaches the session.
279
+ externalClear(keysA.storageKey);
280
+ await flush(20);
281
+ expect(controller.getMessages()).toHaveLength(1);
282
+
283
+ externalClear(keysB.storageKey);
284
+ await flush(20);
285
+ expect(controller.getMessages()).toHaveLength(0);
286
+ });
287
+
288
+ it("is inert after destroy()", async () => {
289
+ const keys = await visitorStoreKeys(TOKEN_A, KEY_PREFIX);
290
+ window.localStorage.setItem(keys.storageKey, "cvt_alpha");
291
+ const { controller } = mount({
292
+ clientToken: TOKEN_A,
293
+ persistState: { keyPrefix: KEY_PREFIX },
294
+ });
295
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
296
+ await recorder.instances[0]!.store.ready;
297
+ await flush(20);
298
+
299
+ controller.injectAssistantMessage({ content: "before the reset" });
300
+ controller.destroy();
301
+
302
+ externalClear(keys.storageKey);
303
+ await flush(20);
304
+
305
+ // Nothing observes it: the message state is left exactly as destroyed.
306
+ expect(controller.getMessages()).toHaveLength(1);
307
+ });
308
+ });
309
+
310
+ describe("destroy()", () => {
311
+ it("removes the storage listener and releases a held first-init lease", async () => {
312
+ const keys = await visitorStoreKeys(TOKEN_A, KEY_PREFIX);
313
+ // Seeded so the widget's own bootstrap does not contend for the lease
314
+ // this test takes deliberately.
315
+ window.localStorage.setItem(keys.storageKey, "cvt_alpha");
316
+ // jsdom has no Web Locks, so first init takes the localStorage lease path.
317
+ expect(
318
+ (globalThis.navigator as Navigator & { locks?: unknown }).locks
319
+ ).toBeUndefined();
320
+
321
+ const live = new Set<unknown>();
322
+ const originalAdd = window.addEventListener.bind(window);
323
+ const originalRemove = window.removeEventListener.bind(window);
324
+ const addSpy = vi
325
+ .spyOn(window, "addEventListener")
326
+ .mockImplementation((type, handler, options) => {
327
+ if (type === "storage") live.add(handler);
328
+ originalAdd(type, handler, options);
329
+ });
330
+ const removeSpy = vi
331
+ .spyOn(window, "removeEventListener")
332
+ .mockImplementation((type, handler, options) => {
333
+ if (type === "storage") live.delete(handler);
334
+ originalRemove(type, handler, options);
335
+ });
336
+
337
+ const { controller } = mount({
338
+ clientToken: TOKEN_A,
339
+ persistState: { keyPrefix: KEY_PREFIX },
340
+ });
341
+ await vi.waitFor(() => expect(recorder.instances.length).toBe(1));
342
+ const store = recorder.instances[0]!.store;
343
+ await store.ready;
344
+ expect(live.size).toBeGreaterThan(0);
345
+
346
+ let leaseWhileHeld: string | null = null;
347
+ let leaseAfterDestroy: string | null = null;
348
+ await store.withFirstInitLock(async () => {
349
+ leaseWhileHeld = window.localStorage.getItem(keys.leaseKey);
350
+ controller.destroy();
351
+ leaseAfterDestroy = window.localStorage.getItem(keys.leaseKey);
352
+ });
353
+
354
+ expect(leaseWhileHeld).not.toBeNull();
355
+ // destroy() releases the lease it owns rather than waiting for expiry.
356
+ expect(leaseAfterDestroy).toBeNull();
357
+ expect(recorder.instances[0]!.destroy).toHaveBeenCalledTimes(1);
358
+ // Every storage listener the controller added is gone.
359
+ expect(live.size).toBe(0);
360
+ expect(addSpy).toHaveBeenCalled();
361
+ expect(removeSpy).toHaveBeenCalled();
362
+ });
363
+ });
364
+
365
+ describe("provider rebuild on update()", () => {
366
+ /** Demo provider plus counted identity/availability subscriptions. */
367
+ const countedProvider = () => {
368
+ const base = createDemoHistoryProvider({ conversations: [] });
369
+ const counts = {
370
+ identitySubscribes: 0,
371
+ identityUnsubscribes: 0,
372
+ availabilitySubscribes: 0,
373
+ availabilityUnsubscribes: 0,
374
+ };
375
+ let availability: ((available: boolean) => void) | null = null;
376
+ const provider: HistoryProvider = {
377
+ ...base,
378
+ subscribeIdentityStatus(callback) {
379
+ counts.identitySubscribes += 1;
380
+ const off = base.subscribeIdentityStatus(callback);
381
+ return () => {
382
+ counts.identityUnsubscribes += 1;
383
+ off();
384
+ };
385
+ },
386
+ subscribeAvailability(callback) {
387
+ counts.availabilitySubscribes += 1;
388
+ availability = callback;
389
+ return () => {
390
+ counts.availabilityUnsubscribes += 1;
391
+ availability = null;
392
+ };
393
+ },
394
+ };
395
+ return {
396
+ provider,
397
+ counts,
398
+ setIdentityStatus: (status: HistoryIdentityStatus) =>
399
+ base.setIdentityStatus(status),
400
+ setAvailable: (available: boolean) => availability?.(available),
401
+ };
402
+ };
403
+
404
+ it("rebinds a rebuilt provider without leaving the old subscriptions live", async () => {
405
+ const first = countedProvider();
406
+ const second = countedProvider();
407
+ let next = first;
408
+ setHistoryProviderFactory(() => next.provider);
409
+
410
+ const { controller } = mount({});
411
+ await flush();
412
+ expect(first.counts.identitySubscribes).toBe(1);
413
+ expect(first.counts.availabilitySubscribes).toBe(1);
414
+
415
+ const seen: HistoryIdentityStatus[] = [];
416
+ controller.on("history:identityStatusChanged", (payload) =>
417
+ seen.push((payload as { status: HistoryIdentityStatus }).status)
418
+ );
419
+
420
+ // A getIdentityProof change rebuilds the provider.
421
+ next = second;
422
+ controller.update({ getIdentityProof: async () => "rt_eu_proof" });
423
+ await flush(20);
424
+
425
+ expect(first.counts.identityUnsubscribes).toBe(1);
426
+ expect(first.counts.availabilityUnsubscribes).toBe(1);
427
+ expect(second.counts.identitySubscribes).toBe(1);
428
+ expect(second.counts.availabilitySubscribes).toBe(1);
429
+
430
+ second.setIdentityStatus({ state: "verifying" });
431
+ await flush();
432
+ // Exactly one emission: the replaced subscription is detached, and the
433
+ // new one is bound exactly once.
434
+ expect(seen).toEqual([{ state: "verifying" }]);
435
+
436
+ // The stale provider is inert: its callback no longer reaches the bus.
437
+ first.setIdentityStatus({ state: "verified" });
438
+ await flush();
439
+ expect(seen).toEqual([{ state: "verifying" }]);
440
+ });
441
+
442
+ it("tears down provider subscriptions when history is turned off", async () => {
443
+ const only = countedProvider();
444
+ setHistoryProviderFactory(() => only.provider);
445
+ const { controller } = mount({});
446
+ await flush();
447
+
448
+ controller.update({ features: { history: { enabled: false } } });
449
+ await flush(20);
450
+
451
+ expect(only.counts.identityUnsubscribes).toBe(1);
452
+ expect(only.counts.availabilityUnsubscribes).toBe(1);
453
+ expect(only.counts.identitySubscribes).toBe(1);
454
+ });
455
+ });
456
+ });