@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,585 @@
1
+ import { render, screen, act } from '@/test/test-utils';
2
+ import { NotificationProvider, useNotificationContext, STABILIZATION_WINDOW_MS } from '../notification-provider';
3
+ import type { DigestResponse, RunDigest } from '@/types';
4
+ import React from 'react';
5
+
6
+ // Mock hooks used by NotificationProvider
7
+ const mockNotify = vi.fn();
8
+ const mockDismiss = vi.fn();
9
+ const mockRequestPermission = vi.fn();
10
+
11
+ vi.mock('@/hooks/use-notifications', () => ({
12
+ useNotifications: () => ({
13
+ notifications: [],
14
+ notify: mockNotify,
15
+ dismiss: mockDismiss,
16
+ requestPermission: mockRequestPermission,
17
+ permission: 'default' as NotificationPermission,
18
+ }),
19
+ }));
20
+
21
+ // Mutable digest data that the usePolling mock reads from.
22
+ // Tests update this variable and rerender to simulate new poll responses.
23
+ let mockDigestData: DigestResponse | null = null;
24
+
25
+ vi.mock('@/hooks/use-polling', () => ({
26
+ usePolling: () => ({
27
+ data: mockDigestData,
28
+ loading: false,
29
+ error: null,
30
+ refresh: vi.fn(),
31
+ }),
32
+ }));
33
+
34
+ // Mock ToastStack to avoid next/navigation dependency
35
+ vi.mock('../toast-stack', () => ({
36
+ ToastStack: ({ notifications, onDismiss: _onDismiss }: { notifications: unknown[]; onDismiss: (id: string) => void }) =>
37
+ React.createElement('div', { 'data-testid': 'toast-stack' }, `toasts: ${(notifications as unknown[]).length}`),
38
+ }));
39
+
40
+ describe('NotificationProvider', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ mockDigestData = null;
44
+ });
45
+
46
+ // -----------------------------------------------------------------------
47
+ // Renders children
48
+ // -----------------------------------------------------------------------
49
+ it('renders its children', () => {
50
+ render(
51
+ <NotificationProvider>
52
+ <div data-testid="child">Hello</div>
53
+ </NotificationProvider>,
54
+ );
55
+
56
+ expect(screen.getByTestId('child')).toBeInTheDocument();
57
+ expect(screen.getByText('Hello')).toBeInTheDocument();
58
+ });
59
+
60
+ // -----------------------------------------------------------------------
61
+ // Renders ToastStack
62
+ // -----------------------------------------------------------------------
63
+ it('renders the ToastStack component', () => {
64
+ render(
65
+ <NotificationProvider>
66
+ <span>child</span>
67
+ </NotificationProvider>,
68
+ );
69
+
70
+ expect(screen.getByTestId('toast-stack')).toBeInTheDocument();
71
+ });
72
+
73
+ // -----------------------------------------------------------------------
74
+ // Context provides notify function
75
+ // -----------------------------------------------------------------------
76
+ it('provides notify function through context', () => {
77
+ function Consumer() {
78
+ const { notify } = useNotificationContext();
79
+ return (
80
+ <button onClick={() => notify('Test', 'Body', 'info')}>
81
+ Notify
82
+ </button>
83
+ );
84
+ }
85
+
86
+ render(
87
+ <NotificationProvider>
88
+ <Consumer />
89
+ </NotificationProvider>,
90
+ );
91
+
92
+ screen.getByText('Notify').click();
93
+
94
+ expect(mockNotify).toHaveBeenCalledWith('Test', 'Body', 'info');
95
+ });
96
+
97
+ // -----------------------------------------------------------------------
98
+ // Context provides dismiss function
99
+ // -----------------------------------------------------------------------
100
+ it('provides dismiss function through context', () => {
101
+ function Consumer() {
102
+ const { dismiss } = useNotificationContext();
103
+ return (
104
+ <button onClick={() => dismiss('notif-1')}>
105
+ Dismiss
106
+ </button>
107
+ );
108
+ }
109
+
110
+ render(
111
+ <NotificationProvider>
112
+ <Consumer />
113
+ </NotificationProvider>,
114
+ );
115
+
116
+ screen.getByText('Dismiss').click();
117
+
118
+ expect(mockDismiss).toHaveBeenCalledWith('notif-1');
119
+ });
120
+
121
+ // -----------------------------------------------------------------------
122
+ // Context provides requestPermission
123
+ // -----------------------------------------------------------------------
124
+ it('provides requestPermission through context', () => {
125
+ function Consumer() {
126
+ const { requestPermission } = useNotificationContext();
127
+ return (
128
+ <button onClick={() => requestPermission()}>
129
+ Request
130
+ </button>
131
+ );
132
+ }
133
+
134
+ render(
135
+ <NotificationProvider>
136
+ <Consumer />
137
+ </NotificationProvider>,
138
+ );
139
+
140
+ screen.getByText('Request').click();
141
+
142
+ expect(mockRequestPermission).toHaveBeenCalled();
143
+ });
144
+
145
+ // -----------------------------------------------------------------------
146
+ // Context provides permission value
147
+ // -----------------------------------------------------------------------
148
+ it('provides permission value through context', () => {
149
+ function Consumer() {
150
+ const { permission } = useNotificationContext();
151
+ return <span data-testid="perm">{permission}</span>;
152
+ }
153
+
154
+ render(
155
+ <NotificationProvider>
156
+ <Consumer />
157
+ </NotificationProvider>,
158
+ );
159
+
160
+ expect(screen.getByTestId('perm').textContent).toBe('default');
161
+ });
162
+
163
+ // -----------------------------------------------------------------------
164
+ // Context provides notifications array
165
+ // -----------------------------------------------------------------------
166
+ it('provides notifications array through context', () => {
167
+ function Consumer() {
168
+ const { notifications } = useNotificationContext();
169
+ return <span data-testid="count">{notifications.length}</span>;
170
+ }
171
+
172
+ render(
173
+ <NotificationProvider>
174
+ <Consumer />
175
+ </NotificationProvider>,
176
+ );
177
+
178
+ expect(screen.getByTestId('count').textContent).toBe('0');
179
+ });
180
+
181
+ // -----------------------------------------------------------------------
182
+ // Default context values (used without provider)
183
+ // -----------------------------------------------------------------------
184
+ it('provides safe default context values when used without a provider', () => {
185
+ function Consumer() {
186
+ const ctx = useNotificationContext();
187
+ return (
188
+ <div>
189
+ <span data-testid="perm">{ctx.permission}</span>
190
+ <span data-testid="count">{ctx.notifications.length}</span>
191
+ <button onClick={() => ctx.notify('a', 'b')}>n</button>
192
+ <button onClick={() => ctx.dismiss('x')}>d</button>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ // Render without provider -- uses the default context
198
+ render(<Consumer />);
199
+
200
+ expect(screen.getByTestId('perm').textContent).toBe('default');
201
+ expect(screen.getByTestId('count').textContent).toBe('0');
202
+ // These should not throw
203
+ screen.getByText('n').click();
204
+ screen.getByText('d').click();
205
+ });
206
+
207
+ // =======================================================================
208
+ // Stabilization window tests
209
+ // =======================================================================
210
+ describe('stabilization window', () => {
211
+ /** Helper to create a RunDigest with sensible defaults. */
212
+ function makeRun(overrides: Partial<RunDigest> = {}): RunDigest {
213
+ return {
214
+ runId: 'run-001',
215
+ latestSeq: 1,
216
+ status: 'pending',
217
+ taskCount: 5,
218
+ completedTasks: 0,
219
+ updatedAt: new Date().toISOString(),
220
+ pendingBreakpoints: 0,
221
+ ...overrides,
222
+ };
223
+ }
224
+
225
+ beforeEach(() => {
226
+ vi.useFakeTimers({ shouldAdvanceTime: false });
227
+ });
228
+
229
+ afterEach(() => {
230
+ vi.useRealTimers();
231
+ });
232
+
233
+ // -------------------------------------------------------------------
234
+ // 1. No notifications during stabilization window
235
+ // -------------------------------------------------------------------
236
+ it('does not fire notifications during the stabilization window', async () => {
237
+ mockDigestData = {
238
+ runs: [
239
+ makeRun({ runId: 'run-001' }),
240
+ makeRun({ runId: 'run-002' }),
241
+ makeRun({ runId: 'run-003' }),
242
+ ],
243
+ };
244
+
245
+ await act(async () => {
246
+ render(
247
+ <NotificationProvider>
248
+ <span>child</span>
249
+ </NotificationProvider>,
250
+ );
251
+ });
252
+
253
+ // We are within the stabilization window — no notifications should fire
254
+ expect(mockNotify).not.toHaveBeenCalled();
255
+ });
256
+
257
+ // -------------------------------------------------------------------
258
+ // 2. Watermarks seeded during stabilization (no duplicate notifications)
259
+ // -------------------------------------------------------------------
260
+ it('seeds watermarks during stabilization so existing runs do not trigger notifications after window', async () => {
261
+ const runs = [
262
+ makeRun({ runId: 'run-001' }),
263
+ makeRun({ runId: 'run-002' }),
264
+ ];
265
+
266
+ mockDigestData = { runs };
267
+
268
+ const { rerender } = await act(async () =>
269
+ render(
270
+ <NotificationProvider>
271
+ <span>child</span>
272
+ </NotificationProvider>,
273
+ ),
274
+ );
275
+
276
+ expect(mockNotify).not.toHaveBeenCalled();
277
+
278
+ // Advance past the stabilization window
279
+ await act(async () => {
280
+ vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
281
+ });
282
+
283
+ // Provide the SAME runs again (simulating a new poll)
284
+ mockDigestData = { runs: [...runs] };
285
+ await act(async () => {
286
+ rerender(
287
+ <NotificationProvider>
288
+ <span>child</span>
289
+ </NotificationProvider>,
290
+ );
291
+ });
292
+
293
+ // No "New Run Started" notifications because watermarks were already seeded
294
+ expect(mockNotify).not.toHaveBeenCalled();
295
+ });
296
+
297
+ // -------------------------------------------------------------------
298
+ // 3. New run after stabilization fires notification
299
+ // -------------------------------------------------------------------
300
+ it('fires "New Run Started" for a genuinely new run after stabilization', async () => {
301
+ const existingRuns = [makeRun({ runId: 'run-001' })];
302
+ mockDigestData = { runs: existingRuns };
303
+
304
+ const { rerender } = await act(async () =>
305
+ render(
306
+ <NotificationProvider>
307
+ <span>child</span>
308
+ </NotificationProvider>,
309
+ ),
310
+ );
311
+
312
+ expect(mockNotify).not.toHaveBeenCalled();
313
+
314
+ // Advance past stabilization window
315
+ await act(async () => {
316
+ vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
317
+ });
318
+
319
+ // Add a new run to the digest
320
+ mockDigestData = {
321
+ runs: [...existingRuns, makeRun({ runId: 'run-new' })],
322
+ };
323
+ await act(async () => {
324
+ rerender(
325
+ <NotificationProvider>
326
+ <span>child</span>
327
+ </NotificationProvider>,
328
+ );
329
+ });
330
+
331
+ expect(mockNotify).toHaveBeenCalledTimes(1);
332
+ expect(mockNotify).toHaveBeenCalledWith(
333
+ 'New Run Started',
334
+ expect.stringContaining('started'),
335
+ 'info',
336
+ expect.objectContaining({ href: '/runs/run-new' }),
337
+ );
338
+ });
339
+
340
+ // -------------------------------------------------------------------
341
+ // 4. Run completed during stabilization doesn't fire notification
342
+ // -------------------------------------------------------------------
343
+ it('does not fire "Run Completed" for a run that was already completed during stabilization', async () => {
344
+ mockDigestData = {
345
+ runs: [makeRun({ runId: 'run-001', status: 'completed' })],
346
+ };
347
+
348
+ const { rerender } = await act(async () =>
349
+ render(
350
+ <NotificationProvider>
351
+ <span>child</span>
352
+ </NotificationProvider>,
353
+ ),
354
+ );
355
+
356
+ expect(mockNotify).not.toHaveBeenCalled();
357
+
358
+ // Advance past stabilization window
359
+ await act(async () => {
360
+ vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
361
+ });
362
+
363
+ // Same run, still completed
364
+ mockDigestData = {
365
+ runs: [makeRun({ runId: 'run-001', status: 'completed' })],
366
+ };
367
+ await act(async () => {
368
+ rerender(
369
+ <NotificationProvider>
370
+ <span>child</span>
371
+ </NotificationProvider>,
372
+ );
373
+ });
374
+
375
+ // No notification since it was completed before stabilization ended
376
+ expect(mockNotify).not.toHaveBeenCalled();
377
+ });
378
+
379
+ // -------------------------------------------------------------------
380
+ // 5. Status transition after stabilization fires once
381
+ // -------------------------------------------------------------------
382
+ it('fires "Run Completed" exactly once when a run transitions to completed after stabilization', async () => {
383
+ mockDigestData = {
384
+ runs: [makeRun({ runId: 'run-001', status: 'pending' })],
385
+ };
386
+
387
+ const { rerender } = await act(async () =>
388
+ render(
389
+ <NotificationProvider>
390
+ <span>child</span>
391
+ </NotificationProvider>,
392
+ ),
393
+ );
394
+
395
+ expect(mockNotify).not.toHaveBeenCalled();
396
+
397
+ // Advance past stabilization window
398
+ await act(async () => {
399
+ vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
400
+ });
401
+
402
+ // Run transitions to completed
403
+ mockDigestData = {
404
+ runs: [makeRun({ runId: 'run-001', status: 'completed' })],
405
+ };
406
+ await act(async () => {
407
+ rerender(
408
+ <NotificationProvider>
409
+ <span>child</span>
410
+ </NotificationProvider>,
411
+ );
412
+ });
413
+
414
+ expect(mockNotify).toHaveBeenCalledTimes(1);
415
+ expect(mockNotify).toHaveBeenCalledWith(
416
+ 'Run Completed',
417
+ expect.stringContaining('finished successfully'),
418
+ 'success',
419
+ expect.objectContaining({ href: '/runs/run-001' }),
420
+ );
421
+
422
+ mockNotify.mockClear();
423
+
424
+ // Same completed state on next poll — should NOT fire again
425
+ mockDigestData = {
426
+ runs: [makeRun({ runId: 'run-001', status: 'completed' })],
427
+ };
428
+ await act(async () => {
429
+ rerender(
430
+ <NotificationProvider>
431
+ <span>child</span>
432
+ </NotificationProvider>,
433
+ );
434
+ });
435
+
436
+ expect(mockNotify).not.toHaveBeenCalled();
437
+ });
438
+
439
+ // -------------------------------------------------------------------
440
+ // 6. Task completion does NOT fire per-task notification (flood fix)
441
+ // -------------------------------------------------------------------
442
+ it('does not fire per-task notifications when completedTasks increases after stabilization', async () => {
443
+ mockDigestData = {
444
+ runs: [makeRun({ runId: 'run-001', completedTasks: 3, taskCount: 10 })],
445
+ };
446
+
447
+ const { rerender } = await act(async () =>
448
+ render(
449
+ <NotificationProvider>
450
+ <span>child</span>
451
+ </NotificationProvider>,
452
+ ),
453
+ );
454
+
455
+ expect(mockNotify).not.toHaveBeenCalled();
456
+
457
+ // Advance past stabilization window
458
+ await act(async () => {
459
+ vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
460
+ });
461
+
462
+ // completedTasks goes from 3 to 5 — watermark updates silently
463
+ mockDigestData = {
464
+ runs: [makeRun({ runId: 'run-001', completedTasks: 5, taskCount: 10 })],
465
+ };
466
+ await act(async () => {
467
+ rerender(
468
+ <NotificationProvider>
469
+ <span>child</span>
470
+ </NotificationProvider>,
471
+ );
472
+ });
473
+
474
+ // No per-task "Tasks Completed" notification should fire (flood fix).
475
+ // The terminal "Run Completed" notification covers this use case.
476
+ expect(mockNotify).not.toHaveBeenCalled();
477
+ });
478
+
479
+ // -------------------------------------------------------------------
480
+ // 7. Waiting state notification after stabilization
481
+ // -------------------------------------------------------------------
482
+ it('fires a persistent breakpoint notification when a run transitions to waiting after stabilization', async () => {
483
+ mockDigestData = {
484
+ runs: [makeRun({ runId: 'run-001', status: 'pending' })],
485
+ };
486
+
487
+ const { rerender } = await act(async () =>
488
+ render(
489
+ <NotificationProvider>
490
+ <span>child</span>
491
+ </NotificationProvider>,
492
+ ),
493
+ );
494
+
495
+ expect(mockNotify).not.toHaveBeenCalled();
496
+
497
+ // Advance past stabilization window
498
+ await act(async () => {
499
+ vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
500
+ });
501
+
502
+ // Run transitions to waiting
503
+ mockDigestData = {
504
+ runs: [
505
+ makeRun({
506
+ runId: 'run-001',
507
+ status: 'waiting',
508
+ breakpointQuestion: 'Approve deployment?',
509
+ }),
510
+ ],
511
+ };
512
+ await act(async () => {
513
+ rerender(
514
+ <NotificationProvider>
515
+ <span>child</span>
516
+ </NotificationProvider>,
517
+ );
518
+ });
519
+
520
+ expect(mockNotify).toHaveBeenCalledTimes(1);
521
+ expect(mockNotify).toHaveBeenCalledWith(
522
+ expect.stringContaining('needs attention'),
523
+ 'Approve deployment?',
524
+ 'warning',
525
+ expect.objectContaining({ href: '/runs/run-001', persistent: true }),
526
+ );
527
+ });
528
+
529
+ // -------------------------------------------------------------------
530
+ // 8. Breakpoint resolved notification
531
+ // -------------------------------------------------------------------
532
+ it('fires "Breakpoint Resolved" when pendingBreakpoints drops to 0 after stabilization', async () => {
533
+ // Seed with a run that already has a pending breakpoint
534
+ mockDigestData = {
535
+ runs: [
536
+ makeRun({
537
+ runId: 'run-001',
538
+ status: 'waiting',
539
+ pendingBreakpoints: 1,
540
+ }),
541
+ ],
542
+ };
543
+
544
+ const { rerender } = await act(async () =>
545
+ render(
546
+ <NotificationProvider>
547
+ <span>child</span>
548
+ </NotificationProvider>,
549
+ ),
550
+ );
551
+
552
+ expect(mockNotify).not.toHaveBeenCalled();
553
+
554
+ // Advance past stabilization window
555
+ await act(async () => {
556
+ vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
557
+ });
558
+
559
+ // Breakpoint resolved: pendingBreakpoints drops from 1 to 0
560
+ mockDigestData = {
561
+ runs: [
562
+ makeRun({
563
+ runId: 'run-001',
564
+ status: 'pending',
565
+ pendingBreakpoints: 0,
566
+ }),
567
+ ],
568
+ };
569
+ await act(async () => {
570
+ rerender(
571
+ <NotificationProvider>
572
+ <span>child</span>
573
+ </NotificationProvider>,
574
+ );
575
+ });
576
+
577
+ expect(mockNotify).toHaveBeenCalledWith(
578
+ 'Breakpoint Resolved',
579
+ expect.stringContaining('approved'),
580
+ 'success',
581
+ expect.objectContaining({ href: '/runs/run-001' }),
582
+ );
583
+ });
584
+ });
585
+ });