@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,512 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // Mock dependencies
4
+ vi.mock('../watcher', () => {
5
+ const { EventEmitter } = require('events');
6
+ return {
7
+ initWatcher: vi.fn(),
8
+ watcherEvents: new EventEmitter(),
9
+ };
10
+ });
11
+
12
+ vi.mock('../run-cache', () => ({
13
+ discoverAndCacheAll: vi.fn(),
14
+ }));
15
+
16
+ import { initWatcher, watcherEvents } from '../watcher';
17
+ import { discoverAndCacheAll } from '../run-cache';
18
+ import {
19
+ ensureInitialized,
20
+ shutdownServer,
21
+ getInitStatus,
22
+ serverEvents,
23
+ resetDebounceState,
24
+ enqueueRunChanged,
25
+ SSE_DEBOUNCE_MS,
26
+ type BatchedRunChangedEvent,
27
+ } from '../server-init';
28
+
29
+ const mockInitWatcher = vi.mocked(initWatcher);
30
+ const mockDiscoverAndCacheAll = vi.mocked(discoverAndCacheAll);
31
+
32
+ describe('server-init', () => {
33
+ beforeEach(async () => {
34
+ vi.useFakeTimers();
35
+ vi.resetAllMocks();
36
+ // Reset server state between tests
37
+ await shutdownServer();
38
+ resetDebounceState();
39
+ watcherEvents.removeAllListeners();
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.useRealTimers();
44
+ });
45
+
46
+ // -----------------------------------------------------------------------
47
+ // ensureInitialized
48
+ // -----------------------------------------------------------------------
49
+ describe('ensureInitialized', () => {
50
+ it('initializes watcher and populates cache on first call', async () => {
51
+ const cleanupMock = vi.fn();
52
+ mockInitWatcher.mockResolvedValue(cleanupMock);
53
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
54
+
55
+ await ensureInitialized();
56
+
57
+ expect(mockInitWatcher).toHaveBeenCalledTimes(1);
58
+ expect(mockDiscoverAndCacheAll).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('returns immediately on subsequent calls', async () => {
62
+ const cleanupMock = vi.fn();
63
+ mockInitWatcher.mockResolvedValue(cleanupMock);
64
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
65
+
66
+ await ensureInitialized();
67
+ await ensureInitialized();
68
+ await ensureInitialized();
69
+
70
+ // Should only initialize once
71
+ expect(mockInitWatcher).toHaveBeenCalledTimes(1);
72
+ expect(mockDiscoverAndCacheAll).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it('deduplicates concurrent initialization calls', async () => {
76
+ const cleanupMock = vi.fn();
77
+ mockInitWatcher.mockResolvedValue(cleanupMock);
78
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
79
+
80
+ // Call concurrently
81
+ const [_r1, _r2, _r3] = await Promise.all([
82
+ ensureInitialized(),
83
+ ensureInitialized(),
84
+ ensureInitialized(),
85
+ ]);
86
+
87
+ expect(mockInitWatcher).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it('throws and resets state if initialization fails', async () => {
91
+ mockInitWatcher.mockRejectedValue(new Error('init failed'));
92
+
93
+ await expect(ensureInitialized()).rejects.toThrow('init failed');
94
+
95
+ // After failure, should be able to retry
96
+ const cleanupMock = vi.fn();
97
+ mockInitWatcher.mockResolvedValue(cleanupMock);
98
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
99
+
100
+ await ensureInitialized();
101
+
102
+ expect(mockInitWatcher).toHaveBeenCalledTimes(2);
103
+ });
104
+ });
105
+
106
+ // -----------------------------------------------------------------------
107
+ // Event forwarding
108
+ // -----------------------------------------------------------------------
109
+ describe('event forwarding', () => {
110
+ it('forwards run-changed events from watcher to server events as batched event', async () => {
111
+ const cleanupMock = vi.fn();
112
+ mockInitWatcher.mockResolvedValue(cleanupMock);
113
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
114
+
115
+ await ensureInitialized();
116
+
117
+ const handler = vi.fn();
118
+ serverEvents.on('run-changed', handler);
119
+
120
+ watcherEvents.emit('change', { type: 'run-changed', runDir: '/runs/r1' });
121
+
122
+ // Leading-edge debounce fires immediately with batched format
123
+ expect(handler).toHaveBeenCalledWith({
124
+ type: 'run-changed',
125
+ runIds: ['r1'],
126
+ runDirs: ['/runs/r1'],
127
+ });
128
+ });
129
+
130
+ it('does not globally invalidate breakpoint cache on run-changed (v0.12.3 fix)', async () => {
131
+ // Previously, enqueueRunChanged() called forceRefreshBreakpointRuns()
132
+ // on every watcher event, which deleted ALL breakpoint cache entries
133
+ // and caused banner flickering. Now only the specific run is invalidated
134
+ // by the watcher handler (invalidateRun), not all breakpoint entries.
135
+ const cleanupMock = vi.fn();
136
+ mockInitWatcher.mockResolvedValue(cleanupMock);
137
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
138
+
139
+ await ensureInitialized();
140
+
141
+ const handler = vi.fn();
142
+ serverEvents.on('run-changed', handler);
143
+
144
+ watcherEvents.emit('change', { type: 'run-changed', runDir: '/runs/r1' });
145
+
146
+ // The event should still be forwarded
147
+ expect(handler).toHaveBeenCalledTimes(1);
148
+
149
+ serverEvents.off('run-changed', handler);
150
+ });
151
+
152
+ it('forwards new-run events from watcher to server events', async () => {
153
+ const cleanupMock = vi.fn();
154
+ mockInitWatcher.mockResolvedValue(cleanupMock);
155
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
156
+
157
+ await ensureInitialized();
158
+
159
+ const handler = vi.fn();
160
+ serverEvents.on('new-run', handler);
161
+
162
+ watcherEvents.emit('change', { type: 'new-run', runDir: '/runs' });
163
+
164
+ expect(handler).toHaveBeenCalledWith({ type: 'new-run', runDir: '/runs' });
165
+ });
166
+
167
+ it('forwards error events from watcher as watcher-error with dedup', async () => {
168
+ const cleanupMock = vi.fn();
169
+ mockInitWatcher.mockResolvedValue(cleanupMock);
170
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
171
+
172
+ await ensureInitialized();
173
+
174
+ const handler = vi.fn();
175
+ serverEvents.on('watcher-error', handler);
176
+
177
+ const errorEvent = { type: 'error', runDir: '/runs', error: new Error('watch error') };
178
+ watcherEvents.emit('change', errorEvent);
179
+
180
+ expect(handler).toHaveBeenCalledWith(errorEvent);
181
+ });
182
+
183
+ it('suppresses duplicate watcher errors within 5s dedup window', async () => {
184
+ const cleanupMock = vi.fn();
185
+ mockInitWatcher.mockResolvedValue(cleanupMock);
186
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
187
+
188
+ await ensureInitialized();
189
+
190
+ const handler = vi.fn();
191
+ serverEvents.on('watcher-error', handler);
192
+
193
+ const errorEvent1 = { type: 'error', runDir: '/runs', error: new Error('watch error 1') };
194
+ const errorEvent2 = { type: 'error', runDir: '/runs', error: new Error('watch error 2') };
195
+
196
+ // First error goes through
197
+ const now = Date.now();
198
+ vi.spyOn(Date, 'now').mockReturnValue(now);
199
+ watcherEvents.emit('change', errorEvent1);
200
+ expect(handler).toHaveBeenCalledTimes(1);
201
+
202
+ // Second error within 5s dedup window is suppressed
203
+ vi.spyOn(Date, 'now').mockReturnValue(now + 3000);
204
+ watcherEvents.emit('change', errorEvent2);
205
+ expect(handler).toHaveBeenCalledTimes(1); // still 1
206
+
207
+ // Third error after 5s dedup window goes through
208
+ vi.spyOn(Date, 'now').mockReturnValue(now + 6000);
209
+ watcherEvents.emit('change', errorEvent2);
210
+ expect(handler).toHaveBeenCalledTimes(2);
211
+ });
212
+ });
213
+
214
+ // -----------------------------------------------------------------------
215
+ // Leading-edge debounce for SSE broadcasts
216
+ // -----------------------------------------------------------------------
217
+ describe('SSE broadcast debounce (enqueueRunChanged)', () => {
218
+ beforeEach(() => {
219
+ resetDebounceState();
220
+ });
221
+
222
+ it('fires immediately on the first event (leading edge)', () => {
223
+ const handler = vi.fn();
224
+ serverEvents.on('run-changed', handler);
225
+
226
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
227
+
228
+ expect(handler).toHaveBeenCalledTimes(1);
229
+ const event: BatchedRunChangedEvent = handler.mock.calls[0][0];
230
+ expect(event.type).toBe('run-changed');
231
+ expect(event.runIds).toEqual(['r1']);
232
+ expect(event.runDirs).toEqual(['/runs/r1']);
233
+
234
+ serverEvents.off('run-changed', handler);
235
+ });
236
+
237
+ it('collects subsequent events within the 500ms window and emits a single batch', () => {
238
+ const handler = vi.fn();
239
+ serverEvents.on('run-changed', handler);
240
+
241
+ // First event — fires immediately (leading edge)
242
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
243
+ expect(handler).toHaveBeenCalledTimes(1);
244
+
245
+ // Subsequent events within window — should NOT fire immediately
246
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
247
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r3' });
248
+ expect(handler).toHaveBeenCalledTimes(1); // still just the leading edge
249
+
250
+ // Advance past the debounce window
251
+ vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
252
+
253
+ // Now the batch should have flushed
254
+ expect(handler).toHaveBeenCalledTimes(2);
255
+ const batchEvent: BatchedRunChangedEvent = handler.mock.calls[1][0];
256
+ expect(batchEvent.type).toBe('run-changed');
257
+ expect(batchEvent.runIds).toEqual(expect.arrayContaining(['r2', 'r3']));
258
+ expect(batchEvent.runIds).toHaveLength(2);
259
+ expect(batchEvent.runDirs).toEqual(expect.arrayContaining(['/runs/r2', '/runs/r3']));
260
+
261
+ serverEvents.off('run-changed', handler);
262
+ });
263
+
264
+ it('deduplicates the same runDir within the window', () => {
265
+ const handler = vi.fn();
266
+ serverEvents.on('run-changed', handler);
267
+
268
+ // First event — leading edge
269
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
270
+ expect(handler).toHaveBeenCalledTimes(1);
271
+
272
+ // Same runDir multiple times within window
273
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
274
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
275
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
276
+
277
+ vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
278
+
279
+ expect(handler).toHaveBeenCalledTimes(2);
280
+ const batchEvent: BatchedRunChangedEvent = handler.mock.calls[1][0];
281
+ // Set deduplicates: only one r2
282
+ expect(batchEvent.runIds).toEqual(['r2']);
283
+
284
+ serverEvents.off('run-changed', handler);
285
+ });
286
+
287
+ it('does not emit a trailing batch when there are no pending events', () => {
288
+ const handler = vi.fn();
289
+ serverEvents.on('run-changed', handler);
290
+
291
+ // Single event — leading edge only
292
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
293
+ expect(handler).toHaveBeenCalledTimes(1);
294
+
295
+ // Advance past window — no pending events, so no trailing emit
296
+ vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
297
+ expect(handler).toHaveBeenCalledTimes(1); // no additional call
298
+
299
+ serverEvents.off('run-changed', handler);
300
+ });
301
+
302
+ it('resets the debounce window after flush, allowing new leading edge', () => {
303
+ const handler = vi.fn();
304
+ serverEvents.on('run-changed', handler);
305
+
306
+ // First burst
307
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
308
+ expect(handler).toHaveBeenCalledTimes(1); // leading edge
309
+
310
+ // Flush the window
311
+ vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
312
+
313
+ // Second burst — should fire as a new leading edge
314
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
315
+ expect(handler).toHaveBeenCalledTimes(2); // new leading edge
316
+
317
+ const event: BatchedRunChangedEvent = handler.mock.calls[1][0];
318
+ expect(event.runIds).toEqual(['r2']);
319
+
320
+ // Flush
321
+ vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
322
+ expect(handler).toHaveBeenCalledTimes(2); // no trailing (no pending)
323
+
324
+ serverEvents.off('run-changed', handler);
325
+ });
326
+
327
+ it('extends the window when new events arrive (timer reset)', () => {
328
+ const handler = vi.fn();
329
+ serverEvents.on('run-changed', handler);
330
+
331
+ // Leading edge
332
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
333
+ expect(handler).toHaveBeenCalledTimes(1);
334
+
335
+ // At 300ms, new event arrives — resets the 500ms timer
336
+ vi.advanceTimersByTime(300);
337
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
338
+
339
+ // At 600ms (300ms after last event) — should NOT have flushed yet
340
+ vi.advanceTimersByTime(300);
341
+ expect(handler).toHaveBeenCalledTimes(1); // still just leading edge
342
+
343
+ // At 800ms (500ms after last event at 300ms) — should flush
344
+ vi.advanceTimersByTime(200);
345
+ expect(handler).toHaveBeenCalledTimes(2);
346
+ const batchEvent: BatchedRunChangedEvent = handler.mock.calls[1][0];
347
+ expect(batchEvent.runIds).toEqual(['r2']);
348
+
349
+ serverEvents.off('run-changed', handler);
350
+ });
351
+
352
+ it('shutdownServer clears pending debounce state', async () => {
353
+ const handler = vi.fn();
354
+ serverEvents.on('run-changed', handler);
355
+
356
+ // Leading edge
357
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
358
+ expect(handler).toHaveBeenCalledTimes(1);
359
+
360
+ // Queue up events
361
+ enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
362
+
363
+ // Shutdown clears everything including debounce timers and listeners
364
+ await shutdownServer();
365
+
366
+ // Re-listen after shutdown
367
+ const handler2 = vi.fn();
368
+ serverEvents.on('run-changed', handler2);
369
+
370
+ // Advance past window — pending batch should have been cleared
371
+ vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
372
+ expect(handler2).not.toHaveBeenCalled();
373
+
374
+ serverEvents.off('run-changed', handler2);
375
+ });
376
+
377
+ it('integrates with watcher events end-to-end', async () => {
378
+ const cleanupMock = vi.fn();
379
+ mockInitWatcher.mockResolvedValue(cleanupMock);
380
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
381
+
382
+ await ensureInitialized();
383
+
384
+ const handler = vi.fn();
385
+ serverEvents.on('run-changed', handler);
386
+
387
+ // Rapid watcher events
388
+ watcherEvents.emit('change', { type: 'run-changed', runDir: '/project/runs/abc123' });
389
+ watcherEvents.emit('change', { type: 'run-changed', runDir: '/project/runs/def456' });
390
+ watcherEvents.emit('change', { type: 'run-changed', runDir: '/project/runs/abc123' }); // duplicate
391
+
392
+ // Leading edge should have fired immediately with first event
393
+ expect(handler).toHaveBeenCalledTimes(1);
394
+ expect(handler.mock.calls[0][0].runIds).toEqual(['abc123']);
395
+
396
+ // Flush the window
397
+ vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
398
+
399
+ // Batch should contain the 2 unique subsequent runDirs
400
+ expect(handler).toHaveBeenCalledTimes(2);
401
+ const batch: BatchedRunChangedEvent = handler.mock.calls[1][0];
402
+ expect(batch.runIds).toEqual(expect.arrayContaining(['def456', 'abc123']));
403
+
404
+ serverEvents.off('run-changed', handler);
405
+ });
406
+ });
407
+
408
+ // -----------------------------------------------------------------------
409
+ // shutdownServer
410
+ // -----------------------------------------------------------------------
411
+ describe('shutdownServer', () => {
412
+ it('calls the cleanup function from watcher', async () => {
413
+ const cleanupMock = vi.fn();
414
+ mockInitWatcher.mockResolvedValue(cleanupMock);
415
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
416
+
417
+ await ensureInitialized();
418
+ await shutdownServer();
419
+
420
+ expect(cleanupMock).toHaveBeenCalledTimes(1);
421
+ });
422
+
423
+ it('removes all server event listeners', async () => {
424
+ const cleanupMock = vi.fn();
425
+ mockInitWatcher.mockResolvedValue(cleanupMock);
426
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
427
+
428
+ await ensureInitialized();
429
+
430
+ serverEvents.on('run-changed', () => {});
431
+ expect(serverEvents.listenerCount('run-changed')).toBeGreaterThan(0);
432
+
433
+ await shutdownServer();
434
+
435
+ expect(serverEvents.listenerCount('run-changed')).toBe(0);
436
+ });
437
+
438
+ it('allows re-initialization after shutdown', async () => {
439
+ const cleanupMock = vi.fn();
440
+ mockInitWatcher.mockResolvedValue(cleanupMock);
441
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
442
+
443
+ await ensureInitialized();
444
+ await shutdownServer();
445
+
446
+ // Re-initialize
447
+ mockInitWatcher.mockResolvedValue(vi.fn());
448
+ await ensureInitialized();
449
+
450
+ expect(mockInitWatcher).toHaveBeenCalledTimes(2);
451
+ });
452
+
453
+ it('is safe to call even when not initialized', async () => {
454
+ await expect(shutdownServer()).resolves.not.toThrow();
455
+ });
456
+ });
457
+
458
+ // -----------------------------------------------------------------------
459
+ // getInitStatus
460
+ // -----------------------------------------------------------------------
461
+ describe('getInitStatus', () => {
462
+ it('returns not initialized before init', () => {
463
+ const status = getInitStatus();
464
+
465
+ expect(status.initialized).toBe(false);
466
+ expect(status.hasCleanup).toBe(false);
467
+ });
468
+
469
+ it('returns initialized after successful init', async () => {
470
+ const cleanupMock = vi.fn();
471
+ mockInitWatcher.mockResolvedValue(cleanupMock);
472
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
473
+
474
+ await ensureInitialized();
475
+
476
+ const status = getInitStatus();
477
+
478
+ expect(status.initialized).toBe(true);
479
+ expect(status.hasCleanup).toBe(true);
480
+ });
481
+
482
+ it('returns not initialized after shutdown', async () => {
483
+ const cleanupMock = vi.fn();
484
+ mockInitWatcher.mockResolvedValue(cleanupMock);
485
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
486
+
487
+ await ensureInitialized();
488
+ await shutdownServer();
489
+
490
+ const status = getInitStatus();
491
+
492
+ expect(status.initialized).toBe(false);
493
+ expect(status.hasCleanup).toBe(false);
494
+ });
495
+
496
+ it('reports server event listener count', async () => {
497
+ const cleanupMock = vi.fn();
498
+ mockInitWatcher.mockResolvedValue(cleanupMock);
499
+ mockDiscoverAndCacheAll.mockResolvedValue(undefined);
500
+
501
+ await ensureInitialized();
502
+
503
+ // The init itself registers a listener on watcherEvents, not serverEvents
504
+ // Let's add a listener and check
505
+ serverEvents.on('run-changed', () => {});
506
+
507
+ const status = getInitStatus();
508
+
509
+ expect(status.serverEventListeners).toBeGreaterThan(0);
510
+ });
511
+ });
512
+ });