@a5c-ai/babysitter-observer-dashboard 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/next.config.mjs +25 -0
  4. package/package.json +104 -0
  5. package/postcss.config.mjs +8 -0
  6. package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
  7. package/src/app/actions/approve-breakpoint.ts +145 -0
  8. package/src/app/api/config/route.ts +137 -0
  9. package/src/app/api/digest/route.ts +45 -0
  10. package/src/app/api/runs/[runId]/events/route.ts +56 -0
  11. package/src/app/api/runs/[runId]/route.ts +84 -0
  12. package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
  13. package/src/app/api/runs/route.ts +48 -0
  14. package/src/app/api/stream/route.ts +136 -0
  15. package/src/app/api/test/route.ts +1 -0
  16. package/src/app/api/version/route.ts +57 -0
  17. package/src/app/globals.css +555 -0
  18. package/src/app/icon.svg +20 -0
  19. package/src/app/layout.tsx +39 -0
  20. package/src/app/not-found.tsx +16 -0
  21. package/src/app/page.tsx +120 -0
  22. package/src/app/runs/[runId]/page.tsx +279 -0
  23. package/src/cli.ts +271 -0
  24. package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
  25. package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
  26. package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
  27. package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
  28. package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
  29. package/src/components/breakpoint/file-preview.tsx +215 -0
  30. package/src/components/dashboard/.gitkeep +0 -0
  31. package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
  32. package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
  33. package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
  34. package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
  35. package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
  36. package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
  37. package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
  38. package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
  39. package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
  40. package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
  41. package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
  42. package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
  43. package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
  44. package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
  45. package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
  46. package/src/components/dashboard/breakpoint-banner.tsx +301 -0
  47. package/src/components/dashboard/catch-up-banner.tsx +88 -0
  48. package/src/components/dashboard/executive-summary-banner.tsx +174 -0
  49. package/src/components/dashboard/global-search.tsx +323 -0
  50. package/src/components/dashboard/kpi-grid.tsx +140 -0
  51. package/src/components/dashboard/pagination-controls.tsx +100 -0
  52. package/src/components/dashboard/project-accordion.tsx +72 -0
  53. package/src/components/dashboard/project-health-card.tsx +536 -0
  54. package/src/components/dashboard/project-list-view.tsx +246 -0
  55. package/src/components/dashboard/project-search-input.tsx +41 -0
  56. package/src/components/dashboard/project-section-header.tsx +73 -0
  57. package/src/components/dashboard/project-section.tsx +89 -0
  58. package/src/components/dashboard/run-card.tsx +218 -0
  59. package/src/components/dashboard/run-filter-bar.tsx +100 -0
  60. package/src/components/dashboard/run-list.tsx +77 -0
  61. package/src/components/dashboard/search-filter.tsx +69 -0
  62. package/src/components/dashboard/virtualized-run-list.tsx +130 -0
  63. package/src/components/details/.gitkeep +0 -0
  64. package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
  65. package/src/components/details/__tests__/json-tree.test.tsx +347 -0
  66. package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
  67. package/src/components/details/__tests__/task-detail.test.tsx +212 -0
  68. package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
  69. package/src/components/details/agent-panel.tsx +234 -0
  70. package/src/components/details/json-tree/categorize.ts +131 -0
  71. package/src/components/details/json-tree/index.tsx +120 -0
  72. package/src/components/details/json-tree/json-node.tsx +223 -0
  73. package/src/components/details/json-tree/smart-summary.tsx +596 -0
  74. package/src/components/details/json-tree/tree-controls.tsx +47 -0
  75. package/src/components/details/json-tree.tsx +9 -0
  76. package/src/components/details/log-viewer.tsx +140 -0
  77. package/src/components/details/task-detail.tsx +114 -0
  78. package/src/components/details/timing-panel.tsx +247 -0
  79. package/src/components/events/.gitkeep +0 -0
  80. package/src/components/events/__tests__/event-item.test.tsx +211 -0
  81. package/src/components/events/__tests__/event-stream.test.tsx +225 -0
  82. package/src/components/events/event-item.tsx +121 -0
  83. package/src/components/events/event-stream.tsx +260 -0
  84. package/src/components/notifications/.gitkeep +0 -0
  85. package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
  86. package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
  87. package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
  88. package/src/components/notifications/notification-panel.tsx +124 -0
  89. package/src/components/notifications/notification-provider.tsx +175 -0
  90. package/src/components/notifications/toast-stack.tsx +75 -0
  91. package/src/components/pipeline/.gitkeep +0 -0
  92. package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
  93. package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
  94. package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
  95. package/src/components/pipeline/parallel-group.tsx +39 -0
  96. package/src/components/pipeline/pipeline-view.tsx +197 -0
  97. package/src/components/pipeline/step-card.tsx +166 -0
  98. package/src/components/providers/event-stream-provider.tsx +29 -0
  99. package/src/components/providers.tsx +24 -0
  100. package/src/components/shared/.gitkeep +0 -0
  101. package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
  102. package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
  103. package/src/components/shared/__tests__/kbd.test.tsx +45 -0
  104. package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
  105. package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
  106. package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
  107. package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
  108. package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
  109. package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
  110. package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
  111. package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
  112. package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
  113. package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
  114. package/src/components/shared/app-footer.tsx +80 -0
  115. package/src/components/shared/app-header.tsx +160 -0
  116. package/src/components/shared/empty-state.tsx +18 -0
  117. package/src/components/shared/error-boundary.tsx +81 -0
  118. package/src/components/shared/friendly-id.tsx +48 -0
  119. package/src/components/shared/kbd.tsx +15 -0
  120. package/src/components/shared/kind-badge.tsx +51 -0
  121. package/src/components/shared/metrics-row.tsx +106 -0
  122. package/src/components/shared/outcome-banner.tsx +56 -0
  123. package/src/components/shared/progress-bar.tsx +42 -0
  124. package/src/components/shared/session-pill.tsx +69 -0
  125. package/src/components/shared/settings-modal.tsx +509 -0
  126. package/src/components/shared/shortcuts-help.tsx +113 -0
  127. package/src/components/shared/status-badge.tsx +110 -0
  128. package/src/components/shared/theme-provider.tsx +46 -0
  129. package/src/components/shared/truncated-id.tsx +51 -0
  130. package/src/components/ui/.gitkeep +0 -0
  131. package/src/components/ui/__tests__/accordion.test.tsx +96 -0
  132. package/src/components/ui/__tests__/badge.test.tsx +69 -0
  133. package/src/components/ui/__tests__/button.test.tsx +113 -0
  134. package/src/components/ui/__tests__/tabs.test.tsx +75 -0
  135. package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
  136. package/src/components/ui/accordion.tsx +61 -0
  137. package/src/components/ui/badge.tsx +25 -0
  138. package/src/components/ui/button.tsx +40 -0
  139. package/src/components/ui/card.tsx +21 -0
  140. package/src/components/ui/scroll-area.tsx +35 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/tabs.tsx +64 -0
  143. package/src/components/ui/tooltip.tsx +37 -0
  144. package/src/hooks/.gitkeep +0 -0
  145. package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
  146. package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
  147. package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
  148. package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
  149. package/src/hooks/__tests__/use-notifications.test.ts +230 -0
  150. package/src/hooks/__tests__/use-polling.test.ts +274 -0
  151. package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
  152. package/src/hooks/__tests__/use-projects.test.ts +248 -0
  153. package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
  154. package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
  155. package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
  156. package/src/hooks/use-animated-number.ts +87 -0
  157. package/src/hooks/use-batched-updates.ts +150 -0
  158. package/src/hooks/use-event-stream.ts +150 -0
  159. package/src/hooks/use-keyboard.ts +45 -0
  160. package/src/hooks/use-notifications.ts +82 -0
  161. package/src/hooks/use-persisted-state.ts +60 -0
  162. package/src/hooks/use-polling.ts +60 -0
  163. package/src/hooks/use-project-runs.ts +51 -0
  164. package/src/hooks/use-projects.ts +26 -0
  165. package/src/hooks/use-run-dashboard.ts +207 -0
  166. package/src/hooks/use-run-detail.ts +77 -0
  167. package/src/hooks/use-smart-polling.ts +144 -0
  168. package/src/lib/.gitkeep +0 -0
  169. package/src/lib/__tests__/cn.test.ts +69 -0
  170. package/src/lib/__tests__/config-loader.test.ts +210 -0
  171. package/src/lib/__tests__/config.test.ts +561 -0
  172. package/src/lib/__tests__/error-handler.test.ts +143 -0
  173. package/src/lib/__tests__/fetcher.test.ts +517 -0
  174. package/src/lib/__tests__/global-registry.test.ts +214 -0
  175. package/src/lib/__tests__/parser.test.ts +1532 -0
  176. package/src/lib/__tests__/path-resolver.test.ts +112 -0
  177. package/src/lib/__tests__/run-cache.test.ts +591 -0
  178. package/src/lib/__tests__/server-init.test.ts +512 -0
  179. package/src/lib/__tests__/source-discovery.test.ts +246 -0
  180. package/src/lib/__tests__/utils.test.ts +160 -0
  181. package/src/lib/__tests__/watcher.test.ts +227 -0
  182. package/src/lib/cn.ts +6 -0
  183. package/src/lib/config-loader.ts +195 -0
  184. package/src/lib/config.ts +20 -0
  185. package/src/lib/error-handler.ts +76 -0
  186. package/src/lib/fetcher.ts +394 -0
  187. package/src/lib/global-registry.ts +117 -0
  188. package/src/lib/parser.ts +794 -0
  189. package/src/lib/path-resolver.ts +16 -0
  190. package/src/lib/run-cache.ts +404 -0
  191. package/src/lib/server-init.ts +226 -0
  192. package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
  193. package/src/lib/services/run-query-service.ts +286 -0
  194. package/src/lib/source-discovery.ts +216 -0
  195. package/src/lib/utils.ts +103 -0
  196. package/src/lib/watcher.ts +265 -0
  197. package/src/test/fixtures.ts +269 -0
  198. package/src/test/mocks/handlers.ts +110 -0
  199. package/src/test/mocks/server.ts +17 -0
  200. package/src/test/setup.ts +200 -0
  201. package/src/test/test-utils.tsx +36 -0
  202. package/src/types/.gitkeep +0 -0
  203. package/src/types/breakpoint.ts +17 -0
  204. package/src/types/index.ts +214 -0
  205. package/tsconfig.json +50 -0
