@nkmc/agent-fs 0.1.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 (42) hide show
  1. package/dist/chunk-7LIZT7L3.js +966 -0
  2. package/dist/index.cjs +1278 -0
  3. package/dist/index.d.cts +96 -0
  4. package/dist/index.d.ts +96 -0
  5. package/dist/index.js +419 -0
  6. package/dist/rpc-D1IHpjF_.d.cts +330 -0
  7. package/dist/rpc-D1IHpjF_.d.ts +330 -0
  8. package/dist/testing.cjs +842 -0
  9. package/dist/testing.d.cts +29 -0
  10. package/dist/testing.d.ts +29 -0
  11. package/dist/testing.js +10 -0
  12. package/package.json +25 -0
  13. package/src/agent-fs.ts +151 -0
  14. package/src/backends/http.ts +835 -0
  15. package/src/backends/memory.ts +183 -0
  16. package/src/backends/rpc.ts +456 -0
  17. package/src/index.ts +36 -0
  18. package/src/mount.ts +84 -0
  19. package/src/parser.ts +162 -0
  20. package/src/server.ts +158 -0
  21. package/src/testing.ts +3 -0
  22. package/src/types.ts +52 -0
  23. package/test/agent-fs.test.ts +325 -0
  24. package/test/http-204.test.ts +102 -0
  25. package/test/http-auth-prefix.test.ts +79 -0
  26. package/test/http-cloudflare.test.ts +533 -0
  27. package/test/http-form-encoding.test.ts +119 -0
  28. package/test/http-github.test.ts +580 -0
  29. package/test/http-listkey.test.ts +128 -0
  30. package/test/http-oauth2.test.ts +174 -0
  31. package/test/http-pagination.test.ts +200 -0
  32. package/test/http-param-styles.test.ts +98 -0
  33. package/test/http-passthrough.test.ts +282 -0
  34. package/test/http-retry.test.ts +132 -0
  35. package/test/http.test.ts +360 -0
  36. package/test/memory.test.ts +120 -0
  37. package/test/mount.test.ts +94 -0
  38. package/test/parser.test.ts +100 -0
  39. package/test/rpc-crud.test.ts +627 -0
  40. package/test/rpc-evm.test.ts +390 -0
  41. package/tsconfig.json +8 -0
  42. package/tsup.config.ts +8 -0
