@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,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
|
+
});
|