@@ -0,0 +1,315 @@
1
+ import { renderHook, act } from "@testing-library/react";
2
+ import { useBatchedUpdates, BURST_THRESHOLD, BURST_WINDOW_MS, CATCHUP_HOLD_MS } from "../use-batched-updates";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mock the SSE event stream subscribe function
6
+ // ---------------------------------------------------------------------------
7
+
8
+ type EventCallback = (event: { type: string; runId?: string }) => void;
9
+ let subscriberCallbacks: Set<EventCallback> = new Set();
10
+
11
+ vi.mock("../use-event-stream", () => ({
12
+ subscribe: (callback: EventCallback) => {
13
+ subscriberCallbacks.add(callback);
14
+ return () => {
15
+ subscriberCallbacks.delete(callback);
16
+ };
17
+ },
18
+ }));
19
+
20
+ function emitSSE(event: { type: string; runId?: string }) {
21
+ subscriberCallbacks.forEach((cb) => cb(event));
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Tests
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe("useBatchedUpdates", () => {
29
+ beforeEach(() => {
30
+ vi.useFakeTimers();
31
+ subscriberCallbacks = new Set();
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.useRealTimers();
36
+ });
37
+
38
+ it("starts inactive with 0 buffered count", () => {
39
+ const { result } = renderHook(() => useBatchedUpdates());
40
+ expect(result.current.active).toBe(false);
41
+ expect(result.current.bufferedCount).toBe(0);
42
+ });
43
+
44
+ it("does not activate for a small number of events", () => {
45
+ const { result } = renderHook(() =>
46
+ useBatchedUpdates({
47
+ sseFilter: (e) => e.type === "update",
48
+ })
49
+ );
50
+
51
+ // Send fewer events than the threshold
52
+ for (let i = 0; i < BURST_THRESHOLD - 1; i++) {
53
+ act(() => {
54
+ emitSSE({ type: "update", runId: `run-${i}` });
55
+ });
56
+ }
57
+
58
+ expect(result.current.active).toBe(false);
59
+ });
60
+
61
+ it("activates catch-up mode when burst threshold is reached", () => {
62
+ const { result } = renderHook(() =>
63
+ useBatchedUpdates({
64
+ sseFilter: (e) => e.type === "update",
65
+ })
66
+ );
67
+
68
+ // Send enough events to trigger burst detection
69
+ for (let i = 0; i < BURST_THRESHOLD; i++) {
70
+ act(() => {
71
+ emitSSE({ type: "update", runId: `run-${i}` });
72
+ });
73
+ }
74
+
75
+ expect(result.current.active).toBe(true);
76
+ expect(result.current.bufferedCount).toBe(BURST_THRESHOLD);
77
+ });
78
+
79
+ it("increments bufferedCount for events received during catch-up mode", () => {
80
+ const { result } = renderHook(() =>
81
+ useBatchedUpdates({
82
+ sseFilter: (e) => e.type === "update",
83
+ })
84
+ );
85
+
86
+ // Trigger catch-up mode
87
+ for (let i = 0; i < BURST_THRESHOLD; i++) {
88
+ act(() => {
89
+ emitSSE({ type: "update", runId: `run-${i}` });
90
+ });
91
+ }
92
+
93
+ expect(result.current.active).toBe(true);
94
+
95
+ // Send more events
96
+ act(() => {
97
+ emitSSE({ type: "update", runId: "extra-1" });
98
+ });
99
+ act(() => {
100
+ emitSSE({ type: "update", runId: "extra-2" });
101
+ });
102
+
103
+ expect(result.current.bufferedCount).toBe(BURST_THRESHOLD + 2);
104
+ });
105
+
106
+ it("ignores non-data events (connected, disconnect, error)", () => {
107
+ const { result } = renderHook(() => useBatchedUpdates());
108
+
109
+ act(() => {
110
+ emitSSE({ type: "connected" });
111
+ emitSSE({ type: "disconnect" });
112
+ emitSSE({ type: "error" });
113
+ });
114
+
115
+ expect(result.current.active).toBe(false);
116
+ expect(result.current.bufferedCount).toBe(0);
117
+ });
118
+
119
+ it("respects sseFilter — does not count filtered-out events", () => {
120
+ const { result } = renderHook(() =>
121
+ useBatchedUpdates({
122
+ sseFilter: (e) => e.type === "update",
123
+ })
124
+ );
125
+
126
+ // Send events that don't match the filter
127
+ for (let i = 0; i < BURST_THRESHOLD + 5; i++) {
128
+ act(() => {
129
+ emitSSE({ type: "heartbeat", runId: `run-${i}` });
130
+ });
131
+ }
132
+
133
+ expect(result.current.active).toBe(false);
134
+ });
135
+
136
+ it("auto-exits catch-up mode after hold period with no new events", async () => {
137
+ const onFlush = vi.fn();
138
+ const { result } = renderHook(() =>
139
+ useBatchedUpdates({
140
+ sseFilter: (e) => e.type === "update",
141
+ onFlush,
142
+ })
143
+ );
144
+
145
+ // Trigger catch-up mode
146
+ for (let i = 0; i < BURST_THRESHOLD; i++) {
147
+ act(() => {
148
+ emitSSE({ type: "update", runId: `run-${i}` });
149
+ });
150
+ }
151
+
152
+ expect(result.current.active).toBe(true);
153
+
154
+ // Wait for the hold period to expire
155
+ await act(async () => {
156
+ await vi.advanceTimersByTimeAsync(CATCHUP_HOLD_MS + 100);
157
+ });
158
+
159
+ expect(result.current.active).toBe(false);
160
+ expect(result.current.bufferedCount).toBe(0);
161
+ expect(onFlush).toHaveBeenCalledTimes(1);
162
+ });
163
+
164
+ it("resets the hold timer when new events arrive during catch-up", async () => {
165
+ const onFlush = vi.fn();
166
+ const { result } = renderHook(() =>
167
+ useBatchedUpdates({
168
+ sseFilter: (e) => e.type === "update",
169
+ onFlush,
170
+ })
171
+ );
172
+
173
+ // Trigger catch-up mode
174
+ for (let i = 0; i < BURST_THRESHOLD; i++) {
175
+ act(() => {
176
+ emitSSE({ type: "update", runId: `run-${i}` });
177
+ });
178
+ }
179
+
180
+ expect(result.current.active).toBe(true);
181
+
182
+ // Wait almost to the hold timeout
183
+ await act(async () => {
184
+ await vi.advanceTimersByTimeAsync(CATCHUP_HOLD_MS - 500);
185
+ });
186
+
187
+ // Send another event — should reset the timer
188
+ act(() => {
189
+ emitSSE({ type: "update", runId: "late-event" });
190
+ });
191
+
192
+ // The original timeout would have expired by now, but the timer was reset
193
+ await act(async () => {
194
+ await vi.advanceTimersByTimeAsync(1000);
195
+ });
196
+ expect(result.current.active).toBe(true);
197
+ expect(onFlush).not.toHaveBeenCalled();
198
+
199
+ // Now wait for the full hold period from the last event
200
+ await act(async () => {
201
+ await vi.advanceTimersByTimeAsync(CATCHUP_HOLD_MS);
202
+ });
203
+ expect(result.current.active).toBe(false);
204
+ expect(onFlush).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it("flush() immediately exits catch-up mode and calls onFlush", () => {
208
+ const onFlush = vi.fn();
209
+ const { result } = renderHook(() =>
210
+ useBatchedUpdates({
211
+ sseFilter: (e) => e.type === "update",
212
+ onFlush,
213
+ })
214
+ );
215
+
216
+ // Trigger catch-up mode
217
+ for (let i = 0; i < BURST_THRESHOLD; i++) {
218
+ act(() => {
219
+ emitSSE({ type: "update", runId: `run-${i}` });
220
+ });
221
+ }
222
+
223
+ expect(result.current.active).toBe(true);
224
+
225
+ // Flush
226
+ act(() => {
227
+ result.current.flush();
228
+ });
229
+
230
+ expect(result.current.active).toBe(false);
231
+ expect(result.current.bufferedCount).toBe(0);
232
+ expect(onFlush).toHaveBeenCalledTimes(1);
233
+ });
234
+
235
+ it("flush() is a no-op when not in catch-up mode", () => {
236
+ const onFlush = vi.fn();
237
+ const { result } = renderHook(() =>
238
+ useBatchedUpdates({ onFlush })
239
+ );
240
+
241
+ act(() => {
242
+ result.current.flush();
243
+ });
244
+
245
+ expect(onFlush).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it("does nothing when disabled", () => {
249
+ const { result } = renderHook(() =>
250
+ useBatchedUpdates({ enabled: false })
251
+ );
252
+
253
+ // Send many events
254
+ for (let i = 0; i < BURST_THRESHOLD + 5; i++) {
255
+ act(() => {
256
+ emitSSE({ type: "update", runId: `run-${i}` });
257
+ });
258
+ }
259
+
260
+ expect(result.current.active).toBe(false);
261
+ });
262
+
263
+ it("cleans up on unmount", () => {
264
+ const { unmount } = renderHook(() =>
265
+ useBatchedUpdates({
266
+ sseFilter: (e) => e.type === "update",
267
+ })
268
+ );
269
+
270
+ // Verify we have a subscriber
271
+ expect(subscriberCallbacks.size).toBeGreaterThan(0);
272
+
273
+ unmount();
274
+
275
+ // Subscriber should be removed
276
+ // Note: the exact count depends on how many hooks subscribe,
277
+ // but after unmount the hook's subscriber should be gone
278
+ const _countAfter = subscriberCallbacks.size;
279
+ // Verify no errors when emitting after unmount
280
+ act(() => {
281
+ emitSSE({ type: "update" });
282
+ });
283
+ });
284
+
285
+ it("prunes old timestamps outside burst window", async () => {
286
+ const { result } = renderHook(() =>
287
+ useBatchedUpdates({
288
+ sseFilter: (e) => e.type === "update",
289
+ })
290
+ );
291
+
292
+ // Send some events
293
+ for (let i = 0; i < BURST_THRESHOLD - 2; i++) {
294
+ act(() => {
295
+ emitSSE({ type: "update", runId: `run-${i}` });
296
+ });
297
+ }
298
+
299
+ // Wait longer than the burst window so old timestamps expire
300
+ await act(async () => {
301
+ await vi.advanceTimersByTimeAsync(BURST_WINDOW_MS + 100);
302
+ });
303
+
304
+ // Send a few more events — not enough to reach threshold from scratch
305
+ act(() => {
306
+ emitSSE({ type: "update", runId: "late-1" });
307
+ });
308
+ act(() => {
309
+ emitSSE({ type: "update", runId: "late-2" });
310
+ });
311
+
312
+ // Should NOT be in catch-up mode because old events were pruned
313
+ expect(result.current.active).toBe(false);
314
+ });
315
+ });
@@ -0,0 +1,243 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { useEventStream, subscribe } from '../use-event-stream';
3
+
4
+ type MockEventSourceInstance = {
5
+ onopen: ((event: Event) => void) | null;
6
+ onmessage: ((event: MessageEvent) => void) | null;
7
+ onerror: ((event: Event) => void) | null;
8
+ close: ReturnType<typeof vi.fn>;
9
+ readyState: number;
10
+ url: string;
11
+ };
12
+
13
+ let mockEventSourceInstances: MockEventSourceInstance[] = [];
14
+
15
+ class MockEventSource {
16
+ static CONNECTING = 0;
17
+ static OPEN = 1;
18
+ static CLOSED = 2;
19
+
20
+ onopen: ((event: Event) => void) | null = null;
21
+ onmessage: ((event: MessageEvent) => void) | null = null;
22
+ onerror: ((event: Event) => void) | null = null;
23
+ close = vi.fn();
24
+ readyState = MockEventSource.OPEN;
25
+ url: string;
26
+
27
+ constructor(url: string) {
28
+ this.url = url;
29
+ mockEventSourceInstances.push(this);
30
+ // Simulate async open
31
+ setTimeout(() => {
32
+ if (this.onopen) {
33
+ this.onopen(new Event('open'));
34
+ }
35
+ }, 0);
36
+ }
37
+ }
38
+
39
+ // We need to track unsubscribe functions to clean up between tests
40
+ let activeUnsubscribers: Array<() => void> = [];
41
+
42
+ describe('use-event-stream', () => {
43
+ beforeEach(() => {
44
+ vi.useFakeTimers();
45
+ mockEventSourceInstances = [];
46
+ vi.stubGlobal('EventSource', MockEventSource);
47
+ });
48
+
49
+ afterEach(() => {
50
+ // Unsubscribe all active subscribers to reset module-level state
51
+ for (const unsub of activeUnsubscribers) {
52
+ unsub();
53
+ }
54
+ activeUnsubscribers = [];
55
+ vi.useRealTimers();
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ describe('subscribe', () => {
60
+ it('creates a shared EventSource on first subscriber', () => {
61
+ const callback = vi.fn();
62
+ const unsubscribe = subscribe(callback);
63
+ activeUnsubscribers.push(unsubscribe);
64
+
65
+ expect(mockEventSourceInstances).toHaveLength(1);
66
+ expect(mockEventSourceInstances[0].url).toBe('/api/stream');
67
+ });
68
+
69
+ it('reuses the same EventSource for multiple subscribers', () => {
70
+ const cb1 = vi.fn();
71
+ const cb2 = vi.fn();
72
+
73
+ const unsub1 = subscribe(cb1);
74
+ const unsub2 = subscribe(cb2);
75
+ activeUnsubscribers.push(unsub1, unsub2);
76
+
77
+ expect(mockEventSourceInstances).toHaveLength(1);
78
+ });
79
+
80
+ it('delivers messages to all subscribers', () => {
81
+ const cb1 = vi.fn();
82
+ const cb2 = vi.fn();
83
+
84
+ const unsub1 = subscribe(cb1);
85
+ const unsub2 = subscribe(cb2);
86
+ activeUnsubscribers.push(unsub1, unsub2);
87
+
88
+ const instance = mockEventSourceInstances[0];
89
+ const messageData = { type: 'run_updated', runId: 'run-1' };
90
+
91
+ instance.onmessage!(
92
+ new MessageEvent('message', { data: JSON.stringify(messageData) })
93
+ );
94
+
95
+ expect(cb1).toHaveBeenCalledWith(messageData);
96
+ expect(cb2).toHaveBeenCalledWith(messageData);
97
+ });
98
+
99
+ it('closes EventSource when last subscriber unsubscribes', () => {
100
+ const cb1 = vi.fn();
101
+ const cb2 = vi.fn();
102
+
103
+ const unsub1 = subscribe(cb1);
104
+ const unsub2 = subscribe(cb2);
105
+
106
+ unsub1();
107
+ // Still one subscriber, should not close
108
+ expect(mockEventSourceInstances[0].close).not.toHaveBeenCalled();
109
+
110
+ unsub2();
111
+ // Last subscriber gone, should close
112
+ expect(mockEventSourceInstances[0].close).toHaveBeenCalled();
113
+ // Do not add to activeUnsubscribers since we already cleaned up
114
+ });
115
+
116
+ it('notifies subscribers with disconnect event on SSE error', () => {
117
+ const callback = vi.fn();
118
+ const unsub = subscribe(callback);
119
+ activeUnsubscribers.push(unsub);
120
+
121
+ const instance = mockEventSourceInstances[0];
122
+
123
+ instance.onerror!(new Event('error'));
124
+
125
+ expect(callback).toHaveBeenCalledWith({ type: 'disconnect' });
126
+ });
127
+
128
+ it('attempts reconnect with backoff on error', async () => {
129
+ const callback = vi.fn();
130
+ const unsub = subscribe(callback);
131
+ activeUnsubscribers.push(unsub);
132
+
133
+ const instance = mockEventSourceInstances[0];
134
+
135
+ // Trigger error
136
+ instance.onerror!(new Event('error'));
137
+
138
+ expect(mockEventSourceInstances).toHaveLength(1); // only original
139
+
140
+ // After reconnectAttempts++ (0->1), delay = delays[1] = 2000ms
141
+ await act(async () => {
142
+ await vi.advanceTimersByTimeAsync(2000);
143
+ });
144
+
145
+ expect(mockEventSourceInstances).toHaveLength(2); // reconnected
146
+ });
147
+ });
148
+
149
+ describe('useEventStream', () => {
150
+ it('returns connected, lastEvent, error initial state', () => {
151
+ const { result } = renderHook(() => useEventStream());
152
+
153
+ expect(result.current.connected).toBe(false);
154
+ expect(result.current.lastEvent).toBeNull();
155
+ expect(result.current.error).toBeNull();
156
+ });
157
+
158
+ it('updates lastEvent when a message is received', () => {
159
+ const { result } = renderHook(() => useEventStream());
160
+
161
+ const instance = mockEventSourceInstances[0];
162
+ const eventData = { type: 'run_completed', runId: 'run-1' };
163
+
164
+ act(() => {
165
+ instance.onmessage!(
166
+ new MessageEvent('message', { data: JSON.stringify(eventData) })
167
+ );
168
+ });
169
+
170
+ expect(result.current.lastEvent).toEqual(eventData);
171
+ expect(result.current.connected).toBe(true);
172
+ });
173
+
174
+ it('does not set connected=true for disconnect events', () => {
175
+ const { result } = renderHook(() => useEventStream());
176
+
177
+ const instance = mockEventSourceInstances[0];
178
+
179
+ // First send a real event to set connected=true
180
+ act(() => {
181
+ instance.onmessage!(
182
+ new MessageEvent('message', { data: JSON.stringify({ type: 'update', runId: 'r1' }) })
183
+ );
184
+ });
185
+ expect(result.current.connected).toBe(true);
186
+
187
+ // Now trigger SSE error which emits disconnect
188
+ act(() => {
189
+ instance.onerror!(new Event('error'));
190
+ });
191
+
192
+ // connected should not have been set to true by the disconnect event
193
+ // (the interval checker will eventually update it, but the event itself should not)
194
+ expect(result.current.lastEvent).toEqual({ type: 'disconnect' });
195
+ });
196
+
197
+ it('does not set connected=true for error events', () => {
198
+ const { result } = renderHook(() => useEventStream());
199
+
200
+ const instance = mockEventSourceInstances[0];
201
+
202
+ // Send a server-side error event (type: 'error')
203
+ act(() => {
204
+ instance.onmessage!(
205
+ new MessageEvent('message', { data: JSON.stringify({ type: 'error', error: 'test' }) })
206
+ );
207
+ });
208
+
209
+ // lastEvent should be set but connected should remain false
210
+ expect(result.current.lastEvent).toEqual({ type: 'error', error: 'test' });
211
+ expect(result.current.connected).toBe(false);
212
+ });
213
+
214
+ it('cleans up on unmount', () => {
215
+ const { unmount } = renderHook(() => useEventStream());
216
+
217
+ expect(mockEventSourceInstances).toHaveLength(1);
218
+
219
+ unmount();
220
+
221
+ // After unmount, the EventSource should be closed
222
+ expect(mockEventSourceInstances[0].close).toHaveBeenCalled();
223
+ });
224
+ });
225
+ });
226
+
227
+ describe('use-event-stream (no EventSource)', () => {
228
+ beforeEach(() => {
229
+ // Remove EventSource from global scope
230
+ vi.stubGlobal('EventSource', undefined);
231
+ });
232
+
233
+ afterEach(() => {
234
+ vi.restoreAllMocks();
235
+ });
236
+
237
+ it('sets error when EventSource is not supported', () => {
238
+ const { result } = renderHook(() => useEventStream());
239
+
240
+ expect(result.current.error).toBe('EventSource not supported');
241
+ expect(result.current.connected).toBe(false);
242
+ });
243
+ });