@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.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +34 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +121 -0
  7. package/dist/dual-replica.js +247 -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 +47 -0
  33. package/dist/fixtures.js +90 -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 +66 -3
@@ -0,0 +1,121 @@
1
+ import type { BootstrapManifest } from "@capybara-hq/plugin-sdk";
2
+ /** 双副本测试工具的选项。 */
3
+ export interface DualReplicaOptions {
4
+ /** Extensions to load (absolute paths, file URLs, or package names resolved from `resolveFrom`). */
5
+ extensions: string[];
6
+ /** 解析裸包名插件的目录,默认调用方的 `process.cwd()`(与 `createKernel` 的 resolveFrom 同义)。 */
7
+ resolveFrom?: string;
8
+ /** 部署级插件配置,按插件名索引。 */
9
+ extensionConfig?: Record<string, unknown>;
10
+ /**
11
+ * The bootstrap step (package ecosystem spec §7): every replica's kernel gets `bootstrapDir: dir`
12
+ * and `bootstrapManifest: manifest`, so the replicas run it at start (once across them, under the
13
+ * advisory lock) before loading extensions, as a deployment without an init container does. `dir`
14
+ * holds the packages the manifest pins (`<name>-<version>.tgz`); build both with the kernel
15
+ * tests' `bootstrapFixture` helper (it packs plugin directories with `pnpm pack-plugin`).
16
+ */
17
+ bootstrap?: {
18
+ dir: string;
19
+ manifest: BootstrapManifest;
20
+ };
21
+ /**
22
+ * 复用已有的 PostgreSQL(管理员连接串,需能 CREATE DATABASE)。传入则只创建并在 stop() 时删除本 harness 的 `cap_*` 库。
23
+ * Omitted: use the run-wide shared PostgreSQL of the vitest globalSetup (same per-harness database),
24
+ * or, without one / with `CAPYBARA_TEST_NO_SHARED=1`, start a container and stop it in stop().
25
+ */
26
+ pgUrl?: string;
27
+ /**
28
+ * 复用已有的 Redis。省略则每个 harness 自己起一个容器(完全隔离);
29
+ * 传入时不做隔离,调用方须保证同一 Redis 不被多个同时存活的 harness 共用。
30
+ */
31
+ redisUrl?: string;
32
+ /**
33
+ * 额外的内核配置,深合并进副本配置(如 `{ auth: { cookieSecure: false, setupToken: "…" } }`)。
34
+ * 与 harness 自身设定的键冲突时以这里为准(数组整体替换)。经 IPC 传给副本,不进 argv / env。
35
+ * harness 默认开启 `extensionPermissions.autoApprove`(插件权限自动批准),传 `false` 可关闭。
36
+ */
37
+ config?: Record<string, unknown>;
38
+ /**
39
+ * Kernel roles per replica (`[replica 0, replica 1]`), e.g. `[["api", "worker"], ["gateway"]]`.
40
+ * Defaults to all roles (`api`, `worker`, `gateway`) on both. `request()` only goes to replicas
41
+ * with the `api` role; use `requestTo(index, …)` to reach a specific replica.
42
+ */
43
+ roles?: [string[], string[]];
44
+ }
45
+ /** 一个副本(独立子进程)的句柄。 */
46
+ export interface ReplicaHandle {
47
+ /** Replica index (0 and 1 for the two base replicas). */
48
+ index: number;
49
+ /** Kernel roles this replica runs. */
50
+ roles: string[];
51
+ /** 该副本实际监听的 HTTP 端口(127.0.0.1)。 */
52
+ port: number;
53
+ /** 该副本子进程 pid。 */
54
+ pid: number;
55
+ }
56
+ /** A replica added with `Harness.startExtraReplica`; `kill()` SIGKILLs it like `Harness.kill(index)`. */
57
+ export interface ExtraReplicaHandle extends ReplicaHandle {
58
+ kill(): Promise<void>;
59
+ }
60
+ /** 副本在响应头里标明自己的序号(runner 把 `http.replicaId` 设为 `"0"` / `"1"`)。 */
61
+ export declare const REPLICA_HEADER = "x-capybara-replica";
62
+ /**
63
+ * Reads the `x-capybara-replica` response header to get the index of the replica that handled the
64
+ * request; undefined when the response did not come from a harness replica (no header, or not a
65
+ * non-negative integer). Use it to confirm where a direct `fetch` to a replica port (e.g. SSE) landed.
66
+ */
67
+ export declare function replicaOf(res: Response): number | undefined;
68
+ /** `Harness.request` 的返回值:普通 `Response` 加上处理它的副本序号(取自 `x-capybara-replica` 响应头)。 */
69
+ export type ReplicaResponse = Response & {
70
+ readonly replica: number;
71
+ };
72
+ /** Two kernel replicas in separate processes, plus a round-robin request entry over the api replicas. */
73
+ export interface Harness {
74
+ /**
75
+ * Round-robin over the replicas that run the `api` role. With the default roles this strictly
76
+ * alternates: calls 1, 3, 5… go to replica 0 and calls 2, 4, 6… to replica 1.
77
+ * The result's `replica` comes from the `x-capybara-replica` response header; throws when the
78
+ * header is missing or does not match the target, or when no replica runs the `api` role.
79
+ */
80
+ request(path: string, init?: RequestInit): Promise<ReplicaResponse>;
81
+ /**
82
+ * Sends the request to replica `index`, whatever its roles (a replica without `api` only serves
83
+ * health endpoints and, with `gateway`, plugin routes). Same header check as `request()`.
84
+ */
85
+ requestTo(index: number, path: string, init?: RequestInit): Promise<ReplicaResponse>;
86
+ replicas: [ReplicaHandle, ReplicaHandle];
87
+ /**
88
+ * Starts one more replica with the given roles on the same PG / Redis / masterKey and resolves
89
+ * once it is ready. It gets the next index (2, 3, …): `requestTo(index)` and `kill(index)` reach
90
+ * it, an `api` replica joins the `request()` rotation, and `stop()` stops it too.
91
+ */
92
+ startExtraReplica(roles: string[]): Promise<ExtraReplicaHandle>;
93
+ /** 本 harness 独占数据库的连接串,测试可直接连上去查表。 */
94
+ dbUrl: string;
95
+ /** The Redis the replicas share (tests may read kernel keys from it). */
96
+ redisUrl: string;
97
+ /**
98
+ * 模拟副本硬崩溃:对该副本子进程发 SIGKILL 并等它退出(不走优雅停机)。之后发往它的请求直接抛错;
99
+ * 另一个副本照常工作。已退出时是空操作。
100
+ */
101
+ kill(index: number): Promise<void>;
102
+ /** 停两个副本(15s 后 SIGKILL)、删临时库、关自有容器;幂等。 */
103
+ stop(): Promise<void>;
104
+ }
105
+ /**
106
+ * 子进程的 node 参数。`.ts` 的 runner(monorepo / 开发态)需要 tsx 与源码导出条件;
107
+ * 编译后的 `.js` runner(发布态)直接用 node,不依赖 tsx。
108
+ */
109
+ export declare function runnerArgv(runnerPath: string): string[];
110
+ /**
111
+ * 子进程的环境变量。`.ts` 的 runner 额外设置 `TSX_TSCONFIG_PATH` 指向仓库的 tsconfig.base.json:
112
+ * tsx 只认子进程 cwd 下(且 include 覆盖到的)tsconfig,否则内核源码丢掉 experimentalDecorators,
113
+ * Nest 装饰器无法编译。编译后的 `.js` runner 不经 tsx,环境原样传入。
114
+ */
115
+ export declare function runnerEnv(runnerPath: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
116
+ /**
117
+ * 启动两个共享同一套 PG / Redis、同一 masterKey 的内核副本,**每个副本一个独立子进程**,
118
+ * 模块缓存、进程内状态完全隔离,贴近生产多副本。请求经真实 HTTP 交替发往含 api 角色的副本。
119
+ * 两个副本都就绪后才 resolve;用来暴露「只在多副本下才出现」的进程内状态缺陷。
120
+ */
121
+ export declare function createDualReplicaHarness(opts: DualReplicaOptions): Promise<Harness>;
@@ -0,0 +1,247 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { extname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createFreshDatabase, sharedPgAdminUrl, startPostgresContainer, startTestRedis, } from "./containers.js";
6
+ /** 副本在响应头里标明自己的序号(runner 把 `http.replicaId` 设为 `"0"` / `"1"`)。 */
7
+ export const REPLICA_HEADER = "x-capybara-replica";
8
+ /**
9
+ * Reads the `x-capybara-replica` response header to get the index of the replica that handled the
10
+ * request; undefined when the response did not come from a harness replica (no header, or not a
11
+ * non-negative integer). Use it to confirm where a direct `fetch` to a replica port (e.g. SSE) landed.
12
+ */
13
+ export function replicaOf(res) {
14
+ const v = res.headers.get(REPLICA_HEADER);
15
+ return v !== null && /^(0|[1-9][0-9]*)$/.test(v) ? Number(v) : undefined;
16
+ }
17
+ /** Default roles of every replica. */
18
+ const ALL_ROLES = ["api", "worker", "gateway"];
19
+ const STOP_TIMEOUT_MS = 15_000;
20
+ const START_TIMEOUT_MS = 60_000;
21
+ const STDERR_TAIL = 4000;
22
+ /** runner 与本文件同目录;源码下是 .ts,编译后是 .js。 */
23
+ const runnerPath = fileURLToPath(new URL(`./replica-runner${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
24
+ /**
25
+ * 子进程的 node 参数。`.ts` 的 runner(monorepo / 开发态)需要 tsx 与源码导出条件;
26
+ * 编译后的 `.js` runner(发布态)直接用 node,不依赖 tsx。
27
+ */
28
+ export function runnerArgv(runnerPath) {
29
+ return runnerPath.endsWith(".ts")
30
+ ? ["--import", "tsx", "--conditions=@capybara-hq/source", runnerPath]
31
+ : [runnerPath];
32
+ }
33
+ /** 仓库根的 tsconfig.base.json(仅 monorepo 源码态存在)。 */
34
+ const REPO_TSCONFIG = fileURLToPath(new URL("../../../tsconfig.base.json", import.meta.url));
35
+ /**
36
+ * 子进程的环境变量。`.ts` 的 runner 额外设置 `TSX_TSCONFIG_PATH` 指向仓库的 tsconfig.base.json:
37
+ * tsx 只认子进程 cwd 下(且 include 覆盖到的)tsconfig,否则内核源码丢掉 experimentalDecorators,
38
+ * Nest 装饰器无法编译。编译后的 `.js` runner 不经 tsx,环境原样传入。
39
+ */
40
+ export function runnerEnv(runnerPath, env = process.env) {
41
+ return runnerPath.endsWith(".ts") ? { ...env, TSX_TSCONFIG_PATH: REPO_TSCONFIG } : env;
42
+ }
43
+ /** 启动一个副本子进程,等它通过 IPC 报告 ready。失败时抛出带 输出尾部的错误(不含配置)。 */
44
+ async function startReplica(config, index) {
45
+ const child = spawn(process.execPath, runnerArgv(runnerPath), {
46
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
47
+ env: runnerEnv(runnerPath),
48
+ cwd: fileURLToPath(new URL("..", import.meta.url)),
49
+ });
50
+ let tail = "";
51
+ const onData = (b) => {
52
+ tail = (tail + b.toString()).slice(-STDERR_TAIL);
53
+ };
54
+ child.stderr?.on("data", onData);
55
+ child.stdout?.on("data", onData);
56
+ const runner = {
57
+ child,
58
+ output: () => tail,
59
+ exited: Promise.resolve(),
60
+ crashed: undefined,
61
+ };
62
+ runner.exited = new Promise((resolve) => child.once("exit", (code, signal) => {
63
+ runner.crashed = `exited with ${signal ? `signal ${signal}` : `code ${code}`}`;
64
+ resolve();
65
+ }));
66
+ const exited = runner.exited;
67
+ const port = await new Promise((resolve, reject) => {
68
+ const timer = setTimeout(() => reject(new Error("replica start timed out")), START_TIMEOUT_MS);
69
+ const fail = (why) => {
70
+ clearTimeout(timer);
71
+ reject(new Error(`replica failed to start: ${why}\n${tail}`));
72
+ };
73
+ child.on("message", (m) => {
74
+ if (m.type === "ready" && typeof m.port === "number") {
75
+ clearTimeout(timer);
76
+ resolve(m.port);
77
+ }
78
+ else if (m.type === "failed")
79
+ fail(m.error ?? "unknown");
80
+ });
81
+ child.once("exit", (code) => fail(`runner exited with code ${code}`));
82
+ child.once("error", (e) => fail(e.message));
83
+ child.send({ type: "start", config: { ...config, replicaId: String(index) } });
84
+ }).catch(async (e) => {
85
+ child.kill("SIGKILL");
86
+ await exited;
87
+ throw e;
88
+ });
89
+ return { runner, port };
90
+ }
91
+ /**
92
+ * 启动两个共享同一套 PG / Redis、同一 masterKey 的内核副本,**每个副本一个独立子进程**,
93
+ * 模块缓存、进程内状态完全隔离,贴近生产多副本。请求经真实 HTTP 交替发往含 api 角色的副本。
94
+ * 两个副本都就绪后才 resolve;用来暴露「只在多副本下才出现」的进程内状态缺陷。
95
+ */
96
+ export async function createDualReplicaHarness(opts) {
97
+ const owned = [];
98
+ const runners = [];
99
+ let dropDb;
100
+ let stopPromise;
101
+ const stopRunner = async (r) => {
102
+ if (r.child.exitCode !== null || r.child.signalCode !== null)
103
+ return;
104
+ if (r.child.connected)
105
+ r.child.send({ type: "stop" });
106
+ let timer;
107
+ const killed = new Promise((resolve) => {
108
+ timer = setTimeout(() => {
109
+ r.child.kill("SIGKILL");
110
+ resolve();
111
+ }, STOP_TIMEOUT_MS);
112
+ });
113
+ await Promise.race([r.exited, killed]);
114
+ clearTimeout(timer);
115
+ await r.exited;
116
+ };
117
+ const stop = () => {
118
+ stopPromise ??= (async () => {
119
+ await Promise.allSettled(runners.map(stopRunner));
120
+ await Promise.allSettled([dropDb?.()]);
121
+ await Promise.allSettled(owned.map((c) => c.stop()));
122
+ })();
123
+ return stopPromise;
124
+ };
125
+ try {
126
+ // Without `pgUrl`: the run-wide shared PostgreSQL when the vitest globalSetup started one,
127
+ // else a container of our own. Either way the replicas get a fresh database created below.
128
+ let pgUrl = opts.pgUrl ?? sharedPgAdminUrl();
129
+ if (pgUrl === undefined) {
130
+ const c = await startPostgresContainer();
131
+ owned.push(c);
132
+ pgUrl = c.url;
133
+ }
134
+ let redisUrl = opts.redisUrl;
135
+ if (redisUrl === undefined) {
136
+ const c = await startTestRedis();
137
+ owned.push(c);
138
+ redisUrl = c.url;
139
+ }
140
+ const db = await createFreshDatabase(pgUrl);
141
+ dropDb = db.drop;
142
+ const config = {
143
+ dbUrl: db.url,
144
+ redisUrl,
145
+ masterKey: randomBytes(32).toString("base64"),
146
+ extensions: opts.extensions,
147
+ ...(opts.bootstrap !== undefined && {
148
+ bootstrapDir: resolve(opts.bootstrap.dir),
149
+ bootstrapManifest: opts.bootstrap.manifest,
150
+ }),
151
+ // 副本进程的 cwd 不是调用方的 cwd,相对路径必须先按调用方 cwd 绝对化再经 IPC 传过去
152
+ resolveFrom: resolve(opts.resolveFrom ?? process.cwd()),
153
+ extensionConfig: opts.extensionConfig ?? {},
154
+ overrides: opts.config ?? {},
155
+ };
156
+ const roles = opts.roles ?? [ALL_ROLES, ALL_ROLES];
157
+ const started = await Promise.allSettled(roles.map((r, i) => startReplica({ ...config, roles: [...r] }, i)));
158
+ for (const s of started)
159
+ if (s.status === "fulfilled")
160
+ runners.push(s.value.runner);
161
+ const failed = started.find((s) => s.status === "rejected");
162
+ if (failed)
163
+ throw failed.reason;
164
+ const [a, b] = started.map((s) => s.value);
165
+ const replicas = [
166
+ { index: 0, roles: [...roles[0]], port: a.port, pid: a.runner.child.pid },
167
+ { index: 1, roles: [...roles[1]], port: b.port, pid: b.runner.child.pid },
168
+ ];
169
+ // Index-aligned with `replicas`; extra replicas append to both.
170
+ const handles = [...replicas];
171
+ const replicaRunners = [a.runner, b.runner];
172
+ const runnerAt = (index) => {
173
+ const handle = handles[index];
174
+ const runner = replicaRunners[index];
175
+ if (handle === undefined || runner === undefined)
176
+ throw new Error(`no replica ${index}`);
177
+ return { handle, runner };
178
+ };
179
+ const send = async (index, path, init) => {
180
+ const { handle: r, runner } = runnerAt(index);
181
+ if (runner.crashed !== undefined) {
182
+ throw new Error(`replica ${index} (pid ${r.pid}) ${runner.crashed}\n${runner.output()}`);
183
+ }
184
+ const res = await fetch(`http://127.0.0.1:${r.port}${path}`, init);
185
+ const replica = replicaOf(res);
186
+ if (replica !== index) {
187
+ await res.body?.cancel();
188
+ throw new Error(`response from port ${r.port} has ${REPLICA_HEADER}=${replica}, want ${index}`);
189
+ }
190
+ return Object.assign(res, { replica });
191
+ };
192
+ let calls = 0;
193
+ let extraChain = Promise.resolve();
194
+ const kill = async (index) => {
195
+ const { runner: r } = runnerAt(index);
196
+ if (r.child.exitCode === null && r.child.signalCode === null)
197
+ r.child.kill("SIGKILL");
198
+ await r.exited;
199
+ };
200
+ return {
201
+ replicas,
202
+ dbUrl: db.url,
203
+ redisUrl,
204
+ stop,
205
+ kill,
206
+ startExtraReplica(extraRoles) {
207
+ // Extra starts run one at a time: the index is the next free slot when the start begins,
208
+ // and a failed start appends nothing, so there are never holes in the replica indexes
209
+ const p = extraChain.then(async () => {
210
+ if (stopPromise)
211
+ throw new Error("harness is stopped");
212
+ const index = handles.length;
213
+ const started = await startReplica({ ...config, roles: [...extraRoles] }, index);
214
+ runners.push(started.runner);
215
+ // A stop() that began while this replica was starting must not leave it running
216
+ if (stopPromise) {
217
+ await stopRunner(started.runner);
218
+ throw new Error("harness is stopped");
219
+ }
220
+ const handle = {
221
+ index,
222
+ roles: [...extraRoles],
223
+ port: started.port,
224
+ pid: started.runner.child.pid,
225
+ };
226
+ handles.push(handle);
227
+ replicaRunners.push(started.runner);
228
+ return { ...handle, kill: () => kill(index) };
229
+ });
230
+ extraChain = p.then(() => { }, () => { });
231
+ return p;
232
+ },
233
+ async request(path, init) {
234
+ const api = handles.filter((h) => h.roles.includes("api"));
235
+ if (api.length === 0)
236
+ throw new Error("no replica runs the api role");
237
+ const target = api[calls++ % api.length];
238
+ return send(target.index, path, init);
239
+ },
240
+ requestTo: send,
241
+ };
242
+ }
243
+ catch (e) {
244
+ await stop();
245
+ throw e;
246
+ }
247
+ }
@@ -0,0 +1,9 @@
1
+ import type { ExtensionManifest } from "@capybara-hq/plugin-sdk";
2
+ /**
3
+ * For extension tests: runs the kernel's secret marker check done when loading an extension (the same
4
+ * `assertSecretsReachable` as `ExtensionHost` uses); throws when a `secret()` is not on an object
5
+ * field path (the kernel would refuse to load the extension).
6
+ */
7
+ export declare function assertExtensionSecretsReachable(manifest: ExtensionManifest): void;
8
+ /** For extension tests: the deployment config secret values the kernel registers for log redaction (the same collection logic as `ExtensionHost` uses when loading). */
9
+ export declare function extensionDeploymentSecretValues(manifest: ExtensionManifest, config: unknown): string[];
@@ -0,0 +1,16 @@
1
+ import { assertSecretsReachable, secretValues } from "@capybara-hq/kernel";
2
+ /**
3
+ * For extension tests: runs the kernel's secret marker check done when loading an extension (the same
4
+ * `assertSecretsReachable` as `ExtensionHost` uses); throws when a `secret()` is not on an object
5
+ * field path (the kernel would refuse to load the extension).
6
+ */
7
+ export function assertExtensionSecretsReachable(manifest) {
8
+ assertSecretsReachable(manifest);
9
+ }
10
+ /** For extension tests: the deployment config secret values the kernel registers for log redaction (the same collection logic as `ExtensionHost` uses when loading). */
11
+ export function extensionDeploymentSecretValues(manifest, config) {
12
+ const schema = manifest.config?.deployment;
13
+ if (!schema)
14
+ return [];
15
+ return secretValues(schema, schema.parse(config));
16
+ }
@@ -0,0 +1,162 @@
1
+ import type { CardAction, ChannelTarget, DirectoryMember, InboundEvent, OutboundMessage } from "@capybara-hq/plugin-sdk";
2
+ /**
3
+ * 可编程的假 IM 平台(渠道插件测试用):一个 HTTP 服务同时扮演 IM 平台的几块能力——
4
+ *
5
+ * - **机器人**:`POST /bot/verify` 校验应用凭据;`GET /connect?connection=<id>` 建一条 NDJSON 长连接,
6
+ * 测试用 {@link FakeChannelServer.push} 向某个渠道连接当前在线的长连接推入站事件;`POST /send`、`POST /update`
7
+ * 记下机器人发出 / 更新的消息(含卡片),`/send` 按 `idempotencyKey` 去重(同一应用、同一键只发一次,
8
+ * 重复的请求原样返回第一次的 messageId,并记进 `duplicateSends`)。
9
+ * - **登录(OAuth)**:`GET /oauth/authorize` 以 `as=<externalId>` 指定「在平台上登录的人」,签发一次性授权码并
10
+ * 302 回 `redirect_uri`;`POST /oauth/token` 用授权码换该用户资料与发起时的 nonce。授权时带了 PKCE
11
+ * `code_challenge`(S256)的,换 token 必须带对应的 `codeVerifier`,否则 `invalid_grant`。
12
+ * - **通讯录**:`GET /d/<key>/members?cursor=<n>` 按页返回 {@link FakeChannelServer.book} 的成员,可让某页失败、让第一页挂起。
13
+ * - **其他写操作**:`POST /posts` 记下插件工具发的「公告」(`posts`),供断言工具确实执行(且只执行一次)。
14
+ *
15
+ * - **Webhook** (the platform calling the kernel): {@link signFakeWebhook} signs a body with HMAC-SHA256
16
+ * for the `x-fake-signature` header; {@link fakeWebhookEvents} / {@link fakeWebhookChallenge} build bodies.
17
+ *
18
+ * 所有请求读 `x-pid` 头记下发起进程,双副本测试据此判断是哪个副本连上 / 发出的。
19
+ * It tracks the peak number of concurrent long connections per channel connection (at most one at
20
+ * any time). The matching test extension is this package's fake `fake-im`
21
+ * (`fakeExtensionEntry("fake-im")`).
22
+ */
23
+ /** 一条长连接。 */
24
+ export interface FakeChannelStream {
25
+ connectionId: string;
26
+ /** 请求头 `x-app-id`。 */
27
+ appId: string;
28
+ /** 发起连接的进程 pid(请求头 `x-pid`)。 */
29
+ pid: number;
30
+ openedAt: number;
31
+ closedAt: number | undefined;
32
+ }
33
+ /** 机器人发出(或更新)的一条消息。 */
34
+ export interface FakeChannelSent {
35
+ appId: string;
36
+ target: ChannelTarget;
37
+ message: OutboundMessage;
38
+ messageId: string;
39
+ /** 发出请求的进程 pid(请求头 `x-pid`)。 */
40
+ pid: number;
41
+ /** 内核给的幂等键(`ChannelSendArgs.idempotencyKey`)。 */
42
+ idempotencyKey?: string;
43
+ /** 内核给的组织语言(`ChannelSendArgs.locale`)。 */
44
+ locale?: "zh" | "en";
45
+ at: number;
46
+ }
47
+ /** 平台上的一个用户(OAuth 登录时可用 `as=<externalId>` 选中)。 */
48
+ export interface FakeChannelUser {
49
+ externalId: string;
50
+ tenantId: string;
51
+ email?: string;
52
+ emailVerified?: boolean;
53
+ displayName: string;
54
+ }
55
+ /** 一份假通讯录(按路径前缀 `/d/<key>` 区分)。 */
56
+ export interface FakeDirectoryBook {
57
+ /** 通讯录插件的组织级配置 `url`。 */
58
+ url: string;
59
+ members: DirectoryMember[];
60
+ /** 每页条数(默认 100)。 */
61
+ pageSize: number;
62
+ /** 第几页(从 0 计)返回 500;null 不失败。 */
63
+ failOnPage: number | null;
64
+ /** 给了就让第一页(不带游标的请求)等它 resolve 再返回。 */
65
+ hold: Promise<void> | null;
66
+ /** 每次请求的页号与发起进程 pid。 */
67
+ requests: {
68
+ page: number;
69
+ pid: number;
70
+ }[];
71
+ /** 整轮同步开始的次数(不带游标的请求数)。 */
72
+ starts(): number;
73
+ }
74
+ /** 插件工具经 `POST /posts` 发出的一条记录。 */
75
+ export interface FakeChannelPost {
76
+ body: Record<string, unknown>;
77
+ pid: number;
78
+ at: number;
79
+ }
80
+ export interface FakeChannelServerOptions {
81
+ /**
82
+ * `/send` 是否按 `(appId, idempotencyKey)` 去重(默认 true,像飞书的 `uuid`)。
83
+ * 断言「内核自己就不重发」的测试传 false:每次发送都记进 `sent`,重发即可见。
84
+ */
85
+ dedupeSends?: boolean;
86
+ }
87
+ export interface FakeChannelServer {
88
+ url: string;
89
+ streams: FakeChannelStream[];
90
+ /** 平台上真实出现的消息(去重后)。 */
91
+ sent: FakeChannelSent[];
92
+ /** 机器人对已发消息的更新(`messageId` 为被更新的消息)。 */
93
+ updated: FakeChannelSent[];
94
+ /** 命中幂等键、被平台去重的发送请求(没有产生新消息)。 */
95
+ duplicateSends: FakeChannelSent[];
96
+ /** 插件工具发的公告。 */
97
+ posts: FakeChannelPost[];
98
+ /** 平台用户(OAuth 登录用),按 externalId 索引。 */
99
+ users: Map<string, FakeChannelUser>;
100
+ /**
101
+ * 让接下来 `count` 次 `/send`(或 `/update`)返回该状态码(不记录);`retryAfterMs` 与 `reason` 放进响应体,
102
+ * 测试插件据此抛 `ChannelSendError`(429 / 5xx 可重试,带等待时长;其他不可重试,带失败原因)。
103
+ * `delayMs`:失败响应推迟这么久才返回(模拟慢的失败请求,让会话的后续事件在投递任务持锁期间到达)。
104
+ */
105
+ failNext(path: "/send" | "/update", status: number, count?: number, retryAfterMs?: number, reason?: "auth" | "target_gone" | "rejected" | "too_large", delayMs?: number): void;
106
+ /**
107
+ * Delay the response of the next `count` successful `/send` (or `/update`) requests by `delayMs`.
108
+ * The message is recorded (visible in `sent` / `updated`) as soon as the request arrives; only the
109
+ * response carrying its `messageId` is late (a slow platform API, or a sender that dies mid-call).
110
+ * `onlyCards`: count and delay only requests whose message carries cards.
111
+ */
112
+ delayNext(path: "/send" | "/update", delayMs: number, opts?: {
113
+ count?: number;
114
+ onlyCards?: boolean;
115
+ }): void;
116
+ /** 让 /connect 返回该状态码(null 恢复正常)。 */
117
+ failConnect(status: number | null): void;
118
+ /** 该渠道连接当前在线的长连接。 */
119
+ active(connectionId: string): FakeChannelStream[];
120
+ /** 该渠道连接同时在线长连接数的峰值。 */
121
+ maxConcurrent(connectionId: string): number;
122
+ /** 推送给该渠道连接当前所有在线长连接,返回推送到的条数。 */
123
+ push(connectionId: string, event: InboundEvent): number;
124
+ /** 取(没有则新建)一份通讯录。 */
125
+ book(key: string): FakeDirectoryBook;
126
+ /** 消息最终显示的文本:有更新取最后一次更新,否则为发出时的文本。 */
127
+ finalText(s: FakeChannelSent): string;
128
+ stop(): Promise<void>;
129
+ }
130
+ /** 校验应用凭据时一律拒绝的密钥。 */
131
+ export declare const FAKE_CHANNEL_BAD_SECRET = "bad-secret";
132
+ /** 启动假 IM 平台(监听 127.0.0.1 随机端口)。 */
133
+ export declare function startFakeChannelServer(opts?: FakeChannelServerOptions): Promise<FakeChannelServer>;
134
+ /** 平台上的发言人资料(`InboundSender` 的可选字段)。 */
135
+ export interface FakeSenderProfile {
136
+ displayName?: string;
137
+ avatarUrl?: string;
138
+ }
139
+ /** 私聊文本消息(chat id 为 `dm-<externalId>`);`profile` 给了就带上发言人的显示名 / 头像。 */
140
+ export declare function fakeDm(messageId: string, externalId: string, text: string, tenantId?: string, profile?: FakeSenderProfile): InboundEvent;
141
+ /** 群聊文本消息(默认 chat `grp-1`、@ 了机器人)。 */
142
+ export declare function fakeGroupMessage(messageId: string, externalId: string, text: string, opts?: {
143
+ chatId?: string;
144
+ threadId?: string;
145
+ mentionsBot?: boolean;
146
+ tenantId?: string;
147
+ } & FakeSenderProfile): InboundEvent;
148
+ /** 卡片按钮操作;`target` 取卡片所在的消息目标(群里带 threadId)。 */
149
+ export declare function fakeCardAction(messageId: string, externalId: string, action: CardAction, target: ChannelTarget, tenantId?: string, profile?: FakeSenderProfile): InboundEvent;
150
+ /** 通讯录成员(租户 t1;邮箱缺省 `<id>@corp.example`、已验证)。 */
151
+ export declare function fakeDirectoryMember(externalId: string, over?: Partial<DirectoryMember>): DirectoryMember;
152
+ /** Signature header of the fake platform's webhooks (`sha256=<hex HMAC-SHA256 of the raw body>`). */
153
+ export declare const FAKE_WEBHOOK_SIGNATURE_HEADER = "x-fake-signature";
154
+ /**
155
+ * Signs a fake-platform webhook body the way the `fake-im` test plugin verifies it: returns the
156
+ * value for {@link FAKE_WEBHOOK_SIGNATURE_HEADER}, `sha256=` + hex HMAC-SHA256(secret, body).
157
+ */
158
+ export declare function signFakeWebhook(body: string | Uint8Array, secret: string): string;
159
+ /** Body of a fake-platform webhook that delivers events (`fake-im` answers with its ack). */
160
+ export declare const fakeWebhookEvents: (events: InboundEvent[]) => string;
161
+ /** Body of a fake-platform URL verification request (`fake-im` echoes `{ challenge }`). */
162
+ export declare const fakeWebhookChallenge: (challenge: string) => string;