@capybara-hq/testing 0.1.0-rc.2 → 0.1.0-rc.4
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/dist/dual-replica.d.ts +4 -1
- package/dist/fake-channel.d.ts +10 -1
- package/dist/fake-channel.js +9 -2
- package/dist/fixtures.d.ts +3 -1
- package/dist/fixtures.js +5 -0
- package/dist/index.d.ts +3 -3
- package/dist/kernel-helpers.d.ts +34 -13
- package/dist/kernel-helpers.js +38 -10
- package/dist/replica-runner.js +16 -8
- package/fixtures/extensions/fake-config-form/.capybara-plugin/plugin.json +11 -0
- package/fixtures/extensions/fake-config-form/package.json +12 -0
- package/fixtures/extensions/fake-config-form/src/index.ts +205 -0
- package/fixtures/extensions/fake-storage-user/src/index.ts +1 -1
- package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +1 -1
- package/fixtures/extensions/fake-tools-ui/src/index.ts +1 -1
- package/fixtures/extensions/fake-web/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-web/package.json +12 -0
- package/fixtures/extensions/fake-web/src/index.ts +22 -0
- package/fixtures/extensions/fake-web/src/web.ts +183 -0
- package/fixtures/extensions/fake-web-2/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-web-2/package.json +12 -0
- package/fixtures/extensions/fake-web-2/src/index.ts +16 -0
- package/fixtures/extensions/fake-web-2/src/web.ts +183 -0
- package/package.json +5 -5
package/dist/dual-replica.d.ts
CHANGED
|
@@ -8,7 +8,10 @@ export interface DualReplicaOptions {
|
|
|
8
8
|
* this package is installed from npm; nothing needs to be set in `NODE_OPTIONS`.
|
|
9
9
|
*/
|
|
10
10
|
extensions: string[];
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Where extensions named by package resolve from; default the caller's `process.cwd()` (the
|
|
13
|
+
* `resolveFrom` of `createKernelApp`).
|
|
14
|
+
*/
|
|
12
15
|
resolveFrom?: string;
|
|
13
16
|
/** 部署级插件配置,按插件名索引。 */
|
|
14
17
|
extensionConfig?: Record<string, unknown>;
|
package/dist/fake-channel.d.ts
CHANGED
|
@@ -71,6 +71,12 @@ export interface FakeDirectoryBook {
|
|
|
71
71
|
/** 整轮同步开始的次数(不带游标的请求数)。 */
|
|
72
72
|
starts(): number;
|
|
73
73
|
}
|
|
74
|
+
/** Which `/send` or `/update` requests a {@link FakeChannelServer.failNext} failure applies to. */
|
|
75
|
+
export interface FakeChannelFailMatch {
|
|
76
|
+
chatId?: string;
|
|
77
|
+
/** The message's text contains it. */
|
|
78
|
+
textIncludes?: string;
|
|
79
|
+
}
|
|
74
80
|
/** 插件工具经 `POST /posts` 发出的一条记录。 */
|
|
75
81
|
export interface FakeChannelPost {
|
|
76
82
|
body: Record<string, unknown>;
|
|
@@ -101,8 +107,11 @@ export interface FakeChannelServer {
|
|
|
101
107
|
* 让接下来 `count` 次 `/send`(或 `/update`)返回该状态码(不记录);`retryAfterMs` 与 `reason` 放进响应体,
|
|
102
108
|
* 测试插件据此抛 `ChannelSendError`(429 / 5xx 可重试,带等待时长;其他不可重试,带失败原因)。
|
|
103
109
|
* `delayMs`:失败响应推迟这么久才返回(模拟慢的失败请求,让会话的后续事件在投递任务持锁期间到达)。
|
|
110
|
+
* `match`: only matching requests count and fail; a string is a chat id, an object also matches
|
|
111
|
+
* text (`textIncludes`: the message's text contains it). Without it the next requests to any chat
|
|
112
|
+
* fail, so a late delivery from an earlier test can take the failure meant for the request under test.
|
|
104
113
|
*/
|
|
105
|
-
failNext(path: "/send" | "/update", status: number, count?: number, retryAfterMs?: number, reason?: "auth" | "target_gone" | "rejected" | "too_large", delayMs?: number): void;
|
|
114
|
+
failNext(path: "/send" | "/update", status: number, count?: number, retryAfterMs?: number, reason?: "auth" | "target_gone" | "rejected" | "too_large", delayMs?: number, match?: string | FakeChannelFailMatch): void;
|
|
106
115
|
/**
|
|
107
116
|
* Delay the response of the next `count` successful `/send` (or `/update`) requests by `delayMs`.
|
|
108
117
|
* The message is recorded (visible in `sent` / `updated`) as soon as the request arrives; only the
|
package/dist/fake-channel.js
CHANGED
|
@@ -61,7 +61,11 @@ export async function startFakeChannelServer(opts = {}) {
|
|
|
61
61
|
};
|
|
62
62
|
const sendOrUpdate = async (req, res, path) => {
|
|
63
63
|
const body = await readBody(req);
|
|
64
|
-
const
|
|
64
|
+
const parsed = JSON.parse(body);
|
|
65
|
+
const matches = (m) => m === undefined ||
|
|
66
|
+
((m.chatId === undefined || m.chatId === parsed.target?.chatId) &&
|
|
67
|
+
(m.textIncludes === undefined || (parsed.message?.text ?? "").includes(m.textIncludes)));
|
|
68
|
+
const f = failures.find((x) => x.path === path && x.left > 0 && matches(x.match));
|
|
65
69
|
if (f) {
|
|
66
70
|
f.left--;
|
|
67
71
|
if (f.delayMs !== undefined)
|
|
@@ -203,7 +207,7 @@ export async function startFakeChannelServer(opts = {}) {
|
|
|
203
207
|
duplicateSends,
|
|
204
208
|
posts,
|
|
205
209
|
users,
|
|
206
|
-
failNext(path, status, count = 1, retryAfterMs, reason, delayMs) {
|
|
210
|
+
failNext(path, status, count = 1, retryAfterMs, reason, delayMs, match) {
|
|
207
211
|
failures.push({
|
|
208
212
|
path,
|
|
209
213
|
status,
|
|
@@ -211,6 +215,9 @@ export async function startFakeChannelServer(opts = {}) {
|
|
|
211
215
|
...(retryAfterMs !== undefined && { retryAfterMs }),
|
|
212
216
|
...(reason !== undefined && { reason }),
|
|
213
217
|
...(delayMs !== undefined && { delayMs }),
|
|
218
|
+
...(match !== undefined && {
|
|
219
|
+
match: typeof match === "string" ? { chatId: match } : match,
|
|
220
|
+
}),
|
|
214
221
|
});
|
|
215
222
|
},
|
|
216
223
|
delayNext(path, delayMs, opts = {}) {
|
package/dist/fixtures.d.ts
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* | `fake-bootstrap-b` | `object-store@1` kept in its own PG table; `bucket` + secret `secretKey` |
|
|
18
18
|
* | `fake-dep-provider` / `fake-dep-requirer` | a dependency pair on `counter@1` (the requirer's plugin.json depends on the provider) |
|
|
19
19
|
* | `fake-im` | a whole IM platform for `startFakeChannelServer`: `channel@1` (cards, edits, threads, webhook inbound), `identity-source@1`, `directory@1` (org config `url` of a directory book) and tool `post_announcement` (default policy ask, posts to the platform); deployment config `platformUrl`, `tenant`, `clientId`, secret `clientSecret`. Its `src/index.ts` also exports `makeFakeImChannel`, `fakeImIdentitySource`, `fakeImDirectory` for test extensions that need one part |
|
|
20
|
+
* | `fake-web` / `fake-web-2` | `web-search@1` and `web-fetch@1`, scripted by a prefix of the query or of the fetched URL's path (`error:<kind>`, `throw:`, `hang:`, `redirect:<url>`, `junk:`, `empty:`, `big:<n>`; see `makeFakeWeb`); results name the provider; org config secret `token`, `configured()` iff it is set (throws for `throw:configured`); calls recorded on `globalThis.__fakeWeb` (`fakeWebProbe`) |
|
|
21
|
+
* | `fake-config-form` | display name 表单样例 / Form sample; no extension point: an openai-compatible-shaped deployment config (endpoints with a preset menu, an API key per endpoint in the secret record `apiKeys`, model rows, advanced fields, localized custom issues) carrying the config form hints of the extension config forms spec §6, for the web form e2e |
|
|
20
22
|
*/
|
|
21
|
-
export declare const FAKE_EXTENSIONS: readonly ["fake-connector", "fake-openapi", "fake-object-store", "fake-tools-ui", "fake-storage-user", "fake-embed", "fake-bootstrap-a", "fake-bootstrap-b", "fake-dep-provider", "fake-dep-requirer", "fake-im"];
|
|
23
|
+
export declare const FAKE_EXTENSIONS: readonly ["fake-connector", "fake-openapi", "fake-object-store", "fake-tools-ui", "fake-storage-user", "fake-embed", "fake-bootstrap-a", "fake-bootstrap-b", "fake-dep-provider", "fake-dep-requirer", "fake-im", "fake-web", "fake-web-2", "fake-config-form"];
|
|
22
24
|
/** The name of a fake extension (also its plugin and extension name). */
|
|
23
25
|
export type FakeExtensionName = (typeof FAKE_EXTENSIONS)[number];
|
|
24
26
|
/** The plugin directory of a fake (pack layout), for `packFixture` or a packer of your own. */
|
package/dist/fixtures.js
CHANGED
|
@@ -20,6 +20,8 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
* | `fake-bootstrap-b` | `object-store@1` kept in its own PG table; `bucket` + secret `secretKey` |
|
|
21
21
|
* | `fake-dep-provider` / `fake-dep-requirer` | a dependency pair on `counter@1` (the requirer's plugin.json depends on the provider) |
|
|
22
22
|
* | `fake-im` | a whole IM platform for `startFakeChannelServer`: `channel@1` (cards, edits, threads, webhook inbound), `identity-source@1`, `directory@1` (org config `url` of a directory book) and tool `post_announcement` (default policy ask, posts to the platform); deployment config `platformUrl`, `tenant`, `clientId`, secret `clientSecret`. Its `src/index.ts` also exports `makeFakeImChannel`, `fakeImIdentitySource`, `fakeImDirectory` for test extensions that need one part |
|
|
23
|
+
* | `fake-web` / `fake-web-2` | `web-search@1` and `web-fetch@1`, scripted by a prefix of the query or of the fetched URL's path (`error:<kind>`, `throw:`, `hang:`, `redirect:<url>`, `junk:`, `empty:`, `big:<n>`; see `makeFakeWeb`); results name the provider; org config secret `token`, `configured()` iff it is set (throws for `throw:configured`); calls recorded on `globalThis.__fakeWeb` (`fakeWebProbe`) |
|
|
24
|
+
* | `fake-config-form` | display name 表单样例 / Form sample; no extension point: an openai-compatible-shaped deployment config (endpoints with a preset menu, an API key per endpoint in the secret record `apiKeys`, model rows, advanced fields, localized custom issues) carrying the config form hints of the extension config forms spec §6, for the web form e2e |
|
|
23
25
|
*/
|
|
24
26
|
export const FAKE_EXTENSIONS = [
|
|
25
27
|
"fake-connector",
|
|
@@ -33,6 +35,9 @@ export const FAKE_EXTENSIONS = [
|
|
|
33
35
|
"fake-dep-provider",
|
|
34
36
|
"fake-dep-requirer",
|
|
35
37
|
"fake-im",
|
|
38
|
+
"fake-web",
|
|
39
|
+
"fake-web-2",
|
|
40
|
+
"fake-config-form",
|
|
36
41
|
];
|
|
37
42
|
/** `fixtures/extensions/` of this package (same path from `src/` and from `dist/`). */
|
|
38
43
|
const FIXTURES_DIR = fileURLToPath(new URL("../fixtures/extensions/", import.meta.url));
|
package/dist/index.d.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* package and the SDK: the database helpers and types, and model-visible texts, queue names and
|
|
4
4
|
* keys the kernel owns (assert on them instead of copying their values).
|
|
5
5
|
*/
|
|
6
|
-
export { type ConversationEvent, createDb, gatewayReplicasKey, type
|
|
6
|
+
export { type ConversationEvent, createDb, gatewayReplicasKey, type KernelConfig, type KernelSchemaDb, kernelDb, MAINTENANCE_QUEUE_NAME, runJobId, SANDBOX_LOCK_TTL_MS, SANDBOX_RECREATED_NOTICE, SANDBOX_STARTED_NOTICE, SANDBOX_SUSPEND_JOB, UNKNOWN_RESULT_TEXT, } from "@capybara-hq/kernel";
|
|
7
7
|
export { createFreshDatabase, startTestPostgres, startTestRedis, startTestS3, TEST_S3_IMAGE, type TestContainer, type TestS3, } from "./containers.js";
|
|
8
8
|
export { createDualReplicaHarness, type DualReplicaOptions, type ExtraReplicaHandle, type Harness, REPLICA_HEADER, type ReplicaHandle, type ReplicaResponse, replicaOf, } from "./dual-replica.js";
|
|
9
9
|
export { assertExtensionSecretsReachable, extensionDeploymentSecretValues, } from "./extension-config.js";
|
|
10
|
-
export { FAKE_CHANNEL_BAD_SECRET, FAKE_WEBHOOK_SIGNATURE_HEADER, type FakeChannelPost, type FakeChannelSent, type FakeChannelServer, type FakeChannelServerOptions, type FakeChannelStream, type FakeChannelUser, type FakeDirectoryBook, type FakeSenderProfile, fakeCardAction, fakeDirectoryMember, fakeDm, fakeGroupMessage, fakeWebhookChallenge, fakeWebhookEvents, signFakeWebhook, startFakeChannelServer, } from "./fake-channel.js";
|
|
10
|
+
export { FAKE_CHANNEL_BAD_SECRET, FAKE_WEBHOOK_SIGNATURE_HEADER, type FakeChannelFailMatch, type FakeChannelPost, type FakeChannelSent, type FakeChannelServer, type FakeChannelServerOptions, type FakeChannelStream, type FakeChannelUser, type FakeDirectoryBook, type FakeSenderProfile, fakeCardAction, fakeDirectoryMember, fakeDm, fakeGroupMessage, fakeWebhookChallenge, fakeWebhookEvents, signFakeWebhook, startFakeChannelServer, } from "./fake-channel.js";
|
|
11
11
|
export { type FakeE2BCall, type FakeE2BMatch, type FakeE2BOptions, type FakeE2BSandbox, type FakeE2BServer, startFakeE2B, } from "./fake-e2b.js";
|
|
12
12
|
export { type FakeGitCommitOptions, type FakeGitRequest, type FakeGitServer, startFakeGitServer, } from "./fake-git-server.js";
|
|
13
13
|
export { encryptLarkWebhook, type FakeLarkAck, type FakeLarkApi, type FakeLarkButton, type FakeLarkCall, type FakeLarkDepartment, type FakeLarkFailure, type FakeLarkMessage, type FakeLarkOptions, type FakeLarkServer, type FakeLarkUser, findLarkButton, larkCardAction, larkMessageEvent, signLarkWebhook, startFakeLark, } from "./fake-lark.js";
|
|
@@ -19,7 +19,7 @@ export { type FakeOAuthClient, type FakeOAuthServer, type FakeOAuthServerOptions
|
|
|
19
19
|
export { defaultFakeOpenApiSpec, type FakeOpenApiRequest, type FakeOpenApiServer, type FakeOpenApiServerOptions, type FakePet, startFakeOpenApiServer, } from "./fake-openapi-server.js";
|
|
20
20
|
export { type FakeSlackAck, type FakeSlackCall, type FakeSlackFailure, type FakeSlackMessage, type FakeSlackOptions, type FakeSlackServer, type FakeSlackUser, findSlackButton, signSlackWebhook, slackBlockAction, slackDmEvent, slackEventCallback, slackInteractiveForm, slackMentionEvent, startFakeSlack, } from "./fake-slack.js";
|
|
21
21
|
export { FAKE_EXTENSIONS, type FakeExtensionName, fakeExtensionDir, fakeExtensionEntry, type PackedFixture, packFixture, } from "./fixtures.js";
|
|
22
|
-
export { cardRecorded, cookieRequest, createAgent, createConversation, createLocalAccount, createOrg, eventsOf, joinOrg, type KernelRequest, type KernelRequestOptions, type KernelResponse, login, pidOwningLocalPort, postMessage, sleep, startTestKernel, TEST_PASSWORD, testKernelConfig, truncateTables, waitFor, waitForEvent, waitForTerminal, } from "./kernel-helpers.js";
|
|
22
|
+
export { cardRecorded, cookieRequest, createAgent, createConversation, createLocalAccount, createOrg, eventsOf, joinOrg, type KernelApp, type KernelRequest, type KernelRequestOptions, type KernelResponse, login, pidOwningLocalPort, postMessage, sleep, startTestKernel, TEST_PASSWORD, type TestKernel, testKernelConfig, truncateTables, waitFor, waitForEvent, waitForTerminal, } from "./kernel-helpers.js";
|
|
23
23
|
export { isPngBase64, pdfBase64, pdfSampleWord, redPngBase64, solidPngBase64, } from "./media-samples.js";
|
|
24
24
|
export { type ConformanceTarget, checkDeclaredParameters, describeModelProviderConformance, MEDIA_SAMPLES, thinkingReplayParams, } from "./model-conformance.js";
|
|
25
25
|
export { openSse, type SseClient, type SseMessage } from "./sse.js";
|
package/dist/kernel-helpers.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ConversationEvent, type
|
|
1
|
+
import { type ConversationEvent, type KernelConfig, type KernelHttp, type KernelSchemaDb, type LogLevel, startKernelApp } from "@capybara-hq/kernel";
|
|
2
2
|
import { type Kysely } from "kysely";
|
|
3
3
|
/**
|
|
4
4
|
* Helpers for extension tests that run a real kernel: in process (`startTestKernel` +
|
|
@@ -33,11 +33,32 @@ export declare function testKernelConfig(urls: {
|
|
|
33
33
|
* it before creating anything.
|
|
34
34
|
*/
|
|
35
35
|
export declare function assertOfflineOfficialMarketplace(config: KernelConfig, allow?: boolean): void;
|
|
36
|
+
/** The kernel's Nest application (what `createKernelApp` / `startKernelApp` return). */
|
|
37
|
+
export type KernelApp = Awaited<ReturnType<typeof startKernelApp>>;
|
|
38
|
+
/** A running in-process kernel ({@link startTestKernel}). */
|
|
39
|
+
export interface TestKernel {
|
|
40
|
+
/**
|
|
41
|
+
* The kernel's Nest application. Core's own tests reach a kernel service with `app.get(Class)`;
|
|
42
|
+
* a plugin test cannot import kernel classes (`plugin-test-imports`), so it drives the kernel
|
|
43
|
+
* through `http` and the helpers of this package.
|
|
44
|
+
*/
|
|
45
|
+
readonly app: KernelApp;
|
|
46
|
+
/** In-process HTTP: `inject` requests, list the routes. */
|
|
47
|
+
readonly http: KernelHttp;
|
|
48
|
+
/**
|
|
49
|
+
* The port the kernel listens on (an ephemeral one with `http.port: 0`); undefined after
|
|
50
|
+
* `close()`.
|
|
51
|
+
*/
|
|
52
|
+
readonly port: number | undefined;
|
|
53
|
+
/** Stops the kernel (the stop phases within their budgets, then Redis and the pool); idempotent. */
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
}
|
|
36
56
|
/**
|
|
37
|
-
* Creates and starts an in-process kernel
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
57
|
+
* Creates and starts an in-process kernel (`startKernelApp`; it listens on `config.http.port`).
|
|
58
|
+
* The kernel gets no process-wide signal handlers: a test stops it with `close()`. Logs are
|
|
59
|
+
* discarded unless `log` is given (JSON lines at `log.level` and above go to `log.write`, e.g. to
|
|
60
|
+
* assert a secret never reaches the log). Extensions named by package resolve from `resolveFrom`
|
|
61
|
+
* (default `process.cwd()`).
|
|
41
62
|
*/
|
|
42
63
|
export declare function startTestKernel(config: KernelConfig, opts?: {
|
|
43
64
|
resolveFrom?: string;
|
|
@@ -47,7 +68,7 @@ export declare function startTestKernel(config: KernelConfig, opts?: {
|
|
|
47
68
|
};
|
|
48
69
|
/** Only for a test about the real default: allow the official marketplace at its real URL. */
|
|
49
70
|
allowOfficialMarketplace?: boolean;
|
|
50
|
-
}): Promise<
|
|
71
|
+
}): Promise<TestKernel>;
|
|
51
72
|
/** Options of a {@link KernelRequest} call (`http.inject` options; `csrf: false` drops the header). */
|
|
52
73
|
export type KernelRequestOptions = Parameters<KernelHttp["inject"]>[0] & {
|
|
53
74
|
csrf?: boolean;
|
|
@@ -61,16 +82,16 @@ export type KernelRequest = (opts: KernelRequestOptions) => Promise<KernelRespon
|
|
|
61
82
|
/** A request function with its own cookie jar (signed out until a response sets cookies). */
|
|
62
83
|
export declare function cookieRequest(http: KernelHttp): KernelRequest;
|
|
63
84
|
/** Creates a local account with {@link TEST_PASSWORD}; returns its id. */
|
|
64
|
-
export declare function createLocalAccount(db:
|
|
85
|
+
export declare function createLocalAccount(db: KernelSchemaDb, login: string, displayName?: string): Promise<string>;
|
|
65
86
|
/** Signs in over HTTP and returns the user's request function. */
|
|
66
|
-
export declare function login(k: Pick<
|
|
87
|
+
export declare function login(k: Pick<TestKernel, "http">, name: string, password?: string): Promise<KernelRequest>;
|
|
67
88
|
/** Creates an org as `req`'s user (who becomes its owner); returns its id. */
|
|
68
89
|
export declare function createOrg(req: KernelRequest, name?: string): Promise<string>;
|
|
69
90
|
/**
|
|
70
91
|
* A new account joins the org through an invitation (created by `owner`, accepted with `login`
|
|
71
92
|
* and {@link TEST_PASSWORD}); returns the account id and its signed-in request function.
|
|
72
93
|
*/
|
|
73
|
-
export declare function joinOrg(k: Pick<
|
|
94
|
+
export declare function joinOrg(k: Pick<TestKernel, "http">, owner: KernelRequest, orgId: string, name: string, role?: "member" | "admin"): Promise<{
|
|
74
95
|
accountId: string;
|
|
75
96
|
req: KernelRequest;
|
|
76
97
|
}>;
|
|
@@ -90,17 +111,17 @@ export declare function createConversation(req: KernelRequest, orgId: string, ag
|
|
|
90
111
|
/** Posts a user message; returns its event seq. */
|
|
91
112
|
export declare function postMessage(req: KernelRequest, orgId: string, conversationId: string, text: string): Promise<number>;
|
|
92
113
|
/** Every event of a conversation, in seq order (read from the database). */
|
|
93
|
-
export declare function eventsOf(db:
|
|
114
|
+
export declare function eventsOf(db: KernelSchemaDb, conversationId: string): Promise<ConversationEvent[]>;
|
|
94
115
|
/** Waits for the first event matching `pred` and returns it. */
|
|
95
|
-
export declare function waitForEvent(db:
|
|
116
|
+
export declare function waitForEvent(db: KernelSchemaDb, conversationId: string, pred: (e: ConversationEvent) => boolean, timeoutMs?: number, label?: string): Promise<ConversationEvent>;
|
|
96
117
|
/** Waits for the conversation's `n`-th terminal event (run_finished / run_failed / run_aborted). */
|
|
97
|
-
export declare function waitForTerminal(db:
|
|
118
|
+
export declare function waitForTerminal(db: KernelSchemaDb, conversationId: string, n?: number, timeoutMs?: number): Promise<ConversationEvent[]>;
|
|
98
119
|
/**
|
|
99
120
|
* Waits until an IM card's message id is recorded (`channel_card_messages`). The kernel writes it in
|
|
100
121
|
* the transaction after the card is sent; a click before that is treated as "not a card of this
|
|
101
122
|
* conversation", so a test waits for this before it clicks, not only for the platform to get it.
|
|
102
123
|
*/
|
|
103
|
-
export declare function cardRecorded(db:
|
|
124
|
+
export declare function cardRecorded(db: KernelSchemaDb, card: {
|
|
104
125
|
messageId: string;
|
|
105
126
|
} | {
|
|
106
127
|
kind: "approval" | "question";
|
package/dist/kernel-helpers.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
2
3
|
import { readdir, readFile, readlink } from "node:fs/promises";
|
|
3
4
|
import { promisify } from "node:util";
|
|
4
|
-
import { AccountsService,
|
|
5
|
+
import { AccountsModule, AccountsService, closeKernelApp, createLogger, KernelConfigSchema, kernelAppHttp, kernelAppPort, MARKETPLACE_OFFICIAL_URL_DEFAULT, openKernelCliOverPool, startKernelApp, } from "@capybara-hq/kernel";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
/**
|
|
7
8
|
* Helpers for extension tests that run a real kernel: in process (`startTestKernel` +
|
|
@@ -60,20 +61,33 @@ export function assertOfflineOfficialMarketplace(config, allow = false) {
|
|
|
60
61
|
"marketplaces.official.url at a fake, or build the config with testKernelConfig");
|
|
61
62
|
}
|
|
62
63
|
/**
|
|
63
|
-
* Creates and starts an in-process kernel
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `
|
|
64
|
+
* Creates and starts an in-process kernel (`startKernelApp`; it listens on `config.http.port`).
|
|
65
|
+
* The kernel gets no process-wide signal handlers: a test stops it with `close()`. Logs are
|
|
66
|
+
* discarded unless `log` is given (JSON lines at `log.level` and above go to `log.write`, e.g. to
|
|
67
|
+
* assert a secret never reaches the log). Extensions named by package resolve from `resolveFrom`
|
|
68
|
+
* (default `process.cwd()`).
|
|
67
69
|
*/
|
|
68
70
|
export async function startTestKernel(config, opts = {}) {
|
|
69
71
|
assertOfflineOfficialMarketplace(config, opts.allowOfficialMarketplace);
|
|
70
|
-
const
|
|
72
|
+
const app = await startKernelApp(config, {
|
|
71
73
|
env: {},
|
|
72
74
|
logger: createLogger(opts.log?.level ?? "error", { write: opts.log?.write ?? (() => { }) }),
|
|
73
75
|
...(opts.resolveFrom !== undefined && { resolveFrom: opts.resolveFrom }),
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
},
|
|
77
|
+
// A signal source of its own: a test kernel never handles the process's SIGTERM / SIGINT
|
|
78
|
+
{ signals: new EventEmitter() });
|
|
79
|
+
return testKernelOf(app);
|
|
80
|
+
}
|
|
81
|
+
/** The {@link TestKernel} view of a started kernel application. */
|
|
82
|
+
function testKernelOf(app) {
|
|
83
|
+
return {
|
|
84
|
+
app,
|
|
85
|
+
http: kernelAppHttp(app),
|
|
86
|
+
get port() {
|
|
87
|
+
return kernelAppPort(app);
|
|
88
|
+
},
|
|
89
|
+
close: () => closeKernelApp(app),
|
|
90
|
+
};
|
|
77
91
|
}
|
|
78
92
|
/** A request function with its own cookie jar (signed out until a response sets cookies). */
|
|
79
93
|
export function cookieRequest(http) {
|
|
@@ -110,7 +124,21 @@ function expectStatus(res, status, what) {
|
|
|
110
124
|
}
|
|
111
125
|
/** Creates a local account with {@link TEST_PASSWORD}; returns its id. */
|
|
112
126
|
export async function createLocalAccount(db, login, displayName = login) {
|
|
113
|
-
|
|
127
|
+
// the accounts module over the caller's Kysely; closing the context leaves it open
|
|
128
|
+
const app = await openKernelCliOverPool(db, {
|
|
129
|
+
logger: createLogger("error"),
|
|
130
|
+
modules: [AccountsModule],
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
return (await app.get(AccountsService).createLocal({
|
|
134
|
+
login,
|
|
135
|
+
password: TEST_PASSWORD,
|
|
136
|
+
displayName,
|
|
137
|
+
})).id;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await app.close();
|
|
141
|
+
}
|
|
114
142
|
}
|
|
115
143
|
/** Signs in over HTTP and returns the user's request function. */
|
|
116
144
|
export async function login(k, name, password = TEST_PASSWORD) {
|
package/dist/replica-runner.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { KernelConfigSchema, kernelAppPort, startKernelApp } from "@capybara-hq/kernel";
|
|
3
|
+
/**
|
|
4
|
+
* The replica's signal source: the stop message (or the parent going away) raises SIGTERM on it, so
|
|
5
|
+
* the kernel stops through the same shutdown path as `capybara start` — also while it is starting.
|
|
6
|
+
*/
|
|
7
|
+
const signals = new EventEmitter();
|
|
3
8
|
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
9
|
/** 深合并:普通对象逐键递归,其余(含数组)由 `over` 整体替换。 */
|
|
5
10
|
function deepMerge(base, over) {
|
|
@@ -27,22 +32,25 @@ async function startKernel(c) {
|
|
|
27
32
|
log: { level: "error" },
|
|
28
33
|
};
|
|
29
34
|
const config = KernelConfigSchema.parse(deepMerge(base, c.overrides));
|
|
30
|
-
|
|
35
|
+
const app = await startKernelApp(config, {
|
|
31
36
|
resolveFrom: c.resolveFrom,
|
|
32
37
|
...(c.bootstrap !== undefined && { bootstrap: c.bootstrap }),
|
|
33
38
|
...(c.bootstrapManifest !== undefined && { bootstrapManifest: c.bootstrapManifest }),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
process.send?.({ type: "ready", port: kernel.port });
|
|
39
|
+
}, { signals, exit: (code) => process.exit(code) });
|
|
40
|
+
process.send?.({ type: "ready", port: kernelAppPort(app) });
|
|
37
41
|
}
|
|
38
42
|
let exiting = false;
|
|
39
|
-
/**
|
|
43
|
+
/** Stops the kernel, then exits; a stop stuck for 10 s exits with 1. */
|
|
40
44
|
function shutdown() {
|
|
41
45
|
if (exiting)
|
|
42
46
|
return;
|
|
43
47
|
exiting = true;
|
|
44
48
|
setTimeout(() => process.exit(1), 10_000).unref();
|
|
45
|
-
|
|
49
|
+
// No kernel handlers yet (still building) or any more (the start failed): nothing to stop
|
|
50
|
+
if (signals.listenerCount("SIGTERM") === 0)
|
|
51
|
+
process.exit(0);
|
|
52
|
+
else
|
|
53
|
+
signals.emit("SIGTERM", "SIGTERM");
|
|
46
54
|
}
|
|
47
55
|
// 父进程(含被 SIGKILL)消失后 IPC 通道断开,副本自行退出,不留孤儿
|
|
48
56
|
process.on("disconnect", shutdown);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "fake-config-form",
|
|
4
|
+
"displayName": {
|
|
5
|
+
"zh": "表单样例",
|
|
6
|
+
"en": "Form sample"
|
|
7
|
+
},
|
|
8
|
+
"version": "0.1.0",
|
|
9
|
+
"publisher": "Capybara",
|
|
10
|
+
"description": "Test fake: an openai-compatible-shaped deployment config schema with config form hints"
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-config-form",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake: an openai-compatible-shaped deployment config schema with config form hints",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConfigUiHints,
|
|
3
|
+
defineExtension,
|
|
4
|
+
type LocalizedName,
|
|
5
|
+
secret,
|
|
6
|
+
ui,
|
|
7
|
+
} from "@capybara-hq/plugin-sdk";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Test fake of a hinted deployment config (extension config forms spec §6): the shape of
|
|
12
|
+
* openai-compatible's config (endpoints with models, API keys in a top-level secret record keyed by
|
|
13
|
+
* endpoint name) with the hints that plugin carries, so the web form (presets, item secrets, model
|
|
14
|
+
* rows, advanced fields, localized custom issues) is tested without an official plugin. It provides
|
|
15
|
+
* nothing and calls no service: setup only validates the config. Display name 表单样例 / Form sample.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const T = (zh: string, en: string): LocalizedName => ({ zh, en });
|
|
19
|
+
const flag = (zh: string, en: string, help?: LocalizedName) =>
|
|
20
|
+
ui(z.boolean().optional(), { label: T(zh, en), widget: "checkbox", ...(help && { help }) });
|
|
21
|
+
|
|
22
|
+
/** Whether a dialect accepts PDF documents (as in openai-compatible: generic and qwen). */
|
|
23
|
+
const acceptsPdf = (dialect: string): boolean => dialect === "generic" || dialect === "qwen";
|
|
24
|
+
|
|
25
|
+
const EndpointModelSchema = ui(
|
|
26
|
+
z.object({
|
|
27
|
+
id: ui(z.string().min(1), { label: T("模型 ID", "Model ID"), placeholder: "deepseek-chat" }),
|
|
28
|
+
contextWindow: ui(z.number().int().positive(), {
|
|
29
|
+
label: T("上下文窗口", "Context window"),
|
|
30
|
+
unit: T("tokens", "tokens"),
|
|
31
|
+
}),
|
|
32
|
+
maxOutputTokens: ui(z.number().int().positive(), {
|
|
33
|
+
label: T("最大输出", "Max output"),
|
|
34
|
+
unit: T("tokens", "tokens"),
|
|
35
|
+
}),
|
|
36
|
+
vision: flag("图片", "Images"),
|
|
37
|
+
documents: flag(
|
|
38
|
+
"PDF 文档",
|
|
39
|
+
"PDF documents",
|
|
40
|
+
T("仅通用与通义千问方言支持", "Only the generic and Qwen dialects accept PDF"),
|
|
41
|
+
),
|
|
42
|
+
reasoning: flag("推理", "Reasoning"),
|
|
43
|
+
caching: ui(z.boolean().optional(), {
|
|
44
|
+
label: T("缓存", "Caching"),
|
|
45
|
+
widget: "tristate",
|
|
46
|
+
help: T("默认按方言", "Default follows the dialect"),
|
|
47
|
+
advanced: true,
|
|
48
|
+
}),
|
|
49
|
+
streamIdleTimeoutMs: ui(z.number().int().positive().optional(), {
|
|
50
|
+
label: T("流空闲超时", "Stream idle timeout"),
|
|
51
|
+
unit: T("毫秒", "ms"),
|
|
52
|
+
advanced: true,
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
{ layout: "row" },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const EndpointSchema = z.strictObject({
|
|
59
|
+
name: ui(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/), {
|
|
60
|
+
label: T("名称", "Name"),
|
|
61
|
+
help: T("对外模型 ID 为 <名称>/<模型 ID>", "Models are exposed as <name>/<model id>"),
|
|
62
|
+
}),
|
|
63
|
+
baseUrl: ui(z.string().url(), {
|
|
64
|
+
label: T("接口地址", "Base URL"),
|
|
65
|
+
widget: "url",
|
|
66
|
+
placeholder: "https://api.example.com/v1",
|
|
67
|
+
}),
|
|
68
|
+
dialect: ui(z.enum(["generic", "deepseek", "qwen", "ark"]), {
|
|
69
|
+
label: T("方言", "Dialect"),
|
|
70
|
+
options: [
|
|
71
|
+
{ value: "generic", label: T("通用(vLLM 等)", "Generic (vLLM, …)") },
|
|
72
|
+
{ value: "deepseek", label: T("DeepSeek", "DeepSeek") },
|
|
73
|
+
{ value: "qwen", label: T("通义千问(百炼)", "Qwen (Model Studio)") },
|
|
74
|
+
{ value: "ark", label: T("火山方舟", "Volcano Ark") },
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
midConversationSystem: ui(z.boolean().optional(), {
|
|
78
|
+
label: T("对话中途的 system 消息", "Mid-conversation system messages"),
|
|
79
|
+
widget: "tristate",
|
|
80
|
+
advanced: true,
|
|
81
|
+
}),
|
|
82
|
+
models: ui(
|
|
83
|
+
z
|
|
84
|
+
.array(EndpointModelSchema)
|
|
85
|
+
.min(1)
|
|
86
|
+
.refine((ms) => new Set(ms.map((m) => m.id)).size === ms.length, {
|
|
87
|
+
params: { ui: "duplicateModel" },
|
|
88
|
+
}),
|
|
89
|
+
{
|
|
90
|
+
label: T("模型", "Models"),
|
|
91
|
+
item: { title: "{id}", addLabel: T("添加模型", "Add model"), reorder: true },
|
|
92
|
+
},
|
|
93
|
+
),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const ENDPOINTS: ConfigUiHints = {
|
|
97
|
+
label: T("端点", "Endpoints"),
|
|
98
|
+
item: {
|
|
99
|
+
title: "{name}",
|
|
100
|
+
subtitle: "{baseUrl}",
|
|
101
|
+
addLabel: T("添加端点", "Add endpoint"),
|
|
102
|
+
reorder: true,
|
|
103
|
+
},
|
|
104
|
+
secrets: [
|
|
105
|
+
{
|
|
106
|
+
path: "apiKeys",
|
|
107
|
+
keyBy: "name",
|
|
108
|
+
after: "baseUrl",
|
|
109
|
+
label: T("API 密钥", "API key"),
|
|
110
|
+
help: T(
|
|
111
|
+
"留空则请求不带凭据(如内网 vLLM)",
|
|
112
|
+
"Leave empty for services without a key (e.g. an internal vLLM)",
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
presets: [
|
|
117
|
+
{
|
|
118
|
+
id: "deepseek",
|
|
119
|
+
label: T("DeepSeek", "DeepSeek"),
|
|
120
|
+
values: { name: "deepseek", baseUrl: "https://api.deepseek.com", dialect: "deepseek" },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "qwen",
|
|
124
|
+
label: T("通义千问(百炼)", "Qwen (Model Studio)"),
|
|
125
|
+
values: {
|
|
126
|
+
name: "qwen",
|
|
127
|
+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
128
|
+
dialect: "qwen",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "ark",
|
|
133
|
+
label: T("火山方舟", "Volcano Ark"),
|
|
134
|
+
values: { name: "ark", baseUrl: "https://ark.cn-beijing.volces.com/api/v3", dialect: "ark" },
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "generic",
|
|
138
|
+
label: T("其他兼容服务", "Other compatible service"),
|
|
139
|
+
values: { dialect: "generic" },
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** The deployment config: endpoints, and their API keys by endpoint name. */
|
|
145
|
+
export const FakeConfigFormSchema = ui(
|
|
146
|
+
z
|
|
147
|
+
.object({
|
|
148
|
+
endpoints: ui(
|
|
149
|
+
z
|
|
150
|
+
.array(EndpointSchema)
|
|
151
|
+
.min(1)
|
|
152
|
+
.refine((es) => new Set(es.map((e) => e.name)).size === es.length, {
|
|
153
|
+
params: { ui: "duplicateEndpoint" },
|
|
154
|
+
}),
|
|
155
|
+
ENDPOINTS,
|
|
156
|
+
),
|
|
157
|
+
apiKeys: secret(z.record(z.string(), z.string().min(1))).default({}),
|
|
158
|
+
})
|
|
159
|
+
.superRefine((cfg, ctx) => {
|
|
160
|
+
cfg.endpoints.forEach((e, i) => {
|
|
161
|
+
if (acceptsPdf(e.dialect)) return;
|
|
162
|
+
e.models.forEach((m, j) => {
|
|
163
|
+
if (m.documents !== true) return;
|
|
164
|
+
ctx.addIssue({
|
|
165
|
+
code: "custom",
|
|
166
|
+
path: ["endpoints", i, "models", j, "documents"],
|
|
167
|
+
message: `dialect "${e.dialect}" does not accept PDF documents; documents must be false`,
|
|
168
|
+
params: { ui: "pdfDialect" },
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
const names = new Set(cfg.endpoints.map((e) => e.name));
|
|
173
|
+
for (const key of Object.keys(cfg.apiKeys)) {
|
|
174
|
+
if (names.has(key)) continue;
|
|
175
|
+
ctx.addIssue({
|
|
176
|
+
code: "custom",
|
|
177
|
+
path: ["apiKeys", key],
|
|
178
|
+
message: `apiKeys["${key}"] does not match any endpoint name`,
|
|
179
|
+
params: { ui: "unknownKey" },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
{
|
|
184
|
+
messages: {
|
|
185
|
+
duplicateEndpoint: T("端点名称重复", "Endpoint names must be unique"),
|
|
186
|
+
duplicateModel: T("模型 ID 重复", "Model ids must be unique within an endpoint"),
|
|
187
|
+
pdfDialect: T(
|
|
188
|
+
"该方言不支持 PDF,请取消勾选",
|
|
189
|
+
"This dialect does not accept PDF; clear the box",
|
|
190
|
+
),
|
|
191
|
+
unknownKey: T("密钥没有对应的端点", "This key has no endpoint"),
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
export default defineExtension({
|
|
197
|
+
name: "fake-config-form",
|
|
198
|
+
version: "0.1.0",
|
|
199
|
+
sdk: "^0.1.0-rc.0",
|
|
200
|
+
provides: [],
|
|
201
|
+
config: { deployment: FakeConfigFormSchema },
|
|
202
|
+
setup(ctx) {
|
|
203
|
+
FakeConfigFormSchema.parse(ctx.config ?? {});
|
|
204
|
+
},
|
|
205
|
+
});
|
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Test fake of an extension that uses `ctx.storage` (`permissions.storage`): tool `note`
|
|
6
|
-
* (model-visible `
|
|
6
|
+
* (model-visible `plugin__fake-storage-user__note`) writes the text to `notes/<conversationId>.txt`, which
|
|
7
7
|
* the kernel prefixes with `plugins/fake-storage-user/<orgId>/`, and returns the stored size; tool
|
|
8
8
|
* `read_note` returns it (or says there is none). Needs an `object-store@1` provider.
|
|
9
9
|
*/
|
|
@@ -11,7 +11,7 @@ import { z } from "zod";
|
|
|
11
11
|
* Test fake of a tool + UI extension: the kernel contracts an extension tool, a schema-rendered
|
|
12
12
|
* view, a migration and a full plugin bundle exercise, without the official todo plugin. Like todo
|
|
13
13
|
* it needs no object store (storage is `fake-storage-user`'s).
|
|
14
|
-
* - tool `write` (model-visible `
|
|
14
|
+
* - tool `write` (model-visible `plugin__fake-tools-ui__write`, default policy allow): replaces the
|
|
15
15
|
* conversation's item list in the extension's own table, then invalidates the view `tasks`;
|
|
16
16
|
* - view `tasks` (conversation tab): badge `<done>/<total>` and a checklist;
|
|
17
17
|
* - the plugin also ships the skill `fake-notes` and the connector template `fake-notes`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake: a scripted web search and web fetch provider (web-search@1, web-fetch@1)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineExtension } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import { FakeWebOrgConfig, makeFakeWeb } from "./web.js";
|
|
3
|
+
|
|
4
|
+
export { type FakeWebCall, FakeWebOrgConfig, fakeWebProbe, makeFakeWeb } from "./web.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Test fake: a scripted web provider (`web-search@1` and `web-fetch@1`; see `makeFakeWeb` for the
|
|
8
|
+
* scripts). Org config `{ token? }` (secret); `configured()` is true iff the token is set.
|
|
9
|
+
* `fake-web-2` is the same with its own name, for the org default provider.
|
|
10
|
+
*/
|
|
11
|
+
export default defineExtension({
|
|
12
|
+
name: "fake-web",
|
|
13
|
+
version: "0.1.0",
|
|
14
|
+
sdk: "^0.1.0-rc.0",
|
|
15
|
+
provides: ["web-search@1", "web-fetch@1"],
|
|
16
|
+
config: { org: FakeWebOrgConfig },
|
|
17
|
+
setup(ctx) {
|
|
18
|
+
const { search, fetch } = makeFakeWeb("fake-web");
|
|
19
|
+
ctx.provide("web-search@1", search);
|
|
20
|
+
ctx.provide("web-fetch@1", fetch);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
secret,
|
|
3
|
+
type WebFetchPage,
|
|
4
|
+
type WebFetchProvider,
|
|
5
|
+
WebProviderError,
|
|
6
|
+
type WebProviderErrorKind,
|
|
7
|
+
type WebSearchProvider,
|
|
8
|
+
type WebSearchResult,
|
|
9
|
+
} from "@capybara-hq/plugin-sdk";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
// fake-web/src/web.ts and fake-web-2/src/web.ts are byte-identical (a plugin imports only from its
|
|
13
|
+
// own directory); test/fixtures.test.ts of @capybara-hq/testing keeps them equal.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Org config of the fake web providers: `configured()` is true iff `token` is set (and throws for
|
|
17
|
+
* the token `throw:configured`).
|
|
18
|
+
*/
|
|
19
|
+
export const FakeWebOrgConfig = z.object({ token: secret(z.string().min(1)).optional() });
|
|
20
|
+
|
|
21
|
+
/** One provider call, recorded on `globalThis.__fakeWeb` (in-process kernels only). */
|
|
22
|
+
export interface FakeWebCall {
|
|
23
|
+
provider: string;
|
|
24
|
+
op: "search" | "fetch";
|
|
25
|
+
/** The query (search) or the URL (fetch). */
|
|
26
|
+
input: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The probe tests read: every call of every fake web provider in this process. */
|
|
30
|
+
export function fakeWebProbe(): { calls: FakeWebCall[] } {
|
|
31
|
+
const g = globalThis as { __fakeWeb?: { calls: FakeWebCall[] } };
|
|
32
|
+
g.__fakeWeb ??= { calls: [] };
|
|
33
|
+
return g.__fakeWeb;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// capybara:allow-module-state a read-only constant list
|
|
37
|
+
const KINDS: readonly WebProviderErrorKind[] = [
|
|
38
|
+
"not_found",
|
|
39
|
+
"blocked",
|
|
40
|
+
"auth",
|
|
41
|
+
"rate_limited",
|
|
42
|
+
"unavailable",
|
|
43
|
+
"invalid",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** True iff the token is set; the token `throw:configured` makes it throw. */
|
|
47
|
+
const configured = (orgConfig: unknown): boolean => {
|
|
48
|
+
const token = (orgConfig as { token?: unknown } | null)?.token;
|
|
49
|
+
if (token === "throw:configured") throw new Error("fake web: configured() failed");
|
|
50
|
+
return typeof token === "string" && token.length > 0;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The script of a fetch: the decoded URL path (and query) after the first `/`. */
|
|
54
|
+
function fetchScript(url: string): string {
|
|
55
|
+
try {
|
|
56
|
+
const u = new URL(url);
|
|
57
|
+
return decodeURIComponent(u.pathname.slice(1)) + u.search;
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rejects with the signal's reason once it aborts (`hang:`). */
|
|
64
|
+
function hang(signal: AbortSignal): Promise<never> {
|
|
65
|
+
return new Promise((_, reject) => {
|
|
66
|
+
if (signal.aborted) reject(signal.reason);
|
|
67
|
+
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The scripted failures shared by search and fetch; returns normally for other scripts. */
|
|
72
|
+
async function scriptedFailure(name: string, script: string, signal: AbortSignal): Promise<void> {
|
|
73
|
+
if (script.startsWith("error:")) {
|
|
74
|
+
const kind = script.slice("error:".length).split(/[\s/?]/)[0] as WebProviderErrorKind;
|
|
75
|
+
if (!KINDS.includes(kind)) throw new Error(`${name}: unknown scripted error kind ${kind}`);
|
|
76
|
+
throw new WebProviderError(`${name}: scripted ${kind} failure`, { kind });
|
|
77
|
+
}
|
|
78
|
+
if (script.startsWith("throw:")) {
|
|
79
|
+
throw new Error(`${name} internal failure: ${script.slice("throw:".length)}`);
|
|
80
|
+
}
|
|
81
|
+
if (script.startsWith("hang:")) await hang(signal);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The two providers of a fake web extension named `name`. Behavior is scripted by a prefix of the
|
|
86
|
+
* search `query`, or of the fetched URL's path after the first `/` (e.g.
|
|
87
|
+
* `https://example.com/redirect:https://other.com/x`):
|
|
88
|
+
* - `error:<kind>`: throws `WebProviderError` of that kind;
|
|
89
|
+
* - `throw:<text>`: throws a plain `Error` whose message names the provider and carries `<text>`;
|
|
90
|
+
* - `hang:`: waits until the signal aborts, then rejects with its reason;
|
|
91
|
+
* - `redirect:<url>` (fetch): the page's `finalUrl` is `<url>`;
|
|
92
|
+
* - `redirect-long:<n>` (fetch): the page's `finalUrl` is `https://example.com/` followed by `n`
|
|
93
|
+
* characters;
|
|
94
|
+
* - `junk:` (search): unusable results (relative URL, `javascript:`, `ftp:`, empty title) plus one
|
|
95
|
+
* usable result and a second result with its URL; `junk:all`: only the four unusable ones;
|
|
96
|
+
* - `idn:` (search): one result on the IDN host `bücher.example` (its URL parses to the punycode
|
|
97
|
+
* host `xn--bcher-kva.example`) and one on example.org;
|
|
98
|
+
* - `empty:`: no results (search) / an empty `markdown` (fetch);
|
|
99
|
+
* - `big:<n>`: `n` results, ignoring `maxResults` (search) / `n` characters of markdown (fetch).
|
|
100
|
+
* Anything else: three results on docs.example.com, example.org and www.example.net (search) or
|
|
101
|
+
* a short page (fetch). Every title, snippet and page names the provider.
|
|
102
|
+
*/
|
|
103
|
+
export function makeFakeWeb(name: string): { search: WebSearchProvider; fetch: WebFetchProvider } {
|
|
104
|
+
const search: WebSearchProvider = {
|
|
105
|
+
configured,
|
|
106
|
+
async search({ query, signal }) {
|
|
107
|
+
fakeWebProbe().calls.push({ provider: name, op: "search", input: query });
|
|
108
|
+
await scriptedFailure(name, query, signal);
|
|
109
|
+
if (query.startsWith("empty:")) return { results: [] };
|
|
110
|
+
if (query.startsWith("idn:")) {
|
|
111
|
+
return {
|
|
112
|
+
results: [
|
|
113
|
+
{ title: `${name} idn`, url: `https://bücher.example/${name}/1` },
|
|
114
|
+
{ title: `${name} other`, url: `https://example.org/${name}/2` },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (query.startsWith("junk:")) {
|
|
119
|
+
const bad: WebSearchResult[] = [
|
|
120
|
+
{ title: `${name} relative`, url: "/relative/path" },
|
|
121
|
+
{ title: `${name} script`, url: "javascript:alert(1)" },
|
|
122
|
+
{ title: `${name} ftp`, url: "ftp://example.com/file" },
|
|
123
|
+
{ title: "", url: "https://example.com/untitled" },
|
|
124
|
+
];
|
|
125
|
+
if (query.startsWith("junk:all")) return { results: bad };
|
|
126
|
+
return {
|
|
127
|
+
results: [
|
|
128
|
+
...bad,
|
|
129
|
+
{ title: `${name} first`, url: "https://example.com/dup" },
|
|
130
|
+
{ title: `${name} again`, url: "https://example.com/dup" },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (query.startsWith("big:")) {
|
|
135
|
+
const n = Number.parseInt(query.slice("big:".length), 10) || 0;
|
|
136
|
+
return {
|
|
137
|
+
results: Array.from({ length: n }, (_, i) => ({
|
|
138
|
+
title: `${name} result ${i + 1}`,
|
|
139
|
+
url: `https://example.com/${name}/${i + 1}`,
|
|
140
|
+
snippet: `${name} snippet ${i + 1} for ${query}`,
|
|
141
|
+
})),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const hosts = ["docs.example.com", "example.org", "www.example.net"];
|
|
145
|
+
const results: WebSearchResult[] = hosts.map((h, i) => ({
|
|
146
|
+
title: `${name} result ${i + 1}`,
|
|
147
|
+
url: `https://${h}/${name}/${i + 1}`,
|
|
148
|
+
snippet: `${name} snippet ${i + 1} for ${query}`,
|
|
149
|
+
publishedAt: "2026-11-01",
|
|
150
|
+
}));
|
|
151
|
+
return { results };
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const fetch: WebFetchProvider = {
|
|
155
|
+
configured,
|
|
156
|
+
async fetch({ url, signal }): Promise<WebFetchPage> {
|
|
157
|
+
fakeWebProbe().calls.push({ provider: name, op: "fetch", input: url });
|
|
158
|
+
const script = fetchScript(url);
|
|
159
|
+
await scriptedFailure(name, script, signal);
|
|
160
|
+
if (script.startsWith("redirect-long:")) {
|
|
161
|
+
const n = Number.parseInt(script.slice("redirect-long:".length), 10) || 0;
|
|
162
|
+
const finalUrl = `https://example.com/${"a".repeat(n)}`;
|
|
163
|
+
return { finalUrl, title: `${name} redirect`, markdown: "Moved." };
|
|
164
|
+
}
|
|
165
|
+
if (script.startsWith("redirect:")) {
|
|
166
|
+
const finalUrl = script.slice("redirect:".length);
|
|
167
|
+
return { finalUrl, title: `${name} redirect`, markdown: `Moved to ${finalUrl}.` };
|
|
168
|
+
}
|
|
169
|
+
if (script.startsWith("empty:")) return { finalUrl: url, markdown: "" };
|
|
170
|
+
if (script.startsWith("big:")) {
|
|
171
|
+
const n = Number.parseInt(script.slice("big:".length), 10) || 0;
|
|
172
|
+
return { finalUrl: url, title: `${name} big page`, markdown: "x".repeat(n) };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
finalUrl: url,
|
|
176
|
+
title: `${name} page`,
|
|
177
|
+
markdown: `# Page from ${name}\n\nFetched ${url} with ${name}.`,
|
|
178
|
+
contentType: "text/html",
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
return { search, fetch };
|
|
183
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-web-2",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake: a scripted web search and web fetch provider (web-search@1, web-fetch@1)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineExtension } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import { FakeWebOrgConfig, makeFakeWeb } from "./web.js";
|
|
3
|
+
|
|
4
|
+
/** Test fake: `fake-web` under another name (a second provider of `web-search@1` / `web-fetch@1`). */
|
|
5
|
+
export default defineExtension({
|
|
6
|
+
name: "fake-web-2",
|
|
7
|
+
version: "0.1.0",
|
|
8
|
+
sdk: "^0.1.0-rc.0",
|
|
9
|
+
provides: ["web-search@1", "web-fetch@1"],
|
|
10
|
+
config: { org: FakeWebOrgConfig },
|
|
11
|
+
setup(ctx) {
|
|
12
|
+
const { search, fetch } = makeFakeWeb("fake-web-2");
|
|
13
|
+
ctx.provide("web-search@1", search);
|
|
14
|
+
ctx.provide("web-fetch@1", fetch);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
secret,
|
|
3
|
+
type WebFetchPage,
|
|
4
|
+
type WebFetchProvider,
|
|
5
|
+
WebProviderError,
|
|
6
|
+
type WebProviderErrorKind,
|
|
7
|
+
type WebSearchProvider,
|
|
8
|
+
type WebSearchResult,
|
|
9
|
+
} from "@capybara-hq/plugin-sdk";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
// fake-web/src/web.ts and fake-web-2/src/web.ts are byte-identical (a plugin imports only from its
|
|
13
|
+
// own directory); test/fixtures.test.ts of @capybara-hq/testing keeps them equal.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Org config of the fake web providers: `configured()` is true iff `token` is set (and throws for
|
|
17
|
+
* the token `throw:configured`).
|
|
18
|
+
*/
|
|
19
|
+
export const FakeWebOrgConfig = z.object({ token: secret(z.string().min(1)).optional() });
|
|
20
|
+
|
|
21
|
+
/** One provider call, recorded on `globalThis.__fakeWeb` (in-process kernels only). */
|
|
22
|
+
export interface FakeWebCall {
|
|
23
|
+
provider: string;
|
|
24
|
+
op: "search" | "fetch";
|
|
25
|
+
/** The query (search) or the URL (fetch). */
|
|
26
|
+
input: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The probe tests read: every call of every fake web provider in this process. */
|
|
30
|
+
export function fakeWebProbe(): { calls: FakeWebCall[] } {
|
|
31
|
+
const g = globalThis as { __fakeWeb?: { calls: FakeWebCall[] } };
|
|
32
|
+
g.__fakeWeb ??= { calls: [] };
|
|
33
|
+
return g.__fakeWeb;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// capybara:allow-module-state a read-only constant list
|
|
37
|
+
const KINDS: readonly WebProviderErrorKind[] = [
|
|
38
|
+
"not_found",
|
|
39
|
+
"blocked",
|
|
40
|
+
"auth",
|
|
41
|
+
"rate_limited",
|
|
42
|
+
"unavailable",
|
|
43
|
+
"invalid",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** True iff the token is set; the token `throw:configured` makes it throw. */
|
|
47
|
+
const configured = (orgConfig: unknown): boolean => {
|
|
48
|
+
const token = (orgConfig as { token?: unknown } | null)?.token;
|
|
49
|
+
if (token === "throw:configured") throw new Error("fake web: configured() failed");
|
|
50
|
+
return typeof token === "string" && token.length > 0;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The script of a fetch: the decoded URL path (and query) after the first `/`. */
|
|
54
|
+
function fetchScript(url: string): string {
|
|
55
|
+
try {
|
|
56
|
+
const u = new URL(url);
|
|
57
|
+
return decodeURIComponent(u.pathname.slice(1)) + u.search;
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rejects with the signal's reason once it aborts (`hang:`). */
|
|
64
|
+
function hang(signal: AbortSignal): Promise<never> {
|
|
65
|
+
return new Promise((_, reject) => {
|
|
66
|
+
if (signal.aborted) reject(signal.reason);
|
|
67
|
+
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The scripted failures shared by search and fetch; returns normally for other scripts. */
|
|
72
|
+
async function scriptedFailure(name: string, script: string, signal: AbortSignal): Promise<void> {
|
|
73
|
+
if (script.startsWith("error:")) {
|
|
74
|
+
const kind = script.slice("error:".length).split(/[\s/?]/)[0] as WebProviderErrorKind;
|
|
75
|
+
if (!KINDS.includes(kind)) throw new Error(`${name}: unknown scripted error kind ${kind}`);
|
|
76
|
+
throw new WebProviderError(`${name}: scripted ${kind} failure`, { kind });
|
|
77
|
+
}
|
|
78
|
+
if (script.startsWith("throw:")) {
|
|
79
|
+
throw new Error(`${name} internal failure: ${script.slice("throw:".length)}`);
|
|
80
|
+
}
|
|
81
|
+
if (script.startsWith("hang:")) await hang(signal);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The two providers of a fake web extension named `name`. Behavior is scripted by a prefix of the
|
|
86
|
+
* search `query`, or of the fetched URL's path after the first `/` (e.g.
|
|
87
|
+
* `https://example.com/redirect:https://other.com/x`):
|
|
88
|
+
* - `error:<kind>`: throws `WebProviderError` of that kind;
|
|
89
|
+
* - `throw:<text>`: throws a plain `Error` whose message names the provider and carries `<text>`;
|
|
90
|
+
* - `hang:`: waits until the signal aborts, then rejects with its reason;
|
|
91
|
+
* - `redirect:<url>` (fetch): the page's `finalUrl` is `<url>`;
|
|
92
|
+
* - `redirect-long:<n>` (fetch): the page's `finalUrl` is `https://example.com/` followed by `n`
|
|
93
|
+
* characters;
|
|
94
|
+
* - `junk:` (search): unusable results (relative URL, `javascript:`, `ftp:`, empty title) plus one
|
|
95
|
+
* usable result and a second result with its URL; `junk:all`: only the four unusable ones;
|
|
96
|
+
* - `idn:` (search): one result on the IDN host `bücher.example` (its URL parses to the punycode
|
|
97
|
+
* host `xn--bcher-kva.example`) and one on example.org;
|
|
98
|
+
* - `empty:`: no results (search) / an empty `markdown` (fetch);
|
|
99
|
+
* - `big:<n>`: `n` results, ignoring `maxResults` (search) / `n` characters of markdown (fetch).
|
|
100
|
+
* Anything else: three results on docs.example.com, example.org and www.example.net (search) or
|
|
101
|
+
* a short page (fetch). Every title, snippet and page names the provider.
|
|
102
|
+
*/
|
|
103
|
+
export function makeFakeWeb(name: string): { search: WebSearchProvider; fetch: WebFetchProvider } {
|
|
104
|
+
const search: WebSearchProvider = {
|
|
105
|
+
configured,
|
|
106
|
+
async search({ query, signal }) {
|
|
107
|
+
fakeWebProbe().calls.push({ provider: name, op: "search", input: query });
|
|
108
|
+
await scriptedFailure(name, query, signal);
|
|
109
|
+
if (query.startsWith("empty:")) return { results: [] };
|
|
110
|
+
if (query.startsWith("idn:")) {
|
|
111
|
+
return {
|
|
112
|
+
results: [
|
|
113
|
+
{ title: `${name} idn`, url: `https://bücher.example/${name}/1` },
|
|
114
|
+
{ title: `${name} other`, url: `https://example.org/${name}/2` },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (query.startsWith("junk:")) {
|
|
119
|
+
const bad: WebSearchResult[] = [
|
|
120
|
+
{ title: `${name} relative`, url: "/relative/path" },
|
|
121
|
+
{ title: `${name} script`, url: "javascript:alert(1)" },
|
|
122
|
+
{ title: `${name} ftp`, url: "ftp://example.com/file" },
|
|
123
|
+
{ title: "", url: "https://example.com/untitled" },
|
|
124
|
+
];
|
|
125
|
+
if (query.startsWith("junk:all")) return { results: bad };
|
|
126
|
+
return {
|
|
127
|
+
results: [
|
|
128
|
+
...bad,
|
|
129
|
+
{ title: `${name} first`, url: "https://example.com/dup" },
|
|
130
|
+
{ title: `${name} again`, url: "https://example.com/dup" },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (query.startsWith("big:")) {
|
|
135
|
+
const n = Number.parseInt(query.slice("big:".length), 10) || 0;
|
|
136
|
+
return {
|
|
137
|
+
results: Array.from({ length: n }, (_, i) => ({
|
|
138
|
+
title: `${name} result ${i + 1}`,
|
|
139
|
+
url: `https://example.com/${name}/${i + 1}`,
|
|
140
|
+
snippet: `${name} snippet ${i + 1} for ${query}`,
|
|
141
|
+
})),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const hosts = ["docs.example.com", "example.org", "www.example.net"];
|
|
145
|
+
const results: WebSearchResult[] = hosts.map((h, i) => ({
|
|
146
|
+
title: `${name} result ${i + 1}`,
|
|
147
|
+
url: `https://${h}/${name}/${i + 1}`,
|
|
148
|
+
snippet: `${name} snippet ${i + 1} for ${query}`,
|
|
149
|
+
publishedAt: "2026-11-01",
|
|
150
|
+
}));
|
|
151
|
+
return { results };
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const fetch: WebFetchProvider = {
|
|
155
|
+
configured,
|
|
156
|
+
async fetch({ url, signal }): Promise<WebFetchPage> {
|
|
157
|
+
fakeWebProbe().calls.push({ provider: name, op: "fetch", input: url });
|
|
158
|
+
const script = fetchScript(url);
|
|
159
|
+
await scriptedFailure(name, script, signal);
|
|
160
|
+
if (script.startsWith("redirect-long:")) {
|
|
161
|
+
const n = Number.parseInt(script.slice("redirect-long:".length), 10) || 0;
|
|
162
|
+
const finalUrl = `https://example.com/${"a".repeat(n)}`;
|
|
163
|
+
return { finalUrl, title: `${name} redirect`, markdown: "Moved." };
|
|
164
|
+
}
|
|
165
|
+
if (script.startsWith("redirect:")) {
|
|
166
|
+
const finalUrl = script.slice("redirect:".length);
|
|
167
|
+
return { finalUrl, title: `${name} redirect`, markdown: `Moved to ${finalUrl}.` };
|
|
168
|
+
}
|
|
169
|
+
if (script.startsWith("empty:")) return { finalUrl: url, markdown: "" };
|
|
170
|
+
if (script.startsWith("big:")) {
|
|
171
|
+
const n = Number.parseInt(script.slice("big:".length), 10) || 0;
|
|
172
|
+
return { finalUrl: url, title: `${name} big page`, markdown: "x".repeat(n) };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
finalUrl: url,
|
|
176
|
+
title: `${name} page`,
|
|
177
|
+
markdown: `# Page from ${name}\n\nFetched ${url} with ${name}.`,
|
|
178
|
+
contentType: "text/html",
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
return { search, fetch };
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capybara-hq/testing",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.4",
|
|
4
4
|
"description": "Test helpers for Capybara: test containers, dual-replica harness and fake third-party servers",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -47,17 +47,17 @@
|
|
|
47
47
|
"tsx": "^4.23.15",
|
|
48
48
|
"ws": "^8.22.0",
|
|
49
49
|
"zod": "^4.6.7",
|
|
50
|
-
"@capybara-hq/kernel": "^0.1.0-rc.
|
|
50
|
+
"@capybara-hq/kernel": "^0.1.0-rc.4"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/pg": "^8.23.1",
|
|
54
54
|
"@types/ws": "^8.18.2",
|
|
55
|
-
"@capybara-hq/plugin-sdk": "^0.1.0-rc.
|
|
55
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.2"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"vitest": "^5.0.5",
|
|
59
|
-
"@capybara-hq/plugin-
|
|
60
|
-
"@capybara-hq/plugin-
|
|
59
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.2",
|
|
60
|
+
"@capybara-hq/plugin-tools": "^0.1.0-rc.4"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|
|
63
63
|
"@capybara-hq/plugin-tools": {
|