@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,336 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir, readFile, readlink } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { AccountsService, createKernel, createLogger, KernelConfigSchema, MARKETPLACE_OFFICIAL_URL_DEFAULT, } from "@capybara-hq/kernel";
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
/**
|
|
7
|
+
* Helpers for extension tests that run a real kernel: in process (`startTestKernel` +
|
|
8
|
+
* `http.inject`) or as two replicas (`createDualReplicaHarness`). They only use the kernel's public
|
|
9
|
+
* API and HTTP routes, so a plugin's tests can use them without reaching into kernel sources.
|
|
10
|
+
*/
|
|
11
|
+
/** The password `createLocalAccount` gives accounts (and `login` / `joinOrg` use by default). */
|
|
12
|
+
export const TEST_PASSWORD = "correct horse battery";
|
|
13
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
/** Polls `fn` until it returns a value other than null / undefined / false; throws on timeout. */
|
|
15
|
+
export async function waitFor(fn, timeoutMs = 10_000, label = "condition", stepMs = 25) {
|
|
16
|
+
const deadline = Date.now() + timeoutMs;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const v = await fn();
|
|
19
|
+
if (v !== null && v !== undefined && v !== false)
|
|
20
|
+
return v;
|
|
21
|
+
if (Date.now() > deadline)
|
|
22
|
+
throw new Error(`timed out waiting for ${label}`);
|
|
23
|
+
await sleep(stepMs);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A kernel config for tests: the given PG / Redis, a fixed master key, an ephemeral HTTP port,
|
|
28
|
+
* auto-approved extension permissions, short run-lock timings and the official marketplace off
|
|
29
|
+
* (it is on by default and its address is on the internet). `overrides` are top-level config keys
|
|
30
|
+
* and win; `runtime` is merged into the defaults, and `marketplaces` too, so other marketplace
|
|
31
|
+
* settings keep the official one off unless `marketplaces.official` turns it on.
|
|
32
|
+
*/
|
|
33
|
+
export function testKernelConfig(urls, overrides) {
|
|
34
|
+
const { runtime, marketplaces, ...rest } = overrides;
|
|
35
|
+
return KernelConfigSchema.parse({
|
|
36
|
+
db: { url: urls.pg, poolMax: 5 },
|
|
37
|
+
redis: { url: urls.redis },
|
|
38
|
+
masterKey: Buffer.alloc(32, 7).toString("base64"),
|
|
39
|
+
http: { port: 0 },
|
|
40
|
+
extensionPermissions: { autoApprove: true },
|
|
41
|
+
runtime: { jobLockMs: 2000, stalledIntervalMs: 1000, ...runtime },
|
|
42
|
+
marketplaces: {
|
|
43
|
+
...marketplaces,
|
|
44
|
+
official: { enabled: false, ...marketplaces?.official },
|
|
45
|
+
},
|
|
46
|
+
...rest,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Throws when `config` would make a test kernel fetch the real official marketplace (enabled, at its
|
|
51
|
+
* default address on the internet): tests turn it off or point it at a fake. A kernel helper calls
|
|
52
|
+
* it before creating anything.
|
|
53
|
+
*/
|
|
54
|
+
export function assertOfflineOfficialMarketplace(config, allow = false) {
|
|
55
|
+
const o = config.marketplaces.official;
|
|
56
|
+
if (allow || !o.enabled || o.url !== MARKETPLACE_OFFICIAL_URL_DEFAULT)
|
|
57
|
+
return;
|
|
58
|
+
throw new Error("test kernel config would fetch the real official marketplace " +
|
|
59
|
+
`(${MARKETPLACE_OFFICIAL_URL_DEFAULT}): set marketplaces.official.enabled to false, point ` +
|
|
60
|
+
"marketplaces.official.url at a fake, or build the config with testKernelConfig");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Creates and starts an in-process kernel. Logs are discarded unless `log` is given (JSON lines at
|
|
64
|
+
* `log.level` and above go to `log.write`, e.g. to assert a secret never reaches the log).
|
|
65
|
+
* Extensions named by package resolve from `resolveFrom` (default `process.cwd()`). Stop it with
|
|
66
|
+
* `stop()`.
|
|
67
|
+
*/
|
|
68
|
+
export async function startTestKernel(config, opts = {}) {
|
|
69
|
+
assertOfflineOfficialMarketplace(config, opts.allowOfficialMarketplace);
|
|
70
|
+
const k = await createKernel(config, {
|
|
71
|
+
env: {},
|
|
72
|
+
logger: createLogger(opts.log?.level ?? "error", { write: opts.log?.write ?? (() => { }) }),
|
|
73
|
+
...(opts.resolveFrom !== undefined && { resolveFrom: opts.resolveFrom }),
|
|
74
|
+
});
|
|
75
|
+
await k.start();
|
|
76
|
+
return k;
|
|
77
|
+
}
|
|
78
|
+
/** A request function with its own cookie jar (signed out until a response sets cookies). */
|
|
79
|
+
export function cookieRequest(http) {
|
|
80
|
+
const jar = new Map();
|
|
81
|
+
return async ({ csrf = true, ...opts }) => {
|
|
82
|
+
const cookie = [...jar].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("; ");
|
|
83
|
+
const res = await http.inject({
|
|
84
|
+
...opts,
|
|
85
|
+
headers: {
|
|
86
|
+
...(cookie !== "" && { cookie }),
|
|
87
|
+
...(csrf && { "x-capybara-csrf": "1" }),
|
|
88
|
+
...opts.headers,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const set = res.headers["set-cookie"];
|
|
92
|
+
for (const raw of set === undefined ? [] : Array.isArray(set) ? set : [String(set)]) {
|
|
93
|
+
const [pair = "", ...attrs] = raw.split(";");
|
|
94
|
+
const eq = pair.indexOf("=");
|
|
95
|
+
const name = pair.slice(0, eq).trim();
|
|
96
|
+
const value = decodeURIComponent(pair.slice(eq + 1).trim());
|
|
97
|
+
const gone = value === "" || attrs.some((a) => /^\s*max-age=0\s*$/i.test(a));
|
|
98
|
+
if (gone)
|
|
99
|
+
jar.delete(name);
|
|
100
|
+
else
|
|
101
|
+
jar.set(name, value);
|
|
102
|
+
}
|
|
103
|
+
return res;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function expectStatus(res, status, what) {
|
|
107
|
+
if (res.statusCode !== status) {
|
|
108
|
+
throw new Error(`${what}: expected HTTP ${status}, got ${res.statusCode}: ${res.body}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Creates a local account with {@link TEST_PASSWORD}; returns its id. */
|
|
112
|
+
export async function createLocalAccount(db, login, displayName = login) {
|
|
113
|
+
return (await new AccountsService(db).createLocal({ login, password: TEST_PASSWORD, displayName })).id;
|
|
114
|
+
}
|
|
115
|
+
/** Signs in over HTTP and returns the user's request function. */
|
|
116
|
+
export async function login(k, name, password = TEST_PASSWORD) {
|
|
117
|
+
const req = cookieRequest(k.http);
|
|
118
|
+
const res = await req({
|
|
119
|
+
method: "POST",
|
|
120
|
+
url: "/api/v1/auth/login",
|
|
121
|
+
payload: { login: name, password },
|
|
122
|
+
});
|
|
123
|
+
expectStatus(res, 200, `login ${name}`);
|
|
124
|
+
return req;
|
|
125
|
+
}
|
|
126
|
+
/** Creates an org as `req`'s user (who becomes its owner); returns its id. */
|
|
127
|
+
export async function createOrg(req, name = "Acme") {
|
|
128
|
+
const res = await req({ method: "POST", url: "/api/v1/orgs", payload: { name } });
|
|
129
|
+
expectStatus(res, 201, "create org");
|
|
130
|
+
return res.json().org.id;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A new account joins the org through an invitation (created by `owner`, accepted with `login`
|
|
134
|
+
* and {@link TEST_PASSWORD}); returns the account id and its signed-in request function.
|
|
135
|
+
*/
|
|
136
|
+
export async function joinOrg(k, owner, orgId, name, role = "member") {
|
|
137
|
+
const inv = await owner({
|
|
138
|
+
method: "POST",
|
|
139
|
+
url: `/api/v1/orgs/${orgId}/invitations`,
|
|
140
|
+
payload: { role },
|
|
141
|
+
});
|
|
142
|
+
expectStatus(inv, 201, "create invitation");
|
|
143
|
+
const req = cookieRequest(k.http);
|
|
144
|
+
const accepted = await req({
|
|
145
|
+
method: "POST",
|
|
146
|
+
url: `/api/v1/invitations/${inv.json().token}/accept`,
|
|
147
|
+
payload: { login: name, password: TEST_PASSWORD, displayName: name },
|
|
148
|
+
});
|
|
149
|
+
expectStatus(accepted, 200, "accept invitation");
|
|
150
|
+
return { accountId: accepted.json().account.id, req };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Creates an org-visible agent; returns its id. Without `tools` the agent has none (model-fake then
|
|
154
|
+
* answers ordinary messages instead of calling the first tool).
|
|
155
|
+
*/
|
|
156
|
+
export async function createAgent(req, orgId, opts = {}) {
|
|
157
|
+
const res = await req({
|
|
158
|
+
method: "POST",
|
|
159
|
+
url: `/api/v1/orgs/${orgId}/agents`,
|
|
160
|
+
payload: {
|
|
161
|
+
definition: {
|
|
162
|
+
name: "Helper",
|
|
163
|
+
instructions: "Be helpful.",
|
|
164
|
+
model: opts.model ?? { provider: "model-fake", model: "fake-1" },
|
|
165
|
+
tools: opts.tools ?? [],
|
|
166
|
+
},
|
|
167
|
+
visibility: "org",
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
expectStatus(res, 201, "create agent");
|
|
171
|
+
return res.json().agent.id;
|
|
172
|
+
}
|
|
173
|
+
/** Creates a conversation with the agent; returns its id. */
|
|
174
|
+
export async function createConversation(req, orgId, agentId) {
|
|
175
|
+
const res = await req({
|
|
176
|
+
method: "POST",
|
|
177
|
+
url: `/api/v1/orgs/${orgId}/conversations`,
|
|
178
|
+
payload: { agentId },
|
|
179
|
+
});
|
|
180
|
+
expectStatus(res, 201, "create conversation");
|
|
181
|
+
return res.json().conversation.id;
|
|
182
|
+
}
|
|
183
|
+
/** Posts a user message; returns its event seq. */
|
|
184
|
+
export async function postMessage(req, orgId, conversationId, text) {
|
|
185
|
+
const res = await req({
|
|
186
|
+
method: "POST",
|
|
187
|
+
url: `/api/v1/orgs/${orgId}/conversations/${conversationId}/messages`,
|
|
188
|
+
payload: { text },
|
|
189
|
+
});
|
|
190
|
+
expectStatus(res, 202, "post message");
|
|
191
|
+
return res.json().event.seq;
|
|
192
|
+
}
|
|
193
|
+
/** Every event of a conversation, in seq order (read from the database). */
|
|
194
|
+
export async function eventsOf(db, conversationId) {
|
|
195
|
+
const rows = await db
|
|
196
|
+
.selectFrom("conversation_events")
|
|
197
|
+
.select(["conversation_id", "seq", "type", "run_id", "payload", "created_at"])
|
|
198
|
+
.where("conversation_id", "=", conversationId)
|
|
199
|
+
.orderBy("seq")
|
|
200
|
+
.execute();
|
|
201
|
+
return rows.map((r) => ({
|
|
202
|
+
conversationId: r.conversation_id,
|
|
203
|
+
seq: Number(r.seq),
|
|
204
|
+
type: r.type,
|
|
205
|
+
runId: r.run_id,
|
|
206
|
+
payload: r.payload,
|
|
207
|
+
createdAt: r.created_at,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
/** Waits for the first event matching `pred` and returns it. */
|
|
211
|
+
export function waitForEvent(db, conversationId, pred, timeoutMs = 10_000, label = "event") {
|
|
212
|
+
return waitFor(async () => (await eventsOf(db, conversationId)).find(pred), timeoutMs, label);
|
|
213
|
+
}
|
|
214
|
+
/** Waits for the conversation's `n`-th terminal event (run_finished / run_failed / run_aborted). */
|
|
215
|
+
export function waitForTerminal(db, conversationId, n = 1, timeoutMs = 15_000) {
|
|
216
|
+
return waitFor(async () => {
|
|
217
|
+
const t = (await eventsOf(db, conversationId)).filter((e) => ["run_finished", "run_failed", "run_aborted"].includes(e.type));
|
|
218
|
+
return t.length >= n ? t : null;
|
|
219
|
+
}, timeoutMs, `${n} terminal event(s)`);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Waits until an IM card's message id is recorded (`channel_card_messages`). The kernel writes it in
|
|
223
|
+
* the transaction after the card is sent; a click before that is treated as "not a card of this
|
|
224
|
+
* conversation", so a test waits for this before it clicks, not only for the platform to get it.
|
|
225
|
+
*/
|
|
226
|
+
export async function cardRecorded(db, card, timeoutMs = 20_000) {
|
|
227
|
+
await waitFor(async () => {
|
|
228
|
+
const q = db.selectFrom("channel_card_messages").select("card_id");
|
|
229
|
+
const row = await ("messageId" in card
|
|
230
|
+
? q.where("message_id", "=", card.messageId)
|
|
231
|
+
: q.where("card_kind", "=", card.kind)).executeTakeFirst();
|
|
232
|
+
return row !== undefined;
|
|
233
|
+
}, timeoutMs, "card message row");
|
|
234
|
+
}
|
|
235
|
+
/** PG deadlock (40P01) and lock timeout (55P03). */
|
|
236
|
+
const LOCK_CONFLICTS = new Set(["40P01", "55P03"]);
|
|
237
|
+
const TRUNCATE_ATTEMPTS = 8;
|
|
238
|
+
/**
|
|
239
|
+
* `TRUNCATE <tables> CASCADE` between tests, retried on deadlock / lock timeout: a kernel with the
|
|
240
|
+
* worker role keeps scanning in the background (channel supervisor, deliveries, maintenance), and a
|
|
241
|
+
* background join holding one table while waiting for another deadlocks with TRUNCATE's ordered
|
|
242
|
+
* ACCESS EXCLUSIVE locks. TRUNCATE is atomic, so a retry is safe. `tables` is inlined into the SQL:
|
|
243
|
+
* pass only table names written in the test.
|
|
244
|
+
*/
|
|
245
|
+
export async function truncateTables(db, tables) {
|
|
246
|
+
for (let attempt = 1;; attempt++) {
|
|
247
|
+
try {
|
|
248
|
+
await db.transaction().execute(async (t) => {
|
|
249
|
+
await sql `SET LOCAL lock_timeout = '5s'`.execute(t);
|
|
250
|
+
await sql.raw(`TRUNCATE ${tables} CASCADE`).execute(t);
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
const code = typeof e === "object" && e !== null ? (e.code ?? "") : "";
|
|
256
|
+
if (!LOCK_CONFLICTS.has(code) || attempt >= TRUNCATE_ATTEMPTS)
|
|
257
|
+
throw e;
|
|
258
|
+
console.warn(`truncateTables: ${code} on attempt ${attempt}/${TRUNCATE_ATTEMPTS}, retrying`);
|
|
259
|
+
await sleep(50 * attempt + Math.floor(Math.random() * 50));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const run = promisify(execFile);
|
|
264
|
+
/**
|
|
265
|
+
* Finds which of `pids` owns the local TCP port `port` (the client side of a connection). Dual
|
|
266
|
+
* replica tests use it to map a fake platform's long-connection peer port (`socketPeerPorts()`) to
|
|
267
|
+
* a replica process: a real extension connecting to a fake platform carries no pid marker.
|
|
268
|
+
*
|
|
269
|
+
* Linux reads `/proc/net/tcp{,6}` for the socket inode and looks for it under `/proc/<pid>/fd`,
|
|
270
|
+
* falling back to `ss -tnp`, then `lsof`; other platforms (macOS) use `lsof`. Undefined when not
|
|
271
|
+
* found.
|
|
272
|
+
*/
|
|
273
|
+
export async function pidOwningLocalPort(port, pids) {
|
|
274
|
+
if (process.platform === "linux") {
|
|
275
|
+
return (await viaProc(port, pids)) ?? (await viaSs(port, pids)) ?? (await viaLsof(port, pids));
|
|
276
|
+
}
|
|
277
|
+
return viaLsof(port, pids);
|
|
278
|
+
}
|
|
279
|
+
async function viaProc(port, pids) {
|
|
280
|
+
const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
|
|
281
|
+
const inodes = new Set();
|
|
282
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
283
|
+
const text = await readFile(file, "utf8").catch(() => "");
|
|
284
|
+
for (const line of text.split("\n").slice(1)) {
|
|
285
|
+
const cols = line.trim().split(/\s+/);
|
|
286
|
+
const local = cols[1];
|
|
287
|
+
const inode = cols[9];
|
|
288
|
+
if (local?.endsWith(`:${hexPort}`) && inode !== undefined && inode !== "0")
|
|
289
|
+
inodes.add(inode);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (inodes.size === 0)
|
|
293
|
+
return undefined;
|
|
294
|
+
for (const pid of pids) {
|
|
295
|
+
const fds = await readdir(`/proc/${pid}/fd`).catch(() => []);
|
|
296
|
+
for (const fd of fds) {
|
|
297
|
+
const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => "");
|
|
298
|
+
const m = /^socket:\[(\d+)\]$/.exec(target);
|
|
299
|
+
if (m?.[1] !== undefined && inodes.has(m[1]))
|
|
300
|
+
return pid;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
async function viaSs(port, pids) {
|
|
306
|
+
// Lines like `ESTAB 0 0 127.0.0.1:54321 127.0.0.1:8080 users:(("node",pid=1234,fd=20))`
|
|
307
|
+
const out = await run("ss", ["-tnpH", "state", "established"]).then((r) => r.stdout, () => "");
|
|
308
|
+
for (const line of out.split("\n")) {
|
|
309
|
+
const cols = line.trim().split(/\s+/);
|
|
310
|
+
const local = cols.find((c, i) => i < 5 && /:\d+$/.test(c));
|
|
311
|
+
if (!local?.endsWith(`:${port}`))
|
|
312
|
+
continue;
|
|
313
|
+
for (const m of line.matchAll(/pid=(\d+)/g)) {
|
|
314
|
+
const pid = Number(m[1]);
|
|
315
|
+
if (pids.includes(pid))
|
|
316
|
+
return pid;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
async function viaLsof(port, pids) {
|
|
322
|
+
// -a: AND the conditions; -iTCP:<port> matches the local or the peer port, and the peer is the
|
|
323
|
+
// fake platform (this test process, not in pids)
|
|
324
|
+
const args = ["-nP", "-a", "-p", pids.join(","), `-iTCP:${port}`, "-sTCP:ESTABLISHED", "-Fp"];
|
|
325
|
+
const out = await run("lsof", args).then((r) => r.stdout,
|
|
326
|
+
// lsof exits with 1 when nothing matches
|
|
327
|
+
(e) => e.stdout ?? "");
|
|
328
|
+
for (const line of out.split("\n")) {
|
|
329
|
+
if (!line.startsWith("p"))
|
|
330
|
+
continue;
|
|
331
|
+
const pid = Number(line.slice(1));
|
|
332
|
+
if (pids.includes(pid))
|
|
333
|
+
return pid;
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** 纯色 PNG(8 位 RGB),返回 base64。 */
|
|
2
|
+
export declare function solidPngBase64(rgb: [number, number, number], size?: number): string;
|
|
3
|
+
/** 64×64 纯红 PNG 的 base64。 */
|
|
4
|
+
export declare const redPngBase64: () => string;
|
|
5
|
+
/** 一页 PDF,页面上只印 `word`(大写字母与数字),返回 base64。 */
|
|
6
|
+
export declare function pdfBase64(word: string): string;
|
|
7
|
+
/** 夹具用:base64 是否为 PNG(看文件头)。 */
|
|
8
|
+
export declare function isPngBase64(data: unknown): boolean;
|
|
9
|
+
/** 夹具用:从 {@link pdfBase64} 生成的 PDF 里读出印的词;不是这样的 PDF 时返回 null。 */
|
|
10
|
+
export declare function pdfSampleWord(data: unknown): string | null;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { crc32, deflateSync } from "node:zlib";
|
|
2
|
+
/**
|
|
3
|
+
* 一致性套件与夹具用的媒体样本:运行时生成,不在仓库里放二进制文件。
|
|
4
|
+
* - {@link redPngBase64}:64×64 纯红 PNG(真实模型能稳定答出颜色);
|
|
5
|
+
* - {@link pdfBase64}:一页、只印一个单词的最小 PDF(Helvetica,偏移表按实际字节计算)。
|
|
6
|
+
*/
|
|
7
|
+
function pngChunk(type, data) {
|
|
8
|
+
const len = Buffer.alloc(4);
|
|
9
|
+
len.writeUInt32BE(data.length);
|
|
10
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
11
|
+
const crc = Buffer.alloc(4);
|
|
12
|
+
crc.writeUInt32BE(crc32(body) >>> 0);
|
|
13
|
+
return Buffer.concat([len, body, crc]);
|
|
14
|
+
}
|
|
15
|
+
/** 纯色 PNG(8 位 RGB),返回 base64。 */
|
|
16
|
+
export function solidPngBase64(rgb, size = 64) {
|
|
17
|
+
const ihdr = Buffer.alloc(13);
|
|
18
|
+
ihdr.writeUInt32BE(size, 0);
|
|
19
|
+
ihdr.writeUInt32BE(size, 4);
|
|
20
|
+
ihdr[8] = 8; // 位深
|
|
21
|
+
ihdr[9] = 2; // 真彩色
|
|
22
|
+
const row = 1 + size * 3;
|
|
23
|
+
const raw = Buffer.alloc(size * row);
|
|
24
|
+
for (let y = 0; y < size; y++) {
|
|
25
|
+
for (let x = 0; x < size; x++)
|
|
26
|
+
raw.set(rgb, y * row + 1 + x * 3);
|
|
27
|
+
}
|
|
28
|
+
return Buffer.concat([
|
|
29
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
30
|
+
pngChunk("IHDR", ihdr),
|
|
31
|
+
pngChunk("IDAT", deflateSync(raw)),
|
|
32
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
33
|
+
]).toString("base64");
|
|
34
|
+
}
|
|
35
|
+
/** 64×64 纯红 PNG 的 base64。 */
|
|
36
|
+
export const redPngBase64 = () => solidPngBase64([255, 0, 0]);
|
|
37
|
+
/** 一页 PDF,页面上只印 `word`(大写字母与数字),返回 base64。 */
|
|
38
|
+
export function pdfBase64(word) {
|
|
39
|
+
if (!/^[A-Z0-9]+$/.test(word))
|
|
40
|
+
throw new Error("pdfBase64: word must be [A-Z0-9]+");
|
|
41
|
+
const stream = `BT /F1 36 Tf 72 700 Td (${word}) Tj ET`;
|
|
42
|
+
const objects = [
|
|
43
|
+
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
44
|
+
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
45
|
+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " +
|
|
46
|
+
"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
|
47
|
+
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
48
|
+
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
|
49
|
+
];
|
|
50
|
+
let pdf = "%PDF-1.4\n";
|
|
51
|
+
const offsets = [];
|
|
52
|
+
objects.forEach((o, i) => {
|
|
53
|
+
offsets.push(pdf.length);
|
|
54
|
+
pdf += `${i + 1} 0 obj\n${o}\nendobj\n`;
|
|
55
|
+
});
|
|
56
|
+
const xref = pdf.length;
|
|
57
|
+
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
58
|
+
pdf += offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
|
|
59
|
+
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
60
|
+
return Buffer.from(pdf, "latin1").toString("base64");
|
|
61
|
+
}
|
|
62
|
+
/** 夹具用:base64 是否为 PNG(看文件头)。 */
|
|
63
|
+
export function isPngBase64(data) {
|
|
64
|
+
if (typeof data !== "string")
|
|
65
|
+
return false;
|
|
66
|
+
return Buffer.from(data, "base64").subarray(0, 4).toString("latin1") === "\x89PNG";
|
|
67
|
+
}
|
|
68
|
+
/** 夹具用:从 {@link pdfBase64} 生成的 PDF 里读出印的词;不是这样的 PDF 时返回 null。 */
|
|
69
|
+
export function pdfSampleWord(data) {
|
|
70
|
+
if (typeof data !== "string")
|
|
71
|
+
return null;
|
|
72
|
+
const pdf = Buffer.from(data, "base64").toString("latin1");
|
|
73
|
+
if (!pdf.startsWith("%PDF-"))
|
|
74
|
+
return null;
|
|
75
|
+
return /\(([A-Z0-9]+)\) Tj/.exec(pdf)?.[1] ?? null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type ModelInfo, type ModelParameter, type ModelProvider, type ModelProviderV1, type ParamValue } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
interface TargetCommon {
|
|
3
|
+
model: string;
|
|
4
|
+
expect: {
|
|
5
|
+
thinking: boolean;
|
|
6
|
+
caching: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** caching 用例的稳定前缀大小(约 token 数),默认 5000。 */
|
|
9
|
+
cachePrefixTokens?: number;
|
|
10
|
+
teardown(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 被测供应商:`contract` 缺省为 1(`model-provider@1`);`@2` 插件须给 `contract: 2` 与插件名
|
|
14
|
+
* (用于校验厂商专有参数的键名前缀)。
|
|
15
|
+
*/
|
|
16
|
+
export type ConformanceTarget = TargetCommon & ({
|
|
17
|
+
contract?: 1;
|
|
18
|
+
provider: ModelProviderV1;
|
|
19
|
+
} | {
|
|
20
|
+
contract: 2;
|
|
21
|
+
plugin: string;
|
|
22
|
+
provider: ModelProvider;
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* `@2` 参数声明检查:每个模型的 `parameters` 须通过 `assertValidDeclarations`,
|
|
26
|
+
* 每个声明的默认值须通过 `validateParams`。返回问题列表(形如 `<模型 id>: <原因>`),无问题为空。
|
|
27
|
+
*/
|
|
28
|
+
export declare function checkDeclaredParameters(plugin: string, models: ModelInfo[]): string[];
|
|
29
|
+
/**
|
|
30
|
+
* thinking replay 用例的 `@2` 参数:按模型的声明取最高一档 effort、thinking 取 `summarized`(否则 `on`),
|
|
31
|
+
* 未声明的不设置;其余已声明参数补默认值。目的是让思考尽量稳定出现(adaptive 思考在简单问题上会跳过思考)。
|
|
32
|
+
*/
|
|
33
|
+
export declare function thinkingReplayParams(decl: ModelParameter[]): Record<string, ParamValue>;
|
|
34
|
+
/**
|
|
35
|
+
* 媒体用例的样本与期望答案。夹具服务按原生格式认出这些样本后回答对应的词,所以夹具模式也能证明
|
|
36
|
+
* 插件把媒体块映射成了供应商认得的形状。
|
|
37
|
+
*/
|
|
38
|
+
export declare const MEDIA_SAMPLES: {
|
|
39
|
+
/** 64×64 纯红 PNG;期望回答含 "red"。 */
|
|
40
|
+
readonly image: {
|
|
41
|
+
readonly mediaType: "image/png";
|
|
42
|
+
readonly name: "red.png";
|
|
43
|
+
readonly answer: "red";
|
|
44
|
+
};
|
|
45
|
+
/** 一页 PDF,只印这个词。 */
|
|
46
|
+
readonly pdf: {
|
|
47
|
+
readonly mediaType: "application/pdf";
|
|
48
|
+
readonly name: "fruit.pdf";
|
|
49
|
+
readonly word: "PINEAPPLE";
|
|
50
|
+
};
|
|
51
|
+
/** 纯文本文档,内容是 {@link MEDIA_SAMPLES.text.content}。 */
|
|
52
|
+
readonly text: {
|
|
53
|
+
readonly mediaType: "text/plain";
|
|
54
|
+
readonly name: "note.txt";
|
|
55
|
+
readonly content: "The secret word is MANGO.\n";
|
|
56
|
+
readonly word: "MANGO";
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** model-provider 一致性套件(`@1` 与 `@2`):每个供应商插件都应通过。 */
|
|
60
|
+
export declare function describeModelProviderConformance(name: string, setup: () => Promise<ConformanceTarget>): void;
|
|
61
|
+
export {};
|