@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.
@@ -0,0 +1,1233 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { SSEEventRecord } from "../types";
3
+
4
+ // ---------- DOM helpers for Node environment ----------
5
+
6
+ function createMockElement(tag = "div"): any {
7
+ const children: any[] = [];
8
+ const style: Record<string, string> = {};
9
+ const listeners: Record<string, Function[]> = {};
10
+ const classList = new Set<string>();
11
+ const el: any = {
12
+ tagName: tag.toUpperCase(),
13
+ style,
14
+ children,
15
+ childNodes: children,
16
+ firstChild: null,
17
+ parentNode: null,
18
+ value: "",
19
+ type: "",
20
+ placeholder: "",
21
+ disabled: false,
22
+ title: "",
23
+ textContent: "",
24
+ get innerHTML() { return ""; },
25
+ set innerHTML(val: string) {
26
+ if (val === "") {
27
+ children.length = 0;
28
+ el.firstChild = null;
29
+ }
30
+ },
31
+ offsetHeight: 300,
32
+ scrollTop: 0,
33
+ scrollHeight: 300,
34
+ clientHeight: 300,
35
+ // For <select>
36
+ options: [] as any[],
37
+ appendChild(child: any) {
38
+ // Handle DocumentFragment: move its children into this element
39
+ if (child.tagName === "FRAGMENT") {
40
+ const fragChildren = [...child.children];
41
+ for (const fragChild of fragChildren) {
42
+ children.push(fragChild);
43
+ fragChild.parentNode = el;
44
+ if (tag === "select" && fragChild.tagName === "OPTION") {
45
+ el.options.push(fragChild);
46
+ }
47
+ }
48
+ child.children.length = 0;
49
+ el.firstChild = children[0] || null;
50
+ return child;
51
+ }
52
+ children.push(child);
53
+ child.parentNode = el;
54
+ el.firstChild = children[0] || null;
55
+ // Track options for <select>
56
+ if (tag === "select" && child.tagName === "OPTION") {
57
+ el.options.push(child);
58
+ }
59
+ return child;
60
+ },
61
+ remove(index?: number) {
62
+ if (typeof index === "number") {
63
+ // select.remove(index) - removes option at index
64
+ const removed = el.options.splice(index, 1)[0];
65
+ const childIdx = children.indexOf(removed);
66
+ if (childIdx >= 0) children.splice(childIdx, 1);
67
+ } else {
68
+ // el.remove() - remove self from parent
69
+ if (el.parentNode) {
70
+ const idx = el.parentNode.children.indexOf(el);
71
+ if (idx >= 0) el.parentNode.children.splice(idx, 1);
72
+ el.parentNode = null;
73
+ }
74
+ }
75
+ },
76
+ insertBefore(newChild: any, refChild: any) {
77
+ const idx = children.indexOf(refChild);
78
+ if (idx >= 0) {
79
+ children.splice(idx, 0, newChild);
80
+ } else {
81
+ children.push(newChild);
82
+ }
83
+ newChild.parentNode = el;
84
+ return newChild;
85
+ },
86
+ addEventListener(event: string, handler: Function) {
87
+ if (!listeners[event]) listeners[event] = [];
88
+ listeners[event].push(handler);
89
+ },
90
+ removeEventListener(event: string, handler: Function) {
91
+ if (listeners[event]) {
92
+ listeners[event] = listeners[event].filter((h) => h !== handler);
93
+ }
94
+ },
95
+ setAttribute(name: string, value: string) {
96
+ el[`__attr_${name}`] = value;
97
+ },
98
+ getAttribute(name: string) {
99
+ return el[`__attr_${name}`] ?? null;
100
+ },
101
+ classList: {
102
+ add: (...cls: string[]) => cls.forEach((c) => classList.add(c)),
103
+ remove: (...cls: string[]) => cls.forEach((c) => classList.delete(c)),
104
+ contains: (c: string) => classList.has(c),
105
+ },
106
+ closest(selector: string) {
107
+ // Simple mock: check if this element or any parent matches
108
+ if (selector === "button" && el.tagName === "BUTTON") return el;
109
+ // Support attribute selectors like [data-event-id]
110
+ const attrMatch = selector.match(/^\[([^\]]+)\]$/);
111
+ if (attrMatch && el[`__attr_${attrMatch[1]}`] != null) return el;
112
+ if (el.parentNode && el.parentNode.closest) return el.parentNode.closest(selector);
113
+ return null;
114
+ },
115
+ focus: vi.fn(),
116
+ blur: vi.fn(),
117
+ select: vi.fn(),
118
+ scrollTo(opts: { top: number; behavior?: string }) {
119
+ el.scrollTop = opts.top;
120
+ },
121
+ // helper to fire events in tests
122
+ __listeners: listeners,
123
+ __fireEvent(event: string, detail?: any) {
124
+ if (listeners[event]) {
125
+ listeners[event].forEach((h) => h(detail || {}));
126
+ }
127
+ },
128
+ };
129
+ return el;
130
+ }
131
+
132
+ // Stub globals for Node environment
133
+ const origDocument = globalThis.document;
134
+ let rafCallbacks: Function[] = [];
135
+
136
+ function makeEvent(type: string, index: number, payload?: string): SSEEventRecord {
137
+ return {
138
+ id: `evt-${index}`,
139
+ type,
140
+ timestamp: 1000 + index,
141
+ payload: payload ?? JSON.stringify({ index }),
142
+ };
143
+ }
144
+
145
+ function createMockBuffer(events: SSEEventRecord[] = []) {
146
+ const _events = [...events];
147
+ const eventTypes = new Set<string>();
148
+ for (const e of _events) eventTypes.add(e.type);
149
+
150
+ return {
151
+ getAll: vi.fn(() => [..._events]),
152
+ getSize: vi.fn(() => _events.length),
153
+ getEventTypes: vi.fn(() => Array.from(eventTypes).sort()),
154
+ getEvictedCount: vi.fn(() => 0),
155
+ getTotalCaptured: vi.fn(() => _events.length),
156
+ clear: vi.fn(() => {
157
+ _events.length = 0;
158
+ eventTypes.clear();
159
+ }),
160
+ push: vi.fn((e: SSEEventRecord) => {
161
+ _events.push(e);
162
+ eventTypes.add(e.type);
163
+ }),
164
+ _events,
165
+ };
166
+ }
167
+
168
+ beforeEach(() => {
169
+ rafCallbacks = [];
170
+ if (!globalThis.document) {
171
+ (globalThis as any).document = {};
172
+ }
173
+ (globalThis.document as any).createElement = (tag: string) =>
174
+ createMockElement(tag);
175
+ (globalThis.document as any).activeElement = null;
176
+ (globalThis.document as any).createDocumentFragment = () => {
177
+ const frag = createMockElement("fragment");
178
+ // Fragments transfer children on appendChild to a real element
179
+ return frag;
180
+ };
181
+
182
+ (globalThis as any).requestAnimationFrame = (cb: Function) => {
183
+ rafCallbacks.push(cb);
184
+ return rafCallbacks.length;
185
+ };
186
+ (globalThis as any).cancelAnimationFrame = (id: number) => {
187
+ if (id > 0 && id <= rafCallbacks.length) {
188
+ rafCallbacks[id - 1] = () => {};
189
+ }
190
+ };
191
+
192
+ // Mock navigator.clipboard
193
+ const mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
194
+ vi.stubGlobal("navigator", { clipboard: mockClipboard });
195
+ });
196
+
197
+ afterEach(() => {
198
+ if (origDocument) {
199
+ (globalThis as any).document = origDocument;
200
+ }
201
+ vi.restoreAllMocks();
202
+ });
203
+
204
+ // Mock renderLucideIcon to return a simple SVG-like element
205
+ vi.mock("../utils/icons", () => ({
206
+ renderLucideIcon: vi.fn((_name: string) => {
207
+ const svg = createMockElement("svg");
208
+ svg.__iconName = _name;
209
+ return svg;
210
+ }),
211
+ }));
212
+
213
+ // Use dynamic import to load after mocks are set up
214
+ async function loadModule() {
215
+ const mod = await import("./event-stream-view");
216
+ const origCreate = mod.createEventStreamView;
217
+ const wrappedCreate = (options: Parameters<typeof origCreate>[0]): { element: any; update: () => void; destroy: () => void } => {
218
+ return origCreate(options);
219
+ };
220
+ return { ...mod, createEventStreamView: wrappedCreate };
221
+ }
222
+
223
+ // Helper: navigate the new DOM structure
224
+ // container.children = [toolbarOuter, truncationBanner, eventsListWrapper]
225
+ // toolbarOuter.children = [headerBar, searchBar]
226
+ // headerBar.children = [title, countBadge, spacer, filterSelect, copyAllBtn]
227
+ // searchBar.children = [searchIconWrapper, searchInput, searchClearBtn]
228
+ // eventsListWrapper.children = [eventsList, noResultsMsg, scrollIndicator]
229
+
230
+ function getToolbar(element: any) {
231
+ return element.children[0]; // toolbarOuter
232
+ }
233
+ function getHeaderBar(element: any) {
234
+ return getToolbar(element).children[0]; // headerBar
235
+ }
236
+ function getSearchBar(element: any) {
237
+ return getToolbar(element).children[1]; // searchBar
238
+ }
239
+ function getTitle(element: any) {
240
+ return getHeaderBar(element).children[0]; // title span
241
+ }
242
+ function getCountBadge(element: any) {
243
+ return getHeaderBar(element).children[1]; // count badge span
244
+ }
245
+ function getFilterSelect(element: any) {
246
+ return getHeaderBar(element).children[3]; // filterSelect (after title, badge, spacer)
247
+ }
248
+ function getCopyAllBtn(element: any) {
249
+ return getHeaderBar(element).children[4]; // copyAllBtn
250
+ }
251
+ function getSearchInput(element: any) {
252
+ return getSearchBar(element).children[1]; // searchInput (after searchIconWrapper)
253
+ }
254
+ function getSearchClearBtn(element: any) {
255
+ return getSearchBar(element).children[2]; // searchClearBtn
256
+ }
257
+ function getEventsWrapper(element: any) {
258
+ return element.children[2]; // eventsListWrapper
259
+ }
260
+ function getEventsList(element: any) {
261
+ return getEventsWrapper(element).children[0]; // eventsList
262
+ }
263
+ function getNoResultsMsg(element: any) {
264
+ return getEventsWrapper(element).children[1]; // noResultsMsg
265
+ }
266
+
267
+ describe("createEventStreamView", () => {
268
+ it("should create a container element with expected children", async () => {
269
+ const { createEventStreamView } = await loadModule();
270
+ const buffer = createMockBuffer();
271
+ const { element } = createEventStreamView({ buffer: buffer as any });
272
+
273
+ // Container should have tabindex for keyboard events
274
+ expect(element.getAttribute("tabindex")).toBe("0");
275
+
276
+ // Should have toolbarOuter, truncation banner, and events wrapper
277
+ expect(element.children.length).toBe(3);
278
+ });
279
+
280
+ it("should return update and destroy functions", async () => {
281
+ const { createEventStreamView } = await loadModule();
282
+ const buffer = createMockBuffer();
283
+ const view = createEventStreamView({ buffer: buffer as any });
284
+
285
+ expect(typeof view.update).toBe("function");
286
+ expect(typeof view.destroy).toBe("function");
287
+ });
288
+
289
+ describe("header bar", () => {
290
+ it("should show 'Event Stream' title and count badge", async () => {
291
+ const { createEventStreamView } = await loadModule();
292
+ const events = [makeEvent("step_chunk", 1)];
293
+ const buffer = createMockBuffer(events);
294
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
295
+
296
+ update();
297
+
298
+ expect(getTitle(element).textContent).toBe("Events");
299
+ expect(getCountBadge(element).textContent).toBe("1");
300
+ });
301
+
302
+ it("should update count badge when events change", async () => {
303
+ vi.useFakeTimers();
304
+ const { createEventStreamView } = await loadModule();
305
+ const events = [makeEvent("step_chunk", 1)];
306
+ const buffer = createMockBuffer(events);
307
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
308
+
309
+ update();
310
+ expect(getCountBadge(element).textContent).toBe("1");
311
+
312
+ vi.advanceTimersByTime(150);
313
+ buffer.push(makeEvent("step_chunk", 2));
314
+ update();
315
+
316
+ expect(getCountBadge(element).textContent).toBe("2");
317
+ vi.useRealTimers();
318
+ });
319
+ });
320
+
321
+ describe("filter dropdown", () => {
322
+ it("should populate filter options from buffer event types with counts", async () => {
323
+ const { createEventStreamView } = await loadModule();
324
+ const events = [
325
+ makeEvent("step_chunk", 1),
326
+ makeEvent("step_chunk", 2),
327
+ makeEvent("flow_complete", 3),
328
+ ];
329
+ const buffer = createMockBuffer(events);
330
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
331
+
332
+ update();
333
+
334
+ const filterSelect = getFilterSelect(element);
335
+
336
+ // Should have "All events" + 2 type options
337
+ expect(filterSelect.options.length).toBe(3);
338
+ expect(filterSelect.options[0].textContent).toBe("All events");
339
+ expect(filterSelect.options[1].textContent).toBe("flow_complete (1)");
340
+ expect(filterSelect.options[2].textContent).toBe("step_chunk (2)");
341
+ });
342
+
343
+ it("should update counts on subsequent update() calls", async () => {
344
+ vi.useFakeTimers();
345
+ const { createEventStreamView } = await loadModule();
346
+ const events = [makeEvent("step_chunk", 1)];
347
+ const buffer = createMockBuffer(events);
348
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
349
+
350
+ update();
351
+
352
+ const filterSelect = getFilterSelect(element);
353
+ expect(filterSelect.options[0].textContent).toBe("All events");
354
+ expect(filterSelect.options[1].textContent).toBe("step_chunk (1)");
355
+
356
+ // Add another event and advance past throttle window
357
+ buffer.push(makeEvent("step_chunk", 2));
358
+ vi.advanceTimersByTime(150);
359
+ update();
360
+
361
+ expect(filterSelect.options[1].textContent).toBe("step_chunk (2)");
362
+ vi.useRealTimers();
363
+ });
364
+
365
+ it("should filter events when a type is selected", async () => {
366
+ const { createEventStreamView } = await loadModule();
367
+ const events = [
368
+ makeEvent("step_chunk", 1),
369
+ makeEvent("flow_complete", 2),
370
+ makeEvent("step_chunk", 3),
371
+ ];
372
+ const buffer = createMockBuffer(events);
373
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
374
+
375
+ update();
376
+
377
+ const filterSelect = getFilterSelect(element);
378
+ filterSelect.value = "step_chunk";
379
+ filterSelect.__fireEvent("change");
380
+
381
+ expect(buffer.getAll).toHaveBeenCalled();
382
+ });
383
+ });
384
+
385
+ describe("search functionality", () => {
386
+ it("should debounce search input", async () => {
387
+ vi.useFakeTimers();
388
+ const { createEventStreamView } = await loadModule();
389
+ const events = [makeEvent("step_chunk", 1, '{"message":"hello world"}')];
390
+ const buffer = createMockBuffer(events);
391
+ const { element } = createEventStreamView({ buffer: buffer as any });
392
+
393
+ const searchInput = getSearchInput(element);
394
+
395
+ // Type in search
396
+ searchInput.value = "hello";
397
+ searchInput.__fireEvent("input");
398
+
399
+ const callCountBefore = buffer.getAll.mock.calls.length;
400
+
401
+ // Advance past debounce
402
+ vi.advanceTimersByTime(200);
403
+
404
+ expect(buffer.getAll.mock.calls.length).toBeGreaterThan(callCountBefore);
405
+ vi.useRealTimers();
406
+ });
407
+
408
+ it("should show clear button when search has text", async () => {
409
+ const { createEventStreamView } = await loadModule();
410
+ const buffer = createMockBuffer();
411
+ const { element } = createEventStreamView({ buffer: buffer as any });
412
+
413
+ const searchInput = getSearchInput(element);
414
+ const clearBtn = getSearchClearBtn(element);
415
+
416
+ // Initially hidden
417
+ expect(clearBtn.style.display).toBe("none");
418
+
419
+ // Type something
420
+ searchInput.value = "test";
421
+ searchInput.__fireEvent("input");
422
+
423
+ // Clear button should be visible
424
+ expect(clearBtn.style.display).toBe("");
425
+ });
426
+
427
+ it("should clear search when clear button is clicked", async () => {
428
+ vi.useFakeTimers();
429
+ const { createEventStreamView } = await loadModule();
430
+ const buffer = createMockBuffer([makeEvent("a", 1)]);
431
+ const { element } = createEventStreamView({ buffer: buffer as any });
432
+
433
+ const searchInput = getSearchInput(element);
434
+ const clearBtn = getSearchClearBtn(element);
435
+
436
+ // Type and trigger search
437
+ searchInput.value = "test";
438
+ searchInput.__fireEvent("input");
439
+ vi.advanceTimersByTime(200);
440
+
441
+ // Click clear
442
+ clearBtn.__fireEvent("click");
443
+
444
+ expect(searchInput.value).toBe("");
445
+ expect(clearBtn.style.display).toBe("none");
446
+ vi.useRealTimers();
447
+ });
448
+ });
449
+
450
+ describe("no results message", () => {
451
+ it("should show no results message when filters produce empty results", async () => {
452
+ vi.useFakeTimers();
453
+ const { createEventStreamView } = await loadModule();
454
+ const events = [makeEvent("step_chunk", 1, '{"data":"hello"}')];
455
+ const buffer = createMockBuffer(events);
456
+ const { element } = createEventStreamView({ buffer: buffer as any });
457
+
458
+ const searchInput = getSearchInput(element);
459
+
460
+ // Search for something that doesn't match
461
+ searchInput.value = "nonexistent_term_xyz";
462
+ searchInput.__fireEvent("input");
463
+ vi.advanceTimersByTime(200);
464
+
465
+ const noResultsMsg = getNoResultsMsg(element);
466
+
467
+ expect(noResultsMsg.style.display).toBe("");
468
+ expect(noResultsMsg.textContent).toContain("nonexistent_term_xyz");
469
+ vi.useRealTimers();
470
+ });
471
+ });
472
+
473
+ describe("copy all button", () => {
474
+ it("should update title based on active filters", async () => {
475
+ vi.useFakeTimers();
476
+ const { createEventStreamView } = await loadModule();
477
+ const events = [
478
+ makeEvent("step_chunk", 1),
479
+ makeEvent("flow_complete", 2),
480
+ ];
481
+ const buffer = createMockBuffer(events);
482
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
483
+
484
+ const copyAllBtn = getCopyAllBtn(element);
485
+
486
+ update();
487
+
488
+ // No filter: should be "Copy All"
489
+ expect(copyAllBtn.title).toBe("Copy All");
490
+
491
+ // Apply type filter
492
+ const filterSelect = getFilterSelect(element);
493
+ filterSelect.value = "step_chunk";
494
+ filterSelect.__fireEvent("change");
495
+
496
+ // Should now show "Copy Filtered (1)"
497
+ expect(copyAllBtn.title).toBe("Copy Filtered (1)");
498
+ vi.useRealTimers();
499
+ });
500
+
501
+ it("should copy filtered events when filters are active", async () => {
502
+ const { createEventStreamView } = await loadModule();
503
+ const events = [
504
+ makeEvent("step_chunk", 1),
505
+ makeEvent("flow_complete", 2),
506
+ ];
507
+ const buffer = createMockBuffer(events);
508
+ const getFullHistory = vi.fn().mockResolvedValue(events);
509
+ const { element } = createEventStreamView({
510
+ buffer: buffer as any,
511
+ getFullHistory,
512
+ });
513
+
514
+ const filterSelect = getFilterSelect(element);
515
+ const copyAllBtn = getCopyAllBtn(element);
516
+
517
+ // Apply type filter
518
+ filterSelect.value = "step_chunk";
519
+ filterSelect.__fireEvent("change");
520
+
521
+ // Click copy all
522
+ await copyAllBtn.__listeners.click[0]();
523
+
524
+ // Should NOT call getFullHistory when filters are active
525
+ expect(getFullHistory).not.toHaveBeenCalled();
526
+
527
+ // Should copy only filtered events
528
+ const writeCall = (globalThis.navigator.clipboard.writeText as any).mock.calls[0][0];
529
+ const parsed = JSON.parse(writeCall);
530
+ expect(parsed).toHaveLength(1);
531
+ expect(parsed[0].index).toBe(1);
532
+ });
533
+
534
+ it("should copy full history when no filters are active", async () => {
535
+ const { createEventStreamView } = await loadModule();
536
+ const events = [
537
+ makeEvent("step_chunk", 1),
538
+ makeEvent("flow_complete", 2),
539
+ ];
540
+ const buffer = createMockBuffer(events);
541
+ const fullHistory = [...events, makeEvent("old_event", 0)];
542
+ const getFullHistory = vi.fn().mockResolvedValue(fullHistory);
543
+ const { element, update } = createEventStreamView({
544
+ buffer: buffer as any,
545
+ getFullHistory,
546
+ });
547
+
548
+ update();
549
+
550
+ const copyAllBtn = getCopyAllBtn(element);
551
+
552
+ // Click copy all with no filters
553
+ await copyAllBtn.__listeners.click[0]();
554
+
555
+ // Should call getFullHistory
556
+ expect(getFullHistory).toHaveBeenCalled();
557
+ });
558
+ });
559
+
560
+ describe("keyboard shortcuts", () => {
561
+ it("should focus search on Ctrl+F", async () => {
562
+ const { createEventStreamView } = await loadModule();
563
+ const buffer = createMockBuffer();
564
+ const { element } = createEventStreamView({ buffer: buffer as any });
565
+
566
+ const searchInput = getSearchInput(element);
567
+
568
+ // Simulate Ctrl+F
569
+ const event = {
570
+ key: "f",
571
+ ctrlKey: true,
572
+ metaKey: false,
573
+ preventDefault: vi.fn(),
574
+ };
575
+ element.__fireEvent("keydown", event);
576
+
577
+ expect(event.preventDefault).toHaveBeenCalled();
578
+ expect(searchInput.focus).toHaveBeenCalled();
579
+ expect(searchInput.select).toHaveBeenCalled();
580
+ });
581
+
582
+ it("should clear search and blur on Escape when search is focused", async () => {
583
+ vi.useFakeTimers();
584
+ const { createEventStreamView } = await loadModule();
585
+ const buffer = createMockBuffer([makeEvent("a", 1)]);
586
+ const { element } = createEventStreamView({ buffer: buffer as any });
587
+
588
+ const searchInput = getSearchInput(element);
589
+
590
+ // Simulate search having text
591
+ searchInput.value = "test";
592
+ searchInput.__fireEvent("input");
593
+ vi.advanceTimersByTime(200);
594
+
595
+ // Make search input the active element
596
+ (globalThis.document as any).activeElement = searchInput;
597
+
598
+ // Press Escape
599
+ element.__fireEvent("keydown", {
600
+ key: "Escape",
601
+ ctrlKey: false,
602
+ metaKey: false,
603
+ preventDefault: vi.fn(),
604
+ });
605
+
606
+ expect(searchInput.value).toBe("");
607
+ expect(searchInput.blur).toHaveBeenCalled();
608
+ vi.useRealTimers();
609
+ });
610
+
611
+ it("should call onClose on Escape when search is not focused", async () => {
612
+ const { createEventStreamView } = await loadModule();
613
+ const buffer = createMockBuffer();
614
+ const onClose = vi.fn();
615
+ const { element } = createEventStreamView({
616
+ buffer: buffer as any,
617
+ onClose,
618
+ });
619
+
620
+ // Ensure activeElement is NOT the search input
621
+ (globalThis.document as any).activeElement = element;
622
+
623
+ // Press Escape
624
+ element.__fireEvent("keydown", {
625
+ key: "Escape",
626
+ ctrlKey: false,
627
+ metaKey: false,
628
+ preventDefault: vi.fn(),
629
+ });
630
+
631
+ expect(onClose).toHaveBeenCalled();
632
+ });
633
+ });
634
+
635
+ describe("event rows", () => {
636
+ it("should render rows with relative timestamps", async () => {
637
+ const { createEventStreamView } = await loadModule();
638
+ const events = [
639
+ { id: "evt-1", type: "flow_start", timestamp: 1000, payload: '{"flowName":"Test"}' },
640
+ { id: "evt-2", type: "step_start", timestamp: 1361, payload: '{"stepName":"Chatbot 1"}' },
641
+ ];
642
+ const buffer = createMockBuffer(events);
643
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
644
+
645
+ update();
646
+
647
+ // Events are rendered in eventsList
648
+ const eventsList = getEventsList(element);
649
+ // Each event produces a row wrapper
650
+ expect(eventsList.children.length).toBeGreaterThanOrEqual(2);
651
+ });
652
+
653
+ it("should render rows with absolute timestamps when configured", async () => {
654
+ const { createEventStreamView } = await loadModule();
655
+ const events = [makeEvent("step_chunk", 1)];
656
+ const buffer = createMockBuffer(events);
657
+ const { update } = createEventStreamView({
658
+ buffer: buffer as any,
659
+ config: {
660
+ features: { eventStream: { timestampFormat: "absolute" } },
661
+ } as any,
662
+ });
663
+
664
+ update();
665
+
666
+ // Verify render happened
667
+ expect(buffer.getAll).toHaveBeenCalled();
668
+ });
669
+
670
+ it("should extract description from payload fields", async () => {
671
+ const { createEventStreamView } = await loadModule();
672
+ const events = [
673
+ { id: "evt-1", type: "flow_start", timestamp: 1000, payload: '{"flowName":"My Flow"}' },
674
+ ];
675
+ const buffer = createMockBuffer(events);
676
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
677
+
678
+ update();
679
+
680
+ // Verify the row was rendered
681
+ const eventsList = getEventsList(element);
682
+ expect(eventsList.children.length).toBeGreaterThanOrEqual(1);
683
+ });
684
+
685
+ it("should hide sequence numbers when showSequenceNumbers is false", async () => {
686
+ const { createEventStreamView } = await loadModule();
687
+ const events = [makeEvent("step_chunk", 1)];
688
+ const buffer = createMockBuffer(events);
689
+ const { update } = createEventStreamView({
690
+ buffer: buffer as any,
691
+ config: {
692
+ features: { eventStream: { showSequenceNumbers: false } },
693
+ } as any,
694
+ });
695
+
696
+ update();
697
+ expect(buffer.getAll).toHaveBeenCalled();
698
+ });
699
+
700
+ it("should use custom badge colors from config", async () => {
701
+ const { createEventStreamView } = await loadModule();
702
+ const events = [makeEvent("custom_type", 1)];
703
+ const buffer = createMockBuffer(events);
704
+ const { update } = createEventStreamView({
705
+ buffer: buffer as any,
706
+ config: {
707
+ features: {
708
+ eventStream: {
709
+ badgeColors: {
710
+ // Event type keys can be snake_case (e.g. from API)
711
+ ["custom_type" as string]: { bg: "#ff0000", text: "#ffffff" },
712
+ },
713
+ },
714
+ },
715
+ } as any,
716
+ });
717
+
718
+ update();
719
+ expect(buffer.getAll).toHaveBeenCalled();
720
+ });
721
+ });
722
+
723
+ describe("expand/collapse", () => {
724
+ it("should expand row to show inline payload when clicked", async () => {
725
+ const { createEventStreamView } = await loadModule();
726
+ const events = [makeEvent("step_chunk", 1, '{"message":"hello"}')];
727
+ const buffer = createMockBuffer(events);
728
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
729
+
730
+ update();
731
+
732
+ const eventsList = getEventsList(element);
733
+ // First row wrapper (direct child of eventsList after fragment transfer)
734
+ const rowWrapper = eventsList.children[0];
735
+ expect(rowWrapper).toBeDefined();
736
+
737
+ // Row wrapper > container div (from buildDefaultRowContent) > row line div
738
+ const container = rowWrapper.children[0]; // container div
739
+ expect(container).toBeDefined();
740
+ expect(container.children.length).toBeGreaterThanOrEqual(1);
741
+
742
+ const rowLine = container.children[0]; // the flex row line
743
+ expect(rowLine).toBeDefined();
744
+
745
+ // Verify delegated click handler is registered on eventsList (event delegation)
746
+ expect(eventsList.__listeners.click).toBeDefined();
747
+ expect(eventsList.__listeners.click.length).toBeGreaterThan(0);
748
+
749
+ // Verify data-event-id attribute is set on the row
750
+ expect(rowLine.getAttribute("data-event-id")).toBe("evt-1");
751
+
752
+ // Simulate click via event delegation on eventsList - target is the row line
753
+ eventsList.__fireEvent("click", { target: rowLine, stopPropagation: () => {} });
754
+
755
+ // After incremental re-render (Path B: single row replace), the new wrapper
756
+ // replaces the old one in place. The updated container should have 2 children
757
+ // (row line + inline payload).
758
+ const updatedWrapper = eventsList.children[0];
759
+ const updatedContainer = updatedWrapper.children[0];
760
+ expect(updatedContainer.children.length).toBe(2);
761
+ });
762
+
763
+ it("should format JSON payload as pretty-printed in expanded view", async () => {
764
+ const jsonPayload = '{"name":"test","value":42}';
765
+ const formatted = JSON.stringify(JSON.parse(jsonPayload), null, 2);
766
+ expect(formatted).toBe('{\n "name": "test",\n "value": 42\n}');
767
+ });
768
+
769
+ it("should handle non-JSON payload gracefully", async () => {
770
+ const plainPayload = "just plain text, not JSON";
771
+ let result: string;
772
+ try {
773
+ result = JSON.stringify(JSON.parse(plainPayload), null, 2);
774
+ } catch {
775
+ result = plainPayload;
776
+ }
777
+ expect(result).toBe(plainPayload);
778
+ });
779
+ });
780
+
781
+ describe("incremental rendering", () => {
782
+ it("should preserve existing row DOM references when new events are appended", async () => {
783
+ vi.useFakeTimers();
784
+ const { createEventStreamView } = await loadModule();
785
+ const events = [makeEvent("step_chunk", 1), makeEvent("step_chunk", 2)];
786
+ const buffer = createMockBuffer(events);
787
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
788
+
789
+ // Initial render (Path A — first render)
790
+ update();
791
+
792
+ const eventsList = getEventsList(element);
793
+ expect(eventsList.children.length).toBe(2);
794
+
795
+ // Save references to existing rows
796
+ const row1Ref = eventsList.children[0];
797
+ const row2Ref = eventsList.children[1];
798
+
799
+ // Add a new event and update (Path C — incremental append)
800
+ vi.advanceTimersByTime(150);
801
+ buffer.push(makeEvent("step_chunk", 3));
802
+ update();
803
+
804
+ // Should now have 3 rows
805
+ expect(eventsList.children.length).toBe(3);
806
+
807
+ // Original rows should be the same DOM references (not recreated)
808
+ expect(eventsList.children[0]).toBe(row1Ref);
809
+ expect(eventsList.children[1]).toBe(row2Ref);
810
+ vi.useRealTimers();
811
+ });
812
+
813
+ it("should replace only the target row on expand/collapse", async () => {
814
+ vi.useFakeTimers();
815
+ const { createEventStreamView } = await loadModule();
816
+ const events = [
817
+ makeEvent("step_chunk", 1, '{"msg":"first"}'),
818
+ makeEvent("step_chunk", 2, '{"msg":"second"}'),
819
+ makeEvent("step_chunk", 3, '{"msg":"third"}'),
820
+ ];
821
+ const buffer = createMockBuffer(events);
822
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
823
+
824
+ // Initial render
825
+ update();
826
+
827
+ const eventsList = getEventsList(element);
828
+ expect(eventsList.children.length).toBe(3);
829
+
830
+ // Save references
831
+ const row1Ref = eventsList.children[0];
832
+ const row3Ref = eventsList.children[2];
833
+
834
+ // Expand the second row by simulating a click
835
+ vi.advanceTimersByTime(150);
836
+ const row2Container = eventsList.children[1].children[0];
837
+ const row2Line = row2Container.children[0];
838
+ eventsList.__fireEvent("click", { target: row2Line, stopPropagation: () => {} });
839
+
840
+ // Row 1 and Row 3 should be the same DOM references (untouched)
841
+ expect(eventsList.children[0]).toBe(row1Ref);
842
+ expect(eventsList.children[2]).toBe(row3Ref);
843
+
844
+ // Row 2 should be a different reference (replaced)
845
+ expect(eventsList.children[1]).not.toBe(row2Container);
846
+ vi.useRealTimers();
847
+ });
848
+
849
+ it("should do a full rebuild when filter changes", async () => {
850
+ vi.useFakeTimers();
851
+ const { createEventStreamView } = await loadModule();
852
+ const events = [
853
+ makeEvent("step_chunk", 1),
854
+ makeEvent("flow_complete", 2),
855
+ makeEvent("step_chunk", 3),
856
+ ];
857
+ const buffer = createMockBuffer(events);
858
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
859
+
860
+ // Initial render
861
+ update();
862
+
863
+ const eventsList = getEventsList(element);
864
+ const originalRow1 = eventsList.children[0];
865
+
866
+ // Change filter
867
+ vi.advanceTimersByTime(150);
868
+ const filterSelect = getFilterSelect(element);
869
+ filterSelect.value = "step_chunk";
870
+ filterSelect.__fireEvent("change");
871
+
872
+ // After filter change, rows are fully rebuilt (Path A)
873
+ // The first row should be a different DOM reference
874
+ expect(eventsList.children[0]).not.toBe(originalRow1);
875
+ // Should only show filtered events (2 step_chunk events)
876
+ expect(eventsList.children.length).toBe(2);
877
+ vi.useRealTimers();
878
+ });
879
+ });
880
+
881
+ describe("individual event copy", () => {
882
+ it("should format event as structured JSON with parsed payload", async () => {
883
+ const { createEventStreamView } = await loadModule();
884
+ const events = [makeEvent("step_chunk", 1, '{"message":"hello"}')];
885
+ const buffer = createMockBuffer(events);
886
+ const { update } = createEventStreamView({ buffer: buffer as any });
887
+
888
+ update();
889
+
890
+ expect(buffer.getAll).toHaveBeenCalled();
891
+ });
892
+ });
893
+
894
+ describe("clear chat integration", () => {
895
+ it("should reflect empty state after buffer clear and update", async () => {
896
+ vi.useFakeTimers();
897
+ const { createEventStreamView } = await loadModule();
898
+ const events = [
899
+ makeEvent("step_chunk", 1),
900
+ makeEvent("flow_complete", 2),
901
+ ];
902
+ const buffer = createMockBuffer(events);
903
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
904
+
905
+ update();
906
+
907
+ const filterSelect = getFilterSelect(element);
908
+ expect(filterSelect.options[0].textContent).toBe("All events");
909
+
910
+ // Simulate clearChat: buffer.clear() + view.update()
911
+ vi.advanceTimersByTime(150);
912
+ buffer.clear();
913
+ update();
914
+
915
+ // Filter should show "All events"
916
+ expect(filterSelect.options[0].textContent).toBe("All events");
917
+ // No type-specific options remain
918
+ expect(filterSelect.options.length).toBe(1);
919
+ vi.useRealTimers();
920
+ });
921
+
922
+ it("should recover after clear when new events arrive", async () => {
923
+ vi.useFakeTimers();
924
+ const { createEventStreamView } = await loadModule();
925
+ const events = [makeEvent("step_chunk", 1)];
926
+ const buffer = createMockBuffer(events);
927
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
928
+
929
+ update();
930
+
931
+ // Clear (simulate clearChat)
932
+ vi.advanceTimersByTime(150);
933
+ buffer.clear();
934
+ update();
935
+
936
+ const filterSelect = getFilterSelect(element);
937
+ expect(filterSelect.options[0].textContent).toBe("All events");
938
+
939
+ // New events arrive in new session
940
+ vi.advanceTimersByTime(150);
941
+ buffer.push(makeEvent("tool_start", 10));
942
+ update();
943
+
944
+ expect(filterSelect.options[1].textContent).toBe("tool_start (1)");
945
+ vi.useRealTimers();
946
+ });
947
+ });
948
+
949
+ describe("update throttle", () => {
950
+ it("should render immediately on first update call", async () => {
951
+ const { createEventStreamView } = await loadModule();
952
+ const events = [makeEvent("step_chunk", 1)];
953
+ const buffer = createMockBuffer(events);
954
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
955
+
956
+ update();
957
+
958
+ const filterSelect = getFilterSelect(element);
959
+ expect(filterSelect.options[1].textContent).toBe("step_chunk (1)");
960
+ });
961
+
962
+ it("should throttle rapid update calls within 100ms", async () => {
963
+ vi.useFakeTimers();
964
+ const { createEventStreamView } = await loadModule();
965
+ const events = [makeEvent("step_chunk", 1)];
966
+ const buffer = createMockBuffer(events);
967
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
968
+
969
+ // First update renders immediately
970
+ update();
971
+
972
+ const filterSelect = getFilterSelect(element);
973
+ expect(filterSelect.options[1].textContent).toBe("step_chunk (1)");
974
+
975
+ // Add more events and call update rapidly (within throttle window)
976
+ buffer.push(makeEvent("step_chunk", 2));
977
+ buffer.push(makeEvent("step_chunk", 3));
978
+ update();
979
+ update();
980
+ update();
981
+
982
+ // Should NOT have rendered yet (within 100ms throttle, rAF pending)
983
+ expect(filterSelect.options[1].textContent).toBe("step_chunk (1)");
984
+
985
+ // Advance time to flush the rAF callback
986
+ vi.advanceTimersByTime(20);
987
+
988
+ // Now it should have rendered with all 3 events
989
+ expect(filterSelect.options[1].textContent).toBe("step_chunk (3)");
990
+ vi.useRealTimers();
991
+ });
992
+
993
+ it("should render immediately after 100ms has elapsed", async () => {
994
+ vi.useFakeTimers();
995
+ const { createEventStreamView } = await loadModule();
996
+ const events = [makeEvent("step_chunk", 1)];
997
+ const buffer = createMockBuffer(events);
998
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
999
+
1000
+ // First update
1001
+ update();
1002
+
1003
+ const filterSelect = getFilterSelect(element);
1004
+
1005
+ // Wait past the throttle interval
1006
+ vi.advanceTimersByTime(150);
1007
+
1008
+ // Add event and update — should render immediately since 150ms > 100ms
1009
+ buffer.push(makeEvent("flow_complete", 2));
1010
+ update();
1011
+
1012
+ expect(filterSelect.options.length).toBe(3); // All events + flow_complete + step_chunk
1013
+ vi.useRealTimers();
1014
+ });
1015
+
1016
+ it("should coalesce multiple rapid updates into a single render via rAF", async () => {
1017
+ vi.useFakeTimers();
1018
+ const { createEventStreamView } = await loadModule();
1019
+ const buffer = createMockBuffer([makeEvent("step_chunk", 1)]);
1020
+ const { update } = createEventStreamView({ buffer: buffer as any });
1021
+
1022
+ // First call: immediate render
1023
+ update();
1024
+ const callCountAfterFirst = buffer.getAll.mock.calls.length;
1025
+
1026
+ // Rapid burst: 10 updates within throttle window
1027
+ for (let i = 2; i <= 11; i++) {
1028
+ buffer.push(makeEvent("step_chunk", i));
1029
+ update();
1030
+ }
1031
+
1032
+ // Buffer.getAll should NOT have been called again yet (all coalesced via rAF)
1033
+ expect(buffer.getAll.mock.calls.length).toBe(callCountAfterFirst);
1034
+
1035
+ // Flush the rAF
1036
+ vi.advanceTimersByTime(20);
1037
+
1038
+ // Should have been called for the coalesced update
1039
+ expect(buffer.getAll.mock.calls.length).toBeGreaterThan(callCountAfterFirst);
1040
+ vi.useRealTimers();
1041
+ });
1042
+
1043
+ it("should render immediately for user-initiated actions (filter change)", async () => {
1044
+ const { createEventStreamView } = await loadModule();
1045
+ const events = [
1046
+ makeEvent("step_chunk", 1),
1047
+ makeEvent("flow_complete", 2),
1048
+ ];
1049
+ const buffer = createMockBuffer(events);
1050
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
1051
+
1052
+ // Initial render
1053
+ update();
1054
+
1055
+ const filterSelect = getFilterSelect(element);
1056
+ const copyAllBtn = getCopyAllBtn(element);
1057
+
1058
+ // Immediately change filter — this should bypass throttle (uses updateNow internally)
1059
+ filterSelect.value = "step_chunk";
1060
+ filterSelect.__fireEvent("change");
1061
+
1062
+ // Should have updated immediately (Copy All title reflects filter)
1063
+ expect(copyAllBtn.title).toBe("Copy Filtered (1)");
1064
+ });
1065
+
1066
+ it("should cancel pending rAF on destroy", async () => {
1067
+ vi.useFakeTimers();
1068
+ const { createEventStreamView } = await loadModule();
1069
+ const buffer = createMockBuffer([makeEvent("step_chunk", 1)]);
1070
+ const { update, destroy } = createEventStreamView({ buffer: buffer as any });
1071
+
1072
+ // First update — immediate
1073
+ update();
1074
+
1075
+ // Schedule a throttled update
1076
+ buffer.push(makeEvent("step_chunk", 2));
1077
+ update();
1078
+
1079
+ // Destroy before rAF fires — should not throw
1080
+ expect(() => destroy()).not.toThrow();
1081
+
1082
+ // Advancing timers to flush rAF — should not throw even though view is destroyed
1083
+ vi.advanceTimersByTime(20);
1084
+ vi.useRealTimers();
1085
+ });
1086
+ });
1087
+
1088
+ describe("scroll behavior", () => {
1089
+ it("should have scroll listener on events list", async () => {
1090
+ const { createEventStreamView } = await loadModule();
1091
+ const events = [makeEvent("step_chunk", 1)];
1092
+ const buffer = createMockBuffer(events);
1093
+ const { element, update } = createEventStreamView({ buffer: buffer as any });
1094
+
1095
+ update();
1096
+
1097
+ const eventsList = getEventsList(element);
1098
+ expect(eventsList.__listeners.scroll).toBeDefined();
1099
+ expect(eventsList.__listeners.scroll.length).toBeGreaterThan(0);
1100
+
1101
+ // Triggering scroll should not throw
1102
+ expect(() => eventsList.__fireEvent("scroll")).not.toThrow();
1103
+ });
1104
+ });
1105
+
1106
+ describe("destroy", () => {
1107
+ it("should clean up event listeners on destroy", async () => {
1108
+ const { createEventStreamView } = await loadModule();
1109
+ const buffer = createMockBuffer();
1110
+ const { destroy } = createEventStreamView({ buffer: buffer as any });
1111
+
1112
+ // Should not throw
1113
+ expect(() => destroy()).not.toThrow();
1114
+ });
1115
+
1116
+ it("should clear pending search timeout on destroy", async () => {
1117
+ vi.useFakeTimers();
1118
+ const { createEventStreamView } = await loadModule();
1119
+ const buffer = createMockBuffer([makeEvent("a", 1)]);
1120
+ const { element, destroy } = createEventStreamView({ buffer: buffer as any });
1121
+
1122
+ const searchInput = getSearchInput(element);
1123
+
1124
+ // Type to start debounce timer
1125
+ searchInput.value = "test";
1126
+ searchInput.__fireEvent("input");
1127
+
1128
+ // Destroy before debounce fires
1129
+ expect(() => destroy()).not.toThrow();
1130
+
1131
+ // Advancing time should not cause errors
1132
+ vi.advanceTimersByTime(200);
1133
+ vi.useRealTimers();
1134
+ });
1135
+ });
1136
+
1137
+ describe("plugin hooks", () => {
1138
+ it("should use custom renderEventStreamRow plugin when provided", async () => {
1139
+ const { createEventStreamView } = await loadModule();
1140
+ const events = [makeEvent("step_chunk", 1)];
1141
+ const buffer = createMockBuffer(events);
1142
+ const customRow = createMockElement("div");
1143
+ customRow.textContent = "Custom Row";
1144
+
1145
+ const plugin = {
1146
+ id: "test-plugin",
1147
+ renderEventStreamRow: vi.fn(() => customRow),
1148
+ };
1149
+
1150
+ const { update } = createEventStreamView({
1151
+ buffer: buffer as any,
1152
+ config: {} as any,
1153
+ plugins: [plugin],
1154
+ });
1155
+
1156
+ update();
1157
+
1158
+ expect(plugin.renderEventStreamRow).toHaveBeenCalledWith(
1159
+ expect.objectContaining({
1160
+ event: events[0],
1161
+ index: 0,
1162
+ isExpanded: false,
1163
+ })
1164
+ );
1165
+ });
1166
+
1167
+ it("should use custom renderEventStreamToolbar plugin when provided", async () => {
1168
+ const { createEventStreamView } = await loadModule();
1169
+ const buffer = createMockBuffer();
1170
+ const customToolbar = createMockElement("div");
1171
+ customToolbar.textContent = "Custom Toolbar";
1172
+
1173
+ const plugin = {
1174
+ id: "test-plugin",
1175
+ renderEventStreamToolbar: vi.fn(() => customToolbar),
1176
+ };
1177
+
1178
+ const { element } = createEventStreamView({
1179
+ buffer: buffer as any,
1180
+ config: {} as any,
1181
+ plugins: [plugin],
1182
+ });
1183
+
1184
+ expect(plugin.renderEventStreamToolbar).toHaveBeenCalled();
1185
+ // The toolbar should be the custom element
1186
+ expect(element.children[0].textContent).toBe("Custom Toolbar");
1187
+ });
1188
+
1189
+ it("should use custom renderEventStreamView plugin when provided", async () => {
1190
+ const { createEventStreamView } = await loadModule();
1191
+ const buffer = createMockBuffer();
1192
+ const customView = createMockElement("div");
1193
+ customView.textContent = "Fully Custom View";
1194
+
1195
+ const plugin = {
1196
+ id: "test-plugin",
1197
+ renderEventStreamView: vi.fn(() => customView),
1198
+ };
1199
+
1200
+ const { element } = createEventStreamView({
1201
+ buffer: buffer as any,
1202
+ config: {} as any,
1203
+ plugins: [plugin],
1204
+ });
1205
+
1206
+ expect(plugin.renderEventStreamView).toHaveBeenCalled();
1207
+ expect(element.textContent).toBe("Fully Custom View");
1208
+ });
1209
+
1210
+ it("should fall back to default when plugin returns null", async () => {
1211
+ const { createEventStreamView } = await loadModule();
1212
+ const events = [makeEvent("step_chunk", 1)];
1213
+ const buffer = createMockBuffer(events);
1214
+
1215
+ const plugin = {
1216
+ id: "test-plugin",
1217
+ renderEventStreamRow: vi.fn(() => null),
1218
+ };
1219
+
1220
+ const { element, update } = createEventStreamView({
1221
+ buffer: buffer as any,
1222
+ config: {} as any,
1223
+ plugins: [plugin],
1224
+ });
1225
+
1226
+ update();
1227
+
1228
+ // Should still render the default view
1229
+ expect(element.children.length).toBe(3);
1230
+ expect(plugin.renderEventStreamRow).toHaveBeenCalled();
1231
+ });
1232
+ });
1233
+ });