@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.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- 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
|
+
});
|