@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,217 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { fireEvent } from '@testing-library/react';
3
+ import { useKeyboard, Shortcut } from '../use-keyboard';
4
+
5
+ describe('useKeyboard', () => {
6
+ it('calls action when matching key is pressed', () => {
7
+ const action = vi.fn();
8
+ const shortcuts: Shortcut[] = [
9
+ { key: 'r', action, description: 'Refresh' },
10
+ ];
11
+
12
+ renderHook(() => useKeyboard(shortcuts));
13
+
14
+ fireEvent.keyDown(document, { key: 'r' });
15
+ expect(action).toHaveBeenCalledTimes(1);
16
+ });
17
+
18
+ it('does not call action for non-matching key', () => {
19
+ const action = vi.fn();
20
+ const shortcuts: Shortcut[] = [
21
+ { key: 'r', action, description: 'Refresh' },
22
+ ];
23
+
24
+ renderHook(() => useKeyboard(shortcuts));
25
+
26
+ fireEvent.keyDown(document, { key: 'x' });
27
+ expect(action).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('handles ctrl modifier correctly', () => {
31
+ const action = vi.fn();
32
+ const shortcuts: Shortcut[] = [
33
+ { key: 'k', ctrl: true, action, description: 'Open' },
34
+ ];
35
+
36
+ renderHook(() => useKeyboard(shortcuts));
37
+
38
+ // Without ctrl - should not fire
39
+ fireEvent.keyDown(document, { key: 'k', ctrlKey: false });
40
+ expect(action).not.toHaveBeenCalled();
41
+
42
+ // With ctrl - should fire
43
+ fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
44
+ expect(action).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it('handles shift modifier correctly', () => {
48
+ const action = vi.fn();
49
+ const shortcuts: Shortcut[] = [
50
+ { key: 'P', shift: true, action, description: 'Print' },
51
+ ];
52
+
53
+ renderHook(() => useKeyboard(shortcuts));
54
+
55
+ // Without shift - should not fire
56
+ fireEvent.keyDown(document, { key: 'P', shiftKey: false });
57
+ expect(action).not.toHaveBeenCalled();
58
+
59
+ // With shift - should fire
60
+ fireEvent.keyDown(document, { key: 'P', shiftKey: true });
61
+ expect(action).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ it('handles ctrl+shift combination', () => {
65
+ const action = vi.fn();
66
+ const shortcuts: Shortcut[] = [
67
+ { key: 'z', ctrl: true, shift: true, action, description: 'Redo' },
68
+ ];
69
+
70
+ renderHook(() => useKeyboard(shortcuts));
71
+
72
+ // Only ctrl - should not fire
73
+ fireEvent.keyDown(document, { key: 'z', ctrlKey: true, shiftKey: false });
74
+ expect(action).not.toHaveBeenCalled();
75
+
76
+ // Both ctrl+shift - should fire
77
+ fireEvent.keyDown(document, { key: 'z', ctrlKey: true, shiftKey: true });
78
+ expect(action).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ it('ignores keydown events from INPUT elements', () => {
82
+ const action = vi.fn();
83
+ const shortcuts: Shortcut[] = [
84
+ { key: 'r', action, description: 'Refresh' },
85
+ ];
86
+
87
+ renderHook(() => useKeyboard(shortcuts));
88
+
89
+ const input = document.createElement('input');
90
+ document.body.appendChild(input);
91
+ fireEvent.keyDown(input, { key: 'r' });
92
+ expect(action).not.toHaveBeenCalled();
93
+ document.body.removeChild(input);
94
+ });
95
+
96
+ it('ignores keydown events from TEXTAREA elements', () => {
97
+ const action = vi.fn();
98
+ const shortcuts: Shortcut[] = [
99
+ { key: 'r', action, description: 'Refresh' },
100
+ ];
101
+
102
+ renderHook(() => useKeyboard(shortcuts));
103
+
104
+ const textarea = document.createElement('textarea');
105
+ document.body.appendChild(textarea);
106
+ fireEvent.keyDown(textarea, { key: 'r' });
107
+ expect(action).not.toHaveBeenCalled();
108
+ document.body.removeChild(textarea);
109
+ });
110
+
111
+ it('ignores keydown events from SELECT elements', () => {
112
+ const action = vi.fn();
113
+ const shortcuts: Shortcut[] = [
114
+ { key: 'r', action, description: 'Refresh' },
115
+ ];
116
+
117
+ renderHook(() => useKeyboard(shortcuts));
118
+
119
+ const select = document.createElement('select');
120
+ document.body.appendChild(select);
121
+ fireEvent.keyDown(select, { key: 'r' });
122
+ expect(action).not.toHaveBeenCalled();
123
+ document.body.removeChild(select);
124
+ });
125
+
126
+ it('ignores keydown events from contentEditable elements', () => {
127
+ const action = vi.fn();
128
+ const shortcuts: Shortcut[] = [
129
+ { key: 'r', action, description: 'Refresh' },
130
+ ];
131
+
132
+ renderHook(() => useKeyboard(shortcuts));
133
+
134
+ const div = document.createElement('div');
135
+ div.contentEditable = 'true';
136
+ // jsdom does not implement isContentEditable, so mock it
137
+ Object.defineProperty(div, 'isContentEditable', { value: true });
138
+ document.body.appendChild(div);
139
+ fireEvent.keyDown(div, { key: 'r' });
140
+ expect(action).not.toHaveBeenCalled();
141
+ document.body.removeChild(div);
142
+ });
143
+
144
+ it('supports multiple shortcuts simultaneously', () => {
145
+ const actionA = vi.fn();
146
+ const actionB = vi.fn();
147
+ const shortcuts: Shortcut[] = [
148
+ { key: 'a', action: actionA, description: 'Action A' },
149
+ { key: 'b', action: actionB, description: 'Action B' },
150
+ ];
151
+
152
+ renderHook(() => useKeyboard(shortcuts));
153
+
154
+ fireEvent.keyDown(document, { key: 'a' });
155
+ expect(actionA).toHaveBeenCalledTimes(1);
156
+ expect(actionB).not.toHaveBeenCalled();
157
+
158
+ fireEvent.keyDown(document, { key: 'b' });
159
+ expect(actionB).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it('calls preventDefault on matching keydown', () => {
163
+ const action = vi.fn();
164
+ const shortcuts: Shortcut[] = [
165
+ { key: 'r', action, description: 'Refresh' },
166
+ ];
167
+
168
+ renderHook(() => useKeyboard(shortcuts));
169
+
170
+ const event = new KeyboardEvent('keydown', {
171
+ key: 'r',
172
+ bubbles: true,
173
+ cancelable: true,
174
+ });
175
+ const preventSpy = vi.spyOn(event, 'preventDefault');
176
+ document.dispatchEvent(event);
177
+
178
+ expect(preventSpy).toHaveBeenCalled();
179
+ });
180
+
181
+ it('removes event listener on unmount', () => {
182
+ const action = vi.fn();
183
+ const shortcuts: Shortcut[] = [
184
+ { key: 'r', action, description: 'Refresh' },
185
+ ];
186
+
187
+ const { unmount } = renderHook(() => useKeyboard(shortcuts));
188
+
189
+ unmount();
190
+
191
+ fireEvent.keyDown(document, { key: 'r' });
192
+ expect(action).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('uses latest shortcuts via ref (no stale closure)', () => {
196
+ const firstAction = vi.fn();
197
+ const secondAction = vi.fn();
198
+
199
+ const { rerender } = renderHook(
200
+ ({ shortcuts }) => useKeyboard(shortcuts),
201
+ {
202
+ initialProps: {
203
+ shortcuts: [{ key: 'r', action: firstAction, description: 'First' }] as Shortcut[],
204
+ },
205
+ }
206
+ );
207
+
208
+ // Re-render with new action
209
+ rerender({
210
+ shortcuts: [{ key: 'r', action: secondAction, description: 'Second' }] as Shortcut[],
211
+ });
212
+
213
+ fireEvent.keyDown(document, { key: 'r' });
214
+ expect(firstAction).not.toHaveBeenCalled();
215
+ expect(secondAction).toHaveBeenCalledTimes(1);
216
+ });
217
+ });
@@ -0,0 +1,230 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { useNotifications } from '../use-notifications';
3
+
4
+ describe('useNotifications', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ // Mock Notification API
8
+ Object.defineProperty(window, 'Notification', {
9
+ writable: true,
10
+ value: class MockNotification {
11
+ static permission: NotificationPermission = 'default';
12
+ static requestPermission = vi.fn().mockResolvedValue('granted');
13
+ constructor(public title: string, public options?: NotificationOptions) {}
14
+ },
15
+ });
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.useRealTimers();
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ it('starts with empty notification list', () => {
24
+ const { result } = renderHook(() => useNotifications());
25
+ expect(result.current.notifications).toEqual([]);
26
+ });
27
+
28
+ it('adds a notification via notify', () => {
29
+ const { result } = renderHook(() => useNotifications());
30
+
31
+ act(() => {
32
+ result.current.notify('Test Title', 'Test body', 'info');
33
+ });
34
+
35
+ expect(result.current.notifications).toHaveLength(1);
36
+ expect(result.current.notifications[0].title).toBe('Test Title');
37
+ expect(result.current.notifications[0].body).toBe('Test body');
38
+ expect(result.current.notifications[0].type).toBe('info');
39
+ });
40
+
41
+ it('defaults type to info when not specified', () => {
42
+ const { result } = renderHook(() => useNotifications());
43
+
44
+ act(() => {
45
+ result.current.notify('Title', 'Body');
46
+ });
47
+
48
+ expect(result.current.notifications[0].type).toBe('info');
49
+ });
50
+
51
+ it('supports all notification types', () => {
52
+ const { result } = renderHook(() => useNotifications());
53
+
54
+ const types = ['success', 'error', 'warning', 'info'] as const;
55
+ for (const type of types) {
56
+ act(() => {
57
+ result.current.notify(`${type} title`, `${type} body`, type);
58
+ });
59
+ }
60
+
61
+ expect(result.current.notifications).toHaveLength(4);
62
+ types.forEach((type, index) => {
63
+ expect(result.current.notifications[index].type).toBe(type);
64
+ });
65
+ });
66
+
67
+ it('includes href when provided', () => {
68
+ const { result } = renderHook(() => useNotifications());
69
+
70
+ act(() => {
71
+ result.current.notify('Title', 'Body', 'info', { href: '/some/link' });
72
+ });
73
+
74
+ expect(result.current.notifications[0].href).toBe('/some/link');
75
+ });
76
+
77
+ it('assigns unique ids to notifications', () => {
78
+ const { result } = renderHook(() => useNotifications());
79
+
80
+ act(() => {
81
+ result.current.notify('First', 'Body 1');
82
+ result.current.notify('Second', 'Body 2');
83
+ });
84
+
85
+ const ids = result.current.notifications.map((n) => n.id);
86
+ expect(ids[0]).not.toBe(ids[1]);
87
+ });
88
+
89
+ it('includes a timestamp on each notification', () => {
90
+ const now = Date.now();
91
+ vi.setSystemTime(now);
92
+
93
+ const { result } = renderHook(() => useNotifications());
94
+
95
+ act(() => {
96
+ result.current.notify('Title', 'Body');
97
+ });
98
+
99
+ expect(result.current.notifications[0].timestamp).toBe(now);
100
+ });
101
+
102
+ it('dismisses a notification by id', () => {
103
+ const { result } = renderHook(() => useNotifications());
104
+
105
+ act(() => {
106
+ result.current.notify('First', 'Body 1');
107
+ result.current.notify('Second', 'Body 2');
108
+ });
109
+
110
+ expect(result.current.notifications).toHaveLength(2);
111
+ const idToRemove = result.current.notifications[0].id;
112
+
113
+ act(() => {
114
+ result.current.dismiss(idToRemove);
115
+ });
116
+
117
+ expect(result.current.notifications).toHaveLength(1);
118
+ expect(result.current.notifications[0].title).toBe('Second');
119
+ });
120
+
121
+ it('auto-dismisses non-persistent notification after 5 seconds', () => {
122
+ const { result } = renderHook(() => useNotifications());
123
+
124
+ act(() => {
125
+ result.current.notify('Auto dismiss', 'Should disappear');
126
+ });
127
+
128
+ expect(result.current.notifications).toHaveLength(1);
129
+
130
+ // Advance just under 5s
131
+ act(() => {
132
+ vi.advanceTimersByTime(4999);
133
+ });
134
+ expect(result.current.notifications).toHaveLength(1);
135
+
136
+ // Advance past 5s
137
+ act(() => {
138
+ vi.advanceTimersByTime(1);
139
+ });
140
+ expect(result.current.notifications).toHaveLength(0);
141
+ });
142
+
143
+ it('does NOT auto-dismiss persistent notifications', () => {
144
+ const { result } = renderHook(() => useNotifications());
145
+
146
+ act(() => {
147
+ result.current.notify('Breakpoint', 'Needs approval', 'warning', { href: '/runs/abc', persistent: true });
148
+ });
149
+
150
+ expect(result.current.notifications).toHaveLength(1);
151
+ expect(result.current.notifications[0].persistent).toBe(true);
152
+
153
+ // Advance well past the normal auto-dismiss timeout
154
+ act(() => {
155
+ vi.advanceTimersByTime(30000);
156
+ });
157
+
158
+ // Persistent notification should still be present
159
+ expect(result.current.notifications).toHaveLength(1);
160
+ expect(result.current.notifications[0].title).toBe('Breakpoint');
161
+ });
162
+
163
+ it('persistent notifications can still be manually dismissed', () => {
164
+ const { result } = renderHook(() => useNotifications());
165
+
166
+ act(() => {
167
+ result.current.notify('Breakpoint', 'Needs approval', 'warning', { href: '/runs/abc', persistent: true });
168
+ });
169
+
170
+ expect(result.current.notifications).toHaveLength(1);
171
+ const id = result.current.notifications[0].id;
172
+
173
+ act(() => {
174
+ result.current.dismiss(id);
175
+ });
176
+
177
+ expect(result.current.notifications).toHaveLength(0);
178
+ });
179
+
180
+ it('handles multiple auto-dismissals independently', () => {
181
+ const { result } = renderHook(() => useNotifications());
182
+
183
+ act(() => {
184
+ result.current.notify('First', 'Body 1');
185
+ });
186
+
187
+ // Advance 3 seconds, then add another
188
+ act(() => {
189
+ vi.advanceTimersByTime(3000);
190
+ });
191
+
192
+ act(() => {
193
+ result.current.notify('Second', 'Body 2');
194
+ });
195
+
196
+ expect(result.current.notifications).toHaveLength(2);
197
+
198
+ // First should auto-dismiss at 5s (2 more seconds)
199
+ act(() => {
200
+ vi.advanceTimersByTime(2000);
201
+ });
202
+ expect(result.current.notifications).toHaveLength(1);
203
+ expect(result.current.notifications[0].title).toBe('Second');
204
+
205
+ // Second should auto-dismiss at 8s total (3 more seconds)
206
+ act(() => {
207
+ vi.advanceTimersByTime(3000);
208
+ });
209
+ expect(result.current.notifications).toHaveLength(0);
210
+ });
211
+
212
+ it('reads initial notification permission', () => {
213
+ (window.Notification as unknown as { permission: string }).permission = 'granted';
214
+
215
+ const { result } = renderHook(() => useNotifications());
216
+ // Permission is set from useEffect, but the initial state is 'default'.
217
+ // After mount effect runs:
218
+ expect(result.current.permission).toBe('granted');
219
+ });
220
+
221
+ it('requestPermission calls Notification.requestPermission', async () => {
222
+ const { result } = renderHook(() => useNotifications());
223
+
224
+ await act(async () => {
225
+ await result.current.requestPermission();
226
+ });
227
+
228
+ expect(Notification.requestPermission).toHaveBeenCalled();
229
+ });
230
+ });