@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,315 @@
|
|
|
1
|
+
import { renderHook, act } from "@testing-library/react";
|
|
2
|
+
import { useBatchedUpdates, BURST_THRESHOLD, BURST_WINDOW_MS, CATCHUP_HOLD_MS } from "../use-batched-updates";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mock the SSE event stream subscribe function
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
type EventCallback = (event: { type: string; runId?: string }) => void;
|
|
9
|
+
let subscriberCallbacks: Set<EventCallback> = new Set();
|
|
10
|
+
|
|
11
|
+
vi.mock("../use-event-stream", () => ({
|
|
12
|
+
subscribe: (callback: EventCallback) => {
|
|
13
|
+
subscriberCallbacks.add(callback);
|
|
14
|
+
return () => {
|
|
15
|
+
subscriberCallbacks.delete(callback);
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
function emitSSE(event: { type: string; runId?: string }) {
|
|
21
|
+
subscriberCallbacks.forEach((cb) => cb(event));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tests
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
describe("useBatchedUpdates", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.useFakeTimers();
|
|
31
|
+
subscriberCallbacks = new Set();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("starts inactive with 0 buffered count", () => {
|
|
39
|
+
const { result } = renderHook(() => useBatchedUpdates());
|
|
40
|
+
expect(result.current.active).toBe(false);
|
|
41
|
+
expect(result.current.bufferedCount).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not activate for a small number of events", () => {
|
|
45
|
+
const { result } = renderHook(() =>
|
|
46
|
+
useBatchedUpdates({
|
|
47
|
+
sseFilter: (e) => e.type === "update",
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Send fewer events than the threshold
|
|
52
|
+
for (let i = 0; i < BURST_THRESHOLD - 1; i++) {
|
|
53
|
+
act(() => {
|
|
54
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect(result.current.active).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("activates catch-up mode when burst threshold is reached", () => {
|
|
62
|
+
const { result } = renderHook(() =>
|
|
63
|
+
useBatchedUpdates({
|
|
64
|
+
sseFilter: (e) => e.type === "update",
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Send enough events to trigger burst detection
|
|
69
|
+
for (let i = 0; i < BURST_THRESHOLD; i++) {
|
|
70
|
+
act(() => {
|
|
71
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
expect(result.current.active).toBe(true);
|
|
76
|
+
expect(result.current.bufferedCount).toBe(BURST_THRESHOLD);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("increments bufferedCount for events received during catch-up mode", () => {
|
|
80
|
+
const { result } = renderHook(() =>
|
|
81
|
+
useBatchedUpdates({
|
|
82
|
+
sseFilter: (e) => e.type === "update",
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Trigger catch-up mode
|
|
87
|
+
for (let i = 0; i < BURST_THRESHOLD; i++) {
|
|
88
|
+
act(() => {
|
|
89
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
expect(result.current.active).toBe(true);
|
|
94
|
+
|
|
95
|
+
// Send more events
|
|
96
|
+
act(() => {
|
|
97
|
+
emitSSE({ type: "update", runId: "extra-1" });
|
|
98
|
+
});
|
|
99
|
+
act(() => {
|
|
100
|
+
emitSSE({ type: "update", runId: "extra-2" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.current.bufferedCount).toBe(BURST_THRESHOLD + 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("ignores non-data events (connected, disconnect, error)", () => {
|
|
107
|
+
const { result } = renderHook(() => useBatchedUpdates());
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
emitSSE({ type: "connected" });
|
|
111
|
+
emitSSE({ type: "disconnect" });
|
|
112
|
+
emitSSE({ type: "error" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(result.current.active).toBe(false);
|
|
116
|
+
expect(result.current.bufferedCount).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("respects sseFilter — does not count filtered-out events", () => {
|
|
120
|
+
const { result } = renderHook(() =>
|
|
121
|
+
useBatchedUpdates({
|
|
122
|
+
sseFilter: (e) => e.type === "update",
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Send events that don't match the filter
|
|
127
|
+
for (let i = 0; i < BURST_THRESHOLD + 5; i++) {
|
|
128
|
+
act(() => {
|
|
129
|
+
emitSSE({ type: "heartbeat", runId: `run-${i}` });
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
expect(result.current.active).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("auto-exits catch-up mode after hold period with no new events", async () => {
|
|
137
|
+
const onFlush = vi.fn();
|
|
138
|
+
const { result } = renderHook(() =>
|
|
139
|
+
useBatchedUpdates({
|
|
140
|
+
sseFilter: (e) => e.type === "update",
|
|
141
|
+
onFlush,
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Trigger catch-up mode
|
|
146
|
+
for (let i = 0; i < BURST_THRESHOLD; i++) {
|
|
147
|
+
act(() => {
|
|
148
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
expect(result.current.active).toBe(true);
|
|
153
|
+
|
|
154
|
+
// Wait for the hold period to expire
|
|
155
|
+
await act(async () => {
|
|
156
|
+
await vi.advanceTimersByTimeAsync(CATCHUP_HOLD_MS + 100);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(result.current.active).toBe(false);
|
|
160
|
+
expect(result.current.bufferedCount).toBe(0);
|
|
161
|
+
expect(onFlush).toHaveBeenCalledTimes(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("resets the hold timer when new events arrive during catch-up", async () => {
|
|
165
|
+
const onFlush = vi.fn();
|
|
166
|
+
const { result } = renderHook(() =>
|
|
167
|
+
useBatchedUpdates({
|
|
168
|
+
sseFilter: (e) => e.type === "update",
|
|
169
|
+
onFlush,
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Trigger catch-up mode
|
|
174
|
+
for (let i = 0; i < BURST_THRESHOLD; i++) {
|
|
175
|
+
act(() => {
|
|
176
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expect(result.current.active).toBe(true);
|
|
181
|
+
|
|
182
|
+
// Wait almost to the hold timeout
|
|
183
|
+
await act(async () => {
|
|
184
|
+
await vi.advanceTimersByTimeAsync(CATCHUP_HOLD_MS - 500);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Send another event — should reset the timer
|
|
188
|
+
act(() => {
|
|
189
|
+
emitSSE({ type: "update", runId: "late-event" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// The original timeout would have expired by now, but the timer was reset
|
|
193
|
+
await act(async () => {
|
|
194
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
195
|
+
});
|
|
196
|
+
expect(result.current.active).toBe(true);
|
|
197
|
+
expect(onFlush).not.toHaveBeenCalled();
|
|
198
|
+
|
|
199
|
+
// Now wait for the full hold period from the last event
|
|
200
|
+
await act(async () => {
|
|
201
|
+
await vi.advanceTimersByTimeAsync(CATCHUP_HOLD_MS);
|
|
202
|
+
});
|
|
203
|
+
expect(result.current.active).toBe(false);
|
|
204
|
+
expect(onFlush).toHaveBeenCalledTimes(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("flush() immediately exits catch-up mode and calls onFlush", () => {
|
|
208
|
+
const onFlush = vi.fn();
|
|
209
|
+
const { result } = renderHook(() =>
|
|
210
|
+
useBatchedUpdates({
|
|
211
|
+
sseFilter: (e) => e.type === "update",
|
|
212
|
+
onFlush,
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Trigger catch-up mode
|
|
217
|
+
for (let i = 0; i < BURST_THRESHOLD; i++) {
|
|
218
|
+
act(() => {
|
|
219
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
expect(result.current.active).toBe(true);
|
|
224
|
+
|
|
225
|
+
// Flush
|
|
226
|
+
act(() => {
|
|
227
|
+
result.current.flush();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(result.current.active).toBe(false);
|
|
231
|
+
expect(result.current.bufferedCount).toBe(0);
|
|
232
|
+
expect(onFlush).toHaveBeenCalledTimes(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("flush() is a no-op when not in catch-up mode", () => {
|
|
236
|
+
const onFlush = vi.fn();
|
|
237
|
+
const { result } = renderHook(() =>
|
|
238
|
+
useBatchedUpdates({ onFlush })
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
act(() => {
|
|
242
|
+
result.current.flush();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(onFlush).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("does nothing when disabled", () => {
|
|
249
|
+
const { result } = renderHook(() =>
|
|
250
|
+
useBatchedUpdates({ enabled: false })
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Send many events
|
|
254
|
+
for (let i = 0; i < BURST_THRESHOLD + 5; i++) {
|
|
255
|
+
act(() => {
|
|
256
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
expect(result.current.active).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("cleans up on unmount", () => {
|
|
264
|
+
const { unmount } = renderHook(() =>
|
|
265
|
+
useBatchedUpdates({
|
|
266
|
+
sseFilter: (e) => e.type === "update",
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Verify we have a subscriber
|
|
271
|
+
expect(subscriberCallbacks.size).toBeGreaterThan(0);
|
|
272
|
+
|
|
273
|
+
unmount();
|
|
274
|
+
|
|
275
|
+
// Subscriber should be removed
|
|
276
|
+
// Note: the exact count depends on how many hooks subscribe,
|
|
277
|
+
// but after unmount the hook's subscriber should be gone
|
|
278
|
+
const _countAfter = subscriberCallbacks.size;
|
|
279
|
+
// Verify no errors when emitting after unmount
|
|
280
|
+
act(() => {
|
|
281
|
+
emitSSE({ type: "update" });
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("prunes old timestamps outside burst window", async () => {
|
|
286
|
+
const { result } = renderHook(() =>
|
|
287
|
+
useBatchedUpdates({
|
|
288
|
+
sseFilter: (e) => e.type === "update",
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Send some events
|
|
293
|
+
for (let i = 0; i < BURST_THRESHOLD - 2; i++) {
|
|
294
|
+
act(() => {
|
|
295
|
+
emitSSE({ type: "update", runId: `run-${i}` });
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Wait longer than the burst window so old timestamps expire
|
|
300
|
+
await act(async () => {
|
|
301
|
+
await vi.advanceTimersByTimeAsync(BURST_WINDOW_MS + 100);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Send a few more events — not enough to reach threshold from scratch
|
|
305
|
+
act(() => {
|
|
306
|
+
emitSSE({ type: "update", runId: "late-1" });
|
|
307
|
+
});
|
|
308
|
+
act(() => {
|
|
309
|
+
emitSSE({ type: "update", runId: "late-2" });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Should NOT be in catch-up mode because old events were pruned
|
|
313
|
+
expect(result.current.active).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react';
|
|
2
|
+
import { useEventStream, subscribe } from '../use-event-stream';
|
|
3
|
+
|
|
4
|
+
type MockEventSourceInstance = {
|
|
5
|
+
onopen: ((event: Event) => void) | null;
|
|
6
|
+
onmessage: ((event: MessageEvent) => void) | null;
|
|
7
|
+
onerror: ((event: Event) => void) | null;
|
|
8
|
+
close: ReturnType<typeof vi.fn>;
|
|
9
|
+
readyState: number;
|
|
10
|
+
url: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let mockEventSourceInstances: MockEventSourceInstance[] = [];
|
|
14
|
+
|
|
15
|
+
class MockEventSource {
|
|
16
|
+
static CONNECTING = 0;
|
|
17
|
+
static OPEN = 1;
|
|
18
|
+
static CLOSED = 2;
|
|
19
|
+
|
|
20
|
+
onopen: ((event: Event) => void) | null = null;
|
|
21
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
22
|
+
onerror: ((event: Event) => void) | null = null;
|
|
23
|
+
close = vi.fn();
|
|
24
|
+
readyState = MockEventSource.OPEN;
|
|
25
|
+
url: string;
|
|
26
|
+
|
|
27
|
+
constructor(url: string) {
|
|
28
|
+
this.url = url;
|
|
29
|
+
mockEventSourceInstances.push(this);
|
|
30
|
+
// Simulate async open
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
if (this.onopen) {
|
|
33
|
+
this.onopen(new Event('open'));
|
|
34
|
+
}
|
|
35
|
+
}, 0);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// We need to track unsubscribe functions to clean up between tests
|
|
40
|
+
let activeUnsubscribers: Array<() => void> = [];
|
|
41
|
+
|
|
42
|
+
describe('use-event-stream', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.useFakeTimers();
|
|
45
|
+
mockEventSourceInstances = [];
|
|
46
|
+
vi.stubGlobal('EventSource', MockEventSource);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
// Unsubscribe all active subscribers to reset module-level state
|
|
51
|
+
for (const unsub of activeUnsubscribers) {
|
|
52
|
+
unsub();
|
|
53
|
+
}
|
|
54
|
+
activeUnsubscribers = [];
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('subscribe', () => {
|
|
60
|
+
it('creates a shared EventSource on first subscriber', () => {
|
|
61
|
+
const callback = vi.fn();
|
|
62
|
+
const unsubscribe = subscribe(callback);
|
|
63
|
+
activeUnsubscribers.push(unsubscribe);
|
|
64
|
+
|
|
65
|
+
expect(mockEventSourceInstances).toHaveLength(1);
|
|
66
|
+
expect(mockEventSourceInstances[0].url).toBe('/api/stream');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reuses the same EventSource for multiple subscribers', () => {
|
|
70
|
+
const cb1 = vi.fn();
|
|
71
|
+
const cb2 = vi.fn();
|
|
72
|
+
|
|
73
|
+
const unsub1 = subscribe(cb1);
|
|
74
|
+
const unsub2 = subscribe(cb2);
|
|
75
|
+
activeUnsubscribers.push(unsub1, unsub2);
|
|
76
|
+
|
|
77
|
+
expect(mockEventSourceInstances).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('delivers messages to all subscribers', () => {
|
|
81
|
+
const cb1 = vi.fn();
|
|
82
|
+
const cb2 = vi.fn();
|
|
83
|
+
|
|
84
|
+
const unsub1 = subscribe(cb1);
|
|
85
|
+
const unsub2 = subscribe(cb2);
|
|
86
|
+
activeUnsubscribers.push(unsub1, unsub2);
|
|
87
|
+
|
|
88
|
+
const instance = mockEventSourceInstances[0];
|
|
89
|
+
const messageData = { type: 'run_updated', runId: 'run-1' };
|
|
90
|
+
|
|
91
|
+
instance.onmessage!(
|
|
92
|
+
new MessageEvent('message', { data: JSON.stringify(messageData) })
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(cb1).toHaveBeenCalledWith(messageData);
|
|
96
|
+
expect(cb2).toHaveBeenCalledWith(messageData);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('closes EventSource when last subscriber unsubscribes', () => {
|
|
100
|
+
const cb1 = vi.fn();
|
|
101
|
+
const cb2 = vi.fn();
|
|
102
|
+
|
|
103
|
+
const unsub1 = subscribe(cb1);
|
|
104
|
+
const unsub2 = subscribe(cb2);
|
|
105
|
+
|
|
106
|
+
unsub1();
|
|
107
|
+
// Still one subscriber, should not close
|
|
108
|
+
expect(mockEventSourceInstances[0].close).not.toHaveBeenCalled();
|
|
109
|
+
|
|
110
|
+
unsub2();
|
|
111
|
+
// Last subscriber gone, should close
|
|
112
|
+
expect(mockEventSourceInstances[0].close).toHaveBeenCalled();
|
|
113
|
+
// Do not add to activeUnsubscribers since we already cleaned up
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('notifies subscribers with disconnect event on SSE error', () => {
|
|
117
|
+
const callback = vi.fn();
|
|
118
|
+
const unsub = subscribe(callback);
|
|
119
|
+
activeUnsubscribers.push(unsub);
|
|
120
|
+
|
|
121
|
+
const instance = mockEventSourceInstances[0];
|
|
122
|
+
|
|
123
|
+
instance.onerror!(new Event('error'));
|
|
124
|
+
|
|
125
|
+
expect(callback).toHaveBeenCalledWith({ type: 'disconnect' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('attempts reconnect with backoff on error', async () => {
|
|
129
|
+
const callback = vi.fn();
|
|
130
|
+
const unsub = subscribe(callback);
|
|
131
|
+
activeUnsubscribers.push(unsub);
|
|
132
|
+
|
|
133
|
+
const instance = mockEventSourceInstances[0];
|
|
134
|
+
|
|
135
|
+
// Trigger error
|
|
136
|
+
instance.onerror!(new Event('error'));
|
|
137
|
+
|
|
138
|
+
expect(mockEventSourceInstances).toHaveLength(1); // only original
|
|
139
|
+
|
|
140
|
+
// After reconnectAttempts++ (0->1), delay = delays[1] = 2000ms
|
|
141
|
+
await act(async () => {
|
|
142
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(mockEventSourceInstances).toHaveLength(2); // reconnected
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('useEventStream', () => {
|
|
150
|
+
it('returns connected, lastEvent, error initial state', () => {
|
|
151
|
+
const { result } = renderHook(() => useEventStream());
|
|
152
|
+
|
|
153
|
+
expect(result.current.connected).toBe(false);
|
|
154
|
+
expect(result.current.lastEvent).toBeNull();
|
|
155
|
+
expect(result.current.error).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('updates lastEvent when a message is received', () => {
|
|
159
|
+
const { result } = renderHook(() => useEventStream());
|
|
160
|
+
|
|
161
|
+
const instance = mockEventSourceInstances[0];
|
|
162
|
+
const eventData = { type: 'run_completed', runId: 'run-1' };
|
|
163
|
+
|
|
164
|
+
act(() => {
|
|
165
|
+
instance.onmessage!(
|
|
166
|
+
new MessageEvent('message', { data: JSON.stringify(eventData) })
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.current.lastEvent).toEqual(eventData);
|
|
171
|
+
expect(result.current.connected).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not set connected=true for disconnect events', () => {
|
|
175
|
+
const { result } = renderHook(() => useEventStream());
|
|
176
|
+
|
|
177
|
+
const instance = mockEventSourceInstances[0];
|
|
178
|
+
|
|
179
|
+
// First send a real event to set connected=true
|
|
180
|
+
act(() => {
|
|
181
|
+
instance.onmessage!(
|
|
182
|
+
new MessageEvent('message', { data: JSON.stringify({ type: 'update', runId: 'r1' }) })
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
expect(result.current.connected).toBe(true);
|
|
186
|
+
|
|
187
|
+
// Now trigger SSE error which emits disconnect
|
|
188
|
+
act(() => {
|
|
189
|
+
instance.onerror!(new Event('error'));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// connected should not have been set to true by the disconnect event
|
|
193
|
+
// (the interval checker will eventually update it, but the event itself should not)
|
|
194
|
+
expect(result.current.lastEvent).toEqual({ type: 'disconnect' });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('does not set connected=true for error events', () => {
|
|
198
|
+
const { result } = renderHook(() => useEventStream());
|
|
199
|
+
|
|
200
|
+
const instance = mockEventSourceInstances[0];
|
|
201
|
+
|
|
202
|
+
// Send a server-side error event (type: 'error')
|
|
203
|
+
act(() => {
|
|
204
|
+
instance.onmessage!(
|
|
205
|
+
new MessageEvent('message', { data: JSON.stringify({ type: 'error', error: 'test' }) })
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// lastEvent should be set but connected should remain false
|
|
210
|
+
expect(result.current.lastEvent).toEqual({ type: 'error', error: 'test' });
|
|
211
|
+
expect(result.current.connected).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('cleans up on unmount', () => {
|
|
215
|
+
const { unmount } = renderHook(() => useEventStream());
|
|
216
|
+
|
|
217
|
+
expect(mockEventSourceInstances).toHaveLength(1);
|
|
218
|
+
|
|
219
|
+
unmount();
|
|
220
|
+
|
|
221
|
+
// After unmount, the EventSource should be closed
|
|
222
|
+
expect(mockEventSourceInstances[0].close).toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('use-event-stream (no EventSource)', () => {
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
// Remove EventSource from global scope
|
|
230
|
+
vi.stubGlobal('EventSource', undefined);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
afterEach(() => {
|
|
234
|
+
vi.restoreAllMocks();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('sets error when EventSource is not supported', () => {
|
|
238
|
+
const { result } = renderHook(() => useEventStream());
|
|
239
|
+
|
|
240
|
+
expect(result.current.error).toBe('EventSource not supported');
|
|
241
|
+
expect(result.current.connected).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|