@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.
Files changed (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/next.config.mjs +25 -0
  4. package/package.json +104 -0
  5. package/postcss.config.mjs +8 -0
  6. package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
  7. package/src/app/actions/approve-breakpoint.ts +145 -0
  8. package/src/app/api/config/route.ts +137 -0
  9. package/src/app/api/digest/route.ts +45 -0
  10. package/src/app/api/runs/[runId]/events/route.ts +56 -0
  11. package/src/app/api/runs/[runId]/route.ts +84 -0
  12. package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
  13. package/src/app/api/runs/route.ts +48 -0
  14. package/src/app/api/stream/route.ts +136 -0
  15. package/src/app/api/test/route.ts +1 -0
  16. package/src/app/api/version/route.ts +57 -0
  17. package/src/app/globals.css +555 -0
  18. package/src/app/icon.svg +20 -0
  19. package/src/app/layout.tsx +39 -0
  20. package/src/app/not-found.tsx +16 -0
  21. package/src/app/page.tsx +120 -0
  22. package/src/app/runs/[runId]/page.tsx +279 -0
  23. package/src/cli.ts +271 -0
  24. package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
  25. package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
  26. package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
  27. package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
  28. package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
  29. package/src/components/breakpoint/file-preview.tsx +215 -0
  30. package/src/components/dashboard/.gitkeep +0 -0
  31. package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
  32. package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
  33. package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
  34. package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
  35. package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
  36. package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
  37. package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
  38. package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
  39. package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
  40. package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
  41. package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
  42. package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
  43. package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
  44. package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
  45. package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
  46. package/src/components/dashboard/breakpoint-banner.tsx +301 -0
  47. package/src/components/dashboard/catch-up-banner.tsx +88 -0
  48. package/src/components/dashboard/executive-summary-banner.tsx +174 -0
  49. package/src/components/dashboard/global-search.tsx +323 -0
  50. package/src/components/dashboard/kpi-grid.tsx +140 -0
  51. package/src/components/dashboard/pagination-controls.tsx +100 -0
  52. package/src/components/dashboard/project-accordion.tsx +72 -0
  53. package/src/components/dashboard/project-health-card.tsx +536 -0
  54. package/src/components/dashboard/project-list-view.tsx +246 -0
  55. package/src/components/dashboard/project-search-input.tsx +41 -0
  56. package/src/components/dashboard/project-section-header.tsx +73 -0
  57. package/src/components/dashboard/project-section.tsx +89 -0
  58. package/src/components/dashboard/run-card.tsx +218 -0
  59. package/src/components/dashboard/run-filter-bar.tsx +100 -0
  60. package/src/components/dashboard/run-list.tsx +77 -0
  61. package/src/components/dashboard/search-filter.tsx +69 -0
  62. package/src/components/dashboard/virtualized-run-list.tsx +130 -0
  63. package/src/components/details/.gitkeep +0 -0
  64. package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
  65. package/src/components/details/__tests__/json-tree.test.tsx +347 -0
  66. package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
  67. package/src/components/details/__tests__/task-detail.test.tsx +212 -0
  68. package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
  69. package/src/components/details/agent-panel.tsx +234 -0
  70. package/src/components/details/json-tree/categorize.ts +131 -0
  71. package/src/components/details/json-tree/index.tsx +120 -0
  72. package/src/components/details/json-tree/json-node.tsx +223 -0
  73. package/src/components/details/json-tree/smart-summary.tsx +596 -0
  74. package/src/components/details/json-tree/tree-controls.tsx +47 -0
  75. package/src/components/details/json-tree.tsx +9 -0
  76. package/src/components/details/log-viewer.tsx +140 -0
  77. package/src/components/details/task-detail.tsx +114 -0
  78. package/src/components/details/timing-panel.tsx +247 -0
  79. package/src/components/events/.gitkeep +0 -0
  80. package/src/components/events/__tests__/event-item.test.tsx +211 -0
  81. package/src/components/events/__tests__/event-stream.test.tsx +225 -0
  82. package/src/components/events/event-item.tsx +121 -0
  83. package/src/components/events/event-stream.tsx +260 -0
  84. package/src/components/notifications/.gitkeep +0 -0
  85. package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
  86. package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
  87. package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
  88. package/src/components/notifications/notification-panel.tsx +124 -0
  89. package/src/components/notifications/notification-provider.tsx +175 -0
  90. package/src/components/notifications/toast-stack.tsx +75 -0
  91. package/src/components/pipeline/.gitkeep +0 -0
  92. package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
  93. package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
  94. package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
  95. package/src/components/pipeline/parallel-group.tsx +39 -0
  96. package/src/components/pipeline/pipeline-view.tsx +197 -0
  97. package/src/components/pipeline/step-card.tsx +166 -0
  98. package/src/components/providers/event-stream-provider.tsx +29 -0
  99. package/src/components/providers.tsx +24 -0
  100. package/src/components/shared/.gitkeep +0 -0
  101. package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
  102. package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
  103. package/src/components/shared/__tests__/kbd.test.tsx +45 -0
  104. package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
  105. package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
  106. package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
  107. package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
  108. package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
  109. package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
  110. package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
  111. package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
  112. package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
  113. package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
  114. package/src/components/shared/app-footer.tsx +80 -0
  115. package/src/components/shared/app-header.tsx +160 -0
  116. package/src/components/shared/empty-state.tsx +18 -0
  117. package/src/components/shared/error-boundary.tsx +81 -0
  118. package/src/components/shared/friendly-id.tsx +48 -0
  119. package/src/components/shared/kbd.tsx +15 -0
  120. package/src/components/shared/kind-badge.tsx +51 -0
  121. package/src/components/shared/metrics-row.tsx +106 -0
  122. package/src/components/shared/outcome-banner.tsx +56 -0
  123. package/src/components/shared/progress-bar.tsx +42 -0
  124. package/src/components/shared/session-pill.tsx +69 -0
  125. package/src/components/shared/settings-modal.tsx +509 -0
  126. package/src/components/shared/shortcuts-help.tsx +113 -0
  127. package/src/components/shared/status-badge.tsx +110 -0
  128. package/src/components/shared/theme-provider.tsx +46 -0
  129. package/src/components/shared/truncated-id.tsx +51 -0
  130. package/src/components/ui/.gitkeep +0 -0
  131. package/src/components/ui/__tests__/accordion.test.tsx +96 -0
  132. package/src/components/ui/__tests__/badge.test.tsx +69 -0
  133. package/src/components/ui/__tests__/button.test.tsx +113 -0
  134. package/src/components/ui/__tests__/tabs.test.tsx +75 -0
  135. package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
  136. package/src/components/ui/accordion.tsx +61 -0
  137. package/src/components/ui/badge.tsx +25 -0
  138. package/src/components/ui/button.tsx +40 -0
  139. package/src/components/ui/card.tsx +21 -0
  140. package/src/components/ui/scroll-area.tsx +35 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/tabs.tsx +64 -0
  143. package/src/components/ui/tooltip.tsx +37 -0
  144. package/src/hooks/.gitkeep +0 -0
  145. package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
  146. package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
  147. package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
  148. package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
  149. package/src/hooks/__tests__/use-notifications.test.ts +230 -0
  150. package/src/hooks/__tests__/use-polling.test.ts +274 -0
  151. package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
  152. package/src/hooks/__tests__/use-projects.test.ts +248 -0
  153. package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
  154. package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
  155. package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
  156. package/src/hooks/use-animated-number.ts +87 -0
  157. package/src/hooks/use-batched-updates.ts +150 -0
  158. package/src/hooks/use-event-stream.ts +150 -0
  159. package/src/hooks/use-keyboard.ts +45 -0
  160. package/src/hooks/use-notifications.ts +82 -0
  161. package/src/hooks/use-persisted-state.ts +60 -0
  162. package/src/hooks/use-polling.ts +60 -0
  163. package/src/hooks/use-project-runs.ts +51 -0
  164. package/src/hooks/use-projects.ts +26 -0
  165. package/src/hooks/use-run-dashboard.ts +207 -0
  166. package/src/hooks/use-run-detail.ts +77 -0
  167. package/src/hooks/use-smart-polling.ts +144 -0
  168. package/src/lib/.gitkeep +0 -0
  169. package/src/lib/__tests__/cn.test.ts +69 -0
  170. package/src/lib/__tests__/config-loader.test.ts +210 -0
  171. package/src/lib/__tests__/config.test.ts +561 -0
  172. package/src/lib/__tests__/error-handler.test.ts +143 -0
  173. package/src/lib/__tests__/fetcher.test.ts +517 -0
  174. package/src/lib/__tests__/global-registry.test.ts +214 -0
  175. package/src/lib/__tests__/parser.test.ts +1532 -0
  176. package/src/lib/__tests__/path-resolver.test.ts +112 -0
  177. package/src/lib/__tests__/run-cache.test.ts +591 -0
  178. package/src/lib/__tests__/server-init.test.ts +512 -0
  179. package/src/lib/__tests__/source-discovery.test.ts +246 -0
  180. package/src/lib/__tests__/utils.test.ts +160 -0
  181. package/src/lib/__tests__/watcher.test.ts +227 -0
  182. package/src/lib/cn.ts +6 -0
  183. package/src/lib/config-loader.ts +195 -0
  184. package/src/lib/config.ts +20 -0
  185. package/src/lib/error-handler.ts +76 -0
  186. package/src/lib/fetcher.ts +394 -0
  187. package/src/lib/global-registry.ts +117 -0
  188. package/src/lib/parser.ts +794 -0
  189. package/src/lib/path-resolver.ts +16 -0
  190. package/src/lib/run-cache.ts +404 -0
  191. package/src/lib/server-init.ts +226 -0
  192. package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
  193. package/src/lib/services/run-query-service.ts +286 -0
  194. package/src/lib/source-discovery.ts +216 -0
  195. package/src/lib/utils.ts +103 -0
  196. package/src/lib/watcher.ts +265 -0
  197. package/src/test/fixtures.ts +269 -0
  198. package/src/test/mocks/handlers.ts +110 -0
  199. package/src/test/mocks/server.ts +17 -0
  200. package/src/test/setup.ts +200 -0
  201. package/src/test/test-utils.tsx +36 -0
  202. package/src/types/.gitkeep +0 -0
  203. package/src/types/breakpoint.ts +17 -0
  204. package/src/types/index.ts +214 -0
  205. 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
+ });