@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,591 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
|
|
4
|
+
// Mock dependencies before importing the module under test
|
|
5
|
+
vi.mock('../parser', () => ({
|
|
6
|
+
getRunDigest: vi.fn(),
|
|
7
|
+
parseRunDir: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('../source-discovery', () => ({
|
|
11
|
+
discoverAllRunDirs: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('../config-loader', () => ({
|
|
15
|
+
getConfig: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { getRunDigest, parseRunDir } from '../parser';
|
|
19
|
+
import { discoverAllRunDirs } from '../source-discovery';
|
|
20
|
+
import type { WatchSource } from '../config-loader';
|
|
21
|
+
import type { RunDigest, Run } from '@/types';
|
|
22
|
+
import {
|
|
23
|
+
getDigestCached,
|
|
24
|
+
getRunCached,
|
|
25
|
+
invalidateRun,
|
|
26
|
+
invalidateAll,
|
|
27
|
+
getProjectSummaries,
|
|
28
|
+
discoverAndCacheAll,
|
|
29
|
+
getCacheStats,
|
|
30
|
+
forceRefreshBreakpointRuns,
|
|
31
|
+
} from '../run-cache';
|
|
32
|
+
|
|
33
|
+
const mockGetRunDigest = vi.mocked(getRunDigest);
|
|
34
|
+
const mockParseRunDir = vi.mocked(parseRunDir);
|
|
35
|
+
const mockDiscoverAllRunDirs = vi.mocked(discoverAllRunDirs);
|
|
36
|
+
// Use vi.spyOn for fs methods so the same mock is shared with run-cache module
|
|
37
|
+
const mockReadFile = vi.spyOn(fs, 'readFile');
|
|
38
|
+
|
|
39
|
+
const defaultSource: WatchSource = { path: '/projects', depth: 2, label: 'test' };
|
|
40
|
+
|
|
41
|
+
function makeDigest(overrides: Partial<RunDigest> = {}): RunDigest {
|
|
42
|
+
return {
|
|
43
|
+
runId: 'run-001',
|
|
44
|
+
latestSeq: 5,
|
|
45
|
+
status: 'completed',
|
|
46
|
+
taskCount: 3,
|
|
47
|
+
completedTasks: 3,
|
|
48
|
+
updatedAt: '2024-01-15T10:00:00Z',
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeRun(overrides: Partial<Run> & { _journalFileCount?: number } = {}): Run & { _journalFileCount?: number } {
|
|
54
|
+
return {
|
|
55
|
+
runId: 'run-001',
|
|
56
|
+
processId: 'data-pipeline',
|
|
57
|
+
status: 'completed',
|
|
58
|
+
createdAt: '2024-01-15T10:00:00Z',
|
|
59
|
+
updatedAt: '2024-01-15T10:00:05Z',
|
|
60
|
+
tasks: [],
|
|
61
|
+
events: [],
|
|
62
|
+
totalTasks: 3,
|
|
63
|
+
completedTasks: 3,
|
|
64
|
+
failedTasks: 0,
|
|
65
|
+
duration: 5000,
|
|
66
|
+
_journalFileCount: 5,
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('run-cache', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.resetAllMocks();
|
|
74
|
+
vi.useFakeTimers();
|
|
75
|
+
// Clear cache between tests
|
|
76
|
+
invalidateAll();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
// getDigestCached
|
|
85
|
+
// -----------------------------------------------------------------------
|
|
86
|
+
describe('getDigestCached', () => {
|
|
87
|
+
it('fetches and caches a digest on first call', async () => {
|
|
88
|
+
const digest = makeDigest();
|
|
89
|
+
mockGetRunDigest.mockResolvedValue(digest);
|
|
90
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'my-proc' }));
|
|
91
|
+
|
|
92
|
+
const result = await getDigestCached('/runs/run-001', defaultSource, 'my-project');
|
|
93
|
+
|
|
94
|
+
expect(mockGetRunDigest).toHaveBeenCalledWith('/runs/run-001');
|
|
95
|
+
expect(result.runId).toBe('run-001');
|
|
96
|
+
expect(result.processId).toBe('my-proc');
|
|
97
|
+
expect(result.sourceLabel).toBe('test');
|
|
98
|
+
expect(result.projectName).toBe('my-project');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns cached digest within TTL (completed run = 30s)', async () => {
|
|
102
|
+
const digest = makeDigest({ status: 'completed' });
|
|
103
|
+
mockGetRunDigest.mockResolvedValue(digest);
|
|
104
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
105
|
+
|
|
106
|
+
await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
107
|
+
|
|
108
|
+
// Advance time by 20s (within 30s TTL for completed runs)
|
|
109
|
+
vi.advanceTimersByTime(20000);
|
|
110
|
+
|
|
111
|
+
await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
112
|
+
|
|
113
|
+
// Should only call getRunDigest once due to caching
|
|
114
|
+
expect(mockGetRunDigest).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('refetches after TTL expires for completed runs (30s)', async () => {
|
|
118
|
+
const digest = makeDigest({ status: 'completed' });
|
|
119
|
+
mockGetRunDigest.mockResolvedValue(digest);
|
|
120
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
121
|
+
|
|
122
|
+
await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
123
|
+
|
|
124
|
+
// Advance time past 30s TTL
|
|
125
|
+
vi.advanceTimersByTime(31000);
|
|
126
|
+
|
|
127
|
+
await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
128
|
+
|
|
129
|
+
expect(mockGetRunDigest).toHaveBeenCalledTimes(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('uses shorter TTL (5s) for active runs', async () => {
|
|
133
|
+
const digest = makeDigest({ status: 'waiting' });
|
|
134
|
+
mockGetRunDigest.mockResolvedValue(digest);
|
|
135
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
136
|
+
|
|
137
|
+
await getDigestCached('/runs/run-active', defaultSource, 'proj');
|
|
138
|
+
|
|
139
|
+
// At 4s it should still be cached
|
|
140
|
+
vi.advanceTimersByTime(4000);
|
|
141
|
+
await getDigestCached('/runs/run-active', defaultSource, 'proj');
|
|
142
|
+
expect(mockGetRunDigest).toHaveBeenCalledTimes(1);
|
|
143
|
+
|
|
144
|
+
// At 6s it should refetch
|
|
145
|
+
vi.advanceTimersByTime(2000);
|
|
146
|
+
await getDigestCached('/runs/run-active', defaultSource, 'proj');
|
|
147
|
+
expect(mockGetRunDigest).toHaveBeenCalledTimes(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('uses shorter TTL (5s) for pending runs', async () => {
|
|
151
|
+
const digest = makeDigest({ status: 'pending' });
|
|
152
|
+
mockGetRunDigest.mockResolvedValue(digest);
|
|
153
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
154
|
+
|
|
155
|
+
await getDigestCached('/runs/run-pending', defaultSource, 'proj');
|
|
156
|
+
|
|
157
|
+
vi.advanceTimersByTime(6000);
|
|
158
|
+
|
|
159
|
+
await getDigestCached('/runs/run-pending', defaultSource, 'proj');
|
|
160
|
+
expect(mockGetRunDigest).toHaveBeenCalledTimes(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns "unknown" processId when run.json cannot be read', async () => {
|
|
164
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
165
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
166
|
+
|
|
167
|
+
const result = await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
168
|
+
|
|
169
|
+
expect(result.processId).toBe('unknown');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('returns "unknown" processId when run.json has no processId', async () => {
|
|
173
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
174
|
+
mockReadFile.mockResolvedValue(JSON.stringify({}));
|
|
175
|
+
|
|
176
|
+
const result = await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
177
|
+
|
|
178
|
+
expect(result.processId).toBe('unknown');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// -----------------------------------------------------------------------
|
|
183
|
+
// getRunCached
|
|
184
|
+
// -----------------------------------------------------------------------
|
|
185
|
+
describe('getRunCached', () => {
|
|
186
|
+
it('fetches and caches a full run on first call', async () => {
|
|
187
|
+
const run = makeRun();
|
|
188
|
+
mockParseRunDir.mockResolvedValue(run);
|
|
189
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
190
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
191
|
+
|
|
192
|
+
const result = await getRunCached('/runs/run-001', defaultSource, 'proj');
|
|
193
|
+
|
|
194
|
+
expect(mockParseRunDir).toHaveBeenCalledWith('/runs/run-001', undefined);
|
|
195
|
+
expect(result.runId).toBe('run-001');
|
|
196
|
+
expect(result.sourceLabel).toBe('test');
|
|
197
|
+
expect(result.projectName).toBe('proj');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('returns cached full run within TTL', async () => {
|
|
201
|
+
const run = makeRun({ status: 'completed' });
|
|
202
|
+
mockParseRunDir.mockResolvedValue(run);
|
|
203
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed' }));
|
|
204
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
205
|
+
|
|
206
|
+
await getRunCached('/runs/run-001', defaultSource, 'proj');
|
|
207
|
+
|
|
208
|
+
vi.advanceTimersByTime(20000);
|
|
209
|
+
|
|
210
|
+
await getRunCached('/runs/run-001', defaultSource, 'proj');
|
|
211
|
+
|
|
212
|
+
// parseRunDir should only be called once
|
|
213
|
+
expect(mockParseRunDir).toHaveBeenCalledTimes(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('refetches after TTL expires', async () => {
|
|
217
|
+
const run = makeRun({ status: 'completed' });
|
|
218
|
+
mockParseRunDir.mockResolvedValue(run);
|
|
219
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed' }));
|
|
220
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
221
|
+
|
|
222
|
+
await getRunCached('/runs/run-001', defaultSource, 'proj');
|
|
223
|
+
|
|
224
|
+
vi.advanceTimersByTime(31000);
|
|
225
|
+
|
|
226
|
+
await getRunCached('/runs/run-001', defaultSource, 'proj');
|
|
227
|
+
|
|
228
|
+
expect(mockParseRunDir).toHaveBeenCalledTimes(2);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
// invalidateRun
|
|
234
|
+
// -----------------------------------------------------------------------
|
|
235
|
+
describe('invalidateRun', () => {
|
|
236
|
+
it('removes a specific run from cache', async () => {
|
|
237
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
238
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
239
|
+
|
|
240
|
+
await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
241
|
+
|
|
242
|
+
expect(getCacheStats().size).toBe(1);
|
|
243
|
+
|
|
244
|
+
invalidateRun('/runs/run-001');
|
|
245
|
+
|
|
246
|
+
expect(getCacheStats().size).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does nothing when invalidating a non-existent key', () => {
|
|
250
|
+
expect(() => invalidateRun('/runs/nonexistent')).not.toThrow();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// -----------------------------------------------------------------------
|
|
255
|
+
// invalidateAll
|
|
256
|
+
// -----------------------------------------------------------------------
|
|
257
|
+
describe('invalidateAll', () => {
|
|
258
|
+
it('clears all entries from cache', async () => {
|
|
259
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
260
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
261
|
+
|
|
262
|
+
await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
263
|
+
await getDigestCached('/runs/run-002', defaultSource, 'proj');
|
|
264
|
+
|
|
265
|
+
expect(getCacheStats().size).toBe(2);
|
|
266
|
+
|
|
267
|
+
invalidateAll();
|
|
268
|
+
|
|
269
|
+
expect(getCacheStats().size).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// -----------------------------------------------------------------------
|
|
274
|
+
// getProjectSummaries
|
|
275
|
+
// -----------------------------------------------------------------------
|
|
276
|
+
describe('getProjectSummaries', () => {
|
|
277
|
+
it('returns empty array when cache is empty', () => {
|
|
278
|
+
const summaries = getProjectSummaries();
|
|
279
|
+
expect(summaries).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('groups runs by project name and counts statuses', async () => {
|
|
283
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
284
|
+
|
|
285
|
+
// Add runs from different projects with different statuses
|
|
286
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed', updatedAt: '2024-01-15T10:00:00Z' }));
|
|
287
|
+
await getDigestCached('/runs/proj-a/run-1', defaultSource, 'project-a');
|
|
288
|
+
|
|
289
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'failed', updatedAt: '2024-01-15T11:00:00Z' }));
|
|
290
|
+
await getDigestCached('/runs/proj-a/run-2', defaultSource, 'project-a');
|
|
291
|
+
|
|
292
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'waiting', updatedAt: '2024-01-15T12:00:00Z' }));
|
|
293
|
+
await getDigestCached('/runs/proj-a/run-3', defaultSource, 'project-a');
|
|
294
|
+
|
|
295
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed', updatedAt: '2024-01-15T09:00:00Z' }));
|
|
296
|
+
await getDigestCached('/runs/proj-b/run-1', defaultSource, 'project-b');
|
|
297
|
+
|
|
298
|
+
const summaries = getProjectSummaries();
|
|
299
|
+
|
|
300
|
+
expect(summaries).toHaveLength(2);
|
|
301
|
+
|
|
302
|
+
const projA = summaries.find((s) => s.projectName === 'project-a');
|
|
303
|
+
expect(projA).toBeDefined();
|
|
304
|
+
expect(projA!.totalRuns).toBe(3);
|
|
305
|
+
expect(projA!.completedRuns).toBe(1);
|
|
306
|
+
expect(projA!.failedRuns).toBe(1);
|
|
307
|
+
expect(projA!.activeRuns).toBe(1);
|
|
308
|
+
expect(projA!.latestUpdate).toBe('2024-01-15T12:00:00Z');
|
|
309
|
+
|
|
310
|
+
const projB = summaries.find((s) => s.projectName === 'project-b');
|
|
311
|
+
expect(projB).toBeDefined();
|
|
312
|
+
expect(projB!.totalRuns).toBe(1);
|
|
313
|
+
expect(projB!.completedRuns).toBe(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('uses "Unknown" for runs without projectName', async () => {
|
|
317
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
318
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
319
|
+
|
|
320
|
+
// Pass empty string for projectName to simulate missing project
|
|
321
|
+
await getDigestCached('/runs/run-orphan', defaultSource, '');
|
|
322
|
+
|
|
323
|
+
const summaries = getProjectSummaries();
|
|
324
|
+
|
|
325
|
+
// The code checks for `entry.digest.projectName || "Unknown"` but the cache
|
|
326
|
+
// stores projectName as set — if empty string, it becomes "Unknown"
|
|
327
|
+
expect(summaries).toHaveLength(1);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('tracks latest update across all runs in a project', async () => {
|
|
331
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
332
|
+
|
|
333
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ updatedAt: '2024-01-15T08:00:00Z' }));
|
|
334
|
+
await getDigestCached('/runs/r1', defaultSource, 'proj');
|
|
335
|
+
|
|
336
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ updatedAt: '2024-01-15T12:00:00Z' }));
|
|
337
|
+
await getDigestCached('/runs/r2', defaultSource, 'proj');
|
|
338
|
+
|
|
339
|
+
mockGetRunDigest.mockResolvedValue(makeDigest({ updatedAt: '2024-01-15T10:00:00Z' }));
|
|
340
|
+
await getDigestCached('/runs/r3', defaultSource, 'proj');
|
|
341
|
+
|
|
342
|
+
const summaries = getProjectSummaries();
|
|
343
|
+
expect(summaries[0].latestUpdate).toBe('2024-01-15T12:00:00Z');
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// -----------------------------------------------------------------------
|
|
348
|
+
// discoverAndCacheAll
|
|
349
|
+
// -----------------------------------------------------------------------
|
|
350
|
+
describe('discoverAndCacheAll', () => {
|
|
351
|
+
it('discovers runs and populates cache', async () => {
|
|
352
|
+
mockDiscoverAllRunDirs.mockResolvedValue([
|
|
353
|
+
{ runDir: '/runs/r1', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
|
|
354
|
+
{ runDir: '/runs/r2', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
|
|
355
|
+
]);
|
|
356
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
357
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
358
|
+
|
|
359
|
+
await discoverAndCacheAll();
|
|
360
|
+
|
|
361
|
+
expect(getCacheStats().size).toBe(2);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('handles errors for individual runs without failing', async () => {
|
|
365
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
366
|
+
|
|
367
|
+
mockDiscoverAllRunDirs.mockResolvedValue([
|
|
368
|
+
{ runDir: '/runs/ok', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
|
|
369
|
+
{ runDir: '/runs/bad', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
mockGetRunDigest.mockImplementation(async (runDir: string) => {
|
|
373
|
+
if (runDir === '/runs/bad') throw new Error('corrupt');
|
|
374
|
+
return makeDigest();
|
|
375
|
+
});
|
|
376
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
377
|
+
|
|
378
|
+
await discoverAndCacheAll();
|
|
379
|
+
|
|
380
|
+
// At least the successful run should be cached
|
|
381
|
+
expect(getCacheStats().size).toBeGreaterThanOrEqual(1);
|
|
382
|
+
consoleSpy.mockRestore();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// -----------------------------------------------------------------------
|
|
387
|
+
// forceRefreshBreakpointRuns
|
|
388
|
+
// -----------------------------------------------------------------------
|
|
389
|
+
describe('forceRefreshBreakpointRuns', () => {
|
|
390
|
+
it('deletes entries with pendingBreakpoints > 0', async () => {
|
|
391
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
392
|
+
|
|
393
|
+
// Entry with pending breakpoints — should be deleted
|
|
394
|
+
mockGetRunDigest.mockResolvedValue(
|
|
395
|
+
makeDigest({ runId: 'bp-run', status: 'waiting', pendingBreakpoints: 2, waitingKind: 'breakpoint' })
|
|
396
|
+
);
|
|
397
|
+
await getDigestCached('/runs/bp-run', defaultSource, 'proj');
|
|
398
|
+
|
|
399
|
+
// Entry without breakpoints — should survive
|
|
400
|
+
mockGetRunDigest.mockResolvedValue(
|
|
401
|
+
makeDigest({ runId: 'normal-run', status: 'completed', pendingBreakpoints: 0 })
|
|
402
|
+
);
|
|
403
|
+
await getDigestCached('/runs/normal-run', defaultSource, 'proj');
|
|
404
|
+
|
|
405
|
+
expect(getCacheStats().size).toBe(2);
|
|
406
|
+
|
|
407
|
+
forceRefreshBreakpointRuns();
|
|
408
|
+
|
|
409
|
+
expect(getCacheStats().size).toBe(1);
|
|
410
|
+
const remaining = getCacheStats().entries;
|
|
411
|
+
expect(remaining[0].runDir).toBe('/runs/normal-run');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('leaves entries intact when pendingBreakpoints is 0 or undefined', async () => {
|
|
415
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
416
|
+
|
|
417
|
+
// Entry with pendingBreakpoints = 0
|
|
418
|
+
mockGetRunDigest.mockResolvedValue(
|
|
419
|
+
makeDigest({ runId: 'run-zero', status: 'waiting', pendingBreakpoints: 0 })
|
|
420
|
+
);
|
|
421
|
+
await getDigestCached('/runs/run-zero', defaultSource, 'proj');
|
|
422
|
+
|
|
423
|
+
// Entry with pendingBreakpoints undefined
|
|
424
|
+
mockGetRunDigest.mockResolvedValue(
|
|
425
|
+
makeDigest({ runId: 'run-undef', status: 'completed' })
|
|
426
|
+
);
|
|
427
|
+
await getDigestCached('/runs/run-undef', defaultSource, 'proj');
|
|
428
|
+
|
|
429
|
+
expect(getCacheStats().size).toBe(2);
|
|
430
|
+
|
|
431
|
+
forceRefreshBreakpointRuns();
|
|
432
|
+
|
|
433
|
+
expect(getCacheStats().size).toBe(2);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('deletes multiple breakpoint entries in one call', async () => {
|
|
437
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
438
|
+
|
|
439
|
+
mockGetRunDigest.mockResolvedValue(
|
|
440
|
+
makeDigest({ runId: 'bp-1', status: 'waiting', pendingBreakpoints: 1, waitingKind: 'breakpoint' })
|
|
441
|
+
);
|
|
442
|
+
await getDigestCached('/runs/bp-1', defaultSource, 'proj');
|
|
443
|
+
|
|
444
|
+
mockGetRunDigest.mockResolvedValue(
|
|
445
|
+
makeDigest({ runId: 'bp-2', status: 'waiting', pendingBreakpoints: 3, waitingKind: 'breakpoint' })
|
|
446
|
+
);
|
|
447
|
+
await getDigestCached('/runs/bp-2', defaultSource, 'proj');
|
|
448
|
+
|
|
449
|
+
mockGetRunDigest.mockResolvedValue(
|
|
450
|
+
makeDigest({ runId: 'safe', status: 'completed' })
|
|
451
|
+
);
|
|
452
|
+
await getDigestCached('/runs/safe', defaultSource, 'proj');
|
|
453
|
+
|
|
454
|
+
expect(getCacheStats().size).toBe(3);
|
|
455
|
+
|
|
456
|
+
forceRefreshBreakpointRuns();
|
|
457
|
+
|
|
458
|
+
expect(getCacheStats().size).toBe(1);
|
|
459
|
+
expect(getCacheStats().entries[0].runDir).toBe('/runs/safe');
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// -----------------------------------------------------------------------
|
|
464
|
+
// Breakpoint cache behavior (v0.12.3 fix: no destructive eviction)
|
|
465
|
+
// -----------------------------------------------------------------------
|
|
466
|
+
describe('breakpoint cache behavior', () => {
|
|
467
|
+
it('does NOT destructively delete breakpoint entries from cache (v0.12.3 anti-flicker)', async () => {
|
|
468
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
469
|
+
|
|
470
|
+
// Cache a breakpoint entry
|
|
471
|
+
mockGetRunDigest.mockResolvedValue(
|
|
472
|
+
makeDigest({
|
|
473
|
+
runId: 'bp-stable',
|
|
474
|
+
status: 'waiting',
|
|
475
|
+
pendingBreakpoints: 1,
|
|
476
|
+
waitingKind: 'breakpoint',
|
|
477
|
+
breakpointQuestion: 'Deploy?',
|
|
478
|
+
})
|
|
479
|
+
);
|
|
480
|
+
await getDigestCached('/runs/bp-stable', defaultSource, 'proj');
|
|
481
|
+
|
|
482
|
+
// Immediately, breakpoints should be counted
|
|
483
|
+
const before = getProjectSummaries();
|
|
484
|
+
expect(before).toHaveLength(1);
|
|
485
|
+
expect(before[0].pendingBreakpoints).toBe(1);
|
|
486
|
+
|
|
487
|
+
// Advance past old TTL_BREAKPOINT (3s) but within TTL_ACTIVE (5s)
|
|
488
|
+
vi.advanceTimersByTime(3500);
|
|
489
|
+
|
|
490
|
+
// Breakpoint should STILL be visible (not evicted)
|
|
491
|
+
const after = getProjectSummaries();
|
|
492
|
+
expect(after).toHaveLength(1);
|
|
493
|
+
expect(after[0].pendingBreakpoints).toBe(1);
|
|
494
|
+
expect(after[0].breakpointRuns).toHaveLength(1);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('keeps counting breakpoints even after TTL_ACTIVE expires (no flickering)', async () => {
|
|
498
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
499
|
+
|
|
500
|
+
mockGetRunDigest.mockResolvedValue(
|
|
501
|
+
makeDigest({
|
|
502
|
+
runId: 'bp-persistent',
|
|
503
|
+
status: 'waiting',
|
|
504
|
+
pendingBreakpoints: 2,
|
|
505
|
+
waitingKind: 'breakpoint',
|
|
506
|
+
breakpointQuestion: 'Approve?',
|
|
507
|
+
})
|
|
508
|
+
);
|
|
509
|
+
await getDigestCached('/runs/bp-persistent', defaultSource, 'proj');
|
|
510
|
+
|
|
511
|
+
// Within TTL_ACTIVE (5s) — should be counted
|
|
512
|
+
vi.advanceTimersByTime(4000);
|
|
513
|
+
const fresh = getProjectSummaries();
|
|
514
|
+
expect(fresh).toHaveLength(1);
|
|
515
|
+
expect(fresh[0].pendingBreakpoints).toBe(2);
|
|
516
|
+
|
|
517
|
+
// Past TTL_ACTIVE (5s) — breakpoints STILL counted (v0.12.3 fix).
|
|
518
|
+
// Breakpoint state only changes on explicit approval (invalidateRun),
|
|
519
|
+
// not on cache TTL expiry. This prevents banner flickering.
|
|
520
|
+
vi.advanceTimersByTime(2000); // now at 6s
|
|
521
|
+
const afterTtl = getProjectSummaries();
|
|
522
|
+
expect(afterTtl).toHaveLength(1);
|
|
523
|
+
expect(afterTtl[0].pendingBreakpoints).toBe(2);
|
|
524
|
+
expect(afterTtl[0].breakpointRuns).toHaveLength(1);
|
|
525
|
+
expect(afterTtl[0].totalRuns).toBe(1);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('preserves both breakpoint and non-breakpoint entries regardless of TTL', async () => {
|
|
529
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
530
|
+
|
|
531
|
+
// Breakpoint entry
|
|
532
|
+
mockGetRunDigest.mockResolvedValue(
|
|
533
|
+
makeDigest({
|
|
534
|
+
runId: 'bp-entry',
|
|
535
|
+
status: 'waiting',
|
|
536
|
+
pendingBreakpoints: 1,
|
|
537
|
+
waitingKind: 'breakpoint',
|
|
538
|
+
})
|
|
539
|
+
);
|
|
540
|
+
await getDigestCached('/runs/bp-entry', defaultSource, 'proj');
|
|
541
|
+
|
|
542
|
+
// Normal completed entry
|
|
543
|
+
mockGetRunDigest.mockResolvedValue(
|
|
544
|
+
makeDigest({ runId: 'normal', status: 'completed' })
|
|
545
|
+
);
|
|
546
|
+
await getDigestCached('/runs/normal', defaultSource, 'proj');
|
|
547
|
+
|
|
548
|
+
// Advance past TTL_ACTIVE but within completed TTL (30s)
|
|
549
|
+
vi.advanceTimersByTime(6000);
|
|
550
|
+
|
|
551
|
+
const summaries = getProjectSummaries();
|
|
552
|
+
expect(summaries).toHaveLength(1);
|
|
553
|
+
// Both entries still in cache, breakpoint still counted (v0.12.3)
|
|
554
|
+
expect(summaries[0].totalRuns).toBe(2);
|
|
555
|
+
expect(summaries[0].completedRuns).toBe(1);
|
|
556
|
+
expect(summaries[0].pendingBreakpoints).toBe(1);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// -----------------------------------------------------------------------
|
|
561
|
+
// getCacheStats
|
|
562
|
+
// -----------------------------------------------------------------------
|
|
563
|
+
describe('getCacheStats', () => {
|
|
564
|
+
it('returns size and entries info', async () => {
|
|
565
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
566
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
567
|
+
|
|
568
|
+
await getDigestCached('/runs/run-001', defaultSource, 'proj');
|
|
569
|
+
|
|
570
|
+
const stats = getCacheStats();
|
|
571
|
+
|
|
572
|
+
expect(stats.size).toBe(1);
|
|
573
|
+
expect(stats.entries).toHaveLength(1);
|
|
574
|
+
expect(stats.entries[0].runDir).toBe('/runs/run-001');
|
|
575
|
+
expect(stats.entries[0].status).toBe('completed');
|
|
576
|
+
expect(stats.entries[0].hasFullRun).toBe(false);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('shows hasFullRun=true after getRunCached', async () => {
|
|
580
|
+
mockParseRunDir.mockResolvedValue(makeRun());
|
|
581
|
+
mockGetRunDigest.mockResolvedValue(makeDigest());
|
|
582
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
|
|
583
|
+
|
|
584
|
+
await getRunCached('/runs/run-001', defaultSource, 'proj');
|
|
585
|
+
|
|
586
|
+
const stats = getCacheStats();
|
|
587
|
+
|
|
588
|
+
expect(stats.entries[0].hasFullRun).toBe(true);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
});
|