@capybara-hq/testing 0.1.0-rc.4 → 0.1.0-rc.6

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.
@@ -19,6 +19,21 @@
19
19
  * 本机只有一个用户,权限隔离测不了,测试改为核对请求里的用户)。进程以新会话启动(与 setsid 等价),
20
20
  * 客户端断开**不会**结束进程(与 envd 一致),只有 SendSignal、杀进程脚本或删沙箱才会。
21
21
  *
22
+ * - **Template build** (the routes `Template.build(Template().fromImage(image), alias)` calls, e2b 2.58.0
23
+ * `buildApi`; all need `X-API-KEY`): `POST /v3/templates` (body `{name, tags?, cpuCount?, memoryMB?}`,
24
+ * `name` may carry `:tag`; 202 `{templateID, buildID, names, aliases, tags, public}`),
25
+ * `GET /templates/{templateID}/files/{hash}` (upload link of a COPY step; `{present: true}`),
26
+ * `POST /v2/templates/{templateID}/builds/{buildID}` (body `{fromImage, steps, force, startCmd?,
27
+ * readyCmd?}`; 202, and {@link FakeE2BOptions.templateBuildMs} starts counting), `GET
28
+ * /templates/{templateID}/builds/{buildID}/status?logsOffset=` (`{buildID, templateID, status:
29
+ * waiting | building | ready, logEntries, logs}`), `GET /templates/aliases/{alias}` (200
30
+ * `{templateID, public}`, else 404). A template (including the seeded `templates`) creates
31
+ * sandboxes by alias or templateID; one still building answers 404; template names never
32
+ * registered are accepted as before.
33
+ * - **Pause**: `POST /sandboxes/{id}/pause` with `{memory: false}` (the SDK's `mode: "filesystem"`)
34
+ * ends all processes and keeps the files; otherwise (also an empty body) processes stay.
35
+ * {@link FakeE2BServer.removeSandbox} simulates a lost paused sandbox.
36
+ *
22
37
  * `connect` 带 `memory: false`(SDK 的 `onResume: "reboot"`)时结束该沙箱的全部进程(冷启动)。
23
38
  * SendSignal 只杀指定进程(不是进程组)。
24
39
  *
@@ -32,6 +47,19 @@ export interface FakeE2BOptions {
32
47
  pageSize?: number;
33
48
  /** create / connect 返回的沙箱 `domain`(默认 null,即用客户端配置的域名)。 */
34
49
  domain?: string | null;
50
+ /** Existing template aliases (treated as built). */
51
+ templates?: string[];
52
+ /** Milliseconds from triggering a template build to `ready` (default 0). */
53
+ templateBuildMs?: number;
54
+ }
55
+ export interface FakeE2BTemplate {
56
+ templateID: string;
57
+ alias: string;
58
+ /** The image it was built from (undefined for a seeded template). */
59
+ image?: string | undefined;
60
+ buildID?: string | undefined;
61
+ /** When it becomes `ready` (ms timestamp); undefined until the build is triggered (waiting). */
62
+ readyAt?: number | undefined;
35
63
  }
36
64
  export interface FakeE2BSandbox {
37
65
  id: string;
@@ -41,6 +69,8 @@ export interface FakeE2BSandbox {
41
69
  /** 当前的超时(秒):建沙箱时给的,connect 只会把它调大。 */
42
70
  timeout: number;
43
71
  autoPause: boolean;
72
+ /** Whether the last pause kept memory (`pause` body `memory` is not false). */
73
+ pausedWithMemory?: boolean;
44
74
  /** 建沙箱请求体里的出站设置(原样记录)。 */
45
75
  allowInternetAccess?: boolean;
46
76
  network?: unknown;
@@ -71,6 +101,8 @@ export interface FakeE2BServer {
71
101
  url: string;
72
102
  apiKey: string;
73
103
  sandboxes: Map<string, FakeE2BSandbox>;
104
+ /** Templates by alias. */
105
+ templates: Map<string, FakeE2BTemplate>;
74
106
  calls: FakeE2BCall[];
75
107
  /**
76
108
  * 下一个匹配的请求回 `status`:默认 E2B 的错误格式 JSON `{code: status, message}`;`raw` 时回纯文本
@@ -90,6 +122,8 @@ export interface FakeE2BServer {
90
122
  withholdAccessToken: boolean;
91
123
  /** 沙箱直接消失(杀掉它的进程、删掉根目录)。 */
92
124
  expire(id: string): Promise<void>;
125
+ /** Same as {@link FakeE2BServer.expire}: simulates a lost paused sandbox (connect then answers 404). */
126
+ removeSandbox(id: string): Promise<void>;
93
127
  /** 沙箱内路径在本机上的位置。 */
94
128
  hostPath(id: string, sandboxPath: string): string;
95
129
  /** 沙箱里还在跑的进程(由 Start 启动、尚未退出的)。 */
package/dist/fake-e2b.js CHANGED
@@ -84,6 +84,12 @@ export async function startFakeE2B(opts = {}) {
84
84
  await chmod(join(shims, "setsid"), 0o755);
85
85
  const sandboxes = new Map();
86
86
  const procs = new Map();
87
+ const templates = new Map();
88
+ for (const alias of opts.templates ?? [])
89
+ templates.set(alias, { templateID: `t${randomBytes(6).toString("hex")}`, alias });
90
+ const buildMs = opts.templateBuildMs ?? 0;
91
+ const templateByRef = (ref) => templates.get(ref) ?? [...templates.values()].find((t) => t.templateID === ref);
92
+ const ready = (t) => t.readyAt !== undefined && Date.now() >= t.readyAt;
87
93
  const calls = [];
88
94
  const fails = [];
89
95
  const reboots = new Map();
@@ -122,12 +128,13 @@ export async function startFakeE2B(opts = {}) {
122
128
  };
123
129
  const newSandbox = async (body) => {
124
130
  const id = `i${randomBytes(9).toString("hex")}`;
131
+ const tpl = templateByRef(String(body.templateID));
125
132
  const root = join(base, id);
126
133
  for (const d of ROOT_DIRS)
127
134
  await mkdir(join(root, d), { recursive: true });
128
135
  const s = {
129
136
  id,
130
- templateID: String(body.templateID),
137
+ templateID: tpl?.templateID ?? String(body.templateID),
131
138
  metadata: body.metadata ?? {},
132
139
  state: "running",
133
140
  timeout: typeof body.timeout === "number" ? body.timeout : 300,
@@ -160,6 +167,11 @@ export async function startFakeE2B(opts = {}) {
160
167
  sendJson(res, 400, { code: 400, message: "templateID is required" });
161
168
  return;
162
169
  }
170
+ const tpl = templateByRef(body.templateID);
171
+ if (tpl?.buildID !== undefined && !ready(tpl)) {
172
+ sendJson(res, 404, { code: 404, message: `template '${body.templateID}' is not ready` });
173
+ return;
174
+ }
163
175
  const s = await newSandbox(body);
164
176
  sendJson(res, 201, {
165
177
  sandboxID: s.id,
@@ -235,9 +247,89 @@ export async function startFakeE2B(opts = {}) {
235
247
  return;
236
248
  }
237
249
  s.state = "paused";
250
+ s.pausedWithMemory = body.memory !== false;
251
+ if (!s.pausedWithMemory) {
252
+ // filesystem-only snapshot: the processes are gone, the files stay
253
+ for (const p of procs.get(s.id) ?? [])
254
+ killGroup(p);
255
+ procs.delete(s.id);
256
+ }
238
257
  sendJson(res, 204);
239
258
  return;
240
259
  }
260
+ if (method === "POST" && path === "/v3/templates") {
261
+ const name = typeof body.name === "string" ? body.name : "";
262
+ if (name === "") {
263
+ sendJson(res, 400, { code: 400, message: "name is required" });
264
+ return;
265
+ }
266
+ const alias = name.split(":")[0];
267
+ const tags = Array.isArray(body.tags) ? body.tags : [];
268
+ const t = templates.get(alias) ?? {
269
+ templateID: `t${randomBytes(6).toString("hex")}`,
270
+ alias,
271
+ };
272
+ t.buildID = randomBytes(8).toString("hex");
273
+ t.readyAt = undefined;
274
+ t.image = undefined;
275
+ templates.set(alias, t);
276
+ sendJson(res, 202, {
277
+ templateID: t.templateID,
278
+ buildID: t.buildID,
279
+ names: [name],
280
+ aliases: [alias],
281
+ tags,
282
+ public: false,
283
+ });
284
+ return;
285
+ }
286
+ m = /^\/templates\/([^/]+)\/files\/([^/]+)$/.exec(path);
287
+ if (m && method === "GET") {
288
+ sendJson(res, 200, { present: true });
289
+ return;
290
+ }
291
+ m = /^\/v2\/templates\/([^/]+)\/builds\/([^/]+)$/.exec(path);
292
+ if (m && method === "POST") {
293
+ const t = templateByRef(m[1]);
294
+ if (!t || t.buildID !== m[2]) {
295
+ sendJson(res, 404, { code: 404, message: "build not found" });
296
+ return;
297
+ }
298
+ if (typeof body.fromImage !== "string") {
299
+ sendJson(res, 400, { code: 400, message: "fromImage is required" });
300
+ return;
301
+ }
302
+ t.image = body.fromImage;
303
+ t.readyAt = Date.now() + buildMs;
304
+ sendJson(res, 202);
305
+ return;
306
+ }
307
+ m = /^\/templates\/([^/]+)\/builds\/([^/]+)\/status$/.exec(path);
308
+ if (m && method === "GET") {
309
+ const t = templateByRef(m[1]);
310
+ if (!t || t.buildID !== m[2]) {
311
+ sendJson(res, 404, { code: 404, message: "build not found" });
312
+ return;
313
+ }
314
+ sendJson(res, 200, {
315
+ buildID: t.buildID,
316
+ templateID: t.templateID,
317
+ status: t.readyAt === undefined ? "waiting" : ready(t) ? "ready" : "building",
318
+ logEntries: [],
319
+ logs: [],
320
+ });
321
+ return;
322
+ }
323
+ m = /^\/templates\/aliases\/([^/]+)$/.exec(path);
324
+ if (m && method === "GET") {
325
+ const t = templates.get(decodeURIComponent(m[1]));
326
+ if (!t) {
327
+ sendJson(res, 404, { code: 404, message: `template '${m[1]}' not found` });
328
+ return;
329
+ }
330
+ sendJson(res, 200, { templateID: t.templateID, public: false });
331
+ return;
332
+ }
241
333
  m = /^\/sandboxes\/([^/]+)$/.exec(path);
242
334
  if (m && method === "DELETE") {
243
335
  if (!sandboxes.has(m[1]))
@@ -454,6 +546,7 @@ export async function startFakeE2B(opts = {}) {
454
546
  url,
455
547
  apiKey,
456
548
  sandboxes,
549
+ templates,
457
550
  calls,
458
551
  failNext(match, status, message = `fake failure ${status}`, opts = {}) {
459
552
  fails.push({ match, status, message, raw: opts.raw === true });
@@ -469,6 +562,7 @@ export async function startFakeE2B(opts = {}) {
469
562
  hangs.push(match);
470
563
  },
471
564
  expire: destroy,
565
+ removeSandbox: destroy,
472
566
  hostPath(id, sandboxPath) {
473
567
  const s = sandboxes.get(id);
474
568
  if (!s)
@@ -129,8 +129,10 @@ function serveCgi(root, req, res) {
129
129
  res.writeHead(h.status, h.headers);
130
130
  res.write(head.subarray(h.bodyStart));
131
131
  });
132
+ // A child that could not be spawned emits 'error' and then 'close': only the first writes a head
133
+ // (a second writeHead throws ERR_HTTP_HEADERS_SENT, an uncaught exception).
132
134
  p.on("close", () => {
133
- if (!headDone)
135
+ if (!headDone && !res.headersSent)
134
136
  res.writeHead(502);
135
137
  res.end();
136
138
  });
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * package and the SDK: the database helpers and types, and model-visible texts, queue names and
4
4
  * keys the kernel owns (assert on them instead of copying their values).
5
5
  */
6
- export { type ConversationEvent, createDb, gatewayReplicasKey, type KernelConfig, type KernelSchemaDb, kernelDb, MAINTENANCE_QUEUE_NAME, runJobId, SANDBOX_LOCK_TTL_MS, SANDBOX_RECREATED_NOTICE, SANDBOX_STARTED_NOTICE, SANDBOX_SUSPEND_JOB, UNKNOWN_RESULT_TEXT, } from "@capybara-hq/kernel";
6
+ export { type ConversationEvent, createDb, gatewayReplicasKey, type KernelConfig, type KernelSchemaDb, kernelDb, MAINTENANCE_QUEUE_NAME, runJobId, SANDBOX_LOCK_TTL_MS, SANDBOX_RESTARTED_NOTICE, SANDBOX_STARTED_NOTICE, SANDBOX_SUSPEND_JOB, UNKNOWN_RESULT_TEXT, } from "@capybara-hq/kernel";
7
7
  export { createFreshDatabase, startTestPostgres, startTestRedis, startTestS3, TEST_S3_IMAGE, type TestContainer, type TestS3, } from "./containers.js";
8
8
  export { createDualReplicaHarness, type DualReplicaOptions, type ExtraReplicaHandle, type Harness, REPLICA_HEADER, type ReplicaHandle, type ReplicaResponse, replicaOf, } from "./dual-replica.js";
9
9
  export { assertExtensionSecretsReachable, extensionDeploymentSecretValues, } from "./extension-config.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * package and the SDK: the database helpers and types, and model-visible texts, queue names and
4
4
  * keys the kernel owns (assert on them instead of copying their values).
5
5
  */
6
- export { createDb, gatewayReplicasKey, kernelDb, MAINTENANCE_QUEUE_NAME, runJobId, SANDBOX_LOCK_TTL_MS, SANDBOX_RECREATED_NOTICE, SANDBOX_STARTED_NOTICE, SANDBOX_SUSPEND_JOB, UNKNOWN_RESULT_TEXT, } from "@capybara-hq/kernel";
6
+ export { createDb, gatewayReplicasKey, kernelDb, MAINTENANCE_QUEUE_NAME, runJobId, SANDBOX_LOCK_TTL_MS, SANDBOX_RESTARTED_NOTICE, SANDBOX_STARTED_NOTICE, SANDBOX_SUSPEND_JOB, UNKNOWN_RESULT_TEXT, } from "@capybara-hq/kernel";
7
7
  export { createFreshDatabase, startTestPostgres, startTestRedis, startTestS3, TEST_S3_IMAGE, } from "./containers.js";
8
8
  export { createDualReplicaHarness, REPLICA_HEADER, replicaOf, } from "./dual-replica.js";
9
9
  export { assertExtensionSecretsReachable, extensionDeploymentSecretValues, } from "./extension-config.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/testing",
3
- "version": "0.1.0-rc.4",
3
+ "version": "0.1.0-rc.6",
4
4
  "description": "Test helpers for Capybara: test containers, dual-replica harness and fake third-party servers",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -47,17 +47,17 @@
47
47
  "tsx": "^4.23.15",
48
48
  "ws": "^8.22.0",
49
49
  "zod": "^4.6.7",
50
- "@capybara-hq/kernel": "^0.1.0-rc.4"
50
+ "@capybara-hq/kernel": "^0.1.0-rc.5"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/pg": "^8.23.1",
54
54
  "@types/ws": "^8.18.2",
55
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.2"
55
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.3"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "vitest": "^5.0.5",
59
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.2",
60
- "@capybara-hq/plugin-tools": "^0.1.0-rc.4"
59
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.3",
60
+ "@capybara-hq/plugin-tools": "^0.1.0-rc.5"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "@capybara-hq/plugin-tools": {