@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.1

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 (89) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +40 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +142 -0
  7. package/dist/dual-replica.js +270 -0
  8. package/dist/extension-config.d.ts +9 -0
  9. package/dist/extension-config.js +16 -0
  10. package/dist/fake-channel.d.ts +162 -0
  11. package/dist/fake-channel.js +325 -0
  12. package/dist/fake-e2b.d.ts +104 -0
  13. package/dist/fake-e2b.js +500 -0
  14. package/dist/fake-git-server.d.ts +57 -0
  15. package/dist/fake-git-server.js +323 -0
  16. package/dist/fake-lark.d.ts +234 -0
  17. package/dist/fake-lark.js +837 -0
  18. package/dist/fake-marketplace.d.ts +25 -0
  19. package/dist/fake-marketplace.js +58 -0
  20. package/dist/fake-mcp-server.d.ts +134 -0
  21. package/dist/fake-mcp-server.js +308 -0
  22. package/dist/fake-model-server.d.ts +28 -0
  23. package/dist/fake-model-server.js +67 -0
  24. package/dist/fake-npm-registry.d.ts +59 -0
  25. package/dist/fake-npm-registry.js +174 -0
  26. package/dist/fake-oauth-server.d.ts +63 -0
  27. package/dist/fake-oauth-server.js +284 -0
  28. package/dist/fake-openapi-server.d.ts +50 -0
  29. package/dist/fake-openapi-server.js +278 -0
  30. package/dist/fake-slack.d.ts +258 -0
  31. package/dist/fake-slack.js +654 -0
  32. package/dist/fixtures.d.ts +49 -0
  33. package/dist/fixtures.js +92 -0
  34. package/dist/global-setup.d.ts +15 -0
  35. package/dist/global-setup.js +58 -0
  36. package/dist/index.d.ts +25 -0
  37. package/dist/index.js +25 -0
  38. package/dist/kernel-helpers.d.ts +125 -0
  39. package/dist/kernel-helpers.js +336 -0
  40. package/dist/media-samples.d.ts +10 -0
  41. package/dist/media-samples.js +76 -0
  42. package/dist/model-conformance.d.ts +61 -0
  43. package/dist/model-conformance.js +287 -0
  44. package/dist/replica-runner.d.ts +1 -0
  45. package/dist/replica-runner.js +59 -0
  46. package/dist/sse.d.ts +20 -0
  47. package/dist/sse.js +87 -0
  48. package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
  49. package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
  50. package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
  51. package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
  52. package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
  53. package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
  54. package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
  55. package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
  56. package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
  57. package/fixtures/extensions/fake-connector/package.json +13 -0
  58. package/fixtures/extensions/fake-connector/src/index.ts +477 -0
  59. package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
  60. package/fixtures/extensions/fake-dep-provider/package.json +12 -0
  61. package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
  62. package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
  63. package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
  64. package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
  65. package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
  66. package/fixtures/extensions/fake-embed/package.json +12 -0
  67. package/fixtures/extensions/fake-embed/src/index.ts +207 -0
  68. package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
  69. package/fixtures/extensions/fake-im/package.json +12 -0
  70. package/fixtures/extensions/fake-im/src/channel.ts +224 -0
  71. package/fixtures/extensions/fake-im/src/directory.ts +25 -0
  72. package/fixtures/extensions/fake-im/src/identity.ts +66 -0
  73. package/fixtures/extensions/fake-im/src/index.ts +91 -0
  74. package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
  75. package/fixtures/extensions/fake-object-store/package.json +15 -0
  76. package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
  77. package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
  78. package/fixtures/extensions/fake-openapi/package.json +12 -0
  79. package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
  80. package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
  81. package/fixtures/extensions/fake-storage-user/package.json +12 -0
  82. package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
  83. package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
  84. package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
  85. package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
  86. package/fixtures/extensions/fake-tools-ui/package.json +12 -0
  87. package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
  88. package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
  89. package/package.json +67 -3
