@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,561 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import path from 'path';
3
+ import { promises as fs } from 'fs';
4
+ import {
5
+ getConfig,
6
+ invalidateConfigCache,
7
+ invalidateDiscoveryCache,
8
+ writeConfig,
9
+ discoverAllRunDirs,
10
+ findRunDir,
11
+ } from '../config';
12
+
13
+ // Use vi.spyOn so both test and config module share the same mock references
14
+ const mockReadFile = vi.spyOn(fs, 'readFile');
15
+ const mockWriteFile = vi.spyOn(fs, 'writeFile');
16
+ const mockMkdir = vi.spyOn(fs, 'mkdir');
17
+ const mockReaddir = vi.spyOn(fs, 'readdir');
18
+ const mockStat = vi.spyOn(fs, 'stat');
19
+ const mockAccess = vi.spyOn(fs, 'access');
20
+
21
+ describe('config', () => {
22
+ const originalEnv = process.env;
23
+
24
+ beforeEach(() => {
25
+ vi.resetAllMocks();
26
+ // Reset env vars
27
+ process.env = { ...originalEnv };
28
+ delete process.env.OBSERVER_REGISTRY;
29
+ delete process.env.OBSERVER_WATCH_DIR;
30
+ delete process.env.WATCH_DIR;
31
+ delete process.env.WATCH_DIRS;
32
+ delete process.env.OBSERVER_PORT;
33
+ delete process.env.PORT;
34
+ delete process.env.OBSERVER_POLL_INTERVAL;
35
+ delete process.env.POLL_INTERVAL;
36
+ delete process.env.OBSERVER_DEFAULT_THEME;
37
+ delete process.env.THEME;
38
+ // Invalidate any cached config/discovery from previous test
39
+ invalidateConfigCache();
40
+ invalidateDiscoveryCache();
41
+ // Default: fs.access rejects (file does not exist)
42
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
43
+ });
44
+
45
+ afterEach(() => {
46
+ process.env = originalEnv;
47
+ });
48
+
49
+ // -----------------------------------------------------------------------
50
+ // invalidateConfigCache
51
+ // -----------------------------------------------------------------------
52
+ describe('invalidateConfigCache', () => {
53
+ it('clears the cached config so next getConfig re-reads', async () => {
54
+ // First call should read from registry
55
+ mockReadFile.mockRejectedValue(new Error('no file'));
56
+ const config1 = await getConfig();
57
+ expect(config1).toBeDefined();
58
+
59
+ // Invalidate
60
+ invalidateConfigCache();
61
+
62
+ // Second call should re-read
63
+ mockReadFile.mockRejectedValue(new Error('no file'));
64
+ const _config2 = await getConfig();
65
+ // readFile should have been called again
66
+ expect(mockReadFile).toHaveBeenCalledTimes(2);
67
+ });
68
+ });
69
+
70
+ // -----------------------------------------------------------------------
71
+ // getConfig
72
+ // -----------------------------------------------------------------------
73
+ describe('getConfig', () => {
74
+ it('returns default config when registry file does not exist', async () => {
75
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
76
+
77
+ const config = await getConfig();
78
+
79
+ expect(config).toBeDefined();
80
+ expect(config.port).toBe(4800);
81
+ expect(config.pollInterval).toBe(2000);
82
+ expect(config.theme).toBe('dark');
83
+ expect(config.sources.length).toBeGreaterThan(0);
84
+ });
85
+
86
+ it('uses parent of cwd as default source when no env vars are set', async () => {
87
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
88
+
89
+ const config = await getConfig();
90
+
91
+ expect(config.sources[0].path).toBe(path.resolve(process.cwd(), '..'));
92
+ expect(config.sources[0].label).toBe('parent');
93
+ expect(config.sources[0].depth).toBe(3);
94
+ });
95
+
96
+ it('uses OBSERVER_WATCH_DIR env var when set', async () => {
97
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
98
+ process.env.OBSERVER_WATCH_DIR = '/custom/watch/dir';
99
+ invalidateConfigCache();
100
+
101
+ const config = await getConfig();
102
+
103
+ expect(config.sources[0].path).toBe('/custom/watch/dir');
104
+ expect(config.sources[0].label).toBe('cli');
105
+ });
106
+
107
+ it('uses WATCH_DIR env var when set', async () => {
108
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
109
+ process.env.WATCH_DIR = '/legacy/watch/dir';
110
+ invalidateConfigCache();
111
+
112
+ const config = await getConfig();
113
+
114
+ expect(config.sources[0].path).toBe('/legacy/watch/dir');
115
+ expect(config.sources[0].depth).toBe(0);
116
+ expect(config.sources[0].label).toBe('env');
117
+ });
118
+
119
+ it('parses WATCH_DIRS env var (comma-separated)', async () => {
120
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
121
+ process.env.WATCH_DIRS = '/dir/a, /dir/b, /dir/c';
122
+ invalidateConfigCache();
123
+
124
+ const config = await getConfig();
125
+
126
+ expect(config.sources).toHaveLength(3);
127
+ expect(config.sources[0].path).toBe('/dir/a');
128
+ expect(config.sources[1].path).toBe('/dir/b');
129
+ expect(config.sources[2].path).toBe('/dir/c');
130
+ });
131
+
132
+ it('skips empty entries in WATCH_DIRS', async () => {
133
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
134
+ process.env.WATCH_DIRS = '/dir/a, , /dir/b';
135
+ invalidateConfigCache();
136
+
137
+ const config = await getConfig();
138
+
139
+ expect(config.sources).toHaveLength(2);
140
+ });
141
+
142
+ it('reads sources from registry file', async () => {
143
+ mockReadFile.mockResolvedValue(
144
+ JSON.stringify({
145
+ sources: [
146
+ { path: '/registered/path', depth: 3, label: 'registry' },
147
+ ],
148
+ }),
149
+ );
150
+
151
+ const config = await getConfig();
152
+
153
+ expect(config.sources).toHaveLength(1);
154
+ expect(config.sources[0].path).toBe('/registered/path');
155
+ expect(config.sources[0].depth).toBe(3);
156
+ expect(config.sources[0].label).toBe('registry');
157
+ });
158
+
159
+ it('defaults depth to 2 when registry source has no depth', async () => {
160
+ mockReadFile.mockResolvedValue(
161
+ JSON.stringify({
162
+ sources: [{ path: '/some/path' }],
163
+ }),
164
+ );
165
+
166
+ const config = await getConfig();
167
+
168
+ expect(config.sources[0].depth).toBe(2);
169
+ });
170
+
171
+ it('reads port from OBSERVER_PORT env var', async () => {
172
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
173
+ process.env.OBSERVER_PORT = '4000';
174
+ invalidateConfigCache();
175
+
176
+ const config = await getConfig();
177
+
178
+ expect(config.port).toBe(4000);
179
+ });
180
+
181
+ it('reads port from PORT env var as fallback', async () => {
182
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
183
+ process.env.PORT = '5000';
184
+ invalidateConfigCache();
185
+
186
+ const config = await getConfig();
187
+
188
+ expect(config.port).toBe(5000);
189
+ });
190
+
191
+ it('reads pollInterval from registry file', async () => {
192
+ mockReadFile.mockResolvedValue(
193
+ JSON.stringify({
194
+ sources: [],
195
+ pollInterval: 5000,
196
+ }),
197
+ );
198
+
199
+ const config = await getConfig();
200
+
201
+ expect(config.pollInterval).toBe(5000);
202
+ });
203
+
204
+ it('reads pollInterval from OBSERVER_POLL_INTERVAL env var', async () => {
205
+ mockReadFile.mockResolvedValue(JSON.stringify({ sources: [] }));
206
+ process.env.OBSERVER_POLL_INTERVAL = '3000';
207
+ invalidateConfigCache();
208
+
209
+ const config = await getConfig();
210
+
211
+ expect(config.pollInterval).toBe(3000);
212
+ });
213
+
214
+ it('reads theme from registry file', async () => {
215
+ mockReadFile.mockResolvedValue(
216
+ JSON.stringify({
217
+ sources: [],
218
+ theme: 'light',
219
+ }),
220
+ );
221
+
222
+ const config = await getConfig();
223
+
224
+ expect(config.theme).toBe('light');
225
+ });
226
+
227
+ it('reads theme from OBSERVER_DEFAULT_THEME env var', async () => {
228
+ mockReadFile.mockResolvedValue(JSON.stringify({ sources: [] }));
229
+ process.env.OBSERVER_DEFAULT_THEME = 'light';
230
+ invalidateConfigCache();
231
+
232
+ const config = await getConfig();
233
+
234
+ expect(config.theme).toBe('light');
235
+ });
236
+
237
+ it('defaults theme to dark for invalid theme values', async () => {
238
+ mockReadFile.mockResolvedValue(JSON.stringify({ sources: [] }));
239
+ process.env.THEME = 'invalid-theme';
240
+ invalidateConfigCache();
241
+
242
+ const config = await getConfig();
243
+
244
+ expect(config.theme).toBe('dark');
245
+ });
246
+
247
+ it('caches config and returns cached version within TTL', async () => {
248
+ mockReadFile.mockResolvedValue(JSON.stringify({ sources: [] }));
249
+
250
+ const config1 = await getConfig();
251
+ const config2 = await getConfig();
252
+
253
+ // Should only read file once due to caching
254
+ expect(mockReadFile).toHaveBeenCalledTimes(1);
255
+ expect(config1).toBe(config2); // same object reference
256
+ });
257
+
258
+ it('registry sources take priority over env defaults', async () => {
259
+ mockReadFile.mockResolvedValue(
260
+ JSON.stringify({
261
+ sources: [{ path: '/registry/path', depth: 1 }],
262
+ }),
263
+ );
264
+ process.env.OBSERVER_WATCH_DIR = '/env/path';
265
+
266
+ const config = await getConfig();
267
+
268
+ expect(config.sources).toHaveLength(1);
269
+ expect(config.sources[0].path).toBe('/registry/path');
270
+ });
271
+
272
+ it('falls back to defaults when registry has empty sources array', async () => {
273
+ mockReadFile.mockResolvedValue(
274
+ JSON.stringify({ sources: [] }),
275
+ );
276
+
277
+ const config = await getConfig();
278
+
279
+ // Should fall back to default sources (parent of cwd)
280
+ expect(config.sources.length).toBeGreaterThan(0);
281
+ expect(config.sources[0].path).toBe(path.resolve(process.cwd(), '..'));
282
+ });
283
+ });
284
+
285
+ // -----------------------------------------------------------------------
286
+ // writeConfig
287
+ // -----------------------------------------------------------------------
288
+ describe('writeConfig', () => {
289
+ it('creates directory and writes config file', async () => {
290
+ mockMkdir.mockResolvedValue(undefined);
291
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
292
+ mockWriteFile.mockResolvedValue(undefined);
293
+
294
+ await writeConfig({
295
+ sources: [{ path: '/new/path', depth: 1 }],
296
+ });
297
+
298
+ expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
299
+ expect(mockWriteFile).toHaveBeenCalledWith(
300
+ expect.any(String),
301
+ expect.stringContaining('/new/path'),
302
+ 'utf-8',
303
+ );
304
+ });
305
+
306
+ it('preserves existing fields in the registry file', async () => {
307
+ mockMkdir.mockResolvedValue(undefined);
308
+ mockReadFile.mockResolvedValue(
309
+ JSON.stringify({ existingField: 'preserved', sources: [] }),
310
+ );
311
+ mockWriteFile.mockResolvedValue(undefined);
312
+
313
+ await writeConfig({
314
+ sources: [{ path: '/updated', depth: 2 }],
315
+ });
316
+
317
+ const writtenContent = JSON.parse(
318
+ (mockWriteFile.mock.calls[0][1] as string).trim(),
319
+ );
320
+ expect(writtenContent.existingField).toBe('preserved');
321
+ expect(writtenContent.sources[0].path).toBe('/updated');
322
+ });
323
+
324
+ it('writes pollInterval when provided', async () => {
325
+ mockMkdir.mockResolvedValue(undefined);
326
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
327
+ mockWriteFile.mockResolvedValue(undefined);
328
+
329
+ await writeConfig({
330
+ sources: [],
331
+ pollInterval: 5000,
332
+ });
333
+
334
+ const writtenContent = JSON.parse(
335
+ (mockWriteFile.mock.calls[0][1] as string).trim(),
336
+ );
337
+ expect(writtenContent.pollInterval).toBe(5000);
338
+ });
339
+
340
+ it('writes theme when provided', async () => {
341
+ mockMkdir.mockResolvedValue(undefined);
342
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
343
+ mockWriteFile.mockResolvedValue(undefined);
344
+
345
+ await writeConfig({
346
+ sources: [],
347
+ theme: 'light',
348
+ });
349
+
350
+ const writtenContent = JSON.parse(
351
+ (mockWriteFile.mock.calls[0][1] as string).trim(),
352
+ );
353
+ expect(writtenContent.theme).toBe('light');
354
+ });
355
+
356
+ it('does not write pollInterval when not provided', async () => {
357
+ mockMkdir.mockResolvedValue(undefined);
358
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
359
+ mockWriteFile.mockResolvedValue(undefined);
360
+
361
+ await writeConfig({
362
+ sources: [{ path: '/p', depth: 1 }],
363
+ });
364
+
365
+ const writtenContent = JSON.parse(
366
+ (mockWriteFile.mock.calls[0][1] as string).trim(),
367
+ );
368
+ expect(writtenContent.pollInterval).toBeUndefined();
369
+ });
370
+ });
371
+
372
+ // -----------------------------------------------------------------------
373
+ // discoverAllRunDirs
374
+ // -----------------------------------------------------------------------
375
+ describe('discoverAllRunDirs', () => {
376
+ it('returns empty array when source directory does not exist', async () => {
377
+ // Config with a non-existent source
378
+ mockReadFile.mockResolvedValue(
379
+ JSON.stringify({
380
+ sources: [{ path: '/nonexistent', depth: 2 }],
381
+ }),
382
+ );
383
+ invalidateConfigCache();
384
+
385
+ // stat fails for .a5c/runs
386
+ mockStat.mockRejectedValue(new Error('ENOENT'));
387
+ // readdir fails
388
+ mockReaddir.mockRejectedValue(new Error('ENOENT'));
389
+
390
+ const results = await discoverAllRunDirs();
391
+
392
+ expect(results).toEqual([]);
393
+ });
394
+
395
+ it('discovers run directories within .a5c/runs at source root', async () => {
396
+ mockReadFile.mockResolvedValue(
397
+ JSON.stringify({
398
+ sources: [{ path: '/projects/my-project', depth: 2 }],
399
+ }),
400
+ );
401
+ invalidateConfigCache();
402
+
403
+ // stat succeeds for .a5c/runs
404
+ mockStat.mockImplementation(async (p: any) => {
405
+ if (p === path.join('/projects/my-project', '.a5c', 'runs')) {
406
+ return { isDirectory: () => true } as any;
407
+ }
408
+ throw new Error('ENOENT');
409
+ });
410
+
411
+ // readdir for scanning subdirectories and listing runs
412
+ mockReaddir.mockImplementation(async (dir: any, _opts?: any) => {
413
+ const dirStr = typeof dir === 'string' ? dir : dir.toString();
414
+ if (dirStr === '/projects/my-project') {
415
+ // No subdirs to recurse into (only .a5c)
416
+ return [];
417
+ }
418
+ if (dirStr === path.join('/projects/my-project', '.a5c', 'runs')) {
419
+ return [
420
+ { name: 'run-001', isDirectory: () => true },
421
+ { name: 'run-002', isDirectory: () => true },
422
+ ] as any;
423
+ }
424
+ return [];
425
+ });
426
+
427
+ const results = await discoverAllRunDirs();
428
+
429
+ expect(results).toHaveLength(2);
430
+ expect(results[0].runDir).toBe(
431
+ path.join('/projects/my-project', '.a5c', 'runs', 'run-001'),
432
+ );
433
+ expect(results[0].projectName).toBe('my-project');
434
+ expect(results[1].runDir).toBe(
435
+ path.join('/projects/my-project', '.a5c', 'runs', 'run-002'),
436
+ );
437
+ });
438
+
439
+ it('handles depth=0 sources (direct runs directory)', async () => {
440
+ const configJson = JSON.stringify({
441
+ sources: [{ path: '/direct/runs', depth: 0, label: 'direct' }],
442
+ });
443
+ // Use mockImplementation so config reads get the config JSON
444
+ // and run.json reads fail (no run.json file)
445
+ mockReadFile.mockImplementation(async (filePath: any) => {
446
+ const fileStr = typeof filePath === 'string' ? filePath : filePath.toString();
447
+ if (fileStr.includes('run.json')) {
448
+ throw new Error('ENOENT');
449
+ }
450
+ return configJson as any;
451
+ });
452
+ invalidateConfigCache();
453
+
454
+ mockReaddir.mockImplementation(async (dir: any) => {
455
+ const dirStr = typeof dir === 'string' ? dir : dir.toString();
456
+ if (dirStr === '/direct/runs') {
457
+ return [
458
+ { name: 'run-a', isDirectory: () => true },
459
+ { name: 'somefile.txt', isDirectory: () => false },
460
+ ] as any;
461
+ }
462
+ return [];
463
+ });
464
+
465
+ const results = await discoverAllRunDirs();
466
+
467
+ expect(results).toHaveLength(1);
468
+ expect(results[0].runDir).toBe(path.join('/direct/runs', 'run-a'));
469
+ expect(results[0].projectName).toBe('direct');
470
+ });
471
+
472
+ it('skips node_modules and hidden directories during scanning', async () => {
473
+ mockReadFile.mockResolvedValue(
474
+ JSON.stringify({
475
+ sources: [{ path: '/workspace', depth: 1 }],
476
+ }),
477
+ );
478
+ invalidateConfigCache();
479
+
480
+ // No .a5c/runs at root level
481
+ mockStat.mockRejectedValue(new Error('ENOENT'));
482
+
483
+ mockReaddir.mockImplementation(async (dir: any) => {
484
+ const dirStr = typeof dir === 'string' ? dir : dir.toString();
485
+ if (dirStr === '/workspace') {
486
+ return [
487
+ { name: 'node_modules', isDirectory: () => true },
488
+ { name: '.git', isDirectory: () => true },
489
+ { name: 'project-a', isDirectory: () => true },
490
+ ] as any;
491
+ }
492
+ return [];
493
+ });
494
+
495
+ const results = await discoverAllRunDirs();
496
+
497
+ // Only project-a should be scanned, not node_modules or .git
498
+ // Since no .a5c/runs found anywhere, results should be empty
499
+ expect(results).toEqual([]);
500
+ });
501
+ });
502
+
503
+ // -----------------------------------------------------------------------
504
+ // findRunDir
505
+ // -----------------------------------------------------------------------
506
+ describe('findRunDir', () => {
507
+ it('returns null when no matching runId is found', async () => {
508
+ mockReadFile.mockResolvedValue(
509
+ JSON.stringify({
510
+ sources: [{ path: '/projects', depth: 1 }],
511
+ }),
512
+ );
513
+ invalidateConfigCache();
514
+
515
+ mockStat.mockRejectedValue(new Error('ENOENT'));
516
+ mockReaddir.mockRejectedValue(new Error('ENOENT'));
517
+
518
+ const result = await findRunDir('nonexistent-run');
519
+
520
+ expect(result).toBeNull();
521
+ });
522
+
523
+ it('returns DiscoveredRun when matching runId is found', async () => {
524
+ mockReadFile.mockResolvedValue(
525
+ JSON.stringify({
526
+ sources: [{ path: '/projects/myapp', depth: 2 }],
527
+ }),
528
+ );
529
+ invalidateConfigCache();
530
+
531
+ mockStat.mockImplementation(async (p: any) => {
532
+ if (p === path.join('/projects/myapp', '.a5c', 'runs')) {
533
+ return { isDirectory: () => true } as any;
534
+ }
535
+ throw new Error('ENOENT');
536
+ });
537
+
538
+ mockReaddir.mockImplementation(async (dir: any) => {
539
+ const dirStr = typeof dir === 'string' ? dir : dir.toString();
540
+ if (dirStr === '/projects/myapp') {
541
+ return [];
542
+ }
543
+ if (dirStr === path.join('/projects/myapp', '.a5c', 'runs')) {
544
+ return [
545
+ { name: 'target-run', isDirectory: () => true },
546
+ { name: 'other-run', isDirectory: () => true },
547
+ ] as any;
548
+ }
549
+ return [];
550
+ });
551
+
552
+ const result = await findRunDir('target-run');
553
+
554
+ expect(result).not.toBeNull();
555
+ expect(result!.runDir).toBe(
556
+ path.join('/projects/myapp', '.a5c', 'runs', 'target-run'),
557
+ );
558
+ expect(result!.projectName).toBe('myapp');
559
+ });
560
+ });
561
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AppError, normalizeError } from "../error-handler";
3
+
4
+ describe("AppError", () => {
5
+ it("extends Error with code and status fields", () => {
6
+ const err = new AppError("Not found", "NOT_FOUND", 404);
7
+ expect(err).toBeInstanceOf(Error);
8
+ expect(err).toBeInstanceOf(AppError);
9
+ expect(err.message).toBe("Not found");
10
+ expect(err.code).toBe("NOT_FOUND");
11
+ expect(err.status).toBe(404);
12
+ expect(err.name).toBe("AppError");
13
+ });
14
+
15
+ it("has a proper stack trace", () => {
16
+ const err = new AppError("test", "TEST", 500);
17
+ expect(err.stack).toBeDefined();
18
+ expect(err.stack).toContain("AppError");
19
+ });
20
+
21
+ it("supports instanceof checks", () => {
22
+ const err = new AppError("msg", "CODE", 400);
23
+ expect(err instanceof AppError).toBe(true);
24
+ expect(err instanceof Error).toBe(true);
25
+ });
26
+ });
27
+
28
+ describe("normalizeError", () => {
29
+ it("preserves AppError code and status", () => {
30
+ const err = new AppError("Bad request", "BAD_REQUEST", 400);
31
+ const result = normalizeError(err);
32
+ expect(result).toEqual({
33
+ message: "Bad request",
34
+ code: "BAD_REQUEST",
35
+ status: 400,
36
+ });
37
+ });
38
+
39
+ it("maps ENOENT to 404 NOT_FOUND", () => {
40
+ const err = Object.assign(new Error("no such file"), { code: "ENOENT" });
41
+ const result = normalizeError(err);
42
+ expect(result.status).toBe(404);
43
+ expect(result.code).toBe("NOT_FOUND");
44
+ expect(result.message).toBe("Resource not found");
45
+ });
46
+
47
+ it("maps EACCES to 403 PERMISSION_DENIED", () => {
48
+ const err = Object.assign(new Error("permission denied"), { code: "EACCES" });
49
+ const result = normalizeError(err);
50
+ expect(result.status).toBe(403);
51
+ expect(result.code).toBe("PERMISSION_DENIED");
52
+ });
53
+
54
+ it("maps EPERM to 403 PERMISSION_DENIED", () => {
55
+ const err = Object.assign(new Error("operation not permitted"), { code: "EPERM" });
56
+ const result = normalizeError(err);
57
+ expect(result.status).toBe(403);
58
+ expect(result.code).toBe("PERMISSION_DENIED");
59
+ });
60
+
61
+ it("maps ENOTDIR to 400 INVALID_PATH", () => {
62
+ const err = Object.assign(new Error("not a directory"), { code: "ENOTDIR" });
63
+ const result = normalizeError(err);
64
+ expect(result.status).toBe(400);
65
+ expect(result.code).toBe("INVALID_PATH");
66
+ });
67
+
68
+ it("maps EISDIR to 400 INVALID_PATH", () => {
69
+ const err = Object.assign(new Error("is a directory"), { code: "EISDIR" });
70
+ const result = normalizeError(err);
71
+ expect(result.status).toBe(400);
72
+ expect(result.code).toBe("INVALID_PATH");
73
+ });
74
+
75
+ it("maps SyntaxError to 400 PARSE_ERROR", () => {
76
+ let parseErr: Error;
77
+ try {
78
+ JSON.parse("{invalid json}");
79
+ parseErr = new Error("should not reach");
80
+ } catch (e) {
81
+ parseErr = e as Error;
82
+ }
83
+ const result = normalizeError(parseErr);
84
+ expect(result.status).toBe(400);
85
+ expect(result.code).toBe("PARSE_ERROR");
86
+ expect(result.message).toBe("Failed to parse data");
87
+ });
88
+
89
+ it("converts generic Error to 500 INTERNAL_ERROR", () => {
90
+ const result = normalizeError(new Error("something broke"));
91
+ expect(result).toEqual({
92
+ message: "something broke",
93
+ code: "INTERNAL_ERROR",
94
+ status: 500,
95
+ });
96
+ });
97
+
98
+ it("converts string to 500 INTERNAL_ERROR with the string as message", () => {
99
+ const result = normalizeError("oops");
100
+ expect(result).toEqual({
101
+ message: "oops",
102
+ code: "INTERNAL_ERROR",
103
+ status: 500,
104
+ });
105
+ });
106
+
107
+ it("converts null to 500 UNKNOWN_ERROR", () => {
108
+ const result = normalizeError(null);
109
+ expect(result).toEqual({
110
+ message: "An unexpected error occurred",
111
+ code: "UNKNOWN_ERROR",
112
+ status: 500,
113
+ });
114
+ });
115
+
116
+ it("converts undefined to 500 UNKNOWN_ERROR", () => {
117
+ const result = normalizeError(undefined);
118
+ expect(result).toEqual({
119
+ message: "An unexpected error occurred",
120
+ code: "UNKNOWN_ERROR",
121
+ status: 500,
122
+ });
123
+ });
124
+
125
+ it("converts a plain number to 500 UNKNOWN_ERROR", () => {
126
+ const result = normalizeError(42);
127
+ expect(result.code).toBe("UNKNOWN_ERROR");
128
+ expect(result.status).toBe(500);
129
+ });
130
+
131
+ it("converts a plain object to 500 UNKNOWN_ERROR", () => {
132
+ const result = normalizeError({ foo: "bar" });
133
+ expect(result.code).toBe("UNKNOWN_ERROR");
134
+ expect(result.status).toBe(500);
135
+ });
136
+
137
+ it("does not leak stack traces (result has no stack property)", () => {
138
+ const err = new Error("secret internal details");
139
+ const result = normalizeError(err);
140
+ expect(result).not.toHaveProperty("stack");
141
+ expect(Object.keys(result).sort()).toEqual(["code", "message", "status"]);
142
+ });
143
+ });