@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,517 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resilientFetch, type FetchError } from '../fetcher';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Convenience to create a minimal Response-like object from `fetch`. */
|
|
9
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function textResponse(text: string, status: number): Response {
|
|
17
|
+
return new Response(text, { status });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Tests
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe('resilientFetch', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// -----------------------------------------------------------------------
|
|
36
|
+
// Successful fetch
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
describe('successful fetch', () => {
|
|
39
|
+
it('returns parsed JSON data on 200', async () => {
|
|
40
|
+
const payload = { items: [1, 2, 3] };
|
|
41
|
+
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(payload));
|
|
42
|
+
|
|
43
|
+
const result = await resilientFetch<typeof payload>('/api/data');
|
|
44
|
+
|
|
45
|
+
expect(result.ok).toBe(true);
|
|
46
|
+
if (result.ok) {
|
|
47
|
+
expect(result.data).toEqual(payload);
|
|
48
|
+
expect(result.status).toBe(200);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns typed data on 201', async () => {
|
|
53
|
+
const payload = { id: 'abc' };
|
|
54
|
+
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(payload, 201));
|
|
55
|
+
|
|
56
|
+
const result = await resilientFetch<typeof payload>('/api/items', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
body: JSON.stringify({ name: 'test' }),
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(result.ok).toBe(true);
|
|
63
|
+
if (result.ok) {
|
|
64
|
+
expect(result.data).toEqual(payload);
|
|
65
|
+
expect(result.status).toBe(201);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('passes method, headers, and body to fetch', async () => {
|
|
70
|
+
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
71
|
+
|
|
72
|
+
await resilientFetch('/api/action', {
|
|
73
|
+
method: 'PUT',
|
|
74
|
+
headers: { Authorization: 'Bearer token' },
|
|
75
|
+
body: '{"x":1}',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
79
|
+
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
|
80
|
+
expect(url).toBe('/api/action');
|
|
81
|
+
expect(init?.method).toBe('PUT');
|
|
82
|
+
expect(init?.headers).toEqual({ Authorization: 'Bearer token' });
|
|
83
|
+
expect(init?.body).toBe('{"x":1}');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// -----------------------------------------------------------------------
|
|
88
|
+
// Retry on 5xx
|
|
89
|
+
// -----------------------------------------------------------------------
|
|
90
|
+
describe('retry on 5xx', () => {
|
|
91
|
+
it('retries on 500 and succeeds on second attempt', async () => {
|
|
92
|
+
const payload = { recovered: true };
|
|
93
|
+
vi.mocked(fetch)
|
|
94
|
+
.mockResolvedValueOnce(textResponse('Internal Server Error', 500))
|
|
95
|
+
.mockResolvedValueOnce(jsonResponse(payload));
|
|
96
|
+
|
|
97
|
+
const promise = resilientFetch<typeof payload>('/api/data', {
|
|
98
|
+
retries: 2,
|
|
99
|
+
retryDelay: 1000,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// First attempt fails with 500 -> sleep(1000)
|
|
103
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
104
|
+
|
|
105
|
+
const result = await promise;
|
|
106
|
+
|
|
107
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
108
|
+
expect(result.ok).toBe(true);
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
expect(result.data).toEqual(payload);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('retries on 503 up to maxRetries then returns error', async () => {
|
|
115
|
+
// Use mockImplementation so each call gets a fresh Response (body can
|
|
116
|
+
// only be consumed once per Response object).
|
|
117
|
+
vi.mocked(fetch).mockImplementation(
|
|
118
|
+
() => Promise.resolve(textResponse('Service Unavailable', 503)),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const promise = resilientFetch('/api/data', {
|
|
122
|
+
retries: 2,
|
|
123
|
+
retryDelay: 100,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// attempt 0 fails -> sleep(100)
|
|
127
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
128
|
+
// attempt 1 fails -> sleep(200)
|
|
129
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
130
|
+
// attempt 2 fails -> no more retries
|
|
131
|
+
|
|
132
|
+
const result = await promise;
|
|
133
|
+
|
|
134
|
+
expect(fetch).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
135
|
+
expect(result.ok).toBe(false);
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
expect(result.error.status).toBe(503);
|
|
138
|
+
expect(result.error.isRetryable).toBe(true);
|
|
139
|
+
expect(result.error.isAborted).toBe(false);
|
|
140
|
+
expect(result.error.message).toBe('Service Unavailable');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('retries network errors with exponential backoff', async () => {
|
|
145
|
+
const payload = { ok: true };
|
|
146
|
+
vi.mocked(fetch)
|
|
147
|
+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
148
|
+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
149
|
+
.mockResolvedValueOnce(jsonResponse(payload));
|
|
150
|
+
|
|
151
|
+
const promise = resilientFetch<typeof payload>('/api/data', {
|
|
152
|
+
retries: 2,
|
|
153
|
+
retryDelay: 100,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// attempt 0 fails (network) -> sleep(100)
|
|
157
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
158
|
+
// attempt 1 fails (network) -> sleep(200) (exponential)
|
|
159
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
160
|
+
|
|
161
|
+
const result = await promise;
|
|
162
|
+
|
|
163
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
164
|
+
expect(result.ok).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// -----------------------------------------------------------------------
|
|
169
|
+
// No retry on 4xx
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
describe('no retry on 4xx', () => {
|
|
172
|
+
it('does not retry 400 Bad Request', async () => {
|
|
173
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
174
|
+
textResponse('Bad Request', 400),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const result = await resilientFetch('/api/data');
|
|
178
|
+
|
|
179
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(result.ok).toBe(false);
|
|
181
|
+
if (!result.ok) {
|
|
182
|
+
expect(result.error.status).toBe(400);
|
|
183
|
+
expect(result.error.isRetryable).toBe(false);
|
|
184
|
+
expect(result.error.message).toBe('Bad Request');
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('retries 404 Not Found (retryable for HMR)', async () => {
|
|
189
|
+
// 404 is retryable because Next.js dev server returns transient 404s
|
|
190
|
+
// during HMR recompilation when API route handlers are momentarily unavailable.
|
|
191
|
+
vi.mocked(fetch).mockImplementation(
|
|
192
|
+
() => Promise.resolve(textResponse('Not Found', 404)),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const promise = resilientFetch('/api/missing', {
|
|
196
|
+
retries: 2,
|
|
197
|
+
retryDelay: 100,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// attempt 0 fails -> sleep(100)
|
|
201
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
202
|
+
// attempt 1 fails -> sleep(200)
|
|
203
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
204
|
+
// attempt 2 fails -> no more retries
|
|
205
|
+
|
|
206
|
+
const result = await promise;
|
|
207
|
+
|
|
208
|
+
expect(fetch).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
209
|
+
expect(result.ok).toBe(false);
|
|
210
|
+
if (!result.ok) {
|
|
211
|
+
expect(result.error.status).toBe(404);
|
|
212
|
+
expect(result.error.isRetryable).toBe(true);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('does not retry 422 Unprocessable Entity', async () => {
|
|
217
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
218
|
+
textResponse('Validation failed', 422),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const result = await resilientFetch('/api/data');
|
|
222
|
+
|
|
223
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(result.ok).toBe(false);
|
|
225
|
+
if (!result.ok) {
|
|
226
|
+
expect(result.error.status).toBe(422);
|
|
227
|
+
expect(result.error.isRetryable).toBe(false);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
// Abort signal cancellation
|
|
234
|
+
// -----------------------------------------------------------------------
|
|
235
|
+
describe('abort signal', () => {
|
|
236
|
+
it('returns aborted error when external signal is already aborted', async () => {
|
|
237
|
+
const controller = new AbortController();
|
|
238
|
+
controller.abort();
|
|
239
|
+
|
|
240
|
+
const result = await resilientFetch('/api/data', {
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.ok).toBe(false);
|
|
245
|
+
if (!result.ok) {
|
|
246
|
+
expect(result.error.isAborted).toBe(true);
|
|
247
|
+
expect(result.error.message).toBe('Request aborted');
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('cancels in-flight request when signal is aborted', async () => {
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
|
|
254
|
+
// Make fetch hang until aborted
|
|
255
|
+
vi.mocked(fetch).mockImplementationOnce(
|
|
256
|
+
(_url, init) =>
|
|
257
|
+
new Promise((_resolve, reject) => {
|
|
258
|
+
init?.signal?.addEventListener('abort', () => {
|
|
259
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
260
|
+
});
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const promise = resilientFetch('/api/data', {
|
|
265
|
+
signal: controller.signal,
|
|
266
|
+
retries: 0,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Abort after a short delay
|
|
270
|
+
controller.abort();
|
|
271
|
+
|
|
272
|
+
const result = await promise;
|
|
273
|
+
|
|
274
|
+
expect(result.ok).toBe(false);
|
|
275
|
+
if (!result.ok) {
|
|
276
|
+
expect(result.error.isAborted).toBe(true);
|
|
277
|
+
expect(result.error.message).toBe('Request aborted');
|
|
278
|
+
expect(result.error.status).toBe(0);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('aborts during retry sleep and returns aborted error', async () => {
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
|
|
285
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
286
|
+
textResponse('Internal Server Error', 500),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const promise = resilientFetch('/api/data', {
|
|
290
|
+
signal: controller.signal,
|
|
291
|
+
retries: 2,
|
|
292
|
+
retryDelay: 5000,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Let the first attempt complete and enter retry sleep
|
|
296
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
297
|
+
|
|
298
|
+
// Abort while sleeping between retries
|
|
299
|
+
controller.abort();
|
|
300
|
+
|
|
301
|
+
const result = await promise;
|
|
302
|
+
|
|
303
|
+
expect(result.ok).toBe(false);
|
|
304
|
+
if (!result.ok) {
|
|
305
|
+
expect(result.error.isAborted).toBe(true);
|
|
306
|
+
expect(result.error.message).toBe('Request aborted');
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('aborts during retry sleep after a network error', async () => {
|
|
311
|
+
const controller = new AbortController();
|
|
312
|
+
|
|
313
|
+
// First attempt throws a network error
|
|
314
|
+
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
|
315
|
+
|
|
316
|
+
const promise = resilientFetch('/api/data', {
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
retries: 2,
|
|
319
|
+
retryDelay: 5000,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Let the first attempt complete and enter the network-error retry sleep
|
|
323
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
324
|
+
|
|
325
|
+
// Abort while sleeping between retries
|
|
326
|
+
controller.abort();
|
|
327
|
+
|
|
328
|
+
const result = await promise;
|
|
329
|
+
|
|
330
|
+
expect(result.ok).toBe(false);
|
|
331
|
+
if (!result.ok) {
|
|
332
|
+
expect(result.error.isAborted).toBe(true);
|
|
333
|
+
expect(result.error.message).toBe('Request aborted');
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// -----------------------------------------------------------------------
|
|
339
|
+
// Timeout
|
|
340
|
+
// -----------------------------------------------------------------------
|
|
341
|
+
describe('timeout', () => {
|
|
342
|
+
it('returns timeout error when request exceeds timeout', async () => {
|
|
343
|
+
// Make fetch hang indefinitely
|
|
344
|
+
vi.mocked(fetch).mockImplementationOnce(
|
|
345
|
+
(_url, init) =>
|
|
346
|
+
new Promise((_resolve, reject) => {
|
|
347
|
+
init?.signal?.addEventListener('abort', () => {
|
|
348
|
+
reject(new DOMException('The operation was aborted', 'AbortError'));
|
|
349
|
+
});
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const promise = resilientFetch('/api/slow', {
|
|
354
|
+
timeout: 3000,
|
|
355
|
+
retries: 0,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
359
|
+
|
|
360
|
+
const result = await promise;
|
|
361
|
+
|
|
362
|
+
expect(result.ok).toBe(false);
|
|
363
|
+
if (!result.ok) {
|
|
364
|
+
expect(result.error.message).toBe('Request timed out');
|
|
365
|
+
expect(result.error.isAborted).toBe(false);
|
|
366
|
+
expect(result.error.status).toBe(0);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('uses default 10s timeout', async () => {
|
|
371
|
+
vi.mocked(fetch).mockImplementationOnce(
|
|
372
|
+
(_url, init) =>
|
|
373
|
+
new Promise((_resolve, reject) => {
|
|
374
|
+
init?.signal?.addEventListener('abort', () => {
|
|
375
|
+
reject(new DOMException('The operation was aborted', 'AbortError'));
|
|
376
|
+
});
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const promise = resilientFetch('/api/slow', { retries: 0 });
|
|
381
|
+
|
|
382
|
+
// Advance just under 10s -- should still be pending
|
|
383
|
+
await vi.advanceTimersByTimeAsync(9999);
|
|
384
|
+
// Now cross the 10s threshold
|
|
385
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
386
|
+
|
|
387
|
+
const result = await promise;
|
|
388
|
+
|
|
389
|
+
expect(result.ok).toBe(false);
|
|
390
|
+
if (!result.ok) {
|
|
391
|
+
expect(result.error.message).toBe('Request timed out');
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// -----------------------------------------------------------------------
|
|
397
|
+
// Error normalization
|
|
398
|
+
// -----------------------------------------------------------------------
|
|
399
|
+
describe('error normalization', () => {
|
|
400
|
+
it('normalizes network TypeError to FetchError', async () => {
|
|
401
|
+
vi.mocked(fetch).mockRejectedValue(new TypeError('Failed to fetch'));
|
|
402
|
+
|
|
403
|
+
const promise = resilientFetch('/api/data', {
|
|
404
|
+
retries: 0,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const result = await promise;
|
|
408
|
+
|
|
409
|
+
expect(result.ok).toBe(false);
|
|
410
|
+
if (!result.ok) {
|
|
411
|
+
expect(result.error.status).toBe(0);
|
|
412
|
+
expect(result.error.message).toBe('Failed to fetch');
|
|
413
|
+
expect(result.error.isRetryable).toBe(true);
|
|
414
|
+
expect(result.error.isAborted).toBe(false);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('includes response body text as error message for HTTP errors', async () => {
|
|
419
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
420
|
+
textResponse('{"error":"quota_exceeded"}', 429),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const result = await resilientFetch('/api/data');
|
|
424
|
+
|
|
425
|
+
expect(result.ok).toBe(false);
|
|
426
|
+
if (!result.ok) {
|
|
427
|
+
expect(result.error.status).toBe(429);
|
|
428
|
+
expect(result.error.message).toBe('{"error":"quota_exceeded"}');
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('falls back to HTTP status text when body is empty', async () => {
|
|
433
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
434
|
+
new Response('', { status: 401 }),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const result = await resilientFetch('/api/data');
|
|
438
|
+
|
|
439
|
+
expect(result.ok).toBe(false);
|
|
440
|
+
if (!result.ok) {
|
|
441
|
+
expect(result.error.status).toBe(401);
|
|
442
|
+
expect(result.error.message).toBe('HTTP 401');
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('returns consistent shape for all error paths', async () => {
|
|
447
|
+
// Network error path
|
|
448
|
+
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Network down'));
|
|
449
|
+
|
|
450
|
+
const result = await resilientFetch('/api/data', { retries: 0 });
|
|
451
|
+
|
|
452
|
+
expect(result.ok).toBe(false);
|
|
453
|
+
if (!result.ok) {
|
|
454
|
+
const err: FetchError = result.error;
|
|
455
|
+
expect(err).toHaveProperty('status');
|
|
456
|
+
expect(err).toHaveProperty('message');
|
|
457
|
+
expect(err).toHaveProperty('isRetryable');
|
|
458
|
+
expect(err).toHaveProperty('isAborted');
|
|
459
|
+
expect(typeof err.status).toBe('number');
|
|
460
|
+
expect(typeof err.message).toBe('string');
|
|
461
|
+
expect(typeof err.isRetryable).toBe('boolean');
|
|
462
|
+
expect(typeof err.isAborted).toBe('boolean');
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// -----------------------------------------------------------------------
|
|
468
|
+
// Edge cases
|
|
469
|
+
// -----------------------------------------------------------------------
|
|
470
|
+
describe('edge cases', () => {
|
|
471
|
+
it('works with retries set to 0 (no retries)', async () => {
|
|
472
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
473
|
+
textResponse('Server Error', 500),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const result = await resilientFetch('/api/data', { retries: 0 });
|
|
477
|
+
|
|
478
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
479
|
+
expect(result.ok).toBe(false);
|
|
480
|
+
if (!result.ok) {
|
|
481
|
+
expect(result.error.status).toBe(500);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('returns error when response.json() throws on 200 OK (non-JSON body)', async () => {
|
|
486
|
+
// Server returns 200 with a non-JSON body (e.g. plain text / HTML)
|
|
487
|
+
// The implementation treats JSON parse failures as "server temporarily
|
|
488
|
+
// unavailable (recompiling)" and marks them retryable, since this
|
|
489
|
+
// commonly happens during Next.js HMR recompilation.
|
|
490
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
491
|
+
new Response('not json', {
|
|
492
|
+
status: 200,
|
|
493
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const result = await resilientFetch('/api/data', { retries: 0 });
|
|
498
|
+
|
|
499
|
+
expect(result.ok).toBe(false);
|
|
500
|
+
if (!result.ok) {
|
|
501
|
+
expect(result.error.status).toBe(200);
|
|
502
|
+
expect(result.error.isRetryable).toBe(true);
|
|
503
|
+
expect(result.error.isAborted).toBe(false);
|
|
504
|
+
expect(result.error.message).toContain('Server temporarily unavailable');
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('defaults to GET method', async () => {
|
|
509
|
+
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
510
|
+
|
|
511
|
+
await resilientFetch('/api/data');
|
|
512
|
+
|
|
513
|
+
const [, init] = vi.mocked(fetch).mock.calls[0];
|
|
514
|
+
expect(init?.method).toBe('GET');
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
});
|