@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.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +40 -2
- package/dist/containers.d.ts +102 -0
- package/dist/containers.js +314 -0
- package/dist/dual-replica.d.ts +142 -0
- package/dist/dual-replica.js +270 -0
- package/dist/extension-config.d.ts +9 -0
- package/dist/extension-config.js +16 -0
- package/dist/fake-channel.d.ts +162 -0
- package/dist/fake-channel.js +325 -0
- package/dist/fake-e2b.d.ts +104 -0
- package/dist/fake-e2b.js +500 -0
- package/dist/fake-git-server.d.ts +57 -0
- package/dist/fake-git-server.js +323 -0
- package/dist/fake-lark.d.ts +234 -0
- package/dist/fake-lark.js +837 -0
- package/dist/fake-marketplace.d.ts +25 -0
- package/dist/fake-marketplace.js +58 -0
- package/dist/fake-mcp-server.d.ts +134 -0
- package/dist/fake-mcp-server.js +308 -0
- package/dist/fake-model-server.d.ts +28 -0
- package/dist/fake-model-server.js +67 -0
- package/dist/fake-npm-registry.d.ts +59 -0
- package/dist/fake-npm-registry.js +174 -0
- package/dist/fake-oauth-server.d.ts +63 -0
- package/dist/fake-oauth-server.js +284 -0
- package/dist/fake-openapi-server.d.ts +50 -0
- package/dist/fake-openapi-server.js +278 -0
- package/dist/fake-slack.d.ts +258 -0
- package/dist/fake-slack.js +654 -0
- package/dist/fixtures.d.ts +49 -0
- package/dist/fixtures.js +92 -0
- package/dist/global-setup.d.ts +15 -0
- package/dist/global-setup.js +58 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/kernel-helpers.d.ts +125 -0
- package/dist/kernel-helpers.js +336 -0
- package/dist/media-samples.d.ts +10 -0
- package/dist/media-samples.js +76 -0
- package/dist/model-conformance.d.ts +61 -0
- package/dist/model-conformance.js +287 -0
- package/dist/replica-runner.d.ts +1 -0
- package/dist/replica-runner.js +59 -0
- package/dist/sse.d.ts +20 -0
- package/dist/sse.js +87 -0
- package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
- package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
- package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
- package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
- package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
- package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
- package/fixtures/extensions/fake-connector/package.json +13 -0
- package/fixtures/extensions/fake-connector/src/index.ts +477 -0
- package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-dep-provider/package.json +12 -0
- package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
- package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
- package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
- package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
- package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-embed/package.json +12 -0
- package/fixtures/extensions/fake-embed/src/index.ts +207 -0
- package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-im/package.json +12 -0
- package/fixtures/extensions/fake-im/src/channel.ts +224 -0
- package/fixtures/extensions/fake-im/src/directory.ts +25 -0
- package/fixtures/extensions/fake-im/src/identity.ts +66 -0
- package/fixtures/extensions/fake-im/src/index.ts +91 -0
- package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-object-store/package.json +15 -0
- package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
- package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-openapi/package.json +12 -0
- package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
- package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-storage-user/package.json +12 -0
- package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
- package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
- package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
- package/fixtures/extensions/fake-tools-ui/package.json +12 -0
- package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
- package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
- package/package.json +67 -3
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
/** 校验应用凭据时一律拒绝的密钥。 */
|
|
4
|
+
export const FAKE_CHANNEL_BAD_SECRET = "bad-secret";
|
|
5
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
6
|
+
let body = "";
|
|
7
|
+
req.on("data", (c) => {
|
|
8
|
+
body += c.toString();
|
|
9
|
+
});
|
|
10
|
+
req.on("end", () => resolve(body));
|
|
11
|
+
});
|
|
12
|
+
const json = (res, status, body) => {
|
|
13
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
14
|
+
res.end(JSON.stringify(body));
|
|
15
|
+
};
|
|
16
|
+
/** 启动假 IM 平台(监听 127.0.0.1 随机端口)。 */
|
|
17
|
+
export async function startFakeChannelServer(opts = {}) {
|
|
18
|
+
const dedupe = opts.dedupeSends ?? true;
|
|
19
|
+
const streams = [];
|
|
20
|
+
const sent = [];
|
|
21
|
+
const updated = [];
|
|
22
|
+
const duplicateSends = [];
|
|
23
|
+
const posts = [];
|
|
24
|
+
const users = new Map();
|
|
25
|
+
const books = new Map();
|
|
26
|
+
const codes = new Map();
|
|
27
|
+
const byKey = new Map();
|
|
28
|
+
const failures = [];
|
|
29
|
+
const delays = [];
|
|
30
|
+
const live = new Map();
|
|
31
|
+
const peaks = new Map();
|
|
32
|
+
let failStatus = null;
|
|
33
|
+
let seq = 0;
|
|
34
|
+
let base = "";
|
|
35
|
+
const activeOf = (id) => [...live.keys()].filter((s) => s.connectionId === id);
|
|
36
|
+
const pidOf = (req) => Number(req.headers["x-pid"]);
|
|
37
|
+
const connect = (req, res, u) => {
|
|
38
|
+
if (failStatus !== null) {
|
|
39
|
+
res.writeHead(failStatus).end();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const s = {
|
|
43
|
+
connectionId: u.searchParams.get("connection") ?? "",
|
|
44
|
+
appId: String(req.headers["x-app-id"] ?? ""),
|
|
45
|
+
pid: pidOf(req),
|
|
46
|
+
openedAt: Date.now(),
|
|
47
|
+
closedAt: undefined,
|
|
48
|
+
};
|
|
49
|
+
streams.push(s);
|
|
50
|
+
live.set(s, res);
|
|
51
|
+
peaks.set(s.connectionId, Math.max(peaks.get(s.connectionId) ?? 0, activeOf(s.connectionId).length));
|
|
52
|
+
res.writeHead(200, { "content-type": "application/x-ndjson" });
|
|
53
|
+
res.write("\n");
|
|
54
|
+
const close = () => {
|
|
55
|
+
if (s.closedAt === undefined)
|
|
56
|
+
s.closedAt = Date.now();
|
|
57
|
+
live.delete(s);
|
|
58
|
+
};
|
|
59
|
+
req.on("close", close);
|
|
60
|
+
res.on("close", close);
|
|
61
|
+
};
|
|
62
|
+
const sendOrUpdate = async (req, res, path) => {
|
|
63
|
+
const body = await readBody(req);
|
|
64
|
+
const f = failures.find((x) => x.path === path && x.left > 0);
|
|
65
|
+
if (f) {
|
|
66
|
+
f.left--;
|
|
67
|
+
if (f.delayMs !== undefined)
|
|
68
|
+
await new Promise((r) => setTimeout(r, f.delayMs));
|
|
69
|
+
json(res, f.status, { retryAfterMs: f.retryAfterMs ?? null, reason: f.reason ?? null });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const b = JSON.parse(body);
|
|
73
|
+
const key = b.idempotencyKey ? `${b.appId}\u0000${b.idempotencyKey}` : undefined;
|
|
74
|
+
const seen = path === "/send" && key !== undefined ? byKey.get(key) : undefined;
|
|
75
|
+
const messageId = b.messageId ?? seen ?? `om_${++seq}`;
|
|
76
|
+
const rec = { ...b, messageId, pid: pidOf(req), at: Date.now() };
|
|
77
|
+
if (path === "/update")
|
|
78
|
+
updated.push(rec);
|
|
79
|
+
else if (seen !== undefined && dedupe)
|
|
80
|
+
duplicateSends.push(rec);
|
|
81
|
+
else {
|
|
82
|
+
sent.push(rec);
|
|
83
|
+
if (key !== undefined && seen === undefined)
|
|
84
|
+
byKey.set(key, messageId);
|
|
85
|
+
}
|
|
86
|
+
const hasCards = (b.message?.cards?.length ?? 0) > 0;
|
|
87
|
+
const d = delays.find((x) => x.path === path && x.left > 0 && (!x.onlyCards || hasCards));
|
|
88
|
+
if (d) {
|
|
89
|
+
d.left--;
|
|
90
|
+
await new Promise((r) => setTimeout(r, d.delayMs));
|
|
91
|
+
}
|
|
92
|
+
json(res, 200, { messageId });
|
|
93
|
+
};
|
|
94
|
+
const authorize = (res, u) => {
|
|
95
|
+
const redirect = u.searchParams.get("redirect_uri");
|
|
96
|
+
const state = u.searchParams.get("state") ?? "";
|
|
97
|
+
if (redirect === null) {
|
|
98
|
+
json(res, 400, { error: "invalid_request" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const back = new URL(redirect);
|
|
102
|
+
back.searchParams.set("state", state);
|
|
103
|
+
const user = users.get(u.searchParams.get("as") ?? "");
|
|
104
|
+
if (user === undefined) {
|
|
105
|
+
back.searchParams.set("error", "access_denied");
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const code = `fc_${randomBytes(16).toString("hex")}`;
|
|
109
|
+
codes.set(code, {
|
|
110
|
+
user,
|
|
111
|
+
nonce: u.searchParams.get("nonce") ?? "",
|
|
112
|
+
clientId: u.searchParams.get("client_id") ?? "",
|
|
113
|
+
challenge: u.searchParams.get("code_challenge"),
|
|
114
|
+
});
|
|
115
|
+
back.searchParams.set("code", code);
|
|
116
|
+
}
|
|
117
|
+
res.writeHead(302, { location: back.toString() }).end();
|
|
118
|
+
};
|
|
119
|
+
const token = async (req, res) => {
|
|
120
|
+
const b = JSON.parse((await readBody(req)) || "{}");
|
|
121
|
+
const c = b.code === undefined ? undefined : codes.get(b.code);
|
|
122
|
+
if (b.code !== undefined)
|
|
123
|
+
codes.delete(b.code);
|
|
124
|
+
if (c === undefined || c.clientId !== b.clientId) {
|
|
125
|
+
json(res, 400, { error: "invalid_grant" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (c.challenge !== null &&
|
|
129
|
+
(b.codeVerifier === undefined ||
|
|
130
|
+
createHash("sha256").update(b.codeVerifier).digest("base64url") !== c.challenge)) {
|
|
131
|
+
json(res, 400, { error: "invalid_grant" });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (b.clientSecret === FAKE_CHANNEL_BAD_SECRET) {
|
|
135
|
+
json(res, 401, { error: "invalid_client" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
json(res, 200, { user: c.user, nonce: c.nonce });
|
|
139
|
+
};
|
|
140
|
+
const directory = async (req, res, b) => {
|
|
141
|
+
const u = new URL(req.url ?? "/", "http://x");
|
|
142
|
+
const cursor = u.searchParams.get("cursor");
|
|
143
|
+
const page = cursor === null ? 0 : Number(cursor);
|
|
144
|
+
b.requests.push({ page, pid: pidOf(req) });
|
|
145
|
+
if (page === 0 && b.hold)
|
|
146
|
+
await b.hold;
|
|
147
|
+
if (b.failOnPage === page) {
|
|
148
|
+
res.writeHead(500).end();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const start = page * b.pageSize;
|
|
152
|
+
const members = b.members.slice(start, start + b.pageSize);
|
|
153
|
+
const more = start + b.pageSize < b.members.length;
|
|
154
|
+
json(res, 200, { members, ...(more && { next: String(page + 1) }) });
|
|
155
|
+
};
|
|
156
|
+
const server = createServer((req, res) => {
|
|
157
|
+
void (async () => {
|
|
158
|
+
const u = new URL(req.url ?? "/", "http://x");
|
|
159
|
+
const route = `${req.method} ${u.pathname}`;
|
|
160
|
+
if (route === "GET /connect")
|
|
161
|
+
return connect(req, res, u);
|
|
162
|
+
if (route === "POST /send" || route === "POST /update") {
|
|
163
|
+
return sendOrUpdate(req, res, u.pathname);
|
|
164
|
+
}
|
|
165
|
+
if (route === "POST /bot/verify") {
|
|
166
|
+
const b = JSON.parse((await readBody(req)) || "{}");
|
|
167
|
+
if (!b.appId || !b.appSecret || b.appSecret === FAKE_CHANNEL_BAD_SECRET) {
|
|
168
|
+
return json(res, 401, { error: "invalid app credentials" });
|
|
169
|
+
}
|
|
170
|
+
return json(res, 200, {
|
|
171
|
+
botUserId: `bot-${b.appId}`,
|
|
172
|
+
botName: "Fake Bot",
|
|
173
|
+
tenantId: b.tenant ?? "t1",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (route === "GET /oauth/authorize")
|
|
177
|
+
return authorize(res, u);
|
|
178
|
+
if (route === "POST /oauth/token")
|
|
179
|
+
return token(req, res);
|
|
180
|
+
if (route === "POST /posts") {
|
|
181
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
182
|
+
posts.push({ body, pid: pidOf(req), at: Date.now() });
|
|
183
|
+
return json(res, 200, { id: `post_${posts.length}` });
|
|
184
|
+
}
|
|
185
|
+
const m = /^\/d\/([^/]+)\/members$/.exec(u.pathname);
|
|
186
|
+
const b = m && req.method === "GET" ? books.get(m[1]) : undefined;
|
|
187
|
+
if (b)
|
|
188
|
+
return directory(req, res, b);
|
|
189
|
+
res.writeHead(404).end();
|
|
190
|
+
})().catch(() => {
|
|
191
|
+
if (!res.headersSent)
|
|
192
|
+
res.writeHead(500);
|
|
193
|
+
res.end();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
197
|
+
base = `http://127.0.0.1:${server.address().port}`;
|
|
198
|
+
return {
|
|
199
|
+
url: base,
|
|
200
|
+
streams,
|
|
201
|
+
sent,
|
|
202
|
+
updated,
|
|
203
|
+
duplicateSends,
|
|
204
|
+
posts,
|
|
205
|
+
users,
|
|
206
|
+
failNext(path, status, count = 1, retryAfterMs, reason, delayMs) {
|
|
207
|
+
failures.push({
|
|
208
|
+
path,
|
|
209
|
+
status,
|
|
210
|
+
left: count,
|
|
211
|
+
...(retryAfterMs !== undefined && { retryAfterMs }),
|
|
212
|
+
...(reason !== undefined && { reason }),
|
|
213
|
+
...(delayMs !== undefined && { delayMs }),
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
delayNext(path, delayMs, opts = {}) {
|
|
217
|
+
delays.push({ path, delayMs, left: opts.count ?? 1, onlyCards: opts.onlyCards ?? false });
|
|
218
|
+
},
|
|
219
|
+
failConnect(status) {
|
|
220
|
+
failStatus = status;
|
|
221
|
+
},
|
|
222
|
+
active: activeOf,
|
|
223
|
+
maxConcurrent: (id) => peaks.get(id) ?? 0,
|
|
224
|
+
push(connectionId, event) {
|
|
225
|
+
const targets = activeOf(connectionId);
|
|
226
|
+
for (const s of targets)
|
|
227
|
+
live.get(s)?.write(`${JSON.stringify(event)}\n`);
|
|
228
|
+
return targets.length;
|
|
229
|
+
},
|
|
230
|
+
book(key) {
|
|
231
|
+
let b = books.get(key);
|
|
232
|
+
if (!b) {
|
|
233
|
+
const requests = [];
|
|
234
|
+
b = {
|
|
235
|
+
url: `${base}/d/${key}`,
|
|
236
|
+
members: [],
|
|
237
|
+
pageSize: 100,
|
|
238
|
+
failOnPage: null,
|
|
239
|
+
hold: null,
|
|
240
|
+
requests,
|
|
241
|
+
starts: () => requests.filter((r) => r.page === 0).length,
|
|
242
|
+
};
|
|
243
|
+
books.set(key, b);
|
|
244
|
+
}
|
|
245
|
+
return b;
|
|
246
|
+
},
|
|
247
|
+
finalText(s) {
|
|
248
|
+
return (updated.filter((x) => x.messageId === s.messageId).at(-1)?.message.text ?? s.message.text);
|
|
249
|
+
},
|
|
250
|
+
async stop() {
|
|
251
|
+
for (const res of live.values())
|
|
252
|
+
res.destroy();
|
|
253
|
+
live.clear();
|
|
254
|
+
server.closeAllConnections();
|
|
255
|
+
await new Promise((r) => server.close(() => r()));
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const senderOf = (externalId, tenantId, profile = {}) => ({
|
|
260
|
+
externalId,
|
|
261
|
+
tenantId,
|
|
262
|
+
...(profile.displayName !== undefined && { displayName: profile.displayName }),
|
|
263
|
+
...(profile.avatarUrl !== undefined && { avatarUrl: profile.avatarUrl }),
|
|
264
|
+
});
|
|
265
|
+
/** 私聊文本消息(chat id 为 `dm-<externalId>`);`profile` 给了就带上发言人的显示名 / 头像。 */
|
|
266
|
+
export function fakeDm(messageId, externalId, text, tenantId = "t1", profile) {
|
|
267
|
+
return {
|
|
268
|
+
type: "message",
|
|
269
|
+
messageId,
|
|
270
|
+
target: { chatId: `dm-${externalId}`, chatType: "dm" },
|
|
271
|
+
sender: senderOf(externalId, tenantId, profile),
|
|
272
|
+
text,
|
|
273
|
+
mentionsBot: false,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** 群聊文本消息(默认 chat `grp-1`、@ 了机器人)。 */
|
|
277
|
+
export function fakeGroupMessage(messageId, externalId, text, opts = {}) {
|
|
278
|
+
return {
|
|
279
|
+
type: "message",
|
|
280
|
+
messageId,
|
|
281
|
+
target: {
|
|
282
|
+
chatId: opts.chatId ?? "grp-1",
|
|
283
|
+
chatType: "group",
|
|
284
|
+
...(opts.threadId !== undefined && { threadId: opts.threadId }),
|
|
285
|
+
},
|
|
286
|
+
sender: senderOf(externalId, opts.tenantId ?? "t1", opts),
|
|
287
|
+
text,
|
|
288
|
+
mentionsBot: opts.mentionsBot ?? true,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** 卡片按钮操作;`target` 取卡片所在的消息目标(群里带 threadId)。 */
|
|
292
|
+
export function fakeCardAction(messageId, externalId, action, target, tenantId = "t1", profile) {
|
|
293
|
+
return {
|
|
294
|
+
type: "card_action",
|
|
295
|
+
messageId,
|
|
296
|
+
target,
|
|
297
|
+
sender: senderOf(externalId, tenantId, profile),
|
|
298
|
+
action,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** 通讯录成员(租户 t1;邮箱缺省 `<id>@corp.example`、已验证)。 */
|
|
302
|
+
export function fakeDirectoryMember(externalId, over = {}) {
|
|
303
|
+
return {
|
|
304
|
+
externalId,
|
|
305
|
+
tenantId: "t1",
|
|
306
|
+
email: `${externalId}@corp.example`,
|
|
307
|
+
emailVerified: true,
|
|
308
|
+
displayName: externalId,
|
|
309
|
+
active: true,
|
|
310
|
+
...over,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/** Signature header of the fake platform's webhooks (`sha256=<hex HMAC-SHA256 of the raw body>`). */
|
|
314
|
+
export const FAKE_WEBHOOK_SIGNATURE_HEADER = "x-fake-signature";
|
|
315
|
+
/**
|
|
316
|
+
* Signs a fake-platform webhook body the way the `fake-im` test plugin verifies it: returns the
|
|
317
|
+
* value for {@link FAKE_WEBHOOK_SIGNATURE_HEADER}, `sha256=` + hex HMAC-SHA256(secret, body).
|
|
318
|
+
*/
|
|
319
|
+
export function signFakeWebhook(body, secret) {
|
|
320
|
+
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
321
|
+
}
|
|
322
|
+
/** Body of a fake-platform webhook that delivers events (`fake-im` answers with its ack). */
|
|
323
|
+
export const fakeWebhookEvents = (events) => JSON.stringify({ type: "events", events });
|
|
324
|
+
/** Body of a fake-platform URL verification request (`fake-im` echoes `{ challenge }`). */
|
|
325
|
+
export const fakeWebhookChallenge = (challenge) => JSON.stringify({ type: "challenge", challenge });
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可编程的假 E2B(`plugins/sandbox-e2b` 测试用),协议按 `e2b` JS SDK 2.58.0 与 E2B 文档(2026-10-21 核实):
|
|
3
|
+
*
|
|
4
|
+
* - **控制面 API**(请求头 `X-API-KEY`,不符 → 401 `{code, message}`):
|
|
5
|
+
* `POST /v2/sandboxes`(建沙箱,回 201 `{sandboxID, templateID, envdVersion, envdAccessToken, domain}`)、
|
|
6
|
+
* `GET /v2/sandboxes?metadata=<urlencoded k=v>&state=running,paused&limit=&nextToken=`(按元数据列出,
|
|
7
|
+
* 分页游标在响应头 `x-next-token`)、`POST /v2/sandboxes/{id}/connect`(运行中 200、暂停的恢复后 201、
|
|
8
|
+
* 不存在 404;`timeout` 只延长不缩短)、`POST /sandboxes/{id}/pause`(204;已暂停 409;不存在 404)、
|
|
9
|
+
* `DELETE /sandboxes/{id}`(204;不存在 404)。
|
|
10
|
+
* - **envd**(同一个地址;按请求头 `E2b-Sandbox-Id` 路由,`X-Access-Token` 须与建沙箱时给的一致;沙箱不存在或
|
|
11
|
+
* 已暂停时与 E2B 的边缘代理一样回 502):`POST /files?path=&username=`(multipart 字段 `file`)、
|
|
12
|
+
* Connect 协议的 `POST /process.Process/Start`(`application/connect+json` 服务端流:start / data / end 事件,
|
|
13
|
+
* 结尾帧 flags `0x02`)与 `POST /process.Process/SendSignal`(一元 JSON,按 pid 杀进程组;不存在 404
|
|
14
|
+
* `{code: "not_found"}`)。用户来自 `Authorization: Basic base64(<user>:)`(命令)或 `username`(上传)。
|
|
15
|
+
*
|
|
16
|
+
* **命令真的在本机跑**:每个沙箱一个临时目录作根,参数、工作目录、环境变量里以 `/workspace`、`/skills`、`/tmp`、
|
|
17
|
+
* `/opt/capybara` 开头的路径映射到根下;`setsid` / `runuser` / `timeout`(本机没有 GNU timeout 时)由垫片代替(只 exec 后面的命令,不切换用户——
|
|
18
|
+
* 本机只有一个用户,权限隔离测不了,测试改为核对请求里的用户)。进程以新会话启动(与 setsid 等价),
|
|
19
|
+
* 客户端断开**不会**结束进程(与 envd 一致),只有 SendSignal、杀进程脚本或删沙箱才会。
|
|
20
|
+
*
|
|
21
|
+
* `connect` 带 `memory: false`(SDK 的 `onResume: "reboot"`)时结束该沙箱的全部进程(冷启动)。
|
|
22
|
+
* SendSignal 只杀指定进程(不是进程组)。
|
|
23
|
+
*
|
|
24
|
+
* {@link FakeE2BServer.failNext} 让匹配的下一个请求返回指定状态;{@link FakeE2BServer.hangNext} 让它一直不回
|
|
25
|
+
* (测中止);{@link FakeE2BServer.expire} 让沙箱直接消失(过期 / 被别处删掉)。
|
|
26
|
+
*/
|
|
27
|
+
export interface FakeE2BOptions {
|
|
28
|
+
/** 期望的 API key(默认随机生成,见 {@link FakeE2BServer.apiKey})。 */
|
|
29
|
+
apiKey?: string;
|
|
30
|
+
/** 列表接口每页条数(默认 100;测分页时调小)。 */
|
|
31
|
+
pageSize?: number;
|
|
32
|
+
/** create / connect 返回的沙箱 `domain`(默认 null,即用客户端配置的域名)。 */
|
|
33
|
+
domain?: string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface FakeE2BSandbox {
|
|
36
|
+
id: string;
|
|
37
|
+
templateID: string;
|
|
38
|
+
metadata: Record<string, string>;
|
|
39
|
+
state: "running" | "paused";
|
|
40
|
+
/** 当前的超时(秒):建沙箱时给的,connect 只会把它调大。 */
|
|
41
|
+
timeout: number;
|
|
42
|
+
autoPause: boolean;
|
|
43
|
+
/** 建沙箱请求体里的出站设置(原样记录)。 */
|
|
44
|
+
allowInternetAccess?: boolean;
|
|
45
|
+
network?: unknown;
|
|
46
|
+
envdAccessToken: string;
|
|
47
|
+
/** 本机上的根目录。 */
|
|
48
|
+
root: string;
|
|
49
|
+
}
|
|
50
|
+
/** 服务收到的一次请求。 */
|
|
51
|
+
export interface FakeE2BCall {
|
|
52
|
+
method: string;
|
|
53
|
+
path: string;
|
|
54
|
+
query: Record<string, string>;
|
|
55
|
+
/** JSON 请求体(Connect 流请求为解出的第一条消息;上传为 null)。 */
|
|
56
|
+
body: unknown;
|
|
57
|
+
/** envd 请求:目标沙箱。 */
|
|
58
|
+
sandboxId?: string;
|
|
59
|
+
/** envd 请求:以哪个用户(Basic 认证或 `username`)。 */
|
|
60
|
+
user?: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
export interface FakeE2BMatch {
|
|
63
|
+
method?: string;
|
|
64
|
+
path: RegExp;
|
|
65
|
+
/** 再按请求细看(Connect 流请求的 `body` 是解出的第一条消息)。 */
|
|
66
|
+
when?: (call: FakeE2BCall) => boolean;
|
|
67
|
+
}
|
|
68
|
+
export interface FakeE2BServer {
|
|
69
|
+
/** 控制面 API 与 envd 共用的地址 `http://127.0.0.1:<port>`。 */
|
|
70
|
+
url: string;
|
|
71
|
+
apiKey: string;
|
|
72
|
+
sandboxes: Map<string, FakeE2BSandbox>;
|
|
73
|
+
calls: FakeE2BCall[];
|
|
74
|
+
/**
|
|
75
|
+
* 下一个匹配的请求回 `status`:默认 E2B 的错误格式 JSON `{code: status, message}`;`raw` 时回纯文本
|
|
76
|
+
* (模拟前面的代理 / 错配地址回的裸错误)。
|
|
77
|
+
*/
|
|
78
|
+
failNext(match: FakeE2BMatch, status: number, message?: string, opts?: {
|
|
79
|
+
raw?: boolean;
|
|
80
|
+
}): void;
|
|
81
|
+
/** 以 `memory: false` connect(冷启动,进程全部结束)的次数,按沙箱 id。 */
|
|
82
|
+
reboots: Map<string, number>;
|
|
83
|
+
/** 下一个匹配的请求一直不回,直到客户端断开。 */
|
|
84
|
+
hangNext(match: FakeE2BMatch): void;
|
|
85
|
+
/**
|
|
86
|
+
* 为 true 时 create / connect 的响应不带 `envdAccessToken`(模拟没拿到令牌:测提供方是否 fail closed);
|
|
87
|
+
* envd 照样要求令牌。
|
|
88
|
+
*/
|
|
89
|
+
withholdAccessToken: boolean;
|
|
90
|
+
/** 沙箱直接消失(杀掉它的进程、删掉根目录)。 */
|
|
91
|
+
expire(id: string): Promise<void>;
|
|
92
|
+
/** 沙箱内路径在本机上的位置。 */
|
|
93
|
+
hostPath(id: string, sandboxPath: string): string;
|
|
94
|
+
/** 沙箱里还在跑的进程(由 Start 启动、尚未退出的)。 */
|
|
95
|
+
running(id: string): {
|
|
96
|
+
pid: number;
|
|
97
|
+
args: string[];
|
|
98
|
+
}[];
|
|
99
|
+
/** 进程组是否还有存活的进程(含被杀进程脚本漏掉的子进程)。 */
|
|
100
|
+
alive(pid: number): boolean;
|
|
101
|
+
close(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
/** 起一个假 E2B。 */
|
|
104
|
+
export declare function startFakeE2B(opts?: FakeE2BOptions): Promise<FakeE2BServer>;
|