@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,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
|
+
});
|