@@ -0,0 +1,627 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { createServer, type Server } from "node:http";
3
+ import {
4
+ RpcBackend,
5
+ JsonRpcTransport,
6
+ RpcError,
7
+ type RpcResource,
8
+ } from "../src/backends/rpc.js";
9
+ import { AgentFs } from "../src/agent-fs.js";
10
+
11
+ // --- Mock CRUD data store ---
12
+
13
+ type Item = { id: string; name: string };
14
+ type Tag = { id: string; label: string };
15
+
16
+ function createStore() {
17
+ const items = new Map<string, Item>([
18
+ ["1", { id: "1", name: "Alpha" }],
19
+ ["2", { id: "2", name: "Bravo" }],
20
+ ["3", { id: "3", name: "Charlie" }],
21
+ ]);
22
+
23
+ const tags = new Map<string, Tag>([
24
+ ["t1", { id: "t1", label: "urgent" }],
25
+ ["t2", { id: "t2", label: "low" }],
26
+ ]);
27
+
28
+ return { items, tags };
29
+ }
30
+
31
+ // --- Fault injection ---
32
+
33
+ type HttpFault = "429" | "500" | "empty" | "html" | "400";
34
+ type RpcFault = "internal" | "server" | "method-not-found" | "invalid-request" | "invalid-params";
35
+
36
+ function createFaultInjector() {
37
+ const httpFaultQueue: HttpFault[] = [];
38
+ const rpcFaultQueue: RpcFault[] = [];
39
+ let requestCount = 0;
40
+ /** Custom Retry-After value for next 429 */
41
+ let retryAfterValue: string | null = null;
42
+
43
+ return {
44
+ injectHttpFault(...faults: HttpFault[]) {
45
+ httpFaultQueue.push(...faults);
46
+ },
47
+ injectRpcFault(...faults: RpcFault[]) {
48
+ rpcFaultQueue.push(...faults);
49
+ },
50
+ setRetryAfter(value: string | null) {
51
+ retryAfterValue = value;
52
+ },
53
+ getRequestCount() {
54
+ return requestCount;
55
+ },
56
+ resetRequestCount() {
57
+ requestCount = 0;
58
+ },
59
+ /** Called by mock server on each request — returns fault response or null */
60
+ checkHttpFault(res: import("node:http").ServerResponse): boolean {
61
+ requestCount++;
62
+ const fault = httpFaultQueue.shift();
63
+ if (!fault) return false;
64
+
65
+ switch (fault) {
66
+ case "429": {
67
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
68
+ if (retryAfterValue) {
69
+ headers["Retry-After"] = retryAfterValue;
70
+ retryAfterValue = null;
71
+ }
72
+ res.writeHead(429, headers);
73
+ res.end(JSON.stringify({ error: "Too Many Requests" }));
74
+ return true;
75
+ }
76
+ case "500":
77
+ res.writeHead(500, { "Content-Type": "application/json" });
78
+ res.end(JSON.stringify({ error: "Internal Server Error" }));
79
+ return true;
80
+ case "400":
81
+ res.writeHead(400, { "Content-Type": "application/json" });
82
+ res.end(JSON.stringify({ error: "Bad Request" }));
83
+ return true;
84
+ case "empty":
85
+ res.writeHead(200, { "Content-Type": "application/json" });
86
+ res.end("");
87
+ return true;
88
+ case "html":
89
+ res.writeHead(200, { "Content-Type": "text/html" });
90
+ res.end("<html><body>Bad Gateway</body></html>");
91
+ return true;
92
+ }
93
+ },
94
+ /** Called after body is parsed — returns RPC error or null */
95
+ checkRpcFault(): { code: number; message: string } | null {
96
+ const fault = rpcFaultQueue.shift();
97
+ if (!fault) return null;
98
+
99
+ switch (fault) {
100
+ case "internal":
101
+ return { code: -32603, message: "Internal error" };
102
+ case "server":
103
+ return { code: -32050, message: "Server error" };
104
+ case "method-not-found":
105
+ return { code: -32601, message: "Method not found" };
106
+ case "invalid-request":
107
+ return { code: -32600, message: "Invalid Request" };
108
+ case "invalid-params":
109
+ return { code: -32602, message: "Invalid params" };
110
+ }
111
+ },
112
+ };
113
+ }
114
+
115
+ // --- Mock JSON-RPC CRUD server ---
116
+
117
+ function createMockCrudServer(
118
+ store: ReturnType<typeof createStore>,
119
+ faults: ReturnType<typeof createFaultInjector>,
120
+ ): Promise<{ server: Server; baseUrl: string }> {
121
+ return new Promise((resolve) => {
122
+ const srv = createServer((req, res) => {
123
+ // Check HTTP-level faults first (before reading body)
124
+ if (faults.checkHttpFault(res)) return;
125
+
126
+ const chunks: Buffer[] = [];
127
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
128
+ req.on("end", () => {
129
+ const raw = Buffer.concat(chunks).toString();
130
+ let body: any;
131
+ try {
132
+ body = JSON.parse(raw);
133
+ } catch {
134
+ res.writeHead(200, { "Content-Type": "application/json" });
135
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }));
136
+ return;
137
+ }
138
+
139
+ // Batch request
140
+ if (Array.isArray(body)) {
141
+ // Check RPC-level faults for batch
142
+ const rpcFault = faults.checkRpcFault();
143
+ if (rpcFault) {
144
+ const results = body.map((r: any) => ({
145
+ jsonrpc: "2.0",
146
+ id: r.id,
147
+ error: rpcFault,
148
+ }));
149
+ res.writeHead(200, { "Content-Type": "application/json" });
150
+ res.end(JSON.stringify(results));
151
+ return;
152
+ }
153
+
154
+ const results = body.map((r: any) => {
155
+ const { result, error } = handleMethod(r.method, r.params ?? [], store);
156
+ if (error) return { jsonrpc: "2.0", id: r.id, error };
157
+ return { jsonrpc: "2.0", id: r.id, result };
158
+ });
159
+ res.writeHead(200, { "Content-Type": "application/json" });
160
+ res.end(JSON.stringify(results));
161
+ return;
162
+ }
163
+
164
+ // Single request — check RPC fault
165
+ const rpcFault = faults.checkRpcFault();
166
+ if (rpcFault) {
167
+ res.writeHead(200, { "Content-Type": "application/json" });
168
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, error: rpcFault }));
169
+ return;
170
+ }
171
+
172
+ const { result, error } = handleMethod(body.method, body.params ?? [], store);
173
+ res.writeHead(200, { "Content-Type": "application/json" });
174
+ if (error) {
175
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, error }));
176
+ } else {
177
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }));
178
+ }
179
+ });
180
+ });
181
+
182
+ srv.listen(0, () => {
183
+ const addr = srv.address();
184
+ const port = typeof addr === "object" && addr ? addr.port : 0;
185
+ resolve({ server: srv, baseUrl: `http://localhost:${port}` });
186
+ });
187
+ });
188
+ }
189
+
190
+ function handleMethod(
191
+ method: string,
192
+ params: unknown[],
193
+ store: ReturnType<typeof createStore>,
194
+ ): { result?: unknown; error?: { code: number; message: string } } {
195
+ switch (method) {
196
+ case "store.list":
197
+ return { result: Array.from(store.items.values()) };
198
+
199
+ case "store.get": {
200
+ const id = params[0] as string;
201
+ const item = store.items.get(id);
202
+ return { result: item ?? null };
203
+ }
204
+
205
+ case "store.create": {
206
+ const data = params[0] as Item;
207
+ store.items.set(data.id, data);
208
+ return { result: data.id };
209
+ }
210
+
211
+ case "store.update": {
212
+ const [id, updates] = params as [string, Partial<Item>];
213
+ const existing = store.items.get(id);
214
+ if (!existing) return { result: null };
215
+ const updated = { ...existing, ...updates };
216
+ store.items.set(id, updated);
217
+ return { result: updated.id };
218
+ }
219
+
220
+ case "store.delete": {
221
+ const delId = params[0] as string;
222
+ const deleted = store.items.delete(delId);
223
+ return { result: deleted };
224
+ }
225
+
226
+ case "store.search": {
227
+ const pattern = params[0] as string;
228
+ const matches = Array.from(store.items.values()).filter(
229
+ (item) => JSON.stringify(item).includes(pattern),
230
+ );
231
+ return { result: matches };
232
+ }
233
+
234
+ case "tags.list":
235
+ return { result: Array.from(store.tags.values()) };
236
+
237
+ case "tags.get": {
238
+ const tagId = params[0] as string;
239
+ const tag = store.tags.get(tagId);
240
+ return { result: tag ?? null };
241
+ }
242
+
243
+ default:
244
+ return { error: { code: -32601, message: `Method not found: ${method}` } };
245
+ }
246
+ }
247
+
248
+ // --- RPC resource configuration ---
249
+
250
+ function createCrudResources(): RpcResource[] {
251
+ return [
252
+ {
253
+ name: "items",
254
+ idField: "id",
255
+ methods: {
256
+ list: {
257
+ method: "store.list",
258
+ params: () => [],
259
+ },
260
+ read: {
261
+ method: "store.get",
262
+ params: (ctx) => [ctx.id!],
263
+ },
264
+ create: {
265
+ method: "store.create",
266
+ params: (ctx) => [ctx.data],
267
+ },
268
+ write: {
269
+ method: "store.update",
270
+ params: (ctx) => [ctx.id!, ctx.data],
271
+ },
272
+ remove: {
273
+ method: "store.delete",
274
+ params: (ctx) => [ctx.id!],
275
+ },
276
+ search: {
277
+ method: "store.search",
278
+ params: (ctx) => [ctx.pattern!],
279
+ },
280
+ },
281
+ },
282
+ {
283
+ name: "tags",
284
+ idField: "id",
285
+ methods: {
286
+ list: {
287
+ method: "tags.list",
288
+ params: () => [],
289
+ },
290
+ read: {
291
+ method: "tags.get",
292
+ params: (ctx) => [ctx.id!],
293
+ },
294
+ },
295
+ },
296
+ ];
297
+ }
298
+
299
+ // --- Tests ---
300
+
301
+ describe("RpcBackend CRUD + Resilience", () => {
302
+ let server: Server;
303
+ let store: ReturnType<typeof createStore>;
304
+ let faults: ReturnType<typeof createFaultInjector>;
305
+ let transport: JsonRpcTransport;
306
+ let backend: RpcBackend;
307
+ let agentFs: AgentFs;
308
+
309
+ beforeAll(async () => {
310
+ store = createStore();
311
+ faults = createFaultInjector();
312
+ const mock = await createMockCrudServer(store, faults);
313
+ server = mock.server;
314
+
315
+ transport = new JsonRpcTransport({
316
+ url: mock.baseUrl,
317
+ retry: { maxRetries: 3, baseDelayMs: 10 }, // fast retries for tests
318
+ });
319
+
320
+ backend = new RpcBackend({
321
+ transport,
322
+ resources: createCrudResources(),
323
+ });
324
+
325
+ agentFs = new AgentFs({
326
+ mounts: [{ path: "/rpc", backend }],
327
+ });
328
+ });
329
+
330
+ afterAll(
331
+ () => new Promise<void>((resolve) => {
332
+ server.close(() => resolve());
333
+ }),
334
+ );
335
+
336
+ // ============================================================
337
+ // Full-chain: CLI → AgentFs → RpcBackend → Transport → HTTP
338
+ // ============================================================
339
+
340
+ describe("Full-chain: CLI → AgentFs → RpcBackend → Transport → HTTP", () => {
341
+ it("ls / → contains rpc/", async () => {
342
+ const result = await agentFs.execute("ls /");
343
+ expect(result.ok).toBe(true);
344
+ if (result.ok) expect(result.data).toContain("rpc/");
345
+ });
346
+
347
+ it("ls /rpc/ → contains items/, tags/", async () => {
348
+ const result = await agentFs.execute("ls /rpc/");
349
+ expect(result.ok).toBe(true);
350
+ if (result.ok) {
351
+ expect(result.data).toContain("items/");
352
+ expect(result.data).toContain("tags/");
353
+ }
354
+ });
355
+
356
+ it("ls /rpc/items/ → returns item ID list", async () => {
357
+ const result = await agentFs.execute("ls /rpc/items/");
358
+ expect(result.ok).toBe(true);
359
+ if (result.ok) {
360
+ const data = result.data as string[];
361
+ expect(data).toContain("1.json");
362
+ expect(data).toContain("2.json");
363
+ expect(data).toContain("3.json");
364
+ }
365
+ });
366
+
367
+ it("cat /rpc/items/1.json → returns item detail", async () => {
368
+ const result = await agentFs.execute("cat /rpc/items/1.json");
369
+ expect(result.ok).toBe(true);
370
+ if (result.ok) {
371
+ const data = result.data as Item;
372
+ expect(data.id).toBe("1");
373
+ expect(data.name).toBe("Alpha");
374
+ }
375
+ });
376
+
377
+ it("write /rpc/items/ → creates new item", async () => {
378
+ const result = await agentFs.execute(
379
+ 'write /rpc/items/ \'{"id":"4","name":"Delta"}\'',
380
+ );
381
+ expect(result.ok).toBe(true);
382
+ });
383
+
384
+ it("cat /rpc/items/4.json → verifies created item", async () => {
385
+ const result = await agentFs.execute("cat /rpc/items/4.json");
386
+ expect(result.ok).toBe(true);
387
+ if (result.ok) {
388
+ const data = result.data as Item;
389
+ expect(data.id).toBe("4");
390
+ expect(data.name).toBe("Delta");
391
+ }
392
+ });
393
+
394
+ it("write /rpc/items/1.json → updates existing item", async () => {
395
+ const result = await agentFs.execute(
396
+ 'write /rpc/items/1.json \'{"name":"Updated"}\'',
397
+ );
398
+ expect(result.ok).toBe(true);
399
+ });
400
+
401
+ it("cat /rpc/items/1.json → verifies updated item", async () => {
402
+ const result = await agentFs.execute("cat /rpc/items/1.json");
403
+ expect(result.ok).toBe(true);
404
+ if (result.ok) {
405
+ const data = result.data as Item;
406
+ expect(data.name).toBe("Updated");
407
+ }
408
+ });
409
+
410
+ it("rm /rpc/items/2.json → deletes item", async () => {
411
+ const result = await agentFs.execute("rm /rpc/items/2.json");
412
+ expect(result.ok).toBe(true);
413
+ });
414
+
415
+ it("cat /rpc/items/2.json → deleted item returns NotFound", async () => {
416
+ const result = await agentFs.execute("cat /rpc/items/2.json");
417
+ expect(result.ok).toBe(false);
418
+ if (!result.ok) {
419
+ expect(result.error.code).toBe("NOT_FOUND");
420
+ }
421
+ });
422
+
423
+ it("grep Alpha /rpc/items/ → searches matching items", async () => {
424
+ // Note: item 1 was updated to "Updated", so "Alpha" should not match
425
+ // But let's add a fresh one and search
426
+ store.items.set("5", { id: "5", name: "AlphaTwo" });
427
+ const result = await agentFs.execute("grep AlphaTwo /rpc/items/");
428
+ expect(result.ok).toBe(true);
429
+ if (result.ok) {
430
+ const data = result.data as Item[];
431
+ expect(data.length).toBeGreaterThanOrEqual(1);
432
+ expect(data.some((item) => item.name === "AlphaTwo")).toBe(true);
433
+ }
434
+ });
435
+
436
+ it("cat /rpc/tags/t1.json → reads read-only resource", async () => {
437
+ const result = await agentFs.execute("cat /rpc/tags/t1.json");
438
+ expect(result.ok).toBe(true);
439
+ if (result.ok) {
440
+ const data = result.data as Tag;
441
+ expect(data.id).toBe("t1");
442
+ expect(data.label).toBe("urgent");
443
+ }
444
+ });
445
+ });
446
+
447
+ // ============================================================
448
+ // Transport resilience: HTTP-level
449
+ // ============================================================
450
+
451
+ describe("Transport resilience: HTTP-level", () => {
452
+ it("429 twice then success → requestCount=3", async () => {
453
+ faults.resetRequestCount();
454
+ faults.injectHttpFault("429", "429");
455
+ const result = await transport.call("store.list", []);
456
+ expect(faults.getRequestCount()).toBe(3);
457
+ expect(Array.isArray(result)).toBe(true);
458
+ });
459
+
460
+ it("500 twice then success → requestCount=3", async () => {
461
+ faults.resetRequestCount();
462
+ faults.injectHttpFault("500", "500");
463
+ const result = await transport.call("store.list", []);
464
+ expect(faults.getRequestCount()).toBe(3);
465
+ expect(Array.isArray(result)).toBe(true);
466
+ });
467
+
468
+ it("400 does not retry → requestCount=1", async () => {
469
+ faults.resetRequestCount();
470
+ faults.injectHttpFault("400");
471
+ // 400 returns non-JSON-RPC body, safeRpcJson will parse the error JSON
472
+ // but it won't have jsonrpc format, so it'll just return the parsed object
473
+ // Actually the mock returns { error: "Bad Request" } which is not valid JSON-RPC
474
+ // The transport checks HTTP status first — 400 is not retryable and not >=500
475
+ // But it's also not `ok` (200-299). Let's see what happens:
476
+ // isRetryableHttpStatus(400) → false, so we proceed to safeRpcJson
477
+ // safeRpcJson parses { error: "Bad Request" }, result.error is truthy
478
+ // result.error.code is undefined, isRetryableRpcError(undefined) → false
479
+ // So it throws RpcError(undefined, undefined)
480
+ try {
481
+ await transport.call("store.list", []);
482
+ } catch (err) {
483
+ expect(err).toBeInstanceOf(RpcError);
484
+ }
485
+ expect(faults.getRequestCount()).toBe(1);
486
+ });
487
+
488
+ it("Retry-After header overrides baseDelay", async () => {
489
+ faults.resetRequestCount();
490
+ faults.setRetryAfter("0"); // 0 seconds — fast
491
+ faults.injectHttpFault("429");
492
+ const start = Date.now();
493
+ await transport.call("store.list", []);
494
+ const elapsed = Date.now() - start;
495
+ expect(faults.getRequestCount()).toBe(2);
496
+ // With Retry-After: 0, delay should be ~0ms (not baseDelay)
497
+ expect(elapsed).toBeLessThan(200);
498
+ });
499
+
500
+ it("maxRetries exhausted → throws error", async () => {
501
+ // Create a transport with maxRetries=1 for this test
502
+ const limitedTransport = new JsonRpcTransport({
503
+ url: `http://localhost:${(server.address() as any).port}`,
504
+ retry: { maxRetries: 1, baseDelayMs: 10 },
505
+ });
506
+ faults.resetRequestCount();
507
+ faults.injectHttpFault("429", "429"); // 2 faults, only 1 retry allowed
508
+ await expect(
509
+ limitedTransport.call("store.list", []),
510
+ ).rejects.toThrow();
511
+ expect(faults.getRequestCount()).toBe(2); // initial + 1 retry
512
+ });
513
+ });
514
+
515
+ // ============================================================
516
+ // Transport resilience: RPC-level
517
+ // ============================================================
518
+
519
+ describe("Transport resilience: RPC-level", () => {
520
+ it("-32603 retries then succeeds", async () => {
521
+ faults.resetRequestCount();
522
+ faults.injectRpcFault("internal");
523
+ const result = await transport.call("store.list", []);
524
+ expect(faults.getRequestCount()).toBe(2);
525
+ expect(Array.isArray(result)).toBe(true);
526
+ });
527
+
528
+ it("-32050 retries then succeeds", async () => {
529
+ faults.resetRequestCount();
530
+ faults.injectRpcFault("server");
531
+ const result = await transport.call("store.list", []);
532
+ expect(faults.getRequestCount()).toBe(2);
533
+ expect(Array.isArray(result)).toBe(true);
534
+ });
535
+
536
+ it("-32601 does not retry", async () => {
537
+ faults.resetRequestCount();
538
+ faults.injectRpcFault("method-not-found");
539
+ await expect(
540
+ transport.call("store.list", []),
541
+ ).rejects.toThrow(RpcError);
542
+ expect(faults.getRequestCount()).toBe(1);
543
+ });
544
+
545
+ it("-32600 does not retry", async () => {
546
+ faults.resetRequestCount();
547
+ faults.injectRpcFault("invalid-request");
548
+ await expect(
549
+ transport.call("store.list", []),
550
+ ).rejects.toThrow(RpcError);
551
+ expect(faults.getRequestCount()).toBe(1);
552
+ });
553
+
554
+ it("-32602 does not retry", async () => {
555
+ faults.resetRequestCount();
556
+ faults.injectRpcFault("invalid-params");
557
+ await expect(
558
+ transport.call("store.list", []),
559
+ ).rejects.toThrow(RpcError);
560
+ expect(faults.getRequestCount()).toBe(1);
561
+ });
562
+ });
563
+
564
+ // ============================================================
565
+ // Transport resilience: safeRpcJson
566
+ // ============================================================
567
+
568
+ describe("Transport resilience: safeRpcJson", () => {
569
+ it("empty body → RpcError(-32700)", async () => {
570
+ faults.injectHttpFault("empty");
571
+ await expect(
572
+ transport.call("store.list", []),
573
+ ).rejects.toThrow(RpcError);
574
+ try {
575
+ faults.injectHttpFault("empty");
576
+ await transport.call("store.list", []);
577
+ } catch (err) {
578
+ expect(err).toBeInstanceOf(RpcError);
579
+ expect((err as RpcError).code).toBe(-32700);
580
+ }
581
+ });
582
+
583
+ it("non-JSON (HTML) → RpcError(-32700)", async () => {
584
+ faults.injectHttpFault("html");
585
+ await expect(
586
+ transport.call("store.list", []),
587
+ ).rejects.toThrow(RpcError);
588
+ try {
589
+ faults.injectHttpFault("html");
590
+ await transport.call("store.list", []);
591
+ } catch (err) {
592
+ expect(err).toBeInstanceOf(RpcError);
593
+ expect((err as RpcError).code).toBe(-32700);
594
+ }
595
+ });
596
+ });
597
+
598
+ // ============================================================
599
+ // Batch resilience
600
+ // ============================================================
601
+
602
+ describe("Batch resilience", () => {
603
+ it("batch 429 → entire batch retries then succeeds", async () => {
604
+ faults.resetRequestCount();
605
+ faults.injectHttpFault("429");
606
+ const results = await transport.batch!([
607
+ { method: "store.list", params: [] },
608
+ { method: "tags.list", params: [] },
609
+ ]);
610
+ expect(faults.getRequestCount()).toBe(2);
611
+ expect(results).toHaveLength(2);
612
+ expect(Array.isArray(results[0])).toBe(true);
613
+ expect(Array.isArray(results[1])).toBe(true);
614
+ });
615
+
616
+ it("batch with retryable RPC error → entire batch retries", async () => {
617
+ faults.resetRequestCount();
618
+ faults.injectRpcFault("internal");
619
+ const results = await transport.batch!([
620
+ { method: "store.list", params: [] },
621
+ { method: "tags.list", params: [] },
622
+ ]);
623
+ expect(faults.getRequestCount()).toBe(2);
624
+ expect(results).toHaveLength(2);
625
+ });
626
+ });
627
+ });