@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +34 -2
- package/dist/containers.d.ts +102 -0
- package/dist/containers.js +314 -0
- package/dist/dual-replica.d.ts +121 -0
- package/dist/dual-replica.js +247 -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 +47 -0
- package/dist/fixtures.js +90 -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 +66 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { appendFile, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, posix } from "node:path";
|
|
7
|
+
const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
8
|
+
/** Git runs with no user or system config, so a developer's settings never change the repos. */
|
|
9
|
+
function gitEnv(extra = {}) {
|
|
10
|
+
return {
|
|
11
|
+
PATH: process.env.PATH ?? "",
|
|
12
|
+
HOME: tmpdir(),
|
|
13
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
14
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
15
|
+
GIT_AUTHOR_NAME: "Fake Git",
|
|
16
|
+
GIT_AUTHOR_EMAIL: "fake-git@example.com",
|
|
17
|
+
GIT_COMMITTER_NAME: "Fake Git",
|
|
18
|
+
GIT_COMMITTER_EMAIL: "fake-git@example.com",
|
|
19
|
+
...extra,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function git(args, o = {}) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
// A command that reads no input gets no stdin pipe: it can exit before a write to its stdin is
|
|
25
|
+
// dispatched, and that write then fails with EPIPE (an uncaught error, seen under load on Linux).
|
|
26
|
+
const p = spawn("git", args, {
|
|
27
|
+
env: gitEnv(o.env),
|
|
28
|
+
cwd: o.cwd ?? tmpdir(),
|
|
29
|
+
stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
30
|
+
});
|
|
31
|
+
const out = [];
|
|
32
|
+
const err = [];
|
|
33
|
+
let stdinError;
|
|
34
|
+
p.stdout?.on("data", (b) => out.push(b));
|
|
35
|
+
p.stderr?.on("data", (b) => err.push(b));
|
|
36
|
+
p.on("error", reject);
|
|
37
|
+
p.on("close", (code) => {
|
|
38
|
+
if (code !== 0) {
|
|
39
|
+
reject(new Error(`git ${args[0]} failed (${code}): ${Buffer.concat(err).toString()}`));
|
|
40
|
+
}
|
|
41
|
+
else if (stdinError !== undefined) {
|
|
42
|
+
// The input was not fully delivered, so the output is not for that input.
|
|
43
|
+
reject(new Error(`git ${args[0]}: writing stdin failed: ${stdinError.message}`));
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
resolve(Buffer.concat(out).toString("utf8").trim());
|
|
47
|
+
});
|
|
48
|
+
if (o.input !== undefined && p.stdin !== null) {
|
|
49
|
+
p.stdin.on("error", (e) => {
|
|
50
|
+
stdinError = e;
|
|
51
|
+
});
|
|
52
|
+
if (o.input.length === 0)
|
|
53
|
+
p.stdin.end();
|
|
54
|
+
else
|
|
55
|
+
p.stdin.end(o.input);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Starts `git http-backend` as a CGI script for one request (smart HTTP, read-only). */
|
|
60
|
+
function spawnCgi(root, req) {
|
|
61
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
62
|
+
const env = {
|
|
63
|
+
GIT_PROJECT_ROOT: root,
|
|
64
|
+
GIT_HTTP_EXPORT_ALL: "1",
|
|
65
|
+
PATH_INFO: decodeURIComponent(url.pathname),
|
|
66
|
+
QUERY_STRING: url.search.replace(/^\?/, ""),
|
|
67
|
+
REQUEST_METHOD: req.method ?? "GET",
|
|
68
|
+
CONTENT_TYPE: req.headers["content-type"] ?? "",
|
|
69
|
+
REMOTE_ADDR: "127.0.0.1",
|
|
70
|
+
};
|
|
71
|
+
const len = req.headers["content-length"];
|
|
72
|
+
if (typeof len === "string")
|
|
73
|
+
env.CONTENT_LENGTH = len;
|
|
74
|
+
const proto = req.headers["git-protocol"];
|
|
75
|
+
if (typeof proto === "string")
|
|
76
|
+
env.GIT_PROTOCOL = proto;
|
|
77
|
+
const enc = req.headers["content-encoding"];
|
|
78
|
+
if (typeof enc === "string")
|
|
79
|
+
env.HTTP_CONTENT_ENCODING = enc;
|
|
80
|
+
const p = spawn("git", ["http-backend"], { env: gitEnv(env) });
|
|
81
|
+
// http-backend may exit without reading the whole body (a GET, or a request it refuses): the
|
|
82
|
+
// write then fails with EPIPE. Its exit status and output decide the response, not the pipe.
|
|
83
|
+
p.stdin.on("error", () => { });
|
|
84
|
+
req.pipe(p.stdin);
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
/** The CGI head (`Status:` and the headers) once the blank line after it has arrived. */
|
|
88
|
+
function cgiHead(head) {
|
|
89
|
+
const crlf = head.indexOf("\r\n\r\n");
|
|
90
|
+
const lf = head.indexOf("\n\n");
|
|
91
|
+
const end = crlf >= 0 ? crlf : lf;
|
|
92
|
+
if (end < 0)
|
|
93
|
+
return undefined;
|
|
94
|
+
let status = 200;
|
|
95
|
+
const headers = {};
|
|
96
|
+
for (const line of head.subarray(0, end).toString("latin1").split(/\r?\n/)) {
|
|
97
|
+
const i = line.indexOf(":");
|
|
98
|
+
if (i < 0)
|
|
99
|
+
continue;
|
|
100
|
+
const k = line.slice(0, i).trim().toLowerCase();
|
|
101
|
+
const v = line.slice(i + 1).trim();
|
|
102
|
+
if (k === "status")
|
|
103
|
+
status = Number.parseInt(v, 10);
|
|
104
|
+
else
|
|
105
|
+
headers[k] = v;
|
|
106
|
+
}
|
|
107
|
+
return { status, headers, bodyStart: end + (crlf >= 0 ? 4 : 2) };
|
|
108
|
+
}
|
|
109
|
+
/** Streams http-backend's answer to one request. */
|
|
110
|
+
function serveCgi(root, req, res) {
|
|
111
|
+
const p = spawnCgi(root, req);
|
|
112
|
+
// A client that goes away mid-response needs no more output.
|
|
113
|
+
res.on("close", () => {
|
|
114
|
+
if (p.exitCode === null && p.signalCode === null)
|
|
115
|
+
p.kill();
|
|
116
|
+
});
|
|
117
|
+
let head = Buffer.alloc(0);
|
|
118
|
+
let headDone = false;
|
|
119
|
+
p.stdout.on("data", (chunk) => {
|
|
120
|
+
if (headDone) {
|
|
121
|
+
res.write(chunk);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
head = Buffer.concat([head, chunk]);
|
|
125
|
+
const h = cgiHead(head);
|
|
126
|
+
if (h === undefined)
|
|
127
|
+
return;
|
|
128
|
+
headDone = true;
|
|
129
|
+
res.writeHead(h.status, h.headers);
|
|
130
|
+
res.write(head.subarray(h.bodyStart));
|
|
131
|
+
});
|
|
132
|
+
p.on("close", () => {
|
|
133
|
+
if (!headDone)
|
|
134
|
+
res.writeHead(502);
|
|
135
|
+
res.end();
|
|
136
|
+
});
|
|
137
|
+
p.on("error", () => {
|
|
138
|
+
if (!res.headersSent)
|
|
139
|
+
res.writeHead(502);
|
|
140
|
+
res.end();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Runs http-backend for one request and collects its answer (undefined when it printed none). */
|
|
144
|
+
function runCgi(root, req) {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
const p = spawnCgi(root, req);
|
|
147
|
+
const out = [];
|
|
148
|
+
p.stdout.on("data", (b) => out.push(b));
|
|
149
|
+
p.on("error", () => resolve(undefined));
|
|
150
|
+
p.on("close", (code) => {
|
|
151
|
+
const all = Buffer.concat(out);
|
|
152
|
+
const h = cgiHead(all);
|
|
153
|
+
resolve(h === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: {
|
|
156
|
+
status: h.status,
|
|
157
|
+
headers: h.headers,
|
|
158
|
+
body: all.subarray(h.bodyStart),
|
|
159
|
+
ok: code === 0,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const NL = Buffer.from("\n");
|
|
165
|
+
/** C-style quoting of a fast-import path when it needs it. */
|
|
166
|
+
const fastImportPath = (path) => /^"|[\n"\\]/.test(path)
|
|
167
|
+
? `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`
|
|
168
|
+
: path;
|
|
169
|
+
/**
|
|
170
|
+
* A fake git host: bare repos in a temp dir, served read-only by the system `git http-backend`
|
|
171
|
+
* (smart HTTP, as CGI) over a loopback http server. Repos allow fetching any reachable commit by sha
|
|
172
|
+
* (`uploadpack.allowReachableSHA1InWant`, as GitHub does). Needs `git` on PATH.
|
|
173
|
+
*
|
|
174
|
+
* Spawning git is what these tests spend their time on, and it slows down most under load, so the
|
|
175
|
+
* server spawns as little as it can while every answer still comes from git: a commit is one
|
|
176
|
+
* `git fast-import`, and a ref advertisement (`GET …/info/refs`) is answered by http-backend once per
|
|
177
|
+
* repo state, then repeated until the next commit to that repo.
|
|
178
|
+
*/
|
|
179
|
+
export async function startFakeGitServer() {
|
|
180
|
+
const root = await mkdtemp(join(tmpdir(), "capybara-fake-git-"));
|
|
181
|
+
const requests = [];
|
|
182
|
+
let redirect = null;
|
|
183
|
+
/** Ref advertisements by repo, then by request (path, query and `Git-Protocol`). */
|
|
184
|
+
const advertised = new Map();
|
|
185
|
+
/** Bumped by every commit to a repo: an answer read before the commit is not kept. */
|
|
186
|
+
const generation = new Map();
|
|
187
|
+
const repoOf = (pathname) => /^\/([^/]+)\.git\/info\/refs$/.exec(pathname)?.[1];
|
|
188
|
+
const server = createServer((req, res) => {
|
|
189
|
+
const r = { method: req.method ?? "GET", path: req.url ?? "/" };
|
|
190
|
+
if (typeof req.headers.authorization === "string")
|
|
191
|
+
r.authorization = req.headers.authorization;
|
|
192
|
+
requests.push(r);
|
|
193
|
+
if (redirect !== null) {
|
|
194
|
+
res.writeHead(302, { location: redirect }).end();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
198
|
+
const repo = req.method === "GET" ? repoOf(url.pathname) : undefined;
|
|
199
|
+
if (repo === undefined) {
|
|
200
|
+
serveCgi(root, req, res);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const key = `${url.pathname}${url.search}|${req.headers["git-protocol"] ?? ""}`;
|
|
204
|
+
const send = (a) => res.writeHead(a.status, a.headers).end(a.body);
|
|
205
|
+
const known = advertised.get(repo)?.get(key);
|
|
206
|
+
if (known !== undefined) {
|
|
207
|
+
send(known);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const gen = generation.get(repo) ?? 0;
|
|
211
|
+
void runCgi(root, req).then((a) => {
|
|
212
|
+
if (a === undefined) {
|
|
213
|
+
res.writeHead(502).end();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (a.ok && a.status === 200 && (generation.get(repo) ?? 0) === gen) {
|
|
217
|
+
const byKey = advertised.get(repo) ?? new Map();
|
|
218
|
+
byKey.set(key, a);
|
|
219
|
+
advertised.set(repo, byKey);
|
|
220
|
+
}
|
|
221
|
+
send(a);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
225
|
+
const port = server.address().port;
|
|
226
|
+
const repoDir = async (repo) => {
|
|
227
|
+
if (!REPO_RE.test(repo))
|
|
228
|
+
throw new Error(`invalid fake repo name: ${repo}`);
|
|
229
|
+
const dir = join(root, `${repo}.git`);
|
|
230
|
+
if (!existsSync(dir)) {
|
|
231
|
+
await git(["init", "--bare", "--quiet", "--initial-branch=main", dir]);
|
|
232
|
+
await appendFile(join(dir, "config"), "[uploadpack]\n\tallowReachableSHA1InWant = true\n");
|
|
233
|
+
}
|
|
234
|
+
return dir;
|
|
235
|
+
};
|
|
236
|
+
const commit = async (repo, files, opts = {}) => {
|
|
237
|
+
const gitDir = await repoDir(repo);
|
|
238
|
+
const branch = opts.branch ?? "main";
|
|
239
|
+
const env = { GIT_DIR: gitDir };
|
|
240
|
+
// the whole tree, by path (a later entry for the same path wins, as a later file write did)
|
|
241
|
+
const entries = new Map();
|
|
242
|
+
const blobs = [];
|
|
243
|
+
// mark :1 is the commit; blobs are :2, :3, …
|
|
244
|
+
let mark = 1;
|
|
245
|
+
const inline = (content) => {
|
|
246
|
+
const b = Buffer.from(content, "utf8");
|
|
247
|
+
mark++;
|
|
248
|
+
blobs.push(Buffer.from(`blob\nmark :${mark}\ndata ${b.length}\n`), b, NL);
|
|
249
|
+
return `:${mark}`;
|
|
250
|
+
};
|
|
251
|
+
for (const [path, content] of Object.entries(files)) {
|
|
252
|
+
const p = posix.normalize(path.replace(/^\.\//, ""));
|
|
253
|
+
if (p === "." || p === ".." || p.startsWith("../") || posix.isAbsolute(p)) {
|
|
254
|
+
throw new Error(`fake git path escapes: ${path}`);
|
|
255
|
+
}
|
|
256
|
+
entries.set(p, `M 100644 ${inline(content)} ${fastImportPath(p)}`);
|
|
257
|
+
}
|
|
258
|
+
for (const [path, linkTarget] of Object.entries(opts.symlinks ?? {})) {
|
|
259
|
+
entries.set(path, `M 120000 ${inline(linkTarget)} ${fastImportPath(path)}`);
|
|
260
|
+
}
|
|
261
|
+
for (const [path, sha] of Object.entries(opts.submodules ?? {})) {
|
|
262
|
+
entries.set(path, `M 160000 ${sha} ${fastImportPath(path)}`);
|
|
263
|
+
}
|
|
264
|
+
if (opts.treeDag !== undefined) {
|
|
265
|
+
const { name, fanout, depth } = opts.treeDag;
|
|
266
|
+
let level = await git(["mktree"], { env, input: "" });
|
|
267
|
+
for (let d = 0; d < depth; d++) {
|
|
268
|
+
let lines = "";
|
|
269
|
+
for (let i = 0; i < fanout; i++)
|
|
270
|
+
lines += `040000 tree ${level}\td${i}\n`;
|
|
271
|
+
level = await git(["mktree"], { env, input: lines });
|
|
272
|
+
}
|
|
273
|
+
entries.set(name, `M 040000 ${level} ${fastImportPath(name)}`);
|
|
274
|
+
}
|
|
275
|
+
const parent = await readFile(join(gitDir, "refs", "heads", branch), "utf8").then((s) => s.trim(), () => "");
|
|
276
|
+
const who = `Fake Git <fake-git@example.com> ${Math.floor(Date.now() / 1000)} +0000`;
|
|
277
|
+
const message = Buffer.from(`commit on ${branch}`);
|
|
278
|
+
const lines = [
|
|
279
|
+
`commit refs/heads/${branch}`,
|
|
280
|
+
"mark :1",
|
|
281
|
+
`author ${who}`,
|
|
282
|
+
`committer ${who}`,
|
|
283
|
+
`data ${message.length}`,
|
|
284
|
+
message.toString(),
|
|
285
|
+
...(parent ? [`from ${parent}`] : []),
|
|
286
|
+
"deleteall",
|
|
287
|
+
...entries.values(),
|
|
288
|
+
"",
|
|
289
|
+
];
|
|
290
|
+
if (opts.tag !== undefined) {
|
|
291
|
+
const t = opts.tag;
|
|
292
|
+
if (t.annotated) {
|
|
293
|
+
const m = `tag ${t.name}`;
|
|
294
|
+
lines.push(`tag ${t.name}`, "from :1", `tagger ${who}`, `data ${Buffer.byteLength(m)}`, m);
|
|
295
|
+
}
|
|
296
|
+
else
|
|
297
|
+
lines.push(`reset refs/tags/${t.name}`, "from :1", "");
|
|
298
|
+
}
|
|
299
|
+
lines.push("get-mark :1", "done", "");
|
|
300
|
+
const sha = await git(["fast-import", "--quiet", "--force", "--done"], {
|
|
301
|
+
env,
|
|
302
|
+
input: Buffer.concat([...blobs, Buffer.from(lines.join("\n"))]),
|
|
303
|
+
});
|
|
304
|
+
generation.set(repo, (generation.get(repo) ?? 0) + 1);
|
|
305
|
+
advertised.delete(repo);
|
|
306
|
+
return sha;
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
url: (repo) => `http://127.0.0.1:${port}/${repo}.git`,
|
|
310
|
+
commit,
|
|
311
|
+
setRedirect: (l) => {
|
|
312
|
+
redirect = l;
|
|
313
|
+
},
|
|
314
|
+
requests,
|
|
315
|
+
stop: async () => {
|
|
316
|
+
await new Promise((resolve) => {
|
|
317
|
+
server.closeAllConnections();
|
|
318
|
+
server.close(() => resolve());
|
|
319
|
+
});
|
|
320
|
+
await rm(root, { recursive: true, force: true });
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可编程的假 Lark(Lark 渠道插件测试用),扮演开放平台的三块能力:
|
|
3
|
+
*
|
|
4
|
+
* - **OpenAPI**:`tenant_access_token/internal`(App ID 不符 → `10014`,Secret 不符 → `10015`,HTTP 200)、
|
|
5
|
+
* `bot/v3/info`、`tenant/v2/tenant/query`、发送消息 `POST /open-apis/im/v1/messages?receive_id_type=chat_id`、
|
|
6
|
+
* 回复消息 `POST …/messages/:id/reply`(`reply_in_thread`)、更新卡片 `PATCH …/messages/:id`、用户资料
|
|
7
|
+
* `GET /open-apis/contact/v3/users/:id`。带 tenant token 的接口校验 `Authorization: Bearer`(未签发或已吊销 →
|
|
8
|
+
* HTTP 400 `99991663`)。发送 / 回复按 `uuid` 去重(与 Lark 一致,同一 uuid 只发一条);请求体超过 30 KB →
|
|
9
|
+
* `230025`;卡片须是 JSON 2.0(`schema: "2.0"`,`config.update_multi` 不为 false),否则 `230099`。
|
|
10
|
+
* {@link FakeLarkServer.failNext} 让某个接口返回错误码 / HTTP 状态(429 带 `x-ogw-ratelimit-reset`)。
|
|
11
|
+
* - **长连接**:`POST /callback/ws/endpoint`(官方 SDK `WSClient` 取接入点;凭据不符时与真实 Lark 一样回
|
|
12
|
+
* `1000040343 internal error`)返回 `ws://…/ws?…&service_id=1`;WebSocket 上按 SDK 的 protobuf 帧格式
|
|
13
|
+
* `pbbp2.Frame`(字段号与 `@larksuiteoapi/node-sdk` 内置的定义一致)收发:测试用 {@link FakeLarkServer.pushEvent}
|
|
14
|
+
* 推事件帧(`method = 1`,头 `type = event`、`message_id`、`sum = 1`、`seq = 0`),记下客户端回的响应帧
|
|
15
|
+
* (`acks`:`code` 200 / 500 与解码后的 `data`);客户端的 ping 回 pong。
|
|
16
|
+
*
|
|
17
|
+
* - **网页登录(OAuth 2.0 授权码 + PKCE)**:授权页 `GET /open-apis/authen/v1/authorize`(真实 Lark 在
|
|
18
|
+
* `accounts.larksuite.com`;测试在授权地址后加 `&as=<open_id>` 表示以该用户同意授权,302 回
|
|
19
|
+
* `redirect_uri?code=…&state=…`;`client_id` 不符或用户不存在 → `error=access_denied`)、换 token
|
|
20
|
+
* `POST /open-apis/authen/v2/oauth/token`(授权码单次有效:未知 → `20003`、用过 → `20065`;`client_secret` 不符 →
|
|
21
|
+
* `20002`;`redirect_uri` 必须与授权时完全相同,否则 `20071`;带了 `code_challenge` 的授权码必须给对
|
|
22
|
+
* `code_verifier`,否则 `20049`;错误均为 HTTP 400 + `{code, error, error_description}`)、用户信息
|
|
23
|
+
* `GET /open-apis/authen/v1/user_info`(Bearer user_access_token;`open_id`、`union_id`、`tenant_key`、`name`、
|
|
24
|
+
* `en_name`、`avatar_*`、`email`、`enterprise_email`)。
|
|
25
|
+
* - **通讯录**:`GET /open-apis/contact/v3/departments/:id/children`(`fetch_child=true` 时递归;`page_size` 最大 50,
|
|
26
|
+
* `page_token` / `has_more`)、`GET /open-apis/contact/v3/users/find_by_department`(部门直属用户,根部门为 `0`;
|
|
27
|
+
* `page_size` 最大 50;`status` 为 `is_frozen` / `is_resigned` / `is_activated` / `is_exited` / `is_unjoin`,
|
|
28
|
+
* {@link FakeLarkServer.hideUserStatus} 模拟没有开通状态字段所需的权限)。
|
|
29
|
+
*
|
|
30
|
+
* 记录:`messages`(发出的消息,卡片已解析)、`updates`(对已发消息的更新)、`calls`(每次 OpenAPI 调用)。
|
|
31
|
+
*/
|
|
32
|
+
export interface FakeLarkUser {
|
|
33
|
+
openId: string;
|
|
34
|
+
name: string;
|
|
35
|
+
avatar72?: string;
|
|
36
|
+
enName?: string;
|
|
37
|
+
/** 用户所在租户(默认本服务的 `tenantKey`;`user_info` 的 `tenant_key`)。 */
|
|
38
|
+
tenantKey?: string;
|
|
39
|
+
email?: string;
|
|
40
|
+
enterpriseEmail?: string;
|
|
41
|
+
/** 直属部门(`open_department_id`,根部门为 `"0"`);不给则不出现在通讯录列表里。 */
|
|
42
|
+
departments?: string[];
|
|
43
|
+
/** 通讯录 `status`(缺省:已激活,其他均为 false)。 */
|
|
44
|
+
status?: {
|
|
45
|
+
isFrozen?: boolean;
|
|
46
|
+
isResigned?: boolean;
|
|
47
|
+
isActivated?: boolean;
|
|
48
|
+
isExited?: boolean;
|
|
49
|
+
isUnjoin?: boolean;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** 通讯录部门。 */
|
|
53
|
+
export interface FakeLarkDepartment {
|
|
54
|
+
openDepartmentId: string;
|
|
55
|
+
name: string;
|
|
56
|
+
/** 上级部门(根部门为 `"0"`)。 */
|
|
57
|
+
parent: string;
|
|
58
|
+
}
|
|
59
|
+
/** 机器人发出(或更新后)的一条消息。 */
|
|
60
|
+
export interface FakeLarkMessage {
|
|
61
|
+
messageId: string;
|
|
62
|
+
chatId: string;
|
|
63
|
+
/** 回复时:话题根消息 id。 */
|
|
64
|
+
rootId?: string;
|
|
65
|
+
/** 回复时:被回复的消息 id。 */
|
|
66
|
+
parentId?: string;
|
|
67
|
+
replyInThread: boolean;
|
|
68
|
+
msgType: string;
|
|
69
|
+
/** 解析后的 `content`(卡片 JSON)。 */
|
|
70
|
+
card: unknown;
|
|
71
|
+
uuid?: string;
|
|
72
|
+
at: number;
|
|
73
|
+
}
|
|
74
|
+
export interface FakeLarkCall {
|
|
75
|
+
api: string;
|
|
76
|
+
token: string;
|
|
77
|
+
query: Record<string, string>;
|
|
78
|
+
body: Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
/** 客户端对事件帧的响应。 */
|
|
81
|
+
export interface FakeLarkAck {
|
|
82
|
+
id: string;
|
|
83
|
+
code: number;
|
|
84
|
+
/** 响应的 `data`(base64 JSON 解码后);处理函数没有返回值时为 undefined。 */
|
|
85
|
+
data?: unknown;
|
|
86
|
+
at: number;
|
|
87
|
+
}
|
|
88
|
+
export interface FakeLarkFailure {
|
|
89
|
+
/** HTTP 状态(默认 400)。 */
|
|
90
|
+
status?: number;
|
|
91
|
+
/** 响应体的 `code`(不给时只回 HTTP 状态与空体)。 */
|
|
92
|
+
code?: number;
|
|
93
|
+
msg?: string;
|
|
94
|
+
/** 429 时的 `x-ogw-ratelimit-reset`(秒)。 */
|
|
95
|
+
retryAfter?: number;
|
|
96
|
+
}
|
|
97
|
+
export interface FakeLarkOptions {
|
|
98
|
+
appId?: string;
|
|
99
|
+
appSecret?: string;
|
|
100
|
+
tenantKey?: string;
|
|
101
|
+
botOpenId?: string;
|
|
102
|
+
botName?: string;
|
|
103
|
+
/** 同一租户里的其他自建应用(只能取 tenant token、查企业信息;测试「登录 / 机器人不是同一个应用」)。 */
|
|
104
|
+
extraApps?: {
|
|
105
|
+
appId: string;
|
|
106
|
+
appSecret: string;
|
|
107
|
+
}[];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 接口名:`token`、`bot`、`tenant`、`create`、`reply`、`patch`、`user`、`endpoint`、`oauth_token`、`user_info`、
|
|
111
|
+
* `departments`、`find_by_department`。
|
|
112
|
+
*/
|
|
113
|
+
export type FakeLarkApi = "token" | "bot" | "tenant" | "create" | "reply" | "patch" | "user" | "endpoint" | "oauth_token" | "user_info" | "departments" | "find_by_department";
|
|
114
|
+
export interface FakeLarkServer {
|
|
115
|
+
/** 开放平台根地址(插件的 `apiUrl` / SDK 的 `domain`)。 */
|
|
116
|
+
url: string;
|
|
117
|
+
appId: string;
|
|
118
|
+
appSecret: string;
|
|
119
|
+
tenantKey: string;
|
|
120
|
+
botOpenId: string;
|
|
121
|
+
botName: string;
|
|
122
|
+
messages: FakeLarkMessage[];
|
|
123
|
+
updates: FakeLarkMessage[];
|
|
124
|
+
calls: FakeLarkCall[];
|
|
125
|
+
acks: FakeLarkAck[];
|
|
126
|
+
users: Map<string, FakeLarkUser>;
|
|
127
|
+
/** 通讯录部门(按插入顺序列出)。 */
|
|
128
|
+
departments: FakeLarkDepartment[];
|
|
129
|
+
/** 已签发的 user_access_token 数。 */
|
|
130
|
+
userTokensIssued(): number;
|
|
131
|
+
/** 通讯录列表里不带 `status`(模拟没有开通状态字段所需的权限)。 */
|
|
132
|
+
hideUserStatus(hide: boolean): void;
|
|
133
|
+
/** 已签发的 tenant token 数。 */
|
|
134
|
+
tokensIssued(): number;
|
|
135
|
+
/** 吊销所有已签发的 token(之后的调用回 99991663)。 */
|
|
136
|
+
revokeTokens(): void;
|
|
137
|
+
/** 长连接接入点被调用的次数。 */
|
|
138
|
+
endpointCalls(): number;
|
|
139
|
+
/** 成功建立的 WebSocket 连接数。 */
|
|
140
|
+
connectionsOpened(): number;
|
|
141
|
+
openSockets(): number;
|
|
142
|
+
maxConcurrentSockets(): number;
|
|
143
|
+
/**
|
|
144
|
+
* 当前打开的长连接各自的对端(客户端)TCP 端口,按打开顺序。双副本测试据此(经操作系统查端口属于哪个进程)
|
|
145
|
+
* 找出持有长连接的副本——真实插件不带 `x-pid` 之类的标记。
|
|
146
|
+
*/
|
|
147
|
+
socketPeerPorts(): number[];
|
|
148
|
+
/** 接下来 `count` 次(先放过 `skip` 次正常调用)该接口返回 `failure`。 */
|
|
149
|
+
failNext(api: FakeLarkApi, failure: FakeLarkFailure, count?: number, skip?: number): void;
|
|
150
|
+
/**
|
|
151
|
+
* 模拟应用的通讯录权限范围不是「全部成员」:对根部门(`0`)的子部门列表与直属用户列表回 HTTP 400
|
|
152
|
+
* `40004`(无部门权限)。
|
|
153
|
+
*/
|
|
154
|
+
rejectRootDepartment(reject: boolean): void;
|
|
155
|
+
/** 之后该接口的每次调用先等 `ms` 毫秒再响应(0 取消)。 */
|
|
156
|
+
delay(api: FakeLarkApi, ms: number): void;
|
|
157
|
+
/** 接下来 `count` 条 WebSocket 连接在建立后立即被关闭。 */
|
|
158
|
+
dropNextConnections(count: number): void;
|
|
159
|
+
/** 登记一条(用户发的)消息,使之可以被回复。`pushEvent` 推的消息事件会自动登记。 */
|
|
160
|
+
registerMessage(messageId: string, chatId: string): void;
|
|
161
|
+
/**
|
|
162
|
+
* 推一个事件(schema 2.0 负载,如 {@link larkMessageEvent} / {@link larkCardAction} 的结果)给最近打开的连接,
|
|
163
|
+
* 返回帧的 message_id。缺省的 `header.tenant_key` / `app_id` 与 `event.sender.tenant_key` 用本服务的值补上。
|
|
164
|
+
* 该连接还没收到客户端的第一个 ping 时先暂存、收到后再发:SDK 在 `open` 之后(微任务里)才挂上 message 监听,
|
|
165
|
+
* 与握手响应一起到达的帧会被它丢掉(服务端此时已算连接打开)。
|
|
166
|
+
*/
|
|
167
|
+
pushEvent(payload: Record<string, unknown>): string;
|
|
168
|
+
waitForAck(id: string, timeoutMs?: number): Promise<FakeLarkAck>;
|
|
169
|
+
acked(id: string): boolean;
|
|
170
|
+
/** 关闭所有长连接(模拟服务端断开)。 */
|
|
171
|
+
disconnectAll(): void;
|
|
172
|
+
/** 消息最终的卡片(有更新取最后一次)。 */
|
|
173
|
+
finalCard(m: FakeLarkMessage): unknown;
|
|
174
|
+
/** 卡片里全部 markdown 组件的内容(按顺序,换行连接)。 */
|
|
175
|
+
cardText(card: unknown): string;
|
|
176
|
+
stop(): Promise<void>;
|
|
177
|
+
}
|
|
178
|
+
/** 启动假 Lark(监听 127.0.0.1 随机端口)。 */
|
|
179
|
+
export declare function startFakeLark(opts?: FakeLarkOptions): Promise<FakeLarkServer>;
|
|
180
|
+
/** 一条 `im.message.receive_v1` 事件(schema 2.0)。`text` 给的是 text 消息的原文(@ 用 `@_user_N` 占位)。 */
|
|
181
|
+
export declare function larkMessageEvent(o: {
|
|
182
|
+
openId: string;
|
|
183
|
+
chatId: string;
|
|
184
|
+
chatType: "p2p" | "group";
|
|
185
|
+
text?: string;
|
|
186
|
+
messageId?: string;
|
|
187
|
+
rootId?: string;
|
|
188
|
+
parentId?: string;
|
|
189
|
+
threadId?: string;
|
|
190
|
+
mentions?: {
|
|
191
|
+
key: string;
|
|
192
|
+
openId: string;
|
|
193
|
+
name: string;
|
|
194
|
+
}[];
|
|
195
|
+
tenantKey?: string;
|
|
196
|
+
messageType?: string;
|
|
197
|
+
content?: string;
|
|
198
|
+
}): Record<string, unknown>;
|
|
199
|
+
/** 卡片按钮({@link findLarkButton} 的结果)。 */
|
|
200
|
+
export interface FakeLarkButton {
|
|
201
|
+
text: string;
|
|
202
|
+
type?: string;
|
|
203
|
+
name?: string;
|
|
204
|
+
/** 回传值(`behaviors` 里 `callback` 的 `value`)。 */
|
|
205
|
+
value?: unknown;
|
|
206
|
+
/** 打开链接(`behaviors` 里 `open_url` 的 `default_url`)。 */
|
|
207
|
+
url?: string;
|
|
208
|
+
}
|
|
209
|
+
/** 在卡片里找按钮(展开 column_set / form)。 */
|
|
210
|
+
export declare function findLarkButton(card: unknown, match: (b: FakeLarkButton) => boolean): FakeLarkButton | undefined;
|
|
211
|
+
/** 用户点了某条消息卡片上的按钮(`card.action.trigger`,schema 2.0)。`formValue` 为表单提交的数据。 */
|
|
212
|
+
export declare function larkCardAction(user: {
|
|
213
|
+
openId: string;
|
|
214
|
+
tenantKey?: string;
|
|
215
|
+
}, message: FakeLarkMessage, button: FakeLarkButton, opts?: {
|
|
216
|
+
eventId?: string;
|
|
217
|
+
formValue?: Record<string, unknown>;
|
|
218
|
+
}): Record<string, unknown>;
|
|
219
|
+
/**
|
|
220
|
+
* Encrypts a Lark webhook payload the way Lark does when an Encrypt Key is set
|
|
221
|
+
* (https://open.larksuite.com/document/ukTMukTMukTM/uYDNxYjL2QTM24iN0EjN/event-subscription-configure-/encrypt-key-encryption-configuration-case):
|
|
222
|
+
* AES-256-CBC with key = SHA-256(encryptKey), a random 16-byte IV prepended to the ciphertext,
|
|
223
|
+
* base64. Returns the request body `{"encrypt":"<base64>"}`.
|
|
224
|
+
*/
|
|
225
|
+
export declare function encryptLarkWebhook(payload: unknown, encryptKey: string): string;
|
|
226
|
+
/**
|
|
227
|
+
* Signs a Lark webhook request: returns the `x-lark-request-timestamp` (seconds),
|
|
228
|
+
* `x-lark-request-nonce` and `x-lark-signature` headers, where the signature is
|
|
229
|
+
* hex SHA-256(timestamp + nonce + encryptKey + raw body).
|
|
230
|
+
*/
|
|
231
|
+
export declare function signLarkWebhook(body: string | Uint8Array, encryptKey: string, opts?: {
|
|
232
|
+
timestamp?: number;
|
|
233
|
+
nonce?: string;
|
|
234
|
+
}): Record<string, string>;
|