@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,246 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
|
|
5
|
+
// Mock config-loader since source-discovery imports getConfig from it
|
|
6
|
+
vi.mock('../config-loader', () => ({
|
|
7
|
+
isNotFoundError: vi.fn((err: unknown) => {
|
|
8
|
+
if (!(err instanceof Error)) return false;
|
|
9
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
10
|
+
return code === 'ENOENT' || code === 'ENOTDIR' || err.message.includes('ENOENT');
|
|
11
|
+
}),
|
|
12
|
+
getConfig: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { getConfig } from '../config-loader';
|
|
16
|
+
import {
|
|
17
|
+
invalidateDiscoveryCache,
|
|
18
|
+
discoverAllRunDirs,
|
|
19
|
+
discoverAllRunsParentDirs,
|
|
20
|
+
} from '../source-discovery';
|
|
21
|
+
|
|
22
|
+
const mockGetConfig = vi.mocked(getConfig);
|
|
23
|
+
const mockReadFile = vi.spyOn(fs, 'readFile');
|
|
24
|
+
const mockReaddir = vi.spyOn(fs, 'readdir');
|
|
25
|
+
const mockStat = vi.spyOn(fs, 'stat');
|
|
26
|
+
const mockAccess = vi.spyOn(fs, 'access');
|
|
27
|
+
|
|
28
|
+
describe('source-discovery', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.resetAllMocks();
|
|
31
|
+
invalidateDiscoveryCache();
|
|
32
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('invalidateDiscoveryCache', () => {
|
|
36
|
+
it('forces re-discovery on next call', async () => {
|
|
37
|
+
mockGetConfig.mockResolvedValue({
|
|
38
|
+
sources: [{ path: '/projects/my-app', depth: 2 }],
|
|
39
|
+
port: 4800,
|
|
40
|
+
pollInterval: 2000,
|
|
41
|
+
theme: 'dark',
|
|
42
|
+
staleThresholdMs: 3600000,
|
|
43
|
+
recentCompletionWindowMs: 14400000,
|
|
44
|
+
retentionDays: 30,
|
|
45
|
+
hiddenProjects: [],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Return a non-empty result so the discovery cache is populated
|
|
49
|
+
mockStat.mockImplementation(async (p: unknown) => {
|
|
50
|
+
if (String(p) === path.join('/projects/my-app', '.a5c', 'runs')) {
|
|
51
|
+
return { isDirectory: () => true } as unknown as fs.FileHandle;
|
|
52
|
+
}
|
|
53
|
+
throw new Error('ENOENT');
|
|
54
|
+
});
|
|
55
|
+
mockReaddir.mockImplementation(async (dir: unknown) => {
|
|
56
|
+
const dirStr = typeof dir === 'string' ? dir : String(dir);
|
|
57
|
+
if (dirStr === '/projects/my-app') {
|
|
58
|
+
return [] as unknown as ReturnType<typeof fs.readdir>;
|
|
59
|
+
}
|
|
60
|
+
if (dirStr === path.join('/projects/my-app', '.a5c', 'runs')) {
|
|
61
|
+
return [
|
|
62
|
+
{ name: 'run-001', isDirectory: () => true },
|
|
63
|
+
] as unknown as ReturnType<typeof fs.readdir>;
|
|
64
|
+
}
|
|
65
|
+
return [] as unknown as ReturnType<typeof fs.readdir>;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await discoverAllRunDirs();
|
|
69
|
+
await discoverAllRunDirs();
|
|
70
|
+
|
|
71
|
+
// With caching (non-empty results), getConfig should only be called once
|
|
72
|
+
expect(mockGetConfig).toHaveBeenCalledTimes(1);
|
|
73
|
+
|
|
74
|
+
invalidateDiscoveryCache();
|
|
75
|
+
await discoverAllRunDirs();
|
|
76
|
+
|
|
77
|
+
// After invalidation, getConfig should be called again
|
|
78
|
+
expect(mockGetConfig).toHaveBeenCalledTimes(2);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('discoverAllRunDirs', () => {
|
|
83
|
+
it('returns empty array when source directory does not exist', async () => {
|
|
84
|
+
mockGetConfig.mockResolvedValue({
|
|
85
|
+
sources: [{ path: '/nonexistent', depth: 2 }],
|
|
86
|
+
port: 4800,
|
|
87
|
+
pollInterval: 2000,
|
|
88
|
+
theme: 'dark',
|
|
89
|
+
staleThresholdMs: 3600000,
|
|
90
|
+
recentCompletionWindowMs: 14400000,
|
|
91
|
+
retentionDays: 30,
|
|
92
|
+
hiddenProjects: [],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
mockStat.mockRejectedValue(new Error('ENOENT'));
|
|
96
|
+
mockReaddir.mockRejectedValue(new Error('ENOENT'));
|
|
97
|
+
|
|
98
|
+
const results = await discoverAllRunDirs();
|
|
99
|
+
expect(results).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('discovers run directories within .a5c/runs at source root', async () => {
|
|
103
|
+
mockGetConfig.mockResolvedValue({
|
|
104
|
+
sources: [{ path: '/projects/my-project', depth: 2 }],
|
|
105
|
+
port: 4800,
|
|
106
|
+
pollInterval: 2000,
|
|
107
|
+
theme: 'dark',
|
|
108
|
+
staleThresholdMs: 3600000,
|
|
109
|
+
recentCompletionWindowMs: 14400000,
|
|
110
|
+
retentionDays: 30,
|
|
111
|
+
hiddenProjects: [],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
mockStat.mockImplementation(async (p: unknown) => {
|
|
115
|
+
if (p === path.join('/projects/my-project', '.a5c', 'runs')) {
|
|
116
|
+
return { isDirectory: () => true } as unknown as fs.FileHandle;
|
|
117
|
+
}
|
|
118
|
+
throw new Error('ENOENT');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
mockReaddir.mockImplementation(async (dir: unknown) => {
|
|
122
|
+
const dirStr = typeof dir === 'string' ? dir : String(dir);
|
|
123
|
+
if (dirStr === '/projects/my-project') {
|
|
124
|
+
return [] as unknown as ReturnType<typeof fs.readdir>;
|
|
125
|
+
}
|
|
126
|
+
if (dirStr === path.join('/projects/my-project', '.a5c', 'runs')) {
|
|
127
|
+
return [
|
|
128
|
+
{ name: 'run-001', isDirectory: () => true },
|
|
129
|
+
{ name: 'run-002', isDirectory: () => true },
|
|
130
|
+
] as unknown as ReturnType<typeof fs.readdir>;
|
|
131
|
+
}
|
|
132
|
+
return [] as unknown as ReturnType<typeof fs.readdir>;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const results = await discoverAllRunDirs();
|
|
136
|
+
|
|
137
|
+
expect(results).toHaveLength(2);
|
|
138
|
+
expect(results[0].runDir).toBe(
|
|
139
|
+
path.join('/projects/my-project', '.a5c', 'runs', 'run-001'),
|
|
140
|
+
);
|
|
141
|
+
expect(results[0].projectName).toBe('my-project');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('handles depth=0 sources (direct runs directory)', async () => {
|
|
145
|
+
mockGetConfig.mockResolvedValue({
|
|
146
|
+
sources: [{ path: '/direct/runs', depth: 0, label: 'direct' }],
|
|
147
|
+
port: 4800,
|
|
148
|
+
pollInterval: 2000,
|
|
149
|
+
theme: 'dark',
|
|
150
|
+
staleThresholdMs: 3600000,
|
|
151
|
+
recentCompletionWindowMs: 14400000,
|
|
152
|
+
retentionDays: 30,
|
|
153
|
+
hiddenProjects: [],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
157
|
+
|
|
158
|
+
mockReaddir.mockImplementation(async (dir: unknown) => {
|
|
159
|
+
const dirStr = typeof dir === 'string' ? dir : String(dir);
|
|
160
|
+
if (dirStr === '/direct/runs') {
|
|
161
|
+
return [
|
|
162
|
+
{ name: 'run-a', isDirectory: () => true },
|
|
163
|
+
{ name: 'somefile.txt', isDirectory: () => false },
|
|
164
|
+
] as unknown as ReturnType<typeof fs.readdir>;
|
|
165
|
+
}
|
|
166
|
+
return [] as unknown as ReturnType<typeof fs.readdir>;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const results = await discoverAllRunDirs();
|
|
170
|
+
|
|
171
|
+
expect(results).toHaveLength(1);
|
|
172
|
+
expect(results[0].runDir).toBe(path.join('/direct/runs', 'run-a'));
|
|
173
|
+
expect(results[0].projectName).toBe('direct');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('skips node_modules and hidden directories during scanning', async () => {
|
|
177
|
+
mockGetConfig.mockResolvedValue({
|
|
178
|
+
sources: [{ path: '/workspace', depth: 1 }],
|
|
179
|
+
port: 4800,
|
|
180
|
+
pollInterval: 2000,
|
|
181
|
+
theme: 'dark',
|
|
182
|
+
staleThresholdMs: 3600000,
|
|
183
|
+
recentCompletionWindowMs: 14400000,
|
|
184
|
+
retentionDays: 30,
|
|
185
|
+
hiddenProjects: [],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
mockStat.mockRejectedValue(new Error('ENOENT'));
|
|
189
|
+
|
|
190
|
+
mockReaddir.mockImplementation(async (dir: unknown) => {
|
|
191
|
+
const dirStr = typeof dir === 'string' ? dir : String(dir);
|
|
192
|
+
if (dirStr === '/workspace') {
|
|
193
|
+
return [
|
|
194
|
+
{ name: 'node_modules', isDirectory: () => true },
|
|
195
|
+
{ name: '.git', isDirectory: () => true },
|
|
196
|
+
{ name: 'project-a', isDirectory: () => true },
|
|
197
|
+
] as unknown as ReturnType<typeof fs.readdir>;
|
|
198
|
+
}
|
|
199
|
+
return [] as unknown as ReturnType<typeof fs.readdir>;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const results = await discoverAllRunDirs();
|
|
203
|
+
expect(results).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('discoverAllRunsParentDirs', () => {
|
|
208
|
+
it('returns accessible source paths for depth=0 sources', async () => {
|
|
209
|
+
mockGetConfig.mockResolvedValue({
|
|
210
|
+
sources: [{ path: '/direct/runs', depth: 0 }],
|
|
211
|
+
port: 4800,
|
|
212
|
+
pollInterval: 2000,
|
|
213
|
+
theme: 'dark',
|
|
214
|
+
staleThresholdMs: 3600000,
|
|
215
|
+
recentCompletionWindowMs: 14400000,
|
|
216
|
+
retentionDays: 30,
|
|
217
|
+
hiddenProjects: [],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
mockAccess.mockResolvedValue(undefined);
|
|
221
|
+
|
|
222
|
+
const dirs = await discoverAllRunsParentDirs();
|
|
223
|
+
expect(dirs).toContain('/direct/runs');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('returns empty for inaccessible depth=0 sources', async () => {
|
|
227
|
+
mockGetConfig.mockResolvedValue({
|
|
228
|
+
sources: [{ path: '/nonexistent', depth: 0 }],
|
|
229
|
+
port: 4800,
|
|
230
|
+
pollInterval: 2000,
|
|
231
|
+
theme: 'dark',
|
|
232
|
+
staleThresholdMs: 3600000,
|
|
233
|
+
recentCompletionWindowMs: 14400000,
|
|
234
|
+
retentionDays: 30,
|
|
235
|
+
hiddenProjects: [],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
239
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
240
|
+
|
|
241
|
+
const dirs = await discoverAllRunsParentDirs();
|
|
242
|
+
expect(dirs).toHaveLength(0);
|
|
243
|
+
consoleSpy.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
formatDuration,
|
|
4
|
+
formatTimestamp,
|
|
5
|
+
truncateId,
|
|
6
|
+
getStatusColor,
|
|
7
|
+
getStatusBg,
|
|
8
|
+
formatShortId,
|
|
9
|
+
friendlyProcessName,
|
|
10
|
+
} from '../utils';
|
|
11
|
+
|
|
12
|
+
describe('formatDuration', () => {
|
|
13
|
+
it('returns dash for null/undefined', () => {
|
|
14
|
+
expect(formatDuration(null)).toBe('\u2014');
|
|
15
|
+
expect(formatDuration(undefined)).toBe('\u2014');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns dash for negative values', () => {
|
|
19
|
+
expect(formatDuration(-100)).toBe('\u2014');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns "<1s" for zero', () => {
|
|
23
|
+
expect(formatDuration(0)).toBe('<1s');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('formats milliseconds', () => {
|
|
27
|
+
expect(formatDuration(500)).toBe('500ms');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('formats seconds', () => {
|
|
31
|
+
expect(formatDuration(5000)).toBe('5s');
|
|
32
|
+
expect(formatDuration(59000)).toBe('59s');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('formats minutes and seconds', () => {
|
|
36
|
+
expect(formatDuration(90000)).toBe('1m 30s');
|
|
37
|
+
expect(formatDuration(3599000)).toBe('59m 59s');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('formats hours and minutes', () => {
|
|
41
|
+
expect(formatDuration(3600000)).toBe('1h 0m');
|
|
42
|
+
expect(formatDuration(7260000)).toBe('2h 1m');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('formatTimestamp', () => {
|
|
47
|
+
it('returns dash for undefined', () => {
|
|
48
|
+
expect(formatTimestamp(undefined)).toBe('\u2014');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('formats a valid ISO string', () => {
|
|
52
|
+
const result = formatTimestamp('2024-01-15T14:30:00Z');
|
|
53
|
+
// Result depends on locale, just verify it returns something non-dash
|
|
54
|
+
expect(result).not.toBe('\u2014');
|
|
55
|
+
expect(result.length).toBeGreaterThan(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('truncateId', () => {
|
|
60
|
+
it('returns dash for empty string', () => {
|
|
61
|
+
expect(truncateId('')).toBe('\u2014');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns full string when within limit', () => {
|
|
65
|
+
expect(truncateId('short')).toBe('short');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('truncates and appends ellipsis', () => {
|
|
69
|
+
const longId = 'abcdefghijklmnopqrstuvwxyz';
|
|
70
|
+
expect(truncateId(longId)).toBe('abcdefghijkl...');
|
|
71
|
+
expect(truncateId(longId, 5)).toBe('abcde...');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('getStatusColor', () => {
|
|
76
|
+
it('returns success class for completed/resolved/ok', () => {
|
|
77
|
+
expect(getStatusColor('completed')).toBe('text-success');
|
|
78
|
+
expect(getStatusColor('resolved')).toBe('text-success');
|
|
79
|
+
expect(getStatusColor('ok')).toBe('text-success');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns error class for failed/error', () => {
|
|
83
|
+
expect(getStatusColor('failed')).toBe('text-error');
|
|
84
|
+
expect(getStatusColor('error')).toBe('text-error');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns pending class for waiting/pending', () => {
|
|
88
|
+
expect(getStatusColor('waiting')).toBe('text-pending');
|
|
89
|
+
expect(getStatusColor('pending')).toBe('text-pending');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns info class for running/requested', () => {
|
|
93
|
+
expect(getStatusColor('running')).toBe('text-info');
|
|
94
|
+
expect(getStatusColor('requested')).toBe('text-info');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns muted class for unknown status', () => {
|
|
98
|
+
expect(getStatusColor('whatever')).toBe('text-foreground-muted');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('getStatusBg', () => {
|
|
103
|
+
it('returns success bg for completed/resolved/ok', () => {
|
|
104
|
+
expect(getStatusBg('completed')).toBe('bg-success-muted');
|
|
105
|
+
expect(getStatusBg('resolved')).toBe('bg-success-muted');
|
|
106
|
+
expect(getStatusBg('ok')).toBe('bg-success-muted');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns error bg for failed/error', () => {
|
|
110
|
+
expect(getStatusBg('failed')).toBe('bg-error-muted');
|
|
111
|
+
expect(getStatusBg('error')).toBe('bg-error-muted');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns pending bg for waiting/pending', () => {
|
|
115
|
+
expect(getStatusBg('waiting')).toBe('bg-pending-muted');
|
|
116
|
+
expect(getStatusBg('pending')).toBe('bg-pending-muted');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns info bg for running/requested', () => {
|
|
120
|
+
expect(getStatusBg('running')).toBe('bg-info-muted');
|
|
121
|
+
expect(getStatusBg('requested')).toBe('bg-info-muted');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns default bg for unknown status', () => {
|
|
125
|
+
expect(getStatusBg('whatever')).toBe('bg-muted');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('formatShortId', () => {
|
|
130
|
+
it('returns dash for empty string', () => {
|
|
131
|
+
expect(formatShortId('')).toBe('\u2014');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns full string when within limit', () => {
|
|
135
|
+
expect(formatShortId('abcd')).toBe('abcd');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('shows last N chars with leading ellipsis', () => {
|
|
139
|
+
expect(formatShortId('abcdefgh')).toBe('...efgh');
|
|
140
|
+
expect(formatShortId('abcdefgh', 6)).toBe('...cdefgh');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('friendlyProcessName', () => {
|
|
145
|
+
it('returns empty string for empty input', () => {
|
|
146
|
+
expect(friendlyProcessName('')).toBe('');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('capitalizes hyphen-separated words', () => {
|
|
150
|
+
expect(friendlyProcessName('data-pipeline')).toBe('Data Pipeline');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('capitalizes slash-separated words', () => {
|
|
154
|
+
expect(friendlyProcessName('data-pipeline/ingest')).toBe('Data Pipeline Ingest');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('handles single word', () => {
|
|
158
|
+
expect(friendlyProcessName('deploy')).toBe('Deploy');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fsPromises } from 'fs';
|
|
3
|
+
|
|
4
|
+
// Mock source-discovery (watcher.ts imports directly from source-discovery)
|
|
5
|
+
vi.mock('../source-discovery', () => ({
|
|
6
|
+
discoverAllRunDirs: vi.fn(),
|
|
7
|
+
invalidateDiscoveryCache: vi.fn(),
|
|
8
|
+
discoverAllRunsParentDirs: vi.fn().mockResolvedValue([]),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Mock run-cache
|
|
12
|
+
vi.mock('../run-cache', () => ({
|
|
13
|
+
invalidateRun: vi.fn(),
|
|
14
|
+
requestDiscovery: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { discoverAllRunDirs } from '../source-discovery';
|
|
18
|
+
import { invalidateRun } from '../run-cache';
|
|
19
|
+
import { initWatcher, watcherEvents, getWatcherStats } from '../watcher';
|
|
20
|
+
|
|
21
|
+
const mockDiscoverAllRunDirs = vi.mocked(discoverAllRunDirs);
|
|
22
|
+
const _mockInvalidateRun = vi.mocked(invalidateRun);
|
|
23
|
+
const mockAccess = vi.spyOn(fsPromises, 'access');
|
|
24
|
+
|
|
25
|
+
describe('watcher', () => {
|
|
26
|
+
let cleanupFn: (() => void) | null = null;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.resetAllMocks();
|
|
30
|
+
vi.useFakeTimers();
|
|
31
|
+
watcherEvents.removeAllListeners();
|
|
32
|
+
cleanupFn = null;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
if (cleanupFn) {
|
|
37
|
+
cleanupFn();
|
|
38
|
+
cleanupFn = null;
|
|
39
|
+
}
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('initWatcher', () => {
|
|
44
|
+
it('returns a cleanup function', async () => {
|
|
45
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
46
|
+
|
|
47
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
48
|
+
cleanupFn = await initWatcher();
|
|
49
|
+
consoleSpy.mockRestore();
|
|
50
|
+
|
|
51
|
+
expect(typeof cleanupFn).toBe('function');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not throw when discovered dirs have no journal directories', async () => {
|
|
55
|
+
mockDiscoverAllRunDirs.mockResolvedValue([
|
|
56
|
+
{
|
|
57
|
+
runDir: '/project/.a5c/runs/run-001',
|
|
58
|
+
source: { path: '/project', depth: 2 },
|
|
59
|
+
projectName: 'project',
|
|
60
|
+
projectPath: '/project',
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// Journal dir does not exist
|
|
65
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
66
|
+
|
|
67
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
68
|
+
cleanupFn = await initWatcher();
|
|
69
|
+
consoleSpy.mockRestore();
|
|
70
|
+
|
|
71
|
+
expect(cleanupFn).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('cleanup', () => {
|
|
76
|
+
it('removes all event listeners from watcherEvents', async () => {
|
|
77
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
78
|
+
|
|
79
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
80
|
+
cleanupFn = await initWatcher();
|
|
81
|
+
consoleSpy.mockRestore();
|
|
82
|
+
|
|
83
|
+
// Add a listener
|
|
84
|
+
watcherEvents.on('change', () => {});
|
|
85
|
+
expect(watcherEvents.listenerCount('change')).toBeGreaterThan(0);
|
|
86
|
+
|
|
87
|
+
cleanupFn();
|
|
88
|
+
expect(watcherEvents.listenerCount('change')).toBe(0);
|
|
89
|
+
cleanupFn = null;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('can be called multiple times safely', async () => {
|
|
93
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
94
|
+
|
|
95
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
96
|
+
cleanupFn = await initWatcher();
|
|
97
|
+
consoleSpy.mockRestore();
|
|
98
|
+
|
|
99
|
+
// Call cleanup twice - should not throw
|
|
100
|
+
cleanupFn();
|
|
101
|
+
// After first cleanup, listeners are removed
|
|
102
|
+
expect(watcherEvents.listenerCount('change')).toBe(0);
|
|
103
|
+
cleanupFn = null;
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('getWatcherStats', () => {
|
|
108
|
+
it('returns stats about active watchers', async () => {
|
|
109
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
110
|
+
|
|
111
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
112
|
+
cleanupFn = await initWatcher();
|
|
113
|
+
consoleSpy.mockRestore();
|
|
114
|
+
|
|
115
|
+
const stats = getWatcherStats();
|
|
116
|
+
|
|
117
|
+
expect(stats).toHaveProperty('activeWatchers');
|
|
118
|
+
expect(stats).toHaveProperty('watchedPaths');
|
|
119
|
+
expect(stats).toHaveProperty('pendingDebounces');
|
|
120
|
+
expect(typeof stats.activeWatchers).toBe('number');
|
|
121
|
+
expect(Array.isArray(stats.watchedPaths)).toBe(true);
|
|
122
|
+
expect(typeof stats.pendingDebounces).toBe('number');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('has zero active watchers when no runs discovered', async () => {
|
|
126
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
127
|
+
|
|
128
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
129
|
+
cleanupFn = await initWatcher();
|
|
130
|
+
consoleSpy.mockRestore();
|
|
131
|
+
|
|
132
|
+
const stats = getWatcherStats();
|
|
133
|
+
expect(stats.activeWatchers).toBe(0);
|
|
134
|
+
expect(stats.watchedPaths).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shows zero pending debounces initially', async () => {
|
|
138
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
139
|
+
|
|
140
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
141
|
+
cleanupFn = await initWatcher();
|
|
142
|
+
consoleSpy.mockRestore();
|
|
143
|
+
|
|
144
|
+
const stats = getWatcherStats();
|
|
145
|
+
expect(stats.pendingDebounces).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('watcherEvents', () => {
|
|
150
|
+
it('is an EventEmitter', () => {
|
|
151
|
+
expect(typeof watcherEvents.on).toBe('function');
|
|
152
|
+
expect(typeof watcherEvents.emit).toBe('function');
|
|
153
|
+
expect(typeof watcherEvents.removeAllListeners).toBe('function');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('can register and emit change events', () => {
|
|
157
|
+
const handler = vi.fn();
|
|
158
|
+
watcherEvents.on('change', handler);
|
|
159
|
+
|
|
160
|
+
watcherEvents.emit('change', { type: 'run-changed', runDir: '/test' });
|
|
161
|
+
|
|
162
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(handler).toHaveBeenCalledWith({ type: 'run-changed', runDir: '/test' });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('supports multiple listeners', () => {
|
|
167
|
+
const handler1 = vi.fn();
|
|
168
|
+
const handler2 = vi.fn();
|
|
169
|
+
watcherEvents.on('change', handler1);
|
|
170
|
+
watcherEvents.on('change', handler2);
|
|
171
|
+
|
|
172
|
+
watcherEvents.emit('change', { type: 'new-run', runDir: '/test' });
|
|
173
|
+
|
|
174
|
+
expect(handler1).toHaveBeenCalledTimes(1);
|
|
175
|
+
expect(handler2).toHaveBeenCalledTimes(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('supports different event types', () => {
|
|
179
|
+
const handler = vi.fn();
|
|
180
|
+
watcherEvents.on('change', handler);
|
|
181
|
+
|
|
182
|
+
watcherEvents.emit('change', { type: 'run-changed', runDir: '/r1' });
|
|
183
|
+
watcherEvents.emit('change', { type: 'new-run', runDir: '/r2' });
|
|
184
|
+
watcherEvents.emit('change', { type: 'error', runDir: '/r3', error: new Error('fail') });
|
|
185
|
+
|
|
186
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('periodic rescan', () => {
|
|
191
|
+
it('schedules periodic rescans via setInterval', async () => {
|
|
192
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
193
|
+
|
|
194
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
195
|
+
cleanupFn = await initWatcher();
|
|
196
|
+
consoleSpy.mockRestore();
|
|
197
|
+
|
|
198
|
+
// discoverAllRunDirs was called once during init
|
|
199
|
+
const initialCalls = mockDiscoverAllRunDirs.mock.calls.length;
|
|
200
|
+
|
|
201
|
+
// Advance past the 120s rescan interval (RESCAN_INTERVAL_MS = 120000)
|
|
202
|
+
vi.advanceTimersByTime(121000);
|
|
203
|
+
|
|
204
|
+
// Should have been called again for the rescan
|
|
205
|
+
expect(mockDiscoverAllRunDirs.mock.calls.length).toBeGreaterThan(initialCalls);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('stops periodic rescan after cleanup', async () => {
|
|
209
|
+
mockDiscoverAllRunDirs.mockResolvedValue([]);
|
|
210
|
+
|
|
211
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
212
|
+
cleanupFn = await initWatcher();
|
|
213
|
+
consoleSpy.mockRestore();
|
|
214
|
+
|
|
215
|
+
const callsBeforeCleanup = mockDiscoverAllRunDirs.mock.calls.length;
|
|
216
|
+
|
|
217
|
+
cleanupFn();
|
|
218
|
+
cleanupFn = null;
|
|
219
|
+
|
|
220
|
+
// Advance well past rescan interval (RESCAN_INTERVAL_MS = 120000)
|
|
221
|
+
vi.advanceTimersByTime(240000);
|
|
222
|
+
|
|
223
|
+
// No additional calls should have been made
|
|
224
|
+
expect(mockDiscoverAllRunDirs.mock.calls.length).toBe(callsBeforeCleanup);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|