@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.
- package/dist/chunk-7LIZT7L3.js +966 -0
- package/dist/index.cjs +1278 -0
- package/dist/index.d.cts +96 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +419 -0
- package/dist/rpc-D1IHpjF_.d.cts +330 -0
- package/dist/rpc-D1IHpjF_.d.ts +330 -0
- package/dist/testing.cjs +842 -0
- package/dist/testing.d.cts +29 -0
- package/dist/testing.d.ts +29 -0
- package/dist/testing.js +10 -0
- package/package.json +25 -0
- package/src/agent-fs.ts +151 -0
- package/src/backends/http.ts +835 -0
- package/src/backends/memory.ts +183 -0
- package/src/backends/rpc.ts +456 -0
- package/src/index.ts +36 -0
- package/src/mount.ts +84 -0
- package/src/parser.ts +162 -0
- package/src/server.ts +158 -0
- package/src/testing.ts +3 -0
- package/src/types.ts +52 -0
- package/test/agent-fs.test.ts +325 -0
- package/test/http-204.test.ts +102 -0
- package/test/http-auth-prefix.test.ts +79 -0
- package/test/http-cloudflare.test.ts +533 -0
- package/test/http-form-encoding.test.ts +119 -0
- package/test/http-github.test.ts +580 -0
- package/test/http-listkey.test.ts +128 -0
- package/test/http-oauth2.test.ts +174 -0
- package/test/http-pagination.test.ts +200 -0
- package/test/http-param-styles.test.ts +98 -0
- package/test/http-passthrough.test.ts +282 -0
- package/test/http-retry.test.ts +132 -0
- package/test/http.test.ts +360 -0
- package/test/memory.test.ts +120 -0
- package/test/mount.test.ts +94 -0
- package/test/parser.test.ts +100 -0
- package/test/rpc-crud.test.ts +627 -0
- package/test/rpc-evm.test.ts +390 -0
- package/tsconfig.json +8 -0
- 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
|
+
});
|