@capybara-hq/testing 0.1.0-rc.3 → 0.1.0-rc.5
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/fake-channel.d.ts +10 -1
- package/dist/fake-channel.js +9 -2
- package/dist/fake-git-server.js +3 -1
- package/dist/fixtures.d.ts +3 -1
- package/dist/fixtures.js +5 -0
- package/dist/index.d.ts +1 -1
- 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/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/fake-git-server.js
CHANGED
|
@@ -129,8 +129,10 @@ function serveCgi(root, req, res) {
|
|
|
129
129
|
res.writeHead(h.status, h.headers);
|
|
130
130
|
res.write(head.subarray(h.bodyStart));
|
|
131
131
|
});
|
|
132
|
+
// A child that could not be spawned emits 'error' and then 'close': only the first writes a head
|
|
133
|
+
// (a second writeHead throws ERR_HTTP_HEADERS_SENT, an uncaught exception).
|
|
132
134
|
p.on("close", () => {
|
|
133
|
-
if (!headDone)
|
|
135
|
+
if (!headDone && !res.headersSent)
|
|
134
136
|
res.writeHead(502);
|
|
135
137
|
res.end();
|
|
136
138
|
});
|
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
|
@@ -7,7 +7,7 @@ export { type ConversationEvent, createDb, gatewayReplicasKey, type KernelConfig
|
|
|
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";
|
|
@@ -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.5",
|
|
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-tools": "^0.1.0-rc.4",
|
|
60
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.2"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|
|
63
63
|
"@capybara-hq/plugin-tools": {
|