@@ -0,0 +1,500 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ const ROOT_DIRS = ["/workspace", "/skills", "/tmp", "/opt/capybara"];
8
+ const PATH_RE = /(^|[\s'"=:>(])(\/workspace|\/skills|\/tmp|\/opt\/capybara)(?=\/|$|[\s'";)|&])/g;
9
+ const RUNUSER_SHIM = `#!/bin/sh
10
+ while [ $# -gt 0 ]; do
11
+ case "$1" in
12
+ -u|-g) shift 2 ;;
13
+ --) shift; break ;;
14
+ -*) shift ;;
15
+ *) break ;;
16
+ esac
17
+ done
18
+ exec "$@"
19
+ `;
20
+ const SETSID_SHIM = `#!/bin/sh
21
+ while [ $# -gt 0 ]; do
22
+ case "$1" in
23
+ -w|-f|-c|--wait|--fork|--ctty) shift ;;
24
+ *) break ;;
25
+ esac
26
+ done
27
+ exec "$@"
28
+ `;
29
+ /**
30
+ * `timeout` 垫片(命令包装用 `timeout -s KILL <秒> 命令…`):PATH 里(跳过垫片目录)有 `gtimeout` / `timeout`
31
+ * (GNU coreutils)就用它;没有(如未装 coreutils 的 macOS)时用 perl 的小实现:自成进程组、到点按进程组
32
+ * SIGKILL(与 GNU timeout 一样连自己一起),否则返回命令的退出状态。只支持 `[-s 信号] 秒 命令…`,信号一律按 KILL。
33
+ */
34
+ const TIMEOUT_SHIM = `#!/bin/sh
35
+ self=$(cd "$(dirname "$0")" && pwd)
36
+ IFS=:
37
+ for d in $PATH; do
38
+ [ "$d" = "$self" ] && continue
39
+ for n in gtimeout timeout; do
40
+ if [ -x "$d/$n" ]; then unset IFS; exec "$d/$n" "$@"; fi
41
+ done
42
+ done
43
+ unset IFS
44
+ [ "$1" = "-s" ] && shift 2
45
+ exec perl -e 'my $s = shift; setpgrp(0, 0); my $p = fork(); die "fork failed" unless defined $p;
46
+ if ($p == 0) { exec { $ARGV[0] } @ARGV; exit 127 }
47
+ $SIG{ALRM} = sub { kill "KILL", -$$ }; alarm $s; waitpid($p, 0); my $st = $?; alarm 0;
48
+ exit(($st & 127) ? 128 + ($st & 127) : $st >> 8);' "$@"
49
+ `;
50
+ function envelope(flags, msg) {
51
+ const json = Buffer.from(JSON.stringify(msg), "utf8");
52
+ const head = Buffer.alloc(5);
53
+ head.writeUInt8(flags, 0);
54
+ head.writeUInt32BE(json.byteLength, 1);
55
+ return Buffer.concat([head, json]);
56
+ }
57
+ async function readBody(req) {
58
+ const chunks = [];
59
+ for await (const c of req)
60
+ chunks.push(c);
61
+ return Buffer.concat(chunks);
62
+ }
63
+ function sendJson(res, status, body, headers = {}) {
64
+ if (body === undefined) {
65
+ res.writeHead(status, headers).end();
66
+ return;
67
+ }
68
+ res
69
+ .writeHead(status, { "content-type": "application/json", ...headers })
70
+ .end(JSON.stringify(body));
71
+ }
72
+ /** 起一个假 E2B。 */
73
+ export async function startFakeE2B(opts = {}) {
74
+ const apiKey = opts.apiKey ?? `e2b_${randomBytes(16).toString("hex")}`;
75
+ const pageSize = opts.pageSize ?? 100;
76
+ const base = await mkdtemp(join(tmpdir(), "fake-e2b-"));
77
+ const shims = join(base, "bin");
78
+ await mkdir(shims, { recursive: true });
79
+ await writeFile(join(shims, "runuser"), RUNUSER_SHIM);
80
+ await writeFile(join(shims, "setsid"), SETSID_SHIM);
81
+ await writeFile(join(shims, "timeout"), TIMEOUT_SHIM);
82
+ await chmod(join(shims, "timeout"), 0o755);
83
+ await chmod(join(shims, "runuser"), 0o755);
84
+ await chmod(join(shims, "setsid"), 0o755);
85
+ const sandboxes = new Map();
86
+ const procs = new Map();
87
+ const calls = [];
88
+ const fails = [];
89
+ const reboots = new Map();
90
+ let withhold = false;
91
+ const tokenOf = (s) => (withhold ? null : s.envdAccessToken);
92
+ const hangs = [];
93
+ const hanging = new Set();
94
+ const matches = (m, call) => (m.method === undefined || m.method === call.method) &&
95
+ m.path.test(call.path) &&
96
+ (m.when === undefined || m.when(call));
97
+ const mapPath = (root, s) => s.replace(PATH_RE, (_m, pre, dir) => `${pre}${root}${dir}`);
98
+ /** 直接子进程(命令包装里的 `timeout` 自成一个进程组,按进程组杀会漏掉它)。 */
99
+ const childrenOf = (pid) => (spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" }).stdout ?? "")
100
+ .split("\n")
101
+ .filter((l) => l !== "")
102
+ .map(Number);
103
+ /** 杀掉一个进程(Start 启动的)的进程组、它直接子进程的进程组,以及之前记下的子进程组(本机未必有 `pkill -s`)。 */
104
+ const killGroup = (p) => {
105
+ for (const g of [...p.groups, ...childrenOf(p.pid), p.pid]) {
106
+ try {
107
+ process.kill(-g, "SIGKILL");
108
+ }
109
+ catch {
110
+ // 已退出
111
+ }
112
+ }
113
+ };
114
+ const destroy = async (id) => {
115
+ const s = sandboxes.get(id);
116
+ sandboxes.delete(id);
117
+ for (const p of procs.get(id) ?? [])
118
+ killGroup(p);
119
+ procs.delete(id);
120
+ if (s)
121
+ await rm(s.root, { recursive: true, force: true });
122
+ };
123
+ const newSandbox = async (body) => {
124
+ const id = `i${randomBytes(9).toString("hex")}`;
125
+ const root = join(base, id);
126
+ for (const d of ROOT_DIRS)
127
+ await mkdir(join(root, d), { recursive: true });
128
+ const s = {
129
+ id,
130
+ templateID: String(body.templateID),
131
+ metadata: body.metadata ?? {},
132
+ state: "running",
133
+ timeout: typeof body.timeout === "number" ? body.timeout : 300,
134
+ autoPause: body.autoPause === true,
135
+ envdAccessToken: randomBytes(12).toString("hex"),
136
+ root,
137
+ ...(typeof body.allow_internet_access === "boolean" && {
138
+ allowInternetAccess: body.allow_internet_access,
139
+ }),
140
+ ...(body.network !== undefined && { network: body.network }),
141
+ };
142
+ sandboxes.set(id, s);
143
+ return s;
144
+ };
145
+ const notFound = (res, id) => sendJson(res, 404, {
146
+ code: 404,
147
+ message: `sandbox "${id}" doesn't exist or you don't have access to it`,
148
+ });
149
+ // ---------- 控制面 API ----------
150
+ const handleApi = async (req, res, method, path, query, body) => {
151
+ if (req.headers["x-api-key"] !== apiKey) {
152
+ sendJson(res, 401, {
153
+ code: 401,
154
+ message: "Invalid API key, it must start with 'e2b_' prefix",
155
+ });
156
+ return;
157
+ }
158
+ if (method === "POST" && path === "/v2/sandboxes") {
159
+ if (typeof body.templateID !== "string" || body.templateID === "") {
160
+ sendJson(res, 400, { code: 400, message: "templateID is required" });
161
+ return;
162
+ }
163
+ const s = await newSandbox(body);
164
+ sendJson(res, 201, {
165
+ sandboxID: s.id,
166
+ templateID: s.templateID,
167
+ clientID: "fake",
168
+ envdVersion: "0.4.2",
169
+ envdAccessToken: tokenOf(s),
170
+ domain: opts.domain ?? null,
171
+ });
172
+ return;
173
+ }
174
+ if (method === "GET" && path === "/v2/sandboxes") {
175
+ const want = new URLSearchParams(query.metadata ?? "");
176
+ const states = (query.state ?? "running,paused").split(",");
177
+ const all = [...sandboxes.values()].filter((s) => {
178
+ if (!states.includes(s.state))
179
+ return false;
180
+ for (const [k, v] of want) {
181
+ if (s.metadata[decodeURIComponent(k)] !== decodeURIComponent(v))
182
+ return false;
183
+ }
184
+ return true;
185
+ });
186
+ const limit = Math.min(pageSize, Number(query.limit ?? pageSize) || pageSize);
187
+ const offset = Number(query.nextToken ?? 0) || 0;
188
+ const page = all.slice(offset, offset + limit);
189
+ const next = offset + limit < all.length ? String(offset + limit) : undefined;
190
+ sendJson(res, 200, page.map((s) => ({
191
+ sandboxID: s.id,
192
+ templateID: s.templateID,
193
+ metadata: s.metadata,
194
+ state: s.state,
195
+ cpuCount: 1,
196
+ memoryMB: 2048,
197
+ envdVersion: "0.4.2",
198
+ startedAt: new Date().toISOString(),
199
+ endAt: new Date().toISOString(),
200
+ })), next ? { "x-next-token": next } : {});
201
+ return;
202
+ }
203
+ let m = /^\/v2\/sandboxes\/([^/]+)\/connect$/.exec(path);
204
+ if (m && method === "POST") {
205
+ const s = sandboxes.get(m[1]);
206
+ if (!s)
207
+ return notFound(res, m[1]);
208
+ const resumed = s.state === "paused";
209
+ s.state = "running";
210
+ if (body.memory === false) {
211
+ // 冷启动(SDK 的 onResume: "reboot"):内存里的进程全部结束,文件保留
212
+ for (const p of procs.get(s.id) ?? [])
213
+ killGroup(p);
214
+ reboots.set(s.id, (reboots.get(s.id) ?? 0) + 1);
215
+ }
216
+ if (typeof body.timeout === "number" && body.timeout > s.timeout)
217
+ s.timeout = body.timeout;
218
+ sendJson(res, resumed ? 201 : 200, {
219
+ sandboxID: s.id,
220
+ templateID: s.templateID,
221
+ clientID: "fake",
222
+ envdVersion: "0.4.2",
223
+ envdAccessToken: tokenOf(s),
224
+ domain: opts.domain ?? null,
225
+ });
226
+ return;
227
+ }
228
+ m = /^\/sandboxes\/([^/]+)\/pause$/.exec(path);
229
+ if (m && method === "POST") {
230
+ const s = sandboxes.get(m[1]);
231
+ if (!s)
232
+ return notFound(res, m[1]);
233
+ if (s.state === "paused") {
234
+ sendJson(res, 409, { code: 409, message: "sandbox is already paused" });
235
+ return;
236
+ }
237
+ s.state = "paused";
238
+ sendJson(res, 204);
239
+ return;
240
+ }
241
+ m = /^\/sandboxes\/([^/]+)$/.exec(path);
242
+ if (m && method === "DELETE") {
243
+ if (!sandboxes.has(m[1]))
244
+ return notFound(res, m[1]);
245
+ await destroy(m[1]);
246
+ sendJson(res, 204);
247
+ return;
248
+ }
249
+ sendJson(res, 404, { code: 404, message: `no route ${method} ${path}` });
250
+ };
251
+ // ---------- envd ----------
252
+ const userOf = (req) => {
253
+ const a = req.headers.authorization;
254
+ if (!a?.startsWith("Basic "))
255
+ return undefined;
256
+ return Buffer.from(a.slice(6), "base64").toString("utf8").replace(/:.*$/, "");
257
+ };
258
+ const startProcess = (s, msg, res) => {
259
+ const p = msg.process ?? {};
260
+ const args = (p.args ?? []).map((a) => mapPath(s.root, a));
261
+ const env = {
262
+ ...process.env,
263
+ PATH: `${shims}:${process.env.PATH ?? "/usr/bin:/bin"}`,
264
+ };
265
+ for (const [k, v] of Object.entries(p.envs ?? {}))
266
+ env[k] = mapPath(s.root, v);
267
+ const cwd = p.cwd ? mapPath(s.root, p.cwd) : join(s.root, "workspace");
268
+ res.writeHead(200, { "content-type": "application/connect+json" });
269
+ let child;
270
+ try {
271
+ child = spawn(p.cmd ?? "/bin/sh", args, { cwd, env, detached: true, stdio: "pipe" });
272
+ }
273
+ catch (e) {
274
+ res.end(envelope(2, { error: { code: "internal", message: String(e) } }));
275
+ return;
276
+ }
277
+ child.on("error", (e) => {
278
+ if (!res.writableEnded)
279
+ res.end(envelope(2, { error: { code: "internal", message: e.message } }));
280
+ });
281
+ const pid = child.pid;
282
+ if (pid === undefined)
283
+ return;
284
+ const proc = { pid, args: [p.cmd ?? "", ...args], child, groups: new Set() };
285
+ const list = procs.get(s.id) ?? [];
286
+ list.push(proc);
287
+ procs.set(s.id, list);
288
+ child.stdin?.end();
289
+ const write = (msg) => {
290
+ if (!res.writableEnded && !res.destroyed)
291
+ res.write(envelope(0, msg));
292
+ };
293
+ write({ event: { start: { pid } } });
294
+ child.stdout?.on("data", (d) => write({ event: { data: { stdout: d.toString("base64") } } }));
295
+ child.stderr?.on("data", (d) => write({ event: { data: { stderr: d.toString("base64") } } }));
296
+ child.on("close", (code, signal) => {
297
+ const l = procs.get(s.id);
298
+ if (l)
299
+ procs.set(s.id, l.filter((x) => x !== proc));
300
+ const end = signal
301
+ ? { exitCode: -1, status: `signal: ${signal === "SIGKILL" ? "killed" : signal}` }
302
+ : { exited: true, status: `exit status ${code}` };
303
+ if (!signal && code !== 0)
304
+ end.exitCode = code;
305
+ write({ event: { end } });
306
+ if (!res.writableEnded && !res.destroyed)
307
+ res.end(envelope(2, {}));
308
+ });
309
+ };
310
+ const handleEnvd = async (req, res, id, path, query, raw, call) => {
311
+ const s = sandboxes.get(id);
312
+ if (s?.state !== "running") {
313
+ sendJson(res, 502, {
314
+ sandboxId: id,
315
+ message: s ? "The sandbox is paused" : "The sandbox was not found",
316
+ code: 502,
317
+ });
318
+ return;
319
+ }
320
+ if (req.headers["x-access-token"] !== s.envdAccessToken) {
321
+ sendJson(res, 401, { code: 401, message: "invalid access token" });
322
+ return;
323
+ }
324
+ if (path === "/files" && req.method === "POST") {
325
+ call.user = query.username;
326
+ const target = query.path;
327
+ if (!target?.startsWith("/")) {
328
+ sendJson(res, 400, { code: 400, message: "path is required" });
329
+ return;
330
+ }
331
+ const form = await new Response(raw, {
332
+ headers: { "content-type": String(req.headers["content-type"] ?? "") },
333
+ }).formData();
334
+ const file = form.get("file");
335
+ if (!(file instanceof Blob)) {
336
+ sendJson(res, 400, { code: 400, message: "missing file part" });
337
+ return;
338
+ }
339
+ const host = mapPath(s.root, target);
340
+ if (host === target) {
341
+ sendJson(res, 400, { code: 400, message: "path outside the fake sandbox roots" });
342
+ return;
343
+ }
344
+ await mkdir(dirname(host), { recursive: true });
345
+ await writeFile(host, Buffer.from(await file.arrayBuffer()));
346
+ sendJson(res, 200, [{ path: target, name: target.split("/").pop(), type: "file" }]);
347
+ return;
348
+ }
349
+ if (path === "/process.Process/Start" && req.method === "POST") {
350
+ call.user = userOf(req);
351
+ if (!call.user) {
352
+ sendJson(res, 401, { code: "unauthenticated", message: "missing user" });
353
+ return;
354
+ }
355
+ if (raw.byteLength < 5) {
356
+ sendJson(res, 400, { code: "invalid_argument", message: "missing envelope" });
357
+ return;
358
+ }
359
+ const msg = JSON.parse(raw.subarray(5, 5 + raw.readUInt32BE(1)).toString("utf8"));
360
+ call.body = msg;
361
+ startProcess(s, msg, res);
362
+ return;
363
+ }
364
+ if (path === "/process.Process/SendSignal" && req.method === "POST") {
365
+ const msg = JSON.parse(raw.toString("utf8") || "{}");
366
+ call.body = msg;
367
+ const pid = msg.process?.pid;
368
+ const p = (procs.get(s.id) ?? []).find((x) => x.pid === pid);
369
+ if (!p) {
370
+ sendJson(res, 404, { code: "not_found", message: `process with pid ${pid} not found` });
371
+ return;
372
+ }
373
+ // 与 envd 一样只给这个进程发信号(不是整个进程组)。先记下它的子进程组:它死后子进程被收养、
374
+ // 再也按父子关系找不到,删沙箱 / 冷启动时(真 E2B 结束沙箱里的一切)靠这份记录杀掉它们
375
+ for (const g of childrenOf(p.pid))
376
+ p.groups.add(g);
377
+ try {
378
+ process.kill(p.pid, "SIGKILL");
379
+ }
380
+ catch {
381
+ // 已退出
382
+ }
383
+ sendJson(res, 200, {});
384
+ return;
385
+ }
386
+ sendJson(res, 404, { code: "not_found", message: `no route ${req.method} ${path}` });
387
+ };
388
+ const server = createServer((req, res) => {
389
+ void (async () => {
390
+ const u = new URL(req.url ?? "/", "http://fake");
391
+ const method = req.method ?? "GET";
392
+ const query = Object.fromEntries(u.searchParams);
393
+ const raw = await readBody(req);
394
+ const sandboxId = req.headers["e2b-sandbox-id"];
395
+ const isEnvd = typeof sandboxId === "string";
396
+ let body = null;
397
+ const ct = String(req.headers["content-type"] ?? "");
398
+ if (ct.startsWith("application/json") && raw.byteLength > 0) {
399
+ try {
400
+ body = JSON.parse(raw.toString("utf8"));
401
+ }
402
+ catch {
403
+ body = null;
404
+ }
405
+ }
406
+ const call = {
407
+ method,
408
+ path: u.pathname,
409
+ query,
410
+ body,
411
+ ...(isEnvd && { sandboxId }),
412
+ };
413
+ if (u.pathname === "/process.Process/Start" && raw.byteLength >= 5) {
414
+ try {
415
+ call.body = JSON.parse(raw.subarray(5, 5 + raw.readUInt32BE(1)).toString("utf8"));
416
+ }
417
+ catch {
418
+ call.body = null;
419
+ }
420
+ }
421
+ calls.push(call);
422
+ const fi = fails.findIndex((f) => matches(f.match, call));
423
+ if (fi >= 0) {
424
+ const [f] = fails.splice(fi, 1);
425
+ if (f?.raw)
426
+ res.writeHead(f.status, { "content-type": "text/plain" }).end(f.message);
427
+ else if (f)
428
+ sendJson(res, f.status, { code: f.status, message: f.message });
429
+ return;
430
+ }
431
+ const hi = hangs.findIndex((h) => matches(h, call));
432
+ if (hi >= 0) {
433
+ hangs.splice(hi, 1);
434
+ hanging.add(res);
435
+ res.on("close", () => hanging.delete(res));
436
+ return;
437
+ }
438
+ if (isEnvd) {
439
+ await handleEnvd(req, res, sandboxId, u.pathname, query, raw, call);
440
+ }
441
+ else {
442
+ await handleApi(req, res, method, u.pathname, query, (body ?? {}));
443
+ }
444
+ })().catch((e) => {
445
+ if (!res.headersSent)
446
+ sendJson(res, 500, { code: 500, message: String(e) });
447
+ else
448
+ res.destroy();
449
+ });
450
+ });
451
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
452
+ const url = `http://127.0.0.1:${server.address().port}`;
453
+ return {
454
+ url,
455
+ apiKey,
456
+ sandboxes,
457
+ calls,
458
+ failNext(match, status, message = `fake failure ${status}`, opts = {}) {
459
+ fails.push({ match, status, message, raw: opts.raw === true });
460
+ },
461
+ reboots,
462
+ get withholdAccessToken() {
463
+ return withhold;
464
+ },
465
+ set withholdAccessToken(v) {
466
+ withhold = v;
467
+ },
468
+ hangNext(match) {
469
+ hangs.push(match);
470
+ },
471
+ expire: destroy,
472
+ hostPath(id, sandboxPath) {
473
+ const s = sandboxes.get(id);
474
+ if (!s)
475
+ throw new Error(`no fake sandbox ${id}`);
476
+ return mapPath(s.root, sandboxPath);
477
+ },
478
+ running(id) {
479
+ return (procs.get(id) ?? []).map((p) => ({ pid: p.pid, args: p.args }));
480
+ },
481
+ alive(pid) {
482
+ try {
483
+ process.kill(-pid, 0);
484
+ return true;
485
+ }
486
+ catch {
487
+ return false;
488
+ }
489
+ },
490
+ async close() {
491
+ for (const id of [...sandboxes.keys()])
492
+ await destroy(id);
493
+ for (const r of hanging)
494
+ r.destroy();
495
+ server.closeAllConnections();
496
+ await new Promise((r) => server.close(() => r()));
497
+ await rm(base, { recursive: true, force: true });
498
+ },
499
+ };
500
+ }
@@ -0,0 +1,57 @@
1
+ /** One request the fake git server received. */
2
+ export interface FakeGitRequest {
3
+ method: string;
4
+ /** Path and query, e.g. `/x.git/info/refs?service=git-upload-pack`. */
5
+ path: string;
6
+ /** The `Authorization` header. */
7
+ authorization?: string;
8
+ }
9
+ /** Entries that are not regular files, for tests of refused trees. */
10
+ export interface FakeGitCommitOptions {
11
+ /** Default `main` (the bare repo's HEAD). */
12
+ branch?: string;
13
+ /** Symlink entries (mode 120000): path -> link target. */
14
+ symlinks?: Record<string, string>;
15
+ /** Submodule entries (gitlinks, mode 160000): path -> commit sha (need not exist). */
16
+ submodules?: Record<string, string>;
17
+ /**
18
+ * A tree-only DAG at the top-level directory `name`: T0 is the empty tree and each level holds
19
+ * `fanout` entries all pointing at the level below, `depth` levels deep (few objects, fanout^depth
20
+ * paths).
21
+ */
22
+ treeDag?: {
23
+ name: string;
24
+ fanout: number;
25
+ depth: number;
26
+ };
27
+ /** Also tags the commit (`annotated` creates a tag object; default lightweight). */
28
+ tag?: {
29
+ name: string;
30
+ annotated?: boolean;
31
+ };
32
+ }
33
+ export interface FakeGitServer {
34
+ /** `http://127.0.0.1:<port>/<repo>.git`. */
35
+ url(repo: string): string;
36
+ /**
37
+ * Commits exactly `files` (path -> UTF-8 content; the whole tree, not a diff) on `branch`, whose
38
+ * previous head (if any) is the parent; creates the repo on first use. Returns the commit sha.
39
+ */
40
+ commit(repo: string, files: Record<string, string>, opts?: FakeGitCommitOptions): Promise<string>;
41
+ /** Makes every request answer 302 to `location` (`null` restores it). */
42
+ setRedirect(location: string | null): void;
43
+ /** The requests received, in order. */
44
+ requests: FakeGitRequest[];
45
+ stop(): Promise<void>;
46
+ }
47
+ /**
48
+ * A fake git host: bare repos in a temp dir, served read-only by the system `git http-backend`
49
+ * (smart HTTP, as CGI) over a loopback http server. Repos allow fetching any reachable commit by sha
50
+ * (`uploadpack.allowReachableSHA1InWant`, as GitHub does). Needs `git` on PATH.
51
+ *
52
+ * Spawning git is what these tests spend their time on, and it slows down most under load, so the
53
+ * server spawns as little as it can while every answer still comes from git: a commit is one
54
+ * `git fast-import`, and a ref advertisement (`GET …/info/refs`) is answered by http-backend once per
55
+ * repo state, then repeated until the next commit to that repo.
56
+ */
57
+ export declare function startFakeGitServer(): Promise<FakeGitServer>;