@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,274 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { usePolling } from '../use-polling';
3
+
4
+ /**
5
+ * Create a Response-like object that resilientFetch can consume.
6
+ * Uses real Response constructor for correct headers/ok/status behavior,
7
+ * but wraps json() to resolve immediately (avoids fake-timer issues with
8
+ * the real ReadableStream-based body parsing).
9
+ */
10
+ function makeResponse(body: string, init: ResponseInit): Response {
11
+ const res = new Response(body, init);
12
+ // Override json() so it resolves on the next microtask tick rather than
13
+ // going through the ReadableStream path that can stall under fake timers.
14
+ const parsed = JSON.parse(body);
15
+ Object.defineProperty(res, 'json', {
16
+ value: () => Promise.resolve(parsed),
17
+ });
18
+ return res;
19
+ }
20
+
21
+ function mockFetchSuccess(data: unknown) {
22
+ return vi.fn().mockImplementation(() =>
23
+ Promise.resolve(
24
+ makeResponse(JSON.stringify(data), {
25
+ status: 200,
26
+ headers: { 'Content-Type': 'application/json' },
27
+ })
28
+ )
29
+ );
30
+ }
31
+
32
+ function mockFetchFailure(status: number) {
33
+ return vi.fn().mockImplementation(() =>
34
+ Promise.resolve(
35
+ new Response(`HTTP ${status}`, {
36
+ status,
37
+ headers: { 'Content-Type': 'text/plain' },
38
+ })
39
+ )
40
+ );
41
+ }
42
+
43
+ describe('usePolling', () => {
44
+ beforeEach(() => {
45
+ vi.useFakeTimers();
46
+ vi.stubGlobal('fetch', mockFetchSuccess({ items: [1, 2, 3] }));
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.useRealTimers();
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ it('fetches data immediately on mount', async () => {
55
+ const { result } = renderHook(() =>
56
+ usePolling<{ items: number[] }>('/api/data')
57
+ );
58
+
59
+ expect(result.current.loading).toBe(true);
60
+
61
+ await act(async () => {
62
+ await vi.advanceTimersByTimeAsync(0);
63
+ });
64
+
65
+ expect(result.current.data).toEqual({ items: [1, 2, 3] });
66
+ expect(result.current.loading).toBe(false);
67
+ expect(result.current.error).toBeNull();
68
+ expect(fetch).toHaveBeenCalledWith('/api/data', expect.anything());
69
+ });
70
+
71
+ it('polls at the specified interval', async () => {
72
+ renderHook(() =>
73
+ usePolling('/api/data', { interval: 5000 })
74
+ );
75
+
76
+ // Initial fetch
77
+ await act(async () => {
78
+ await vi.advanceTimersByTimeAsync(0);
79
+ });
80
+ expect(fetch).toHaveBeenCalledTimes(1);
81
+
82
+ // After one interval
83
+ await act(async () => {
84
+ await vi.advanceTimersByTimeAsync(5000);
85
+ });
86
+ expect(fetch).toHaveBeenCalledTimes(2);
87
+
88
+ // After another interval
89
+ await act(async () => {
90
+ await vi.advanceTimersByTimeAsync(5000);
91
+ });
92
+ expect(fetch).toHaveBeenCalledTimes(3);
93
+ });
94
+
95
+ it('uses default interval of 2000ms', async () => {
96
+ renderHook(() => usePolling('/api/data'));
97
+
98
+ await act(async () => {
99
+ await vi.advanceTimersByTimeAsync(0);
100
+ });
101
+ expect(fetch).toHaveBeenCalledTimes(1);
102
+
103
+ await act(async () => {
104
+ await vi.advanceTimersByTimeAsync(2000);
105
+ });
106
+ expect(fetch).toHaveBeenCalledTimes(2);
107
+ });
108
+
109
+ it('does not poll when enabled is false', async () => {
110
+ const { result } = renderHook(() =>
111
+ usePolling('/api/data', { enabled: false })
112
+ );
113
+
114
+ await act(async () => {
115
+ await vi.advanceTimersByTimeAsync(10000);
116
+ });
117
+
118
+ expect(fetch).not.toHaveBeenCalled();
119
+ expect(result.current.loading).toBe(false);
120
+ });
121
+
122
+ it('does not fetch when url is empty', async () => {
123
+ const { result } = renderHook(() => usePolling(''));
124
+
125
+ await act(async () => {
126
+ await vi.advanceTimersByTimeAsync(10000);
127
+ });
128
+
129
+ expect(fetch).not.toHaveBeenCalled();
130
+ expect(result.current.loading).toBe(false);
131
+ });
132
+
133
+ it('handles fetch error', async () => {
134
+ // Use 422 (non-retryable 4xx) to avoid retries — resilientFetch retries 5xx and 404
135
+ vi.stubGlobal('fetch', mockFetchFailure(422));
136
+
137
+ const { result } = renderHook(() => usePolling('/api/data'));
138
+
139
+ await act(async () => {
140
+ await vi.advanceTimersByTimeAsync(0);
141
+ });
142
+
143
+ expect(result.current.error).toBe('HTTP 422');
144
+ expect(result.current.loading).toBe(false);
145
+ expect(result.current.data).toBeNull();
146
+ });
147
+
148
+ it('handles network error', async () => {
149
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Network error')));
150
+
151
+ // Use a long interval so the poll timer doesn't abort in-progress retries
152
+ // resilientFetch retries network errors: attempt 0 + sleep(1s) + attempt 1 + sleep(2s) + attempt 2 = ~3s
153
+ const { result } = renderHook(() => usePolling('/api/data', { interval: 30000 }));
154
+
155
+ // Advance enough time for all retries to complete
156
+ await act(async () => {
157
+ await vi.advanceTimersByTimeAsync(4000);
158
+ });
159
+
160
+ expect(result.current.error).toBe('Network error');
161
+ expect(result.current.loading).toBe(false);
162
+ });
163
+
164
+ it('clears error on successful fetch after error', async () => {
165
+ // Use a 422 error (non-retryable 4xx) so the first call fails immediately,
166
+ // then subsequent calls succeed
167
+ const fetchMock = vi.fn()
168
+ .mockResolvedValueOnce(
169
+ new Response('Bad request', {
170
+ status: 422,
171
+ headers: { 'Content-Type': 'text/plain' },
172
+ })
173
+ )
174
+ .mockImplementation(() =>
175
+ Promise.resolve(
176
+ makeResponse(JSON.stringify({ ok: true }), {
177
+ status: 200,
178
+ headers: { 'Content-Type': 'application/json' },
179
+ })
180
+ )
181
+ );
182
+ vi.stubGlobal('fetch', fetchMock);
183
+
184
+ const { result } = renderHook(() =>
185
+ usePolling('/api/data', { interval: 1000 })
186
+ );
187
+
188
+ // First fetch fails (422, no retry)
189
+ await act(async () => {
190
+ await vi.advanceTimersByTimeAsync(0);
191
+ });
192
+ expect(result.current.error).toBe('Bad request');
193
+
194
+ // Second fetch succeeds on next poll
195
+ await act(async () => {
196
+ await vi.advanceTimersByTimeAsync(1000);
197
+ });
198
+ expect(result.current.error).toBeNull();
199
+ expect(result.current.data).toEqual({ ok: true });
200
+ });
201
+
202
+ it('provides a manual refresh function', async () => {
203
+ const { result } = renderHook(() => usePolling('/api/data'));
204
+
205
+ await act(async () => {
206
+ await vi.advanceTimersByTimeAsync(0);
207
+ });
208
+ expect(fetch).toHaveBeenCalledTimes(1);
209
+
210
+ await act(async () => {
211
+ result.current.refresh();
212
+ await vi.advanceTimersByTimeAsync(0);
213
+ });
214
+ expect(fetch).toHaveBeenCalledTimes(2);
215
+ });
216
+
217
+ it('cleans up interval on unmount', async () => {
218
+ const { unmount } = renderHook(() =>
219
+ usePolling('/api/data', { interval: 1000 })
220
+ );
221
+
222
+ await act(async () => {
223
+ await vi.advanceTimersByTimeAsync(0);
224
+ });
225
+ expect(fetch).toHaveBeenCalledTimes(1);
226
+
227
+ unmount();
228
+
229
+ await act(async () => {
230
+ await vi.advanceTimersByTimeAsync(5000);
231
+ });
232
+ // Should not have been called again after unmount (only the initial fetch)
233
+ expect(fetch).toHaveBeenCalledTimes(1);
234
+ });
235
+
236
+ it('restarts polling when url changes', async () => {
237
+ const { rerender } = renderHook(
238
+ ({ url }) => usePolling(url, { interval: 1000 }),
239
+ { initialProps: { url: '/api/data1' } }
240
+ );
241
+
242
+ await act(async () => {
243
+ await vi.advanceTimersByTimeAsync(0);
244
+ });
245
+ expect(fetch).toHaveBeenLastCalledWith('/api/data1', expect.anything());
246
+
247
+ rerender({ url: '/api/data2' });
248
+
249
+ await act(async () => {
250
+ await vi.advanceTimersByTimeAsync(0);
251
+ });
252
+ expect(fetch).toHaveBeenLastCalledWith('/api/data2', expect.anything());
253
+ });
254
+
255
+ it('restarts polling when enabled toggles', async () => {
256
+ const { rerender, result: _result } = renderHook(
257
+ ({ enabled }) => usePolling('/api/data', { enabled, interval: 1000 }),
258
+ { initialProps: { enabled: false } }
259
+ );
260
+
261
+ await act(async () => {
262
+ await vi.advanceTimersByTimeAsync(5000);
263
+ });
264
+ expect(fetch).not.toHaveBeenCalled();
265
+
266
+ // Enable polling
267
+ rerender({ enabled: true });
268
+
269
+ await act(async () => {
270
+ await vi.advanceTimersByTimeAsync(0);
271
+ });
272
+ expect(fetch).toHaveBeenCalledTimes(1);
273
+ });
274
+ });
@@ -0,0 +1,163 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { createMockRun } from '@/test/fixtures';
3
+ import { useProjectRuns } from '../use-project-runs';
4
+
5
+ type MockEventSourceInstance = {
6
+ onopen: ((event: Event) => void) | null;
7
+ onmessage: ((event: MessageEvent) => void) | null;
8
+ onerror: ((event: Event) => void) | null;
9
+ close: ReturnType<typeof vi.fn>;
10
+ readyState: number;
11
+ url: string;
12
+ };
13
+
14
+ let mockEventSourceInstances: MockEventSourceInstance[] = [];
15
+
16
+ class MockEventSource {
17
+ static CONNECTING = 0;
18
+ static OPEN = 1;
19
+ static CLOSED = 2;
20
+
21
+ onopen: ((event: Event) => void) | null = null;
22
+ onmessage: ((event: MessageEvent) => void) | null = null;
23
+ onerror: ((event: Event) => void) | null = null;
24
+ close = vi.fn();
25
+ readyState = MockEventSource.OPEN;
26
+ url: string;
27
+
28
+ constructor(url: string) {
29
+ this.url = url;
30
+ mockEventSourceInstances.push(this);
31
+ }
32
+ }
33
+
34
+ const mockRuns = [
35
+ createMockRun({ runId: 'run-1', projectName: 'my-project' }),
36
+ createMockRun({ runId: 'run-2', projectName: 'my-project' }),
37
+ ];
38
+
39
+ describe('useProjectRuns', () => {
40
+ beforeEach(() => {
41
+ vi.useFakeTimers();
42
+ mockEventSourceInstances = [];
43
+ vi.stubGlobal('EventSource', MockEventSource);
44
+ vi.stubGlobal(
45
+ 'fetch',
46
+ vi.fn().mockImplementation(() =>
47
+ Promise.resolve(
48
+ new Response(
49
+ JSON.stringify({
50
+ runs: mockRuns,
51
+ totalCount: 2,
52
+ project: 'my-project',
53
+ }),
54
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
55
+ )
56
+ )
57
+ )
58
+ );
59
+ });
60
+
61
+ afterEach(() => {
62
+ vi.useRealTimers();
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ it('fetches project runs and returns data', async () => {
67
+ const { result } = renderHook(() => useProjectRuns('my-project'));
68
+
69
+ expect(result.current.loading).toBe(true);
70
+
71
+ await act(async () => {
72
+ await vi.advanceTimersByTimeAsync(0);
73
+ });
74
+
75
+ expect(result.current.runs).toHaveLength(2);
76
+ expect(result.current.totalCount).toBe(2);
77
+ expect(result.current.loading).toBe(false);
78
+ expect(result.current.error).toBeNull();
79
+ });
80
+
81
+ it('constructs URL with correct query params', async () => {
82
+ renderHook(() =>
83
+ useProjectRuns('my-project', {
84
+ limit: 20,
85
+ offset: 10,
86
+ search: 'test',
87
+ status: 'completed',
88
+ })
89
+ );
90
+
91
+ await act(async () => {
92
+ await vi.advanceTimersByTimeAsync(0);
93
+ });
94
+
95
+ const calledUrl = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
96
+ expect(calledUrl).toContain('project=my-project');
97
+ expect(calledUrl).toContain('limit=20');
98
+ expect(calledUrl).toContain('offset=10');
99
+ expect(calledUrl).toContain('search=test');
100
+ expect(calledUrl).toContain('status=completed');
101
+ });
102
+
103
+ it('uses default limit and offset', async () => {
104
+ renderHook(() => useProjectRuns('my-project'));
105
+
106
+ await act(async () => {
107
+ await vi.advanceTimersByTimeAsync(0);
108
+ });
109
+
110
+ const calledUrl = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
111
+ expect(calledUrl).toContain('limit=10');
112
+ expect(calledUrl).toContain('offset=0');
113
+ });
114
+
115
+ it('returns empty runs when enabled is false', async () => {
116
+ const { result } = renderHook(() =>
117
+ useProjectRuns('my-project', { enabled: false })
118
+ );
119
+
120
+ await act(async () => {
121
+ await vi.advanceTimersByTimeAsync(0);
122
+ });
123
+
124
+ expect(result.current.runs).toEqual([]);
125
+ expect(result.current.totalCount).toBe(0);
126
+ });
127
+
128
+ it('handles fetch error', async () => {
129
+ // Use a 422 error to avoid retries from resilientFetch (404 and 5xx are retryable)
130
+ vi.stubGlobal(
131
+ 'fetch',
132
+ vi.fn().mockImplementation(() =>
133
+ Promise.resolve(
134
+ new Response('HTTP 422', { status: 422 })
135
+ )
136
+ )
137
+ );
138
+
139
+ const { result } = renderHook(() => useProjectRuns('my-project'));
140
+
141
+ await act(async () => {
142
+ await vi.advanceTimersByTimeAsync(0);
143
+ });
144
+
145
+ expect(result.current.error).toBe('HTTP 422');
146
+ expect(result.current.runs).toEqual([]);
147
+ });
148
+
149
+ it('provides a refresh function', async () => {
150
+ const { result } = renderHook(() => useProjectRuns('my-project'));
151
+
152
+ await act(async () => {
153
+ await vi.advanceTimersByTimeAsync(0);
154
+ });
155
+ expect(fetch).toHaveBeenCalledTimes(1);
156
+
157
+ await act(async () => {
158
+ result.current.refresh();
159
+ await vi.advanceTimersByTimeAsync(0);
160
+ });
161
+ expect(fetch).toHaveBeenCalledTimes(2);
162
+ });
163
+ });
@@ -0,0 +1,248 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { createMockProjectSummary } from '@/test/fixtures';
3
+ import { useProjects } from '../use-projects';
4
+
5
+ type MockEventSourceInstance = {
6
+ onopen: ((event: Event) => void) | null;
7
+ onmessage: ((event: MessageEvent) => void) | null;
8
+ onerror: ((event: Event) => void) | null;
9
+ close: ReturnType<typeof vi.fn>;
10
+ readyState: number;
11
+ url: string;
12
+ };
13
+
14
+ let mockEventSourceInstances: MockEventSourceInstance[] = [];
15
+
16
+ class MockEventSource {
17
+ static CONNECTING = 0;
18
+ static OPEN = 1;
19
+ static CLOSED = 2;
20
+
21
+ onopen: ((event: Event) => void) | null = null;
22
+ onmessage: ((event: MessageEvent) => void) | null = null;
23
+ onerror: ((event: Event) => void) | null = null;
24
+ close = vi.fn();
25
+ readyState = MockEventSource.OPEN;
26
+ url: string;
27
+
28
+ constructor(url: string) {
29
+ this.url = url;
30
+ mockEventSourceInstances.push(this);
31
+ }
32
+ }
33
+
34
+ const mockProjects = [
35
+ createMockProjectSummary({ projectName: 'project-a', totalRuns: 5 }),
36
+ createMockProjectSummary({ projectName: 'project-b', totalRuns: 3 }),
37
+ ];
38
+
39
+ describe('useProjects', () => {
40
+ beforeEach(() => {
41
+ vi.useFakeTimers();
42
+ mockEventSourceInstances = [];
43
+ vi.stubGlobal('EventSource', MockEventSource);
44
+ vi.stubGlobal(
45
+ 'fetch',
46
+ vi.fn().mockResolvedValue(
47
+ new Response(JSON.stringify({ projects: mockProjects }), {
48
+ status: 200,
49
+ headers: { 'Content-Type': 'application/json' },
50
+ })
51
+ )
52
+ );
53
+ });
54
+
55
+ afterEach(() => {
56
+ vi.useRealTimers();
57
+ vi.restoreAllMocks();
58
+ });
59
+
60
+ it('fetches project list and returns data', async () => {
61
+ const { result } = renderHook(() => useProjects());
62
+
63
+ expect(result.current.loading).toBe(true);
64
+
65
+ await act(async () => {
66
+ await vi.advanceTimersByTimeAsync(0);
67
+ });
68
+
69
+ expect(result.current.projects).toHaveLength(2);
70
+ expect(result.current.projects[0].projectName).toBe('project-a');
71
+ expect(result.current.projects[1].projectName).toBe('project-b');
72
+ expect(result.current.loading).toBe(false);
73
+ expect(result.current.error).toBeNull();
74
+ });
75
+
76
+ it('calls /api/runs?mode=projects endpoint', async () => {
77
+ renderHook(() => useProjects());
78
+
79
+ await act(async () => {
80
+ await vi.advanceTimersByTimeAsync(0);
81
+ });
82
+
83
+ expect(fetch).toHaveBeenCalledWith('/api/runs?mode=projects', expect.anything());
84
+ });
85
+
86
+ it('uses custom interval', async () => {
87
+ renderHook(() => useProjects(10000));
88
+
89
+ await act(async () => {
90
+ await vi.advanceTimersByTimeAsync(0);
91
+ });
92
+ expect(fetch).toHaveBeenCalledTimes(1);
93
+
94
+ await act(async () => {
95
+ await vi.advanceTimersByTimeAsync(10000);
96
+ });
97
+ expect(fetch).toHaveBeenCalledTimes(2);
98
+ });
99
+
100
+ it('returns empty projects array when data is null', async () => {
101
+ vi.stubGlobal(
102
+ 'fetch',
103
+ vi.fn().mockResolvedValue(
104
+ new Response(JSON.stringify(null), {
105
+ status: 200,
106
+ headers: { 'Content-Type': 'application/json' },
107
+ })
108
+ )
109
+ );
110
+
111
+ const { result } = renderHook(() => useProjects());
112
+
113
+ await act(async () => {
114
+ await vi.advanceTimersByTimeAsync(0);
115
+ });
116
+
117
+ expect(result.current.projects).toEqual([]);
118
+ });
119
+
120
+ it('handles fetch error', async () => {
121
+ // Use 422 (non-retryable 4xx) to avoid retries from resilientFetch
122
+ vi.stubGlobal(
123
+ 'fetch',
124
+ vi.fn().mockResolvedValue(
125
+ new Response('HTTP 422', { status: 422 })
126
+ )
127
+ );
128
+
129
+ const { result } = renderHook(() => useProjects());
130
+
131
+ await act(async () => {
132
+ await vi.advanceTimersByTimeAsync(0);
133
+ });
134
+
135
+ expect(result.current.error).toBe('HTTP 422');
136
+ expect(result.current.projects).toEqual([]);
137
+ });
138
+
139
+ it('does not refetch on disconnect or error SSE events', async () => {
140
+ renderHook(() => useProjects());
141
+
142
+ await act(async () => {
143
+ await vi.advanceTimersByTimeAsync(0);
144
+ });
145
+ const callsAfterInit = (fetch as ReturnType<typeof vi.fn>).mock.calls.length;
146
+
147
+ // Simulate disconnect event via EventSource
148
+ const instance = mockEventSourceInstances[0];
149
+ if (instance?.onmessage) {
150
+ act(() => {
151
+ instance.onmessage!(
152
+ new MessageEvent('message', {
153
+ data: JSON.stringify({ type: 'disconnect' }),
154
+ })
155
+ );
156
+ });
157
+ }
158
+
159
+ // Wait past debounce window
160
+ await act(async () => {
161
+ await vi.advanceTimersByTimeAsync(200);
162
+ });
163
+
164
+ // No additional fetch should have been triggered by disconnect
165
+ expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsAfterInit);
166
+
167
+ // Simulate error event
168
+ if (instance?.onmessage) {
169
+ act(() => {
170
+ instance.onmessage!(
171
+ new MessageEvent('message', {
172
+ data: JSON.stringify({ type: 'error', error: 'test' }),
173
+ })
174
+ );
175
+ });
176
+ }
177
+
178
+ await act(async () => {
179
+ await vi.advanceTimersByTimeAsync(200);
180
+ });
181
+
182
+ // Still no additional fetch
183
+ expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsAfterInit);
184
+ });
185
+
186
+ it('refetches on update and new-run SSE events', async () => {
187
+ renderHook(() => useProjects());
188
+
189
+ await act(async () => {
190
+ await vi.advanceTimersByTimeAsync(0);
191
+ });
192
+ const callsAfterInit = (fetch as ReturnType<typeof vi.fn>).mock.calls.length;
193
+
194
+ const instance = mockEventSourceInstances[0];
195
+ if (instance?.onmessage) {
196
+ // Simulate update event
197
+ act(() => {
198
+ instance.onmessage!(
199
+ new MessageEvent('message', {
200
+ data: JSON.stringify({ type: 'update', runId: 'run-1' }),
201
+ })
202
+ );
203
+ });
204
+ }
205
+
206
+ // Wait past debounce window (1500ms)
207
+ await act(async () => {
208
+ await vi.advanceTimersByTimeAsync(1500);
209
+ });
210
+
211
+ const callsAfterFirstEvent = (fetch as ReturnType<typeof vi.fn>).mock.calls.length;
212
+ // Should have at least one additional fetch after the SSE event
213
+ expect(callsAfterFirstEvent).toBeGreaterThan(callsAfterInit);
214
+
215
+ if (instance?.onmessage) {
216
+ // Simulate new-run event
217
+ act(() => {
218
+ instance.onmessage!(
219
+ new MessageEvent('message', {
220
+ data: JSON.stringify({ type: 'new-run', runId: 'run-2' }),
221
+ })
222
+ );
223
+ });
224
+ }
225
+
226
+ await act(async () => {
227
+ await vi.advanceTimersByTimeAsync(1500);
228
+ });
229
+
230
+ // Should have at least one more fetch after the second SSE event
231
+ expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(callsAfterFirstEvent);
232
+ });
233
+
234
+ it('provides a refresh function', async () => {
235
+ const { result } = renderHook(() => useProjects());
236
+
237
+ await act(async () => {
238
+ await vi.advanceTimersByTimeAsync(0);
239
+ });
240
+ expect(fetch).toHaveBeenCalledTimes(1);
241
+
242
+ await act(async () => {
243
+ result.current.refresh();
244
+ await vi.advanceTimersByTimeAsync(0);
245
+ });
246
+ expect(fetch).toHaveBeenCalledTimes(2);
247
+ });
248
+ });