@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,580 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import {
3
+ createServer,
4
+ type Server,
5
+ type IncomingMessage,
6
+ type ServerResponse,
7
+ } from "node:http";
8
+ import {
9
+ HttpBackend,
10
+ type HttpBackendConfig,
11
+ type HttpResource,
12
+ } from "../src/backends/http.js";
13
+
14
+ // --- Mock GitHub API data ---
15
+
16
+ interface GhIssue {
17
+ number: number;
18
+ title: string;
19
+ state: string;
20
+ body: string;
21
+ }
22
+
23
+ interface GhComment {
24
+ id: number;
25
+ body: string;
26
+ user: string;
27
+ }
28
+
29
+ interface GhContent {
30
+ name: string;
31
+ path: string;
32
+ sha: string;
33
+ type: "file" | "dir";
34
+ content?: string;
35
+ encoding?: string;
36
+ }
37
+
38
+ const issues: GhIssue[] = [
39
+ { number: 1, title: "Bug report", state: "open", body: "Something broken" },
40
+ { number: 2, title: "Feature request", state: "open", body: "Add dark mode" },
41
+ { number: 3, title: "Docs update", state: "closed", body: "Fix typos" },
42
+ { number: 4, title: "Performance", state: "open", body: "Slow queries" },
43
+ { number: 5, title: "Security fix", state: "open", body: "XSS vuln" },
44
+ ];
45
+
46
+ const comments: Record<number, GhComment[]> = {
47
+ 1: [
48
+ { id: 101, body: "I can reproduce this", user: "alice" },
49
+ { id: 102, body: "Working on a fix", user: "bob" },
50
+ ],
51
+ 2: [
52
+ { id: 201, body: "+1 for dark mode", user: "charlie" },
53
+ ],
54
+ };
55
+
56
+ const contents: Map<string, GhContent | GhContent[]> = new Map();
57
+ // Root directory listing
58
+ contents.set("", [
59
+ { name: "README.md", path: "README.md", sha: "abc111", type: "file" },
60
+ { name: "src", path: "src", sha: "abc222", type: "dir" },
61
+ { name: "package.json", path: "package.json", sha: "abc333", type: "file" },
62
+ ]);
63
+ // src/ directory listing
64
+ contents.set("src", [
65
+ { name: "index.ts", path: "src/index.ts", sha: "def111", type: "file" },
66
+ { name: "utils.ts", path: "src/utils.ts", sha: "def222", type: "file" },
67
+ ]);
68
+ // Individual files
69
+ contents.set("README.md", {
70
+ name: "README.md",
71
+ path: "README.md",
72
+ sha: "abc111",
73
+ type: "file",
74
+ content: btoa("# Hello World"),
75
+ encoding: "base64",
76
+ });
77
+ contents.set("package.json", {
78
+ name: "package.json",
79
+ path: "package.json",
80
+ sha: "abc333",
81
+ type: "file",
82
+ content: btoa('{"name": "test"}'),
83
+ encoding: "base64",
84
+ });
85
+ contents.set("src/index.ts", {
86
+ name: "index.ts",
87
+ path: "src/index.ts",
88
+ sha: "def111",
89
+ type: "file",
90
+ content: btoa('console.log("hello")'),
91
+ encoding: "base64",
92
+ });
93
+ contents.set("src/utils.ts", {
94
+ name: "utils.ts",
95
+ path: "src/utils.ts",
96
+ sha: "def222",
97
+ type: "file",
98
+ content: btoa("export const add = (a: number, b: number) => a + b"),
99
+ encoding: "base64",
100
+ });
101
+
102
+ // --- Mock GitHub API server ---
103
+
104
+ function json(res: ServerResponse, status: number, data: unknown, headers?: Record<string, string>) {
105
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
106
+ res.end(JSON.stringify(data));
107
+ }
108
+
109
+ function createMockGhServer(): Promise<{ server: Server; baseUrl: string }> {
110
+ return new Promise((resolve) => {
111
+ const srv = createServer((req: IncomingMessage, res: ServerResponse) => {
112
+ const url = new URL(req.url ?? "/", "http://localhost");
113
+ const path = url.pathname;
114
+ const method = req.method ?? "GET";
115
+
116
+ // Verify auth
117
+ const auth = req.headers.authorization;
118
+ if (auth !== "Bearer ghp-test") {
119
+ json(res, 401, { message: "Bad credentials" });
120
+ return;
121
+ }
122
+
123
+ const chunks: Buffer[] = [];
124
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
125
+ req.on("end", () => {
126
+ const body = chunks.length
127
+ ? JSON.parse(Buffer.concat(chunks).toString())
128
+ : undefined;
129
+
130
+ const port = (srv.address() as { port: number }).port;
131
+ const baseUrl = `http://localhost:${port}`;
132
+
133
+ // --- Issues ---
134
+
135
+ // GET /repos/:owner/:repo/issues (with pagination)
136
+ const issuesListMatch = path.match(
137
+ /^\/repos\/([^/]+)\/([^/]+)\/issues$/,
138
+ );
139
+ if (issuesListMatch && method === "GET") {
140
+ const page = parseInt(url.searchParams.get("page") ?? "1");
141
+ const perPage = parseInt(url.searchParams.get("per_page") ?? "2");
142
+ const start = (page - 1) * perPage;
143
+ const end = start + perPage;
144
+ const pageItems = issues.slice(start, end);
145
+ const totalPages = Math.ceil(issues.length / perPage);
146
+
147
+ const linkParts: string[] = [];
148
+ if (page < totalPages) {
149
+ linkParts.push(
150
+ `<${baseUrl}${path}?page=${page + 1}&per_page=${perPage}>; rel="next"`,
151
+ );
152
+ }
153
+ if (page > 1) {
154
+ linkParts.push(
155
+ `<${baseUrl}${path}?page=${page - 1}&per_page=${perPage}>; rel="prev"`,
156
+ );
157
+ }
158
+
159
+ const headers: Record<string, string> = {};
160
+ if (linkParts.length > 0) headers["Link"] = linkParts.join(", ");
161
+
162
+ return json(res, 200, pageItems, headers);
163
+ }
164
+
165
+ // GET /repos/:owner/:repo/issues/:number
166
+ const issueItemMatch = path.match(
167
+ /^\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)$/,
168
+ );
169
+ if (issueItemMatch && method === "GET") {
170
+ const num = parseInt(issueItemMatch[3]);
171
+ const issue = issues.find((i) => i.number === num);
172
+ if (!issue) return json(res, 404, { message: "Not Found" });
173
+ return json(res, 200, issue);
174
+ }
175
+
176
+ // POST /repos/:owner/:repo/issues
177
+ if (issuesListMatch && method === "POST") {
178
+ const newIssue: GhIssue = {
179
+ number: issues.length + 1,
180
+ title: body.title,
181
+ state: "open",
182
+ body: body.body ?? "",
183
+ };
184
+ issues.push(newIssue);
185
+ return json(res, 201, newIssue);
186
+ }
187
+
188
+ // PATCH /repos/:owner/:repo/issues/:number
189
+ if (issueItemMatch && method === "PATCH") {
190
+ const num = parseInt(issueItemMatch[3]);
191
+ const issue = issues.find((i) => i.number === num);
192
+ if (!issue) return json(res, 404, { message: "Not Found" });
193
+ Object.assign(issue, body);
194
+ return json(res, 200, issue);
195
+ }
196
+
197
+ // --- Issue Comments ---
198
+
199
+ // GET /repos/:owner/:repo/issues/:number/comments
200
+ const commentsListMatch = path.match(
201
+ /^\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)\/comments$/,
202
+ );
203
+ if (commentsListMatch && method === "GET") {
204
+ const num = parseInt(commentsListMatch[3]);
205
+ return json(res, 200, comments[num] ?? []);
206
+ }
207
+
208
+ // POST /repos/:owner/:repo/issues/:number/comments
209
+ if (commentsListMatch && method === "POST") {
210
+ const num = parseInt(commentsListMatch[3]);
211
+ if (!comments[num]) comments[num] = [];
212
+ const newComment: GhComment = {
213
+ id: Date.now(),
214
+ body: body.body,
215
+ user: "test-user",
216
+ };
217
+ comments[num].push(newComment);
218
+ return json(res, 201, newComment);
219
+ }
220
+
221
+ // --- Contents ---
222
+
223
+ // Match /repos/:owner/:repo/contents or /repos/:owner/:repo/contents/...
224
+ const contentsMatch = path.match(
225
+ /^\/repos\/([^/]+)\/([^/]+)\/contents(?:\/(.*))?$/,
226
+ );
227
+ if (contentsMatch) {
228
+ const filePath = contentsMatch[3] ?? "";
229
+
230
+ if (method === "GET") {
231
+ const entry = contents.get(filePath);
232
+ if (!entry) return json(res, 404, { message: "Not Found" });
233
+ return json(res, 200, entry);
234
+ }
235
+
236
+ if (method === "PUT") {
237
+ // Create or update file
238
+ const existing = contents.get(filePath);
239
+ if (existing && !Array.isArray(existing)) {
240
+ // Update: require sha
241
+ if (body.sha !== existing.sha) {
242
+ return json(res, 409, { message: "SHA mismatch" });
243
+ }
244
+ const newSha = "sha-" + Date.now();
245
+ const updated: GhContent = {
246
+ name: existing.name,
247
+ path: existing.path,
248
+ sha: newSha,
249
+ type: "file",
250
+ content: body.content,
251
+ encoding: "base64",
252
+ };
253
+ contents.set(filePath, updated);
254
+ return json(res, 200, { content: updated });
255
+ }
256
+ // Create new file
257
+ const name = filePath.split("/").pop() ?? filePath;
258
+ const newSha = "sha-" + Date.now();
259
+ const newFile: GhContent = {
260
+ name,
261
+ path: filePath,
262
+ sha: newSha,
263
+ type: "file",
264
+ content: body.content,
265
+ encoding: "base64",
266
+ };
267
+ contents.set(filePath, newFile);
268
+ return json(res, 201, { content: newFile });
269
+ }
270
+
271
+ if (method === "DELETE") {
272
+ const existing = contents.get(filePath);
273
+ if (!existing || Array.isArray(existing)) {
274
+ return json(res, 404, { message: "Not Found" });
275
+ }
276
+ if (body?.sha !== existing.sha) {
277
+ return json(res, 409, { message: "SHA mismatch" });
278
+ }
279
+ contents.delete(filePath);
280
+ return json(res, 200, { commit: { sha: "commit-" + Date.now() } });
281
+ }
282
+ }
283
+
284
+ json(res, 404, { message: "Not Found" });
285
+ });
286
+ });
287
+
288
+ srv.listen(0, () => {
289
+ const addr = srv.address();
290
+ const port = typeof addr === "object" && addr ? addr.port : 0;
291
+ resolve({ server: srv, baseUrl: `http://localhost:${port}` });
292
+ });
293
+ });
294
+ }
295
+
296
+ // --- GitHub resource configuration ---
297
+
298
+ function createGhResources(): HttpResource[] {
299
+ return [
300
+ {
301
+ name: "issues",
302
+ apiPath: "/repos/:owner/:repo/issues",
303
+ idField: "number",
304
+ updateMethod: "PATCH",
305
+ children: [
306
+ { name: "comments", idField: "id" },
307
+ ],
308
+ },
309
+ {
310
+ name: "contents",
311
+ apiPath: "/repos/:owner/:repo/contents",
312
+ pathMode: "tree",
313
+ idField: "path",
314
+ transform: {
315
+ read: (d: unknown) => {
316
+ const data = d as GhContent;
317
+ if (data.type === "file" && data.content) {
318
+ return { ...data, content: atob(data.content) };
319
+ }
320
+ return data;
321
+ },
322
+ write: (d: unknown) => {
323
+ const data = d as { content: string; message?: string; sha?: string };
324
+ return {
325
+ message: data.message ?? "update",
326
+ content: btoa(data.content),
327
+ ...(data.sha ? { sha: data.sha } : {}),
328
+ };
329
+ },
330
+ remove: (r: unknown) => {
331
+ const data = r as { sha: string };
332
+ return { message: "delete", sha: data.sha };
333
+ },
334
+ list: (item: unknown) => {
335
+ const data = item as { name: string; type: string };
336
+ return data.type === "dir" ? data.name + "/" : data.name;
337
+ },
338
+ },
339
+ readBeforeWrite: {
340
+ inject: (r: unknown, d: unknown) => {
341
+ const readResult = r as { sha: string };
342
+ return { ...(d as object), sha: readResult.sha };
343
+ },
344
+ },
345
+ },
346
+ ];
347
+ }
348
+
349
+ function createGhConfig(baseUrl: string): HttpBackendConfig {
350
+ return {
351
+ baseUrl,
352
+ auth: { type: "bearer", token: "ghp-test" },
353
+ params: { owner: "test-org", repo: "test-repo" },
354
+ pagination: { type: "link-header", maxPages: 5 },
355
+ resources: createGhResources(),
356
+ };
357
+ }
358
+
359
+ // --- Tests ---
360
+
361
+ describe("HttpBackend — GitHub integration", () => {
362
+ let server: Server;
363
+ let backend: HttpBackend;
364
+
365
+ beforeAll(async () => {
366
+ const mock = await createMockGhServer();
367
+ server = mock.server;
368
+ backend = new HttpBackend(createGhConfig(mock.baseUrl));
369
+ });
370
+
371
+ afterAll(
372
+ () => new Promise<void>((resolve) => { server.close(() => resolve()); }),
373
+ );
374
+
375
+ // --- Issues CRUD ---
376
+
377
+ describe("Issues CRUD", () => {
378
+ it("should list issues (paginated, all pages)", async () => {
379
+ const result = await backend.list("/issues/");
380
+ // 5 issues, per_page=2 by default in mock → 3 pages auto-fetched
381
+ expect(result).toContain("1.json");
382
+ expect(result).toContain("2.json");
383
+ expect(result).toContain("3.json");
384
+ expect(result).toContain("4.json");
385
+ expect(result).toContain("5.json");
386
+ expect(result).toHaveLength(5);
387
+ });
388
+
389
+ it("should read a single issue", async () => {
390
+ const result = (await backend.read("/issues/1.json")) as GhIssue;
391
+ expect(result.title).toBe("Bug report");
392
+ expect(result.state).toBe("open");
393
+ });
394
+
395
+ it("should create an issue", async () => {
396
+ const result = await backend.write("/issues/", {
397
+ title: "New issue",
398
+ body: "Test body",
399
+ });
400
+ expect(result.id).toBeDefined();
401
+ expect(Number(result.id)).toBeGreaterThan(5);
402
+ });
403
+
404
+ it("should update an issue with PATCH", async () => {
405
+ const result = await backend.write("/issues/1.json", {
406
+ title: "Bug report (updated)",
407
+ });
408
+ expect(result.id).toBe("1");
409
+
410
+ const updated = (await backend.read("/issues/1.json")) as GhIssue;
411
+ expect(updated.title).toBe("Bug report (updated)");
412
+ });
413
+ });
414
+
415
+ // --- Issue Comments (nested) ---
416
+
417
+ describe("Issue comments", () => {
418
+ it("should list comments under an issue", async () => {
419
+ const result = await backend.list("/issues/1/comments/");
420
+ expect(result).toContain("101.json");
421
+ expect(result).toContain("102.json");
422
+ });
423
+
424
+ it("should create a comment", async () => {
425
+ const result = await backend.write("/issues/2/comments/", {
426
+ body: "Great idea!",
427
+ });
428
+ expect(result.id).toBeDefined();
429
+ });
430
+
431
+ it("should list comments for an issue with no comments", async () => {
432
+ const result = await backend.list("/issues/3/comments/");
433
+ expect(result).toEqual([]);
434
+ });
435
+ });
436
+
437
+ // --- Pagination ---
438
+
439
+ describe("Pagination", () => {
440
+ it("should auto-follow Link header for all pages", async () => {
441
+ // The mock returns 2 items per page, 5 total → pages 1,2,3
442
+ const result = await backend.list("/issues/");
443
+ expect(result).toHaveLength(6); // 5 original + 1 created in earlier test
444
+ });
445
+ });
446
+
447
+ // --- Contents tree mode ---
448
+
449
+ describe("Contents tree mode", () => {
450
+ it("should list root directory", async () => {
451
+ const result = await backend.list("/contents/");
452
+ expect(result).toContain("README.md");
453
+ expect(result).toContain("src/");
454
+ expect(result).toContain("package.json");
455
+ });
456
+
457
+ it("should list subdirectory", async () => {
458
+ const result = await backend.list("/contents/src");
459
+ expect(result).toContain("index.ts");
460
+ expect(result).toContain("utils.ts");
461
+ });
462
+
463
+ it("should read a file (base64 auto-decoded)", async () => {
464
+ const result = (await backend.read("/contents/README.md")) as GhContent;
465
+ expect(result.content).toBe("# Hello World");
466
+ expect(result.type).toBe("file");
467
+ });
468
+
469
+ it("should read a nested file", async () => {
470
+ const result = (await backend.read(
471
+ "/contents/src/index.ts",
472
+ )) as GhContent;
473
+ expect(result.content).toBe('console.log("hello")');
474
+ });
475
+ });
476
+
477
+ // --- Contents readBeforeWrite ---
478
+
479
+ describe("Contents readBeforeWrite", () => {
480
+ it("should update a file with auto-injected SHA", async () => {
481
+ const result = await backend.write("/contents/README.md", {
482
+ content: "# Updated",
483
+ });
484
+ expect(result.id).toBeDefined();
485
+
486
+ // Verify the file was updated
487
+ const updated = (await backend.read("/contents/README.md")) as GhContent;
488
+ expect(updated.content).toBe("# Updated");
489
+ });
490
+
491
+ it("should create a new file (readBeforeWrite 404 graceful skip)", async () => {
492
+ const result = await backend.write("/contents/new-file.md", {
493
+ content: "# New File",
494
+ });
495
+ expect(result.id).toBeDefined();
496
+ });
497
+ });
498
+
499
+ // --- Contents delete ---
500
+
501
+ describe("Contents delete", () => {
502
+ it("should delete a file with SHA in body", async () => {
503
+ // First read to verify it exists
504
+ const before = (await backend.read(
505
+ "/contents/package.json",
506
+ )) as GhContent;
507
+ expect(before.content).toBe('{"name": "test"}');
508
+
509
+ // Delete
510
+ await backend.remove("/contents/package.json");
511
+
512
+ // Verify it's gone
513
+ await expect(backend.read("/contents/package.json")).rejects.toThrow();
514
+ });
515
+ });
516
+
517
+ // --- Auth ---
518
+
519
+ describe("Auth pass-through", () => {
520
+ it("should pass Bearer token in requests", async () => {
521
+ // If auth works, we can read issues; if not, we'd get 401
522
+ const result = (await backend.read("/issues/2.json")) as GhIssue;
523
+ expect(result.title).toBe("Feature request");
524
+ });
525
+
526
+ it("should fail with wrong auth token", async () => {
527
+ const port = (server.address() as { port: number }).port;
528
+ const badBackend = new HttpBackend({
529
+ ...createGhConfig(`http://localhost:${port}`),
530
+ auth: { type: "bearer", token: "wrong-token" },
531
+ });
532
+
533
+ // 401 → non-array → empty list
534
+ const result = await badBackend.list("/issues/");
535
+ expect(result).toEqual([]);
536
+ });
537
+ });
538
+
539
+ // --- AgentFs integration ---
540
+
541
+ describe("AgentFs integration", () => {
542
+ it("should work as a mount in AgentFs", async () => {
543
+ const { AgentFs } = await import("../src/agent-fs.js");
544
+
545
+ const agentFs = new AgentFs({
546
+ mounts: [{ path: "/gh", backend }],
547
+ });
548
+
549
+ // List mount root
550
+ const lsRoot = await agentFs.execute("ls /");
551
+ expect(lsRoot.ok).toBe(true);
552
+ if (lsRoot.ok) {
553
+ expect(lsRoot.data).toContain("gh/");
554
+ }
555
+
556
+ // List resources
557
+ const lsGh = await agentFs.execute("ls /gh/");
558
+ expect(lsGh.ok).toBe(true);
559
+ if (lsGh.ok) {
560
+ expect(lsGh.data).toContain("issues/");
561
+ expect(lsGh.data).toContain("contents/");
562
+ }
563
+
564
+ // Read an issue
565
+ const catIssue = await agentFs.execute("cat /gh/issues/2.json");
566
+ expect(catIssue.ok).toBe(true);
567
+ if (catIssue.ok) {
568
+ expect((catIssue.data as GhIssue).title).toBe("Feature request");
569
+ }
570
+
571
+ // List contents (tree mode)
572
+ const lsContents = await agentFs.execute("ls /gh/contents/");
573
+ expect(lsContents.ok).toBe(true);
574
+ if (lsContents.ok) {
575
+ expect(lsContents.data).toContain("README.md");
576
+ expect(lsContents.data).toContain("src/");
577
+ }
578
+ });
579
+ });
580
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import { HttpBackend } from "../src/backends/http.js";
4
+ import { extractList } from "../src/backends/http.js";
5
+
6
+ describe("extractList", () => {
7
+ it("should return data directly if it's already an array", () => {
8
+ expect(extractList([1, 2, 3])).toEqual([1, 2, 3]);
9
+ });
10
+
11
+ it("should use explicit listKey", () => {
12
+ expect(extractList({ result: [1, 2], total: 2 }, "result")).toEqual([1, 2]);
13
+ });
14
+
15
+ it("should auto-detect first array property (Cloudflare: result)", () => {
16
+ const data = { success: true, errors: [], messages: [], result: [{ id: "z1" }] };
17
+ expect(extractList(data)).toEqual([{ id: "z1" }]);
18
+ });
19
+
20
+ it("should auto-detect first array property (Stripe: data)", () => {
21
+ const data = { object: "list", data: [{ id: "cus_1" }], has_more: false };
22
+ expect(extractList(data)).toEqual([{ id: "cus_1" }]);
23
+ });
24
+
25
+ it("should return data as-is if no array found", () => {
26
+ const data = { id: 1, name: "test" };
27
+ expect(extractList(data)).toEqual(data);
28
+ });
29
+
30
+ it("should handle null/undefined", () => {
31
+ expect(extractList(null)).toBeNull();
32
+ expect(extractList(undefined)).toBeUndefined();
33
+ });
34
+ });
35
+
36
+ describe("HttpBackend listKey auto-inference (mock server)", () => {
37
+ let server: Server;
38
+ let port: number;
39
+
40
+ beforeAll(async () => {
41
+ await new Promise<void>((resolve) => {
42
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
43
+ res.setHeader("Content-Type", "application/json");
44
+
45
+ // Cloudflare-style wrapped response
46
+ if (req.url === "/zones") {
47
+ res.end(JSON.stringify({
48
+ success: true,
49
+ errors: [],
50
+ messages: [],
51
+ result: [
52
+ { id: "zone1", name: "example.com" },
53
+ { id: "zone2", name: "test.com" },
54
+ ],
55
+ }));
56
+ return;
57
+ }
58
+
59
+ // Stripe-style wrapped response
60
+ if (req.url === "/customers") {
61
+ res.end(JSON.stringify({
62
+ object: "list",
63
+ data: [
64
+ { id: "cus_1", name: "Alice" },
65
+ { id: "cus_2", name: "Bob" },
66
+ ],
67
+ has_more: false,
68
+ }));
69
+ return;
70
+ }
71
+
72
+ // Direct array response (GitHub-style)
73
+ if (req.url === "/repos") {
74
+ res.end(JSON.stringify([
75
+ { id: 1, name: "repo-a" },
76
+ { id: 2, name: "repo-b" },
77
+ ]));
78
+ return;
79
+ }
80
+
81
+ res.writeHead(404);
82
+ res.end("{}");
83
+ });
84
+ server.listen(0, () => {
85
+ port = (server.address() as any).port;
86
+ resolve();
87
+ });
88
+ });
89
+ });
90
+
91
+ afterAll(() => server?.close());
92
+
93
+ it("should auto-detect listKey for Cloudflare-style { result: [...] }", async () => {
94
+ const backend = new HttpBackend({
95
+ baseUrl: `http://localhost:${port}`,
96
+ resources: [{ name: "zones", apiPath: "/zones" }],
97
+ });
98
+ const items = await backend.list("/zones/");
99
+ expect(items).toEqual(["zone1.json", "zone2.json"]);
100
+ });
101
+
102
+ it("should auto-detect listKey for Stripe-style { data: [...] }", async () => {
103
+ const backend = new HttpBackend({
104
+ baseUrl: `http://localhost:${port}`,
105
+ resources: [{ name: "customers", apiPath: "/customers" }],
106
+ });
107
+ const items = await backend.list("/customers/");
108
+ expect(items).toEqual(["cus_1.json", "cus_2.json"]);
109
+ });
110
+
111
+ it("should handle direct array response (GitHub-style)", async () => {
112
+ const backend = new HttpBackend({
113
+ baseUrl: `http://localhost:${port}`,
114
+ resources: [{ name: "repos", apiPath: "/repos" }],
115
+ });
116
+ const items = await backend.list("/repos/");
117
+ expect(items).toEqual(["1.json", "2.json"]);
118
+ });
119
+
120
+ it("explicit listKey takes precedence over auto-detect", async () => {
121
+ const backend = new HttpBackend({
122
+ baseUrl: `http://localhost:${port}`,
123
+ resources: [{ name: "zones", apiPath: "/zones", listKey: "result" }],
124
+ });
125
+ const items = await backend.list("/zones/");
126
+ expect(items).toEqual(["zone1.json", "zone2.json"]);
127
+ });
128
+ });