@runtypelabs/persona 1.41.0 → 1.43.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.
- package/README.md +55 -1
- package/dist/index.cjs +30 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +307 -1
- package/dist/index.d.ts +307 -1
- package/dist/index.global.js +71 -71
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +30 -30
- package/dist/index.js.map +1 -1
- package/dist/widget.css +185 -0
- package/package.json +2 -1
- package/src/client.test.ts +645 -0
- package/src/client.ts +373 -0
- package/src/components/event-stream-view.test.ts +1233 -0
- package/src/components/event-stream-view.ts +1179 -0
- package/src/index.ts +19 -1
- package/src/plugins/types.ts +34 -1
- package/src/runtime/init.ts +5 -0
- package/src/session.ts +104 -1
- package/src/styles/widget.css +185 -0
- package/src/types.ts +252 -0
- package/src/ui.ts +281 -4
- package/src/utils/event-stream-buffer.test.ts +268 -0
- package/src/utils/event-stream-buffer.ts +112 -0
- package/src/utils/event-stream-capture.test.ts +539 -0
- package/src/utils/event-stream-controller.test.ts +445 -0
- package/src/utils/event-stream-store.test.ts +181 -0
- package/src/utils/event-stream-store.ts +182 -0
- package/src/utils/virtual-scroller.test.ts +449 -0
- package/src/utils/virtual-scroller.ts +151 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createEventBus } from "./events";
|
|
3
|
+
import type { AgentWidgetControllerEventMap } from "../types";
|
|
4
|
+
|
|
5
|
+
describe("Event Stream Controller Events", () => {
|
|
6
|
+
it("should emit eventStream:opened with timestamp", () => {
|
|
7
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
8
|
+
const handler = vi.fn();
|
|
9
|
+
eventBus.on("eventStream:opened", handler);
|
|
10
|
+
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
eventBus.emit("eventStream:opened", { timestamp: now });
|
|
13
|
+
|
|
14
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
15
|
+
expect(handler).toHaveBeenCalledWith({ timestamp: now });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should emit eventStream:closed with timestamp", () => {
|
|
19
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
20
|
+
const handler = vi.fn();
|
|
21
|
+
eventBus.on("eventStream:closed", handler);
|
|
22
|
+
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
eventBus.emit("eventStream:closed", { timestamp: now });
|
|
25
|
+
|
|
26
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
27
|
+
expect(handler).toHaveBeenCalledWith({ timestamp: now });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should allow unsubscription from eventStream events", () => {
|
|
31
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
32
|
+
const handler = vi.fn();
|
|
33
|
+
const unsub = eventBus.on("eventStream:opened", handler);
|
|
34
|
+
|
|
35
|
+
eventBus.emit("eventStream:opened", { timestamp: Date.now() });
|
|
36
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
37
|
+
|
|
38
|
+
unsub();
|
|
39
|
+
eventBus.emit("eventStream:opened", { timestamp: Date.now() });
|
|
40
|
+
expect(handler).toHaveBeenCalledOnce(); // still 1 - not called again
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should not interfere with other controller events", () => {
|
|
44
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
45
|
+
const openedHandler = vi.fn();
|
|
46
|
+
const closedHandler = vi.fn();
|
|
47
|
+
const widgetOpenedHandler = vi.fn();
|
|
48
|
+
|
|
49
|
+
eventBus.on("eventStream:opened", openedHandler);
|
|
50
|
+
eventBus.on("eventStream:closed", closedHandler);
|
|
51
|
+
eventBus.on("widget:opened", widgetOpenedHandler);
|
|
52
|
+
|
|
53
|
+
eventBus.emit("eventStream:opened", { timestamp: Date.now() });
|
|
54
|
+
|
|
55
|
+
expect(openedHandler).toHaveBeenCalledOnce();
|
|
56
|
+
expect(closedHandler).not.toHaveBeenCalled();
|
|
57
|
+
expect(widgetOpenedHandler).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should support multiple listeners for the same event", () => {
|
|
61
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
62
|
+
const handler1 = vi.fn();
|
|
63
|
+
const handler2 = vi.fn();
|
|
64
|
+
eventBus.on("eventStream:opened", handler1);
|
|
65
|
+
eventBus.on("eventStream:opened", handler2);
|
|
66
|
+
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
eventBus.emit("eventStream:opened", { timestamp: now });
|
|
69
|
+
|
|
70
|
+
expect(handler1).toHaveBeenCalledWith({ timestamp: now });
|
|
71
|
+
expect(handler2).toHaveBeenCalledWith({ timestamp: now });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Tests for programmatic controller methods (showEventStream/hideEventStream/isEventStreamVisible).
|
|
77
|
+
* These simulate the controller method logic from ui.ts without requiring full widget DOM setup.
|
|
78
|
+
*/
|
|
79
|
+
describe("Event Stream Controller Methods", () => {
|
|
80
|
+
it("showEventStream should call toggleEventStreamOn when feature is enabled", () => {
|
|
81
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
82
|
+
let eventStreamVisible = false;
|
|
83
|
+
const showEventStreamToggle = true;
|
|
84
|
+
const eventStreamBuffer = { push: vi.fn(), getAll: () => [] }; // mock buffer
|
|
85
|
+
|
|
86
|
+
const toggleEventStreamOn = vi.fn(() => {
|
|
87
|
+
eventStreamVisible = true;
|
|
88
|
+
eventBus.emit("eventStream:opened", { timestamp: Date.now() });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Simulates controller.showEventStream() logic
|
|
92
|
+
const showEventStream = () => {
|
|
93
|
+
if (!showEventStreamToggle || !eventStreamBuffer) return;
|
|
94
|
+
toggleEventStreamOn();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handler = vi.fn();
|
|
98
|
+
eventBus.on("eventStream:opened", handler);
|
|
99
|
+
|
|
100
|
+
showEventStream();
|
|
101
|
+
|
|
102
|
+
expect(toggleEventStreamOn).toHaveBeenCalledOnce();
|
|
103
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
104
|
+
expect(eventStreamVisible).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("showEventStream should no-op when feature is disabled", () => {
|
|
108
|
+
const showEventStreamToggle = false;
|
|
109
|
+
const eventStreamBuffer = { push: vi.fn(), getAll: () => [] };
|
|
110
|
+
const toggleEventStreamOn = vi.fn();
|
|
111
|
+
|
|
112
|
+
const showEventStream = () => {
|
|
113
|
+
if (!showEventStreamToggle || !eventStreamBuffer) return;
|
|
114
|
+
toggleEventStreamOn();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
showEventStream();
|
|
118
|
+
expect(toggleEventStreamOn).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("showEventStream should no-op when buffer is null", () => {
|
|
122
|
+
const showEventStreamToggle = true;
|
|
123
|
+
const eventStreamBuffer = null;
|
|
124
|
+
const toggleEventStreamOn = vi.fn();
|
|
125
|
+
|
|
126
|
+
const showEventStream = () => {
|
|
127
|
+
if (!showEventStreamToggle || !eventStreamBuffer) return;
|
|
128
|
+
toggleEventStreamOn();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
showEventStream();
|
|
132
|
+
expect(toggleEventStreamOn).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("hideEventStream should call toggleEventStreamOff when visible", () => {
|
|
136
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
137
|
+
let eventStreamVisible = true;
|
|
138
|
+
|
|
139
|
+
const toggleEventStreamOff = vi.fn(() => {
|
|
140
|
+
eventStreamVisible = false;
|
|
141
|
+
eventBus.emit("eventStream:closed", { timestamp: Date.now() });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const hideEventStream = () => {
|
|
145
|
+
if (!eventStreamVisible) return;
|
|
146
|
+
toggleEventStreamOff();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handler = vi.fn();
|
|
150
|
+
eventBus.on("eventStream:closed", handler);
|
|
151
|
+
|
|
152
|
+
hideEventStream();
|
|
153
|
+
|
|
154
|
+
expect(toggleEventStreamOff).toHaveBeenCalledOnce();
|
|
155
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
156
|
+
expect(eventStreamVisible).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("hideEventStream should no-op when already hidden", () => {
|
|
160
|
+
const eventStreamVisible = false;
|
|
161
|
+
const toggleEventStreamOff = vi.fn();
|
|
162
|
+
|
|
163
|
+
const hideEventStream = () => {
|
|
164
|
+
if (!eventStreamVisible) return;
|
|
165
|
+
toggleEventStreamOff();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
hideEventStream();
|
|
169
|
+
expect(toggleEventStreamOff).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("isEventStreamVisible should return current visibility state", () => {
|
|
173
|
+
let eventStreamVisible = false;
|
|
174
|
+
|
|
175
|
+
const isEventStreamVisible = () => eventStreamVisible;
|
|
176
|
+
|
|
177
|
+
expect(isEventStreamVisible()).toBe(false);
|
|
178
|
+
|
|
179
|
+
eventStreamVisible = true;
|
|
180
|
+
expect(isEventStreamVisible()).toBe(true);
|
|
181
|
+
|
|
182
|
+
eventStreamVisible = false;
|
|
183
|
+
expect(isEventStreamVisible()).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("show then hide should fire both events in sequence", () => {
|
|
187
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
188
|
+
let eventStreamVisible = false;
|
|
189
|
+
const showEventStreamToggle = true;
|
|
190
|
+
const eventStreamBuffer = { push: vi.fn() };
|
|
191
|
+
const events: string[] = [];
|
|
192
|
+
|
|
193
|
+
const toggleEventStreamOn = () => {
|
|
194
|
+
eventStreamVisible = true;
|
|
195
|
+
eventBus.emit("eventStream:opened", { timestamp: Date.now() });
|
|
196
|
+
};
|
|
197
|
+
const toggleEventStreamOff = () => {
|
|
198
|
+
if (!eventStreamVisible) return;
|
|
199
|
+
eventStreamVisible = false;
|
|
200
|
+
eventBus.emit("eventStream:closed", { timestamp: Date.now() });
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const showEventStream = () => {
|
|
204
|
+
if (!showEventStreamToggle || !eventStreamBuffer) return;
|
|
205
|
+
toggleEventStreamOn();
|
|
206
|
+
};
|
|
207
|
+
const hideEventStream = () => {
|
|
208
|
+
if (!eventStreamVisible) return;
|
|
209
|
+
toggleEventStreamOff();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
eventBus.on("eventStream:opened", () => events.push("opened"));
|
|
213
|
+
eventBus.on("eventStream:closed", () => events.push("closed"));
|
|
214
|
+
|
|
215
|
+
showEventStream();
|
|
216
|
+
hideEventStream();
|
|
217
|
+
|
|
218
|
+
expect(events).toEqual(["opened", "closed"]);
|
|
219
|
+
expect(eventStreamVisible).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/** Listener type compatible with EventTarget in Node test env (no DOM globals). */
|
|
224
|
+
type EventListenerLike = ((event: Event) => void) | { handleEvent(event: Event): void };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Minimal EventTarget-based mock for window with CustomEvent support.
|
|
228
|
+
* Used because the test environment is Node.js (no browser globals).
|
|
229
|
+
*/
|
|
230
|
+
class MockWindow {
|
|
231
|
+
private target = new EventTarget();
|
|
232
|
+
addEventListener(type: string, listener: EventListenerLike) {
|
|
233
|
+
this.target.addEventListener(type, listener);
|
|
234
|
+
}
|
|
235
|
+
removeEventListener(type: string, listener: EventListenerLike) {
|
|
236
|
+
this.target.removeEventListener(type, listener);
|
|
237
|
+
}
|
|
238
|
+
dispatchEvent(event: Event): boolean {
|
|
239
|
+
return this.target.dispatchEvent(event);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Tests for instance-scoped window events (persona:showEventStream / persona:hideEventStream).
|
|
245
|
+
* These verify the CustomEvent dispatching and instance ID filtering logic from ui.ts.
|
|
246
|
+
*/
|
|
247
|
+
describe("Event Stream Window Events", () => {
|
|
248
|
+
let mockWindow: MockWindow;
|
|
249
|
+
let cleanupFns: (() => void)[];
|
|
250
|
+
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
mockWindow = new MockWindow();
|
|
253
|
+
cleanupFns = [];
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(() => {
|
|
257
|
+
cleanupFns.forEach(fn => fn());
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Creates a mock controller with event stream window event listeners,
|
|
262
|
+
* mirroring the logic in ui.ts lines 3930-3950.
|
|
263
|
+
*/
|
|
264
|
+
function createMockWidgetInstance(mountId: string, featureEnabled = true) {
|
|
265
|
+
let eventStreamVisible = false;
|
|
266
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
267
|
+
|
|
268
|
+
const controller = {
|
|
269
|
+
showEventStream: vi.fn(() => {
|
|
270
|
+
if (!featureEnabled) return;
|
|
271
|
+
eventStreamVisible = true;
|
|
272
|
+
eventBus.emit("eventStream:opened", { timestamp: Date.now() });
|
|
273
|
+
}),
|
|
274
|
+
hideEventStream: vi.fn(() => {
|
|
275
|
+
if (!eventStreamVisible) return;
|
|
276
|
+
eventStreamVisible = false;
|
|
277
|
+
eventBus.emit("eventStream:closed", { timestamp: Date.now() });
|
|
278
|
+
}),
|
|
279
|
+
isEventStreamVisible: () => eventStreamVisible,
|
|
280
|
+
on: eventBus.on.bind(eventBus),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Mirror the window event registration logic from ui.ts
|
|
284
|
+
if (featureEnabled) {
|
|
285
|
+
const instanceId = mountId || "persona-" + Math.random().toString(36).slice(2, 8);
|
|
286
|
+
const handleShowEvent = (e: Event) => {
|
|
287
|
+
const detail = (e as CustomEvent).detail;
|
|
288
|
+
if (!detail?.instanceId || detail.instanceId === instanceId) {
|
|
289
|
+
controller.showEventStream();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const handleHideEvent = (e: Event) => {
|
|
293
|
+
const detail = (e as CustomEvent).detail;
|
|
294
|
+
if (!detail?.instanceId || detail.instanceId === instanceId) {
|
|
295
|
+
controller.hideEventStream();
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
mockWindow.addEventListener("persona:showEventStream", handleShowEvent);
|
|
299
|
+
mockWindow.addEventListener("persona:hideEventStream", handleHideEvent);
|
|
300
|
+
cleanupFns.push(() => {
|
|
301
|
+
mockWindow.removeEventListener("persona:showEventStream", handleShowEvent);
|
|
302
|
+
mockWindow.removeEventListener("persona:hideEventStream", handleHideEvent);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return controller;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
it("should open event stream via window event without instanceId", () => {
|
|
310
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
311
|
+
|
|
312
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
313
|
+
|
|
314
|
+
expect(ctrl.showEventStream).toHaveBeenCalledOnce();
|
|
315
|
+
expect(ctrl.isEventStreamVisible()).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("should close event stream via window event without instanceId", () => {
|
|
319
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
320
|
+
|
|
321
|
+
// First open it
|
|
322
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
323
|
+
expect(ctrl.isEventStreamVisible()).toBe(true);
|
|
324
|
+
|
|
325
|
+
// Then close it
|
|
326
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:hideEventStream"));
|
|
327
|
+
expect(ctrl.hideEventStream).toHaveBeenCalledOnce();
|
|
328
|
+
expect(ctrl.isEventStreamVisible()).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("should respond to matching instanceId", () => {
|
|
332
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
333
|
+
|
|
334
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream", {
|
|
335
|
+
detail: { instanceId: "persona-root" }
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
expect(ctrl.showEventStream).toHaveBeenCalledOnce();
|
|
339
|
+
expect(ctrl.isEventStreamVisible()).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should NOT respond to non-matching instanceId", () => {
|
|
343
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
344
|
+
|
|
345
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream", {
|
|
346
|
+
detail: { instanceId: "wrong-id" }
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
expect(ctrl.showEventStream).not.toHaveBeenCalled();
|
|
350
|
+
expect(ctrl.isEventStreamVisible()).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("should scope events to correct instance with multiple widgets", () => {
|
|
354
|
+
const ctrl1 = createMockWidgetInstance("widget-1");
|
|
355
|
+
const ctrl2 = createMockWidgetInstance("widget-2");
|
|
356
|
+
|
|
357
|
+
// Target only widget-1
|
|
358
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream", {
|
|
359
|
+
detail: { instanceId: "widget-1" }
|
|
360
|
+
}));
|
|
361
|
+
|
|
362
|
+
expect(ctrl1.showEventStream).toHaveBeenCalledOnce();
|
|
363
|
+
expect(ctrl2.showEventStream).not.toHaveBeenCalled();
|
|
364
|
+
expect(ctrl1.isEventStreamVisible()).toBe(true);
|
|
365
|
+
expect(ctrl2.isEventStreamVisible()).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("should broadcast to all instances when no instanceId is provided", () => {
|
|
369
|
+
const ctrl1 = createMockWidgetInstance("widget-1");
|
|
370
|
+
const ctrl2 = createMockWidgetInstance("widget-2");
|
|
371
|
+
|
|
372
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
373
|
+
|
|
374
|
+
expect(ctrl1.showEventStream).toHaveBeenCalledOnce();
|
|
375
|
+
expect(ctrl2.showEventStream).toHaveBeenCalledOnce();
|
|
376
|
+
expect(ctrl1.isEventStreamVisible()).toBe(true);
|
|
377
|
+
expect(ctrl2.isEventStreamVisible()).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("should not register listeners when feature is disabled", () => {
|
|
381
|
+
const ctrl = createMockWidgetInstance("persona-root", false);
|
|
382
|
+
|
|
383
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
384
|
+
|
|
385
|
+
expect(ctrl.showEventStream).not.toHaveBeenCalled();
|
|
386
|
+
expect(ctrl.isEventStreamVisible()).toBe(false);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("should clean up window listeners on destroy", () => {
|
|
390
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
391
|
+
|
|
392
|
+
// Verify it works before cleanup
|
|
393
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
394
|
+
expect(ctrl.showEventStream).toHaveBeenCalledOnce();
|
|
395
|
+
|
|
396
|
+
// Simulate destroy by running cleanup
|
|
397
|
+
cleanupFns.forEach(fn => fn());
|
|
398
|
+
cleanupFns = [];
|
|
399
|
+
|
|
400
|
+
// Reset mock and dispatch again
|
|
401
|
+
ctrl.showEventStream.mockClear();
|
|
402
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
403
|
+
expect(ctrl.showEventStream).not.toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("should fire eventStream:opened event via controller.on when opened via window event", () => {
|
|
407
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
408
|
+
const handler = vi.fn();
|
|
409
|
+
ctrl.on("eventStream:opened", handler);
|
|
410
|
+
|
|
411
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
412
|
+
|
|
413
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
414
|
+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ timestamp: expect.any(Number) }));
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("should fire eventStream:closed event via controller.on when closed via window event", () => {
|
|
418
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
419
|
+
const closedHandler = vi.fn();
|
|
420
|
+
ctrl.on("eventStream:closed", closedHandler);
|
|
421
|
+
|
|
422
|
+
// Open first, then close
|
|
423
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream"));
|
|
424
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:hideEventStream"));
|
|
425
|
+
|
|
426
|
+
expect(closedHandler).toHaveBeenCalledOnce();
|
|
427
|
+
expect(closedHandler).toHaveBeenCalledWith(expect.objectContaining({ timestamp: expect.any(Number) }));
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("should handle detail being null gracefully (broadcast)", () => {
|
|
431
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
432
|
+
|
|
433
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream", { detail: null }));
|
|
434
|
+
|
|
435
|
+
expect(ctrl.showEventStream).toHaveBeenCalledOnce();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("should handle detail with empty object gracefully (broadcast)", () => {
|
|
439
|
+
const ctrl = createMockWidgetInstance("persona-root");
|
|
440
|
+
|
|
441
|
+
mockWindow.dispatchEvent(new CustomEvent("persona:showEventStream", { detail: {} }));
|
|
442
|
+
|
|
443
|
+
expect(ctrl.showEventStream).toHaveBeenCalledOnce();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import "fake-indexeddb/auto";
|
|
3
|
+
import { EventStreamStore } from "./event-stream-store";
|
|
4
|
+
import type { SSEEventRecord } from "../types";
|
|
5
|
+
|
|
6
|
+
function makeEvent(type: string, index: number): SSEEventRecord {
|
|
7
|
+
return {
|
|
8
|
+
id: `evt-${index}`,
|
|
9
|
+
type,
|
|
10
|
+
timestamp: 1000 + index,
|
|
11
|
+
payload: JSON.stringify({ index })
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("EventStreamStore", () => {
|
|
16
|
+
let store: EventStreamStore;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
store = new EventStreamStore("test-db-" + Math.random(), "events");
|
|
20
|
+
await store.open();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await store.destroy();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should open and close without error", () => {
|
|
28
|
+
expect(store).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should store and retrieve events via putBatch + getAll", async () => {
|
|
32
|
+
const events = [makeEvent("a", 1), makeEvent("b", 2), makeEvent("c", 3)];
|
|
33
|
+
store.putBatch(events);
|
|
34
|
+
// Wait for transaction to complete
|
|
35
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
36
|
+
const result = await store.getAll();
|
|
37
|
+
expect(result).toEqual(events);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return events ordered by timestamp", async () => {
|
|
41
|
+
const events = [makeEvent("a", 3), makeEvent("b", 1), makeEvent("c", 2)];
|
|
42
|
+
store.putBatch(events);
|
|
43
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
44
|
+
const result = await store.getAll();
|
|
45
|
+
expect(result.map((e) => e.timestamp)).toEqual([1001, 1002, 1003]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should write events via put with microtask batching", async () => {
|
|
49
|
+
store.put(makeEvent("a", 1));
|
|
50
|
+
store.put(makeEvent("b", 2));
|
|
51
|
+
store.put(makeEvent("c", 3));
|
|
52
|
+
// Wait for microtask flush + transaction
|
|
53
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
54
|
+
const result = await store.getAll();
|
|
55
|
+
expect(result).toHaveLength(3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should return count of stored events", async () => {
|
|
59
|
+
store.putBatch([makeEvent("a", 1), makeEvent("b", 2)]);
|
|
60
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
61
|
+
const count = await store.getCount();
|
|
62
|
+
expect(count).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should clear all events", async () => {
|
|
66
|
+
store.putBatch([makeEvent("a", 1), makeEvent("b", 2)]);
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
await store.clear();
|
|
69
|
+
const result = await store.getAll();
|
|
70
|
+
expect(result).toEqual([]);
|
|
71
|
+
const count = await store.getCount();
|
|
72
|
+
expect(count).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should return empty array when no events stored", async () => {
|
|
76
|
+
const result = await store.getAll();
|
|
77
|
+
expect(result).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should return 0 count when no events stored", async () => {
|
|
81
|
+
const count = await store.getCount();
|
|
82
|
+
expect(count).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should destroy the database", async () => {
|
|
86
|
+
store.putBatch([makeEvent("a", 1)]);
|
|
87
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
88
|
+
await store.destroy();
|
|
89
|
+
// After destroy, getAll should return empty (db is null)
|
|
90
|
+
const result = await store.getAll();
|
|
91
|
+
expect(result).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should handle put gracefully when db is not open", () => {
|
|
95
|
+
const closedStore = new EventStreamStore("closed-db", "events");
|
|
96
|
+
// Should not throw
|
|
97
|
+
closedStore.put(makeEvent("a", 1));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle putBatch gracefully when db is not open", () => {
|
|
101
|
+
const closedStore = new EventStreamStore("closed-db", "events");
|
|
102
|
+
// Should not throw
|
|
103
|
+
closedStore.putBatch([makeEvent("a", 1)]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should clear pending writes on clear", async () => {
|
|
107
|
+
// Put some events that are still pending
|
|
108
|
+
store.put(makeEvent("a", 1));
|
|
109
|
+
// Clear before microtask flushes
|
|
110
|
+
await store.clear();
|
|
111
|
+
// Wait for microtask
|
|
112
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
113
|
+
const result = await store.getAll();
|
|
114
|
+
expect(result).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should handle putBatch with 100 events", async () => {
|
|
118
|
+
const events = Array.from({ length: 100 }, (_, i) => makeEvent("bulk", i));
|
|
119
|
+
store.putBatch(events);
|
|
120
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
121
|
+
const result = await store.getAll();
|
|
122
|
+
expect(result).toHaveLength(100);
|
|
123
|
+
const count = await store.getCount();
|
|
124
|
+
expect(count).toBe(100);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should prevent new writes after destroy via isDestroyed flag", async () => {
|
|
128
|
+
store.put(makeEvent("a", 1));
|
|
129
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
130
|
+
await store.destroy();
|
|
131
|
+
// After destroy, the store should not accept new writes
|
|
132
|
+
// Re-create a store with the same db name to verify no new data was written
|
|
133
|
+
const store2 = new EventStreamStore("verify-destroyed-" + Math.random(), "events");
|
|
134
|
+
await store2.open();
|
|
135
|
+
// The original store is destroyed - calling put should be a no-op
|
|
136
|
+
store.put(makeEvent("b", 2));
|
|
137
|
+
store.putBatch([makeEvent("c", 3)]);
|
|
138
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
139
|
+
await store2.destroy();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should discard pending writes on destroy", async () => {
|
|
143
|
+
// Put events that are pending (not yet flushed)
|
|
144
|
+
store.put(makeEvent("a", 1));
|
|
145
|
+
store.put(makeEvent("b", 2));
|
|
146
|
+
// Destroy immediately before microtask flushes
|
|
147
|
+
await store.destroy();
|
|
148
|
+
// Pending writes should have been discarded, not flushed
|
|
149
|
+
// After destroy, db is null so flushWrites will no-op even if microtask runs
|
|
150
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should not throw when put is called after destroy", async () => {
|
|
154
|
+
await store.destroy();
|
|
155
|
+
// Should not throw
|
|
156
|
+
store.put(makeEvent("a", 1));
|
|
157
|
+
store.putBatch([makeEvent("b", 2)]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should handle IndexedDB being unavailable", async () => {
|
|
161
|
+
const origIndexedDB = globalThis.indexedDB;
|
|
162
|
+
try {
|
|
163
|
+
// Simulate IndexedDB being unavailable by deleting it
|
|
164
|
+
// @ts-expect-error - intentionally removing indexedDB for testing
|
|
165
|
+
delete globalThis.indexedDB;
|
|
166
|
+
const unavailableStore = new EventStreamStore("unavailable-db", "events");
|
|
167
|
+
// open() should reject, but put/putBatch should not throw
|
|
168
|
+
await expect(unavailableStore.open()).rejects.toBeDefined();
|
|
169
|
+
// Operations on a store that never opened should be silent no-ops
|
|
170
|
+
unavailableStore.put(makeEvent("a", 1));
|
|
171
|
+
unavailableStore.putBatch([makeEvent("b", 2)]);
|
|
172
|
+
const result = await unavailableStore.getAll();
|
|
173
|
+
expect(result).toEqual([]);
|
|
174
|
+
const count = await unavailableStore.getCount();
|
|
175
|
+
expect(count).toBe(0);
|
|
176
|
+
await unavailableStore.clear(); // should not throw
|
|
177
|
+
} finally {
|
|
178
|
+
globalThis.indexedDB = origIndexedDB;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|