@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,512 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock dependencies
|
|
4
|
+
vi.mock('../watcher', () => {
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
6
|
+
return {
|
|
7
|
+
initWatcher: vi.fn(),
|
|
8
|
+
watcherEvents: new EventEmitter(),
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
vi.mock('../run-cache', () => ({
|
|
13
|
+
discoverAndCacheAll: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { initWatcher, watcherEvents } from '../watcher';
|
|
17
|
+
import { discoverAndCacheAll } from '../run-cache';
|
|
18
|
+
import {
|
|
19
|
+
ensureInitialized,
|
|
20
|
+
shutdownServer,
|
|
21
|
+
getInitStatus,
|
|
22
|
+
serverEvents,
|
|
23
|
+
resetDebounceState,
|
|
24
|
+
enqueueRunChanged,
|
|
25
|
+
SSE_DEBOUNCE_MS,
|
|
26
|
+
type BatchedRunChangedEvent,
|
|
27
|
+
} from '../server-init';
|
|
28
|
+
|
|
29
|
+
const mockInitWatcher = vi.mocked(initWatcher);
|
|
30
|
+
const mockDiscoverAndCacheAll = vi.mocked(discoverAndCacheAll);
|
|
31
|
+
|
|
32
|
+
describe('server-init', () => {
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
vi.resetAllMocks();
|
|
36
|
+
// Reset server state between tests
|
|
37
|
+
await shutdownServer();
|
|
38
|
+
resetDebounceState();
|
|
39
|
+
watcherEvents.removeAllListeners();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// -----------------------------------------------------------------------
|
|
47
|
+
// ensureInitialized
|
|
48
|
+
// -----------------------------------------------------------------------
|
|
49
|
+
describe('ensureInitialized', () => {
|
|
50
|
+
it('initializes watcher and populates cache on first call', async () => {
|
|
51
|
+
const cleanupMock = vi.fn();
|
|
52
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
53
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
54
|
+
|
|
55
|
+
await ensureInitialized();
|
|
56
|
+
|
|
57
|
+
expect(mockInitWatcher).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(mockDiscoverAndCacheAll).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns immediately on subsequent calls', async () => {
|
|
62
|
+
const cleanupMock = vi.fn();
|
|
63
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
64
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
65
|
+
|
|
66
|
+
await ensureInitialized();
|
|
67
|
+
await ensureInitialized();
|
|
68
|
+
await ensureInitialized();
|
|
69
|
+
|
|
70
|
+
// Should only initialize once
|
|
71
|
+
expect(mockInitWatcher).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(mockDiscoverAndCacheAll).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('deduplicates concurrent initialization calls', async () => {
|
|
76
|
+
const cleanupMock = vi.fn();
|
|
77
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
78
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
79
|
+
|
|
80
|
+
// Call concurrently
|
|
81
|
+
const [_r1, _r2, _r3] = await Promise.all([
|
|
82
|
+
ensureInitialized(),
|
|
83
|
+
ensureInitialized(),
|
|
84
|
+
ensureInitialized(),
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
expect(mockInitWatcher).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws and resets state if initialization fails', async () => {
|
|
91
|
+
mockInitWatcher.mockRejectedValue(new Error('init failed'));
|
|
92
|
+
|
|
93
|
+
await expect(ensureInitialized()).rejects.toThrow('init failed');
|
|
94
|
+
|
|
95
|
+
// After failure, should be able to retry
|
|
96
|
+
const cleanupMock = vi.fn();
|
|
97
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
98
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
99
|
+
|
|
100
|
+
await ensureInitialized();
|
|
101
|
+
|
|
102
|
+
expect(mockInitWatcher).toHaveBeenCalledTimes(2);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
// Event forwarding
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
describe('event forwarding', () => {
|
|
110
|
+
it('forwards run-changed events from watcher to server events as batched event', async () => {
|
|
111
|
+
const cleanupMock = vi.fn();
|
|
112
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
113
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
114
|
+
|
|
115
|
+
await ensureInitialized();
|
|
116
|
+
|
|
117
|
+
const handler = vi.fn();
|
|
118
|
+
serverEvents.on('run-changed', handler);
|
|
119
|
+
|
|
120
|
+
watcherEvents.emit('change', { type: 'run-changed', runDir: '/runs/r1' });
|
|
121
|
+
|
|
122
|
+
// Leading-edge debounce fires immediately with batched format
|
|
123
|
+
expect(handler).toHaveBeenCalledWith({
|
|
124
|
+
type: 'run-changed',
|
|
125
|
+
runIds: ['r1'],
|
|
126
|
+
runDirs: ['/runs/r1'],
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not globally invalidate breakpoint cache on run-changed (v0.12.3 fix)', async () => {
|
|
131
|
+
// Previously, enqueueRunChanged() called forceRefreshBreakpointRuns()
|
|
132
|
+
// on every watcher event, which deleted ALL breakpoint cache entries
|
|
133
|
+
// and caused banner flickering. Now only the specific run is invalidated
|
|
134
|
+
// by the watcher handler (invalidateRun), not all breakpoint entries.
|
|
135
|
+
const cleanupMock = vi.fn();
|
|
136
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
137
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
138
|
+
|
|
139
|
+
await ensureInitialized();
|
|
140
|
+
|
|
141
|
+
const handler = vi.fn();
|
|
142
|
+
serverEvents.on('run-changed', handler);
|
|
143
|
+
|
|
144
|
+
watcherEvents.emit('change', { type: 'run-changed', runDir: '/runs/r1' });
|
|
145
|
+
|
|
146
|
+
// The event should still be forwarded
|
|
147
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
148
|
+
|
|
149
|
+
serverEvents.off('run-changed', handler);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('forwards new-run events from watcher to server events', async () => {
|
|
153
|
+
const cleanupMock = vi.fn();
|
|
154
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
155
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
156
|
+
|
|
157
|
+
await ensureInitialized();
|
|
158
|
+
|
|
159
|
+
const handler = vi.fn();
|
|
160
|
+
serverEvents.on('new-run', handler);
|
|
161
|
+
|
|
162
|
+
watcherEvents.emit('change', { type: 'new-run', runDir: '/runs' });
|
|
163
|
+
|
|
164
|
+
expect(handler).toHaveBeenCalledWith({ type: 'new-run', runDir: '/runs' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('forwards error events from watcher as watcher-error with dedup', async () => {
|
|
168
|
+
const cleanupMock = vi.fn();
|
|
169
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
170
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
171
|
+
|
|
172
|
+
await ensureInitialized();
|
|
173
|
+
|
|
174
|
+
const handler = vi.fn();
|
|
175
|
+
serverEvents.on('watcher-error', handler);
|
|
176
|
+
|
|
177
|
+
const errorEvent = { type: 'error', runDir: '/runs', error: new Error('watch error') };
|
|
178
|
+
watcherEvents.emit('change', errorEvent);
|
|
179
|
+
|
|
180
|
+
expect(handler).toHaveBeenCalledWith(errorEvent);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('suppresses duplicate watcher errors within 5s dedup window', async () => {
|
|
184
|
+
const cleanupMock = vi.fn();
|
|
185
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
186
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
187
|
+
|
|
188
|
+
await ensureInitialized();
|
|
189
|
+
|
|
190
|
+
const handler = vi.fn();
|
|
191
|
+
serverEvents.on('watcher-error', handler);
|
|
192
|
+
|
|
193
|
+
const errorEvent1 = { type: 'error', runDir: '/runs', error: new Error('watch error 1') };
|
|
194
|
+
const errorEvent2 = { type: 'error', runDir: '/runs', error: new Error('watch error 2') };
|
|
195
|
+
|
|
196
|
+
// First error goes through
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
199
|
+
watcherEvents.emit('change', errorEvent1);
|
|
200
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
201
|
+
|
|
202
|
+
// Second error within 5s dedup window is suppressed
|
|
203
|
+
vi.spyOn(Date, 'now').mockReturnValue(now + 3000);
|
|
204
|
+
watcherEvents.emit('change', errorEvent2);
|
|
205
|
+
expect(handler).toHaveBeenCalledTimes(1); // still 1
|
|
206
|
+
|
|
207
|
+
// Third error after 5s dedup window goes through
|
|
208
|
+
vi.spyOn(Date, 'now').mockReturnValue(now + 6000);
|
|
209
|
+
watcherEvents.emit('change', errorEvent2);
|
|
210
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// -----------------------------------------------------------------------
|
|
215
|
+
// Leading-edge debounce for SSE broadcasts
|
|
216
|
+
// -----------------------------------------------------------------------
|
|
217
|
+
describe('SSE broadcast debounce (enqueueRunChanged)', () => {
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
resetDebounceState();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('fires immediately on the first event (leading edge)', () => {
|
|
223
|
+
const handler = vi.fn();
|
|
224
|
+
serverEvents.on('run-changed', handler);
|
|
225
|
+
|
|
226
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
|
|
227
|
+
|
|
228
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
229
|
+
const event: BatchedRunChangedEvent = handler.mock.calls[0][0];
|
|
230
|
+
expect(event.type).toBe('run-changed');
|
|
231
|
+
expect(event.runIds).toEqual(['r1']);
|
|
232
|
+
expect(event.runDirs).toEqual(['/runs/r1']);
|
|
233
|
+
|
|
234
|
+
serverEvents.off('run-changed', handler);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('collects subsequent events within the 500ms window and emits a single batch', () => {
|
|
238
|
+
const handler = vi.fn();
|
|
239
|
+
serverEvents.on('run-changed', handler);
|
|
240
|
+
|
|
241
|
+
// First event — fires immediately (leading edge)
|
|
242
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
|
|
243
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
244
|
+
|
|
245
|
+
// Subsequent events within window — should NOT fire immediately
|
|
246
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
|
|
247
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r3' });
|
|
248
|
+
expect(handler).toHaveBeenCalledTimes(1); // still just the leading edge
|
|
249
|
+
|
|
250
|
+
// Advance past the debounce window
|
|
251
|
+
vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
|
|
252
|
+
|
|
253
|
+
// Now the batch should have flushed
|
|
254
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
255
|
+
const batchEvent: BatchedRunChangedEvent = handler.mock.calls[1][0];
|
|
256
|
+
expect(batchEvent.type).toBe('run-changed');
|
|
257
|
+
expect(batchEvent.runIds).toEqual(expect.arrayContaining(['r2', 'r3']));
|
|
258
|
+
expect(batchEvent.runIds).toHaveLength(2);
|
|
259
|
+
expect(batchEvent.runDirs).toEqual(expect.arrayContaining(['/runs/r2', '/runs/r3']));
|
|
260
|
+
|
|
261
|
+
serverEvents.off('run-changed', handler);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('deduplicates the same runDir within the window', () => {
|
|
265
|
+
const handler = vi.fn();
|
|
266
|
+
serverEvents.on('run-changed', handler);
|
|
267
|
+
|
|
268
|
+
// First event — leading edge
|
|
269
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
|
|
270
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
271
|
+
|
|
272
|
+
// Same runDir multiple times within window
|
|
273
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
|
|
274
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
|
|
275
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
|
|
276
|
+
|
|
277
|
+
vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
|
|
278
|
+
|
|
279
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
280
|
+
const batchEvent: BatchedRunChangedEvent = handler.mock.calls[1][0];
|
|
281
|
+
// Set deduplicates: only one r2
|
|
282
|
+
expect(batchEvent.runIds).toEqual(['r2']);
|
|
283
|
+
|
|
284
|
+
serverEvents.off('run-changed', handler);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('does not emit a trailing batch when there are no pending events', () => {
|
|
288
|
+
const handler = vi.fn();
|
|
289
|
+
serverEvents.on('run-changed', handler);
|
|
290
|
+
|
|
291
|
+
// Single event — leading edge only
|
|
292
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
|
|
293
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
294
|
+
|
|
295
|
+
// Advance past window — no pending events, so no trailing emit
|
|
296
|
+
vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
|
|
297
|
+
expect(handler).toHaveBeenCalledTimes(1); // no additional call
|
|
298
|
+
|
|
299
|
+
serverEvents.off('run-changed', handler);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('resets the debounce window after flush, allowing new leading edge', () => {
|
|
303
|
+
const handler = vi.fn();
|
|
304
|
+
serverEvents.on('run-changed', handler);
|
|
305
|
+
|
|
306
|
+
// First burst
|
|
307
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
|
|
308
|
+
expect(handler).toHaveBeenCalledTimes(1); // leading edge
|
|
309
|
+
|
|
310
|
+
// Flush the window
|
|
311
|
+
vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
|
|
312
|
+
|
|
313
|
+
// Second burst — should fire as a new leading edge
|
|
314
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
|
|
315
|
+
expect(handler).toHaveBeenCalledTimes(2); // new leading edge
|
|
316
|
+
|
|
317
|
+
const event: BatchedRunChangedEvent = handler.mock.calls[1][0];
|
|
318
|
+
expect(event.runIds).toEqual(['r2']);
|
|
319
|
+
|
|
320
|
+
// Flush
|
|
321
|
+
vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
|
|
322
|
+
expect(handler).toHaveBeenCalledTimes(2); // no trailing (no pending)
|
|
323
|
+
|
|
324
|
+
serverEvents.off('run-changed', handler);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('extends the window when new events arrive (timer reset)', () => {
|
|
328
|
+
const handler = vi.fn();
|
|
329
|
+
serverEvents.on('run-changed', handler);
|
|
330
|
+
|
|
331
|
+
// Leading edge
|
|
332
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
|
|
333
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
334
|
+
|
|
335
|
+
// At 300ms, new event arrives — resets the 500ms timer
|
|
336
|
+
vi.advanceTimersByTime(300);
|
|
337
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
|
|
338
|
+
|
|
339
|
+
// At 600ms (300ms after last event) — should NOT have flushed yet
|
|
340
|
+
vi.advanceTimersByTime(300);
|
|
341
|
+
expect(handler).toHaveBeenCalledTimes(1); // still just leading edge
|
|
342
|
+
|
|
343
|
+
// At 800ms (500ms after last event at 300ms) — should flush
|
|
344
|
+
vi.advanceTimersByTime(200);
|
|
345
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
346
|
+
const batchEvent: BatchedRunChangedEvent = handler.mock.calls[1][0];
|
|
347
|
+
expect(batchEvent.runIds).toEqual(['r2']);
|
|
348
|
+
|
|
349
|
+
serverEvents.off('run-changed', handler);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('shutdownServer clears pending debounce state', async () => {
|
|
353
|
+
const handler = vi.fn();
|
|
354
|
+
serverEvents.on('run-changed', handler);
|
|
355
|
+
|
|
356
|
+
// Leading edge
|
|
357
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r1' });
|
|
358
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
359
|
+
|
|
360
|
+
// Queue up events
|
|
361
|
+
enqueueRunChanged({ type: 'run-changed', runDir: '/runs/r2' });
|
|
362
|
+
|
|
363
|
+
// Shutdown clears everything including debounce timers and listeners
|
|
364
|
+
await shutdownServer();
|
|
365
|
+
|
|
366
|
+
// Re-listen after shutdown
|
|
367
|
+
const handler2 = vi.fn();
|
|
368
|
+
serverEvents.on('run-changed', handler2);
|
|
369
|
+
|
|
370
|
+
// Advance past window — pending batch should have been cleared
|
|
371
|
+
vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
|
|
372
|
+
expect(handler2).not.toHaveBeenCalled();
|
|
373
|
+
|
|
374
|
+
serverEvents.off('run-changed', handler2);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('integrates with watcher events end-to-end', async () => {
|
|
378
|
+
const cleanupMock = vi.fn();
|
|
379
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
380
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
381
|
+
|
|
382
|
+
await ensureInitialized();
|
|
383
|
+
|
|
384
|
+
const handler = vi.fn();
|
|
385
|
+
serverEvents.on('run-changed', handler);
|
|
386
|
+
|
|
387
|
+
// Rapid watcher events
|
|
388
|
+
watcherEvents.emit('change', { type: 'run-changed', runDir: '/project/runs/abc123' });
|
|
389
|
+
watcherEvents.emit('change', { type: 'run-changed', runDir: '/project/runs/def456' });
|
|
390
|
+
watcherEvents.emit('change', { type: 'run-changed', runDir: '/project/runs/abc123' }); // duplicate
|
|
391
|
+
|
|
392
|
+
// Leading edge should have fired immediately with first event
|
|
393
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
394
|
+
expect(handler.mock.calls[0][0].runIds).toEqual(['abc123']);
|
|
395
|
+
|
|
396
|
+
// Flush the window
|
|
397
|
+
vi.advanceTimersByTime(SSE_DEBOUNCE_MS);
|
|
398
|
+
|
|
399
|
+
// Batch should contain the 2 unique subsequent runDirs
|
|
400
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
401
|
+
const batch: BatchedRunChangedEvent = handler.mock.calls[1][0];
|
|
402
|
+
expect(batch.runIds).toEqual(expect.arrayContaining(['def456', 'abc123']));
|
|
403
|
+
|
|
404
|
+
serverEvents.off('run-changed', handler);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// -----------------------------------------------------------------------
|
|
409
|
+
// shutdownServer
|
|
410
|
+
// -----------------------------------------------------------------------
|
|
411
|
+
describe('shutdownServer', () => {
|
|
412
|
+
it('calls the cleanup function from watcher', async () => {
|
|
413
|
+
const cleanupMock = vi.fn();
|
|
414
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
415
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
416
|
+
|
|
417
|
+
await ensureInitialized();
|
|
418
|
+
await shutdownServer();
|
|
419
|
+
|
|
420
|
+
expect(cleanupMock).toHaveBeenCalledTimes(1);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('removes all server event listeners', async () => {
|
|
424
|
+
const cleanupMock = vi.fn();
|
|
425
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
426
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
427
|
+
|
|
428
|
+
await ensureInitialized();
|
|
429
|
+
|
|
430
|
+
serverEvents.on('run-changed', () => {});
|
|
431
|
+
expect(serverEvents.listenerCount('run-changed')).toBeGreaterThan(0);
|
|
432
|
+
|
|
433
|
+
await shutdownServer();
|
|
434
|
+
|
|
435
|
+
expect(serverEvents.listenerCount('run-changed')).toBe(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('allows re-initialization after shutdown', async () => {
|
|
439
|
+
const cleanupMock = vi.fn();
|
|
440
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
441
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
442
|
+
|
|
443
|
+
await ensureInitialized();
|
|
444
|
+
await shutdownServer();
|
|
445
|
+
|
|
446
|
+
// Re-initialize
|
|
447
|
+
mockInitWatcher.mockResolvedValue(vi.fn());
|
|
448
|
+
await ensureInitialized();
|
|
449
|
+
|
|
450
|
+
expect(mockInitWatcher).toHaveBeenCalledTimes(2);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('is safe to call even when not initialized', async () => {
|
|
454
|
+
await expect(shutdownServer()).resolves.not.toThrow();
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// -----------------------------------------------------------------------
|
|
459
|
+
// getInitStatus
|
|
460
|
+
// -----------------------------------------------------------------------
|
|
461
|
+
describe('getInitStatus', () => {
|
|
462
|
+
it('returns not initialized before init', () => {
|
|
463
|
+
const status = getInitStatus();
|
|
464
|
+
|
|
465
|
+
expect(status.initialized).toBe(false);
|
|
466
|
+
expect(status.hasCleanup).toBe(false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('returns initialized after successful init', async () => {
|
|
470
|
+
const cleanupMock = vi.fn();
|
|
471
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
472
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
473
|
+
|
|
474
|
+
await ensureInitialized();
|
|
475
|
+
|
|
476
|
+
const status = getInitStatus();
|
|
477
|
+
|
|
478
|
+
expect(status.initialized).toBe(true);
|
|
479
|
+
expect(status.hasCleanup).toBe(true);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('returns not initialized after shutdown', async () => {
|
|
483
|
+
const cleanupMock = vi.fn();
|
|
484
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
485
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
486
|
+
|
|
487
|
+
await ensureInitialized();
|
|
488
|
+
await shutdownServer();
|
|
489
|
+
|
|
490
|
+
const status = getInitStatus();
|
|
491
|
+
|
|
492
|
+
expect(status.initialized).toBe(false);
|
|
493
|
+
expect(status.hasCleanup).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('reports server event listener count', async () => {
|
|
497
|
+
const cleanupMock = vi.fn();
|
|
498
|
+
mockInitWatcher.mockResolvedValue(cleanupMock);
|
|
499
|
+
mockDiscoverAndCacheAll.mockResolvedValue(undefined);
|
|
500
|
+
|
|
501
|
+
await ensureInitialized();
|
|
502
|
+
|
|
503
|
+
// The init itself registers a listener on watcherEvents, not serverEvents
|
|
504
|
+
// Let's add a listener and check
|
|
505
|
+
serverEvents.on('run-changed', () => {});
|
|
506
|
+
|
|
507
|
+
const status = getInitStatus();
|
|
508
|
+
|
|
509
|
+
expect(status.serverEventListeners).toBeGreaterThan(0);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|