@acid-cat/cli 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/conformance/fixture.acidcat.json +63 -5
- package/dist/catalog.d.ts +5 -0
- package/dist/catalog.js +23 -4
- package/dist/conform/check.d.ts +24 -4
- package/dist/conform/check.js +57 -6
- package/dist/conform/cli.d.ts +3 -1
- package/dist/conform/cli.js +94 -53
- package/dist/conform/http.d.ts +1 -1
- package/dist/conform/http.js +44 -32
- package/dist/conform/index.d.ts +6 -5
- package/dist/conform/index.js +20 -9
- package/dist/conform/mcp.d.ts +2 -1
- package/dist/conform/mcp.js +57 -49
- package/dist/conform/properties.d.ts +5 -8
- package/dist/conform/properties.js +111 -39
- package/dist/conform/serve.js +45 -8
- package/dist/conform/webmcp.d.ts +23 -6
- package/dist/conform/webmcp.js +124 -55
- package/dist/json/fixture.acidcat.json.js +44 -4
- package/dist/json/package.json.js +1 -1
- package/dist/json.d.ts +22 -0
- package/dist/json.js +21 -0
- package/dist/main.js +13 -6
- package/package.json +6 -6
package/dist/conform/serve.js
CHANGED
|
@@ -23,7 +23,8 @@ function serve(routes) {
|
|
|
23
23
|
async function answer(incoming, outgoing, prefixes, routes) {
|
|
24
24
|
const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "127.0.0.1"}`);
|
|
25
25
|
const prefix = prefixes.find((candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`));
|
|
26
|
-
|
|
26
|
+
const handler = prefix === void 0 ? void 0 : routes[prefix];
|
|
27
|
+
if (handler === void 0) {
|
|
27
28
|
outgoing.writeHead(404).end();
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
@@ -35,17 +36,19 @@ async function answer(incoming, outgoing, prefixes, routes) {
|
|
|
35
36
|
for (const [name, value] of Object.entries(incoming.headers)) if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
|
|
36
37
|
else if (value !== void 0) headers.set(name, value);
|
|
37
38
|
const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
|
|
38
|
-
const
|
|
39
|
+
const init = {
|
|
39
40
|
method: incoming.method ?? "GET",
|
|
40
41
|
headers,
|
|
41
|
-
signal: abort.signal
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
signal: abort.signal
|
|
43
|
+
};
|
|
44
|
+
const withBody = () => ({
|
|
45
|
+
...init,
|
|
46
|
+
body: bodyOf(incoming),
|
|
47
|
+
duplex: "half"
|
|
46
48
|
});
|
|
49
|
+
const request = new Request(url, hasBody ? withBody() : init);
|
|
47
50
|
try {
|
|
48
|
-
const response = await
|
|
51
|
+
const response = await handler(request);
|
|
49
52
|
outgoing.writeHead(response.status, Object.fromEntries(response.headers));
|
|
50
53
|
if (response.body === null) {
|
|
51
54
|
outgoing.end();
|
|
@@ -58,5 +61,39 @@ async function answer(incoming, outgoing, prefixes, routes) {
|
|
|
58
61
|
outgoing.end();
|
|
59
62
|
}
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The body of an incoming request, as a web stream. The stream reads the request only when the handler reads the body,
|
|
66
|
+
* and the request pauses after each chunk until the handler reads again. A request that the handler does not read stays
|
|
67
|
+
* unread, so that Node.js discards it after the response. When the handler cancels the stream, the request discards the
|
|
68
|
+
* rest of its body.
|
|
69
|
+
*/
|
|
70
|
+
function bodyOf(incoming) {
|
|
71
|
+
let open = true;
|
|
72
|
+
let reading = false;
|
|
73
|
+
const close = (end) => {
|
|
74
|
+
if (open) end();
|
|
75
|
+
open = false;
|
|
76
|
+
};
|
|
77
|
+
return new ReadableStream({
|
|
78
|
+
pull(controller) {
|
|
79
|
+
if (!reading) {
|
|
80
|
+
reading = true;
|
|
81
|
+
incoming.on("data", (chunk) => {
|
|
82
|
+
if (!open) return;
|
|
83
|
+
if (!(chunk instanceof Uint8Array)) return close(() => controller.error(/* @__PURE__ */ new TypeError("a chunk of the request body is not bytes")));
|
|
84
|
+
controller.enqueue(chunk);
|
|
85
|
+
incoming.pause();
|
|
86
|
+
});
|
|
87
|
+
incoming.on("end", () => close(() => controller.close()));
|
|
88
|
+
incoming.on("error", (error) => close(() => controller.error(error)));
|
|
89
|
+
}
|
|
90
|
+
incoming.resume();
|
|
91
|
+
},
|
|
92
|
+
cancel() {
|
|
93
|
+
open = false;
|
|
94
|
+
incoming.resume();
|
|
95
|
+
}
|
|
96
|
+
}, { highWaterMark: 0 });
|
|
97
|
+
}
|
|
61
98
|
//#endregion
|
|
62
99
|
export { serve };
|
package/dist/conform/webmcp.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface RecordedTool {
|
|
|
5
5
|
readonly description: string;
|
|
6
6
|
readonly inputSchema: unknown;
|
|
7
7
|
}
|
|
8
|
+
/** A tool, as a page gives it to `registerTool`. */
|
|
9
|
+
export interface HostTool extends RecordedTool {
|
|
10
|
+
execute(input: unknown, options: {
|
|
11
|
+
readonly signal: AbortSignal;
|
|
12
|
+
}): Promise<unknown>;
|
|
13
|
+
}
|
|
8
14
|
/** What a call to a tool gives: the value that `execute` resolves with, or the error that it rejects with. */
|
|
9
15
|
export type ToolCall = {
|
|
10
16
|
readonly resolved: unknown;
|
|
@@ -20,13 +26,15 @@ export type ToolCall = {
|
|
|
20
26
|
/** The host that the checks give to the page. */
|
|
21
27
|
export interface TestHost {
|
|
22
28
|
readonly modelContext: {
|
|
23
|
-
registerTool(tool:
|
|
29
|
+
registerTool(tool: HostTool, options: {
|
|
24
30
|
readonly signal: AbortSignal;
|
|
25
31
|
}): void;
|
|
26
32
|
};
|
|
27
33
|
tools(): RecordedTool[];
|
|
28
|
-
/** Call a tool.
|
|
29
|
-
call(name: string, input: unknown,
|
|
34
|
+
/** Call a tool. With an `id`, `cancel` can abort the signal of the call. */
|
|
35
|
+
call(name: string, input: unknown, id?: string): Promise<ToolCall>;
|
|
36
|
+
/** Abort the signal of the call that has this `id`, if it runs. */
|
|
37
|
+
cancel(id: string): void;
|
|
30
38
|
}
|
|
31
39
|
/**
|
|
32
40
|
* Make a test host. The function uses no imports and no outer variables, because the browser driver sends its
|
|
@@ -35,8 +43,15 @@ export interface TestHost {
|
|
|
35
43
|
export declare function createTestHost(): TestHost;
|
|
36
44
|
/** The view from the checks: the tools of the page, and calls to them. */
|
|
37
45
|
export interface WebMcpDriver {
|
|
38
|
-
tools(): Promise<RecordedTool[]>;
|
|
39
|
-
|
|
46
|
+
tools(signal: AbortSignal): Promise<RecordedTool[]>;
|
|
47
|
+
/**
|
|
48
|
+
* Call a tool. When `cancel` aborts, the host aborts the signal of the call, and the driver waits for what the tool
|
|
49
|
+
* gives. When `signal` aborts, the host aborts the signal of the call, and the driver stops waiting.
|
|
50
|
+
*/
|
|
51
|
+
call(name: string, input: unknown, options?: {
|
|
52
|
+
readonly signal?: AbortSignal;
|
|
53
|
+
readonly cancel?: AbortSignal;
|
|
54
|
+
}): Promise<ToolCall>;
|
|
40
55
|
close(): Promise<void>;
|
|
41
56
|
}
|
|
42
57
|
/**
|
|
@@ -48,4 +63,6 @@ export type WebMcpInstall = (modelContext: TestHost["modelContext"]) => void | (
|
|
|
48
63
|
export declare function processDriver(install: WebMcpInstall): Promise<WebMcpDriver>;
|
|
49
64
|
/** A driver for a page in Chromium, through Playwright. The page loads with the test host as its `modelContext`. */
|
|
50
65
|
export declare function browserDriver(url: string): Promise<WebMcpDriver>;
|
|
51
|
-
export declare function checkWebMcp(driver: WebMcpDriver
|
|
66
|
+
export declare function checkWebMcp(driver: WebMcpDriver, options: {
|
|
67
|
+
readonly deadline: number;
|
|
68
|
+
}): Promise<Checks>;
|
package/dist/conform/webmcp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isArrayOf, isObject, isStringArray, memberOf, parseJson } from "../json.js";
|
|
2
|
+
import { Checks, abortable, expect, expectEqual, fixture, idsOf, sorted, until } from "./check.js";
|
|
2
3
|
import { REFERENCE, visitObjects } from "@acid-cat/spec-ts";
|
|
3
4
|
//#region src/conform/webmcp.ts
|
|
4
5
|
/**
|
|
@@ -17,12 +18,12 @@ const TARGET = "app";
|
|
|
17
18
|
*/
|
|
18
19
|
function createTestHost() {
|
|
19
20
|
const registered = /* @__PURE__ */ new Map();
|
|
21
|
+
const calls = /* @__PURE__ */ new Map();
|
|
20
22
|
return {
|
|
21
23
|
modelContext: { registerTool(tool, options) {
|
|
22
|
-
|
|
23
|
-
registered.set(recorded.name, recorded);
|
|
24
|
+
registered.set(tool.name, tool);
|
|
24
25
|
options.signal.addEventListener("abort", () => {
|
|
25
|
-
if (registered.get(
|
|
26
|
+
if (registered.get(tool.name) === tool) registered.delete(tool.name);
|
|
26
27
|
});
|
|
27
28
|
} },
|
|
28
29
|
tools: () => [...registered.values()].map((tool) => ({
|
|
@@ -30,36 +31,87 @@ function createTestHost() {
|
|
|
30
31
|
description: tool.description,
|
|
31
32
|
inputSchema: tool.inputSchema
|
|
32
33
|
})),
|
|
33
|
-
async call(name, input,
|
|
34
|
+
async call(name, input, id) {
|
|
34
35
|
const tool = registered.get(name);
|
|
35
36
|
if (tool === void 0) return { missing: true };
|
|
36
37
|
const controller = new AbortController();
|
|
37
|
-
if (
|
|
38
|
+
if (id !== void 0) calls.set(id, controller);
|
|
38
39
|
try {
|
|
39
40
|
return { resolved: await tool.execute(input, { signal: controller.signal }) };
|
|
40
41
|
} catch (error) {
|
|
41
|
-
const
|
|
42
|
+
const hasMembers = typeof error === "object" && error !== null;
|
|
43
|
+
const errorName = hasMembers && "name" in error ? error.name : void 0;
|
|
44
|
+
const kind = hasMembers && "kind" in error ? error.kind : void 0;
|
|
45
|
+
const message = hasMembers && "message" in error ? error.message : void 0;
|
|
42
46
|
return { rejected: {
|
|
43
47
|
name: String(errorName),
|
|
44
48
|
kind,
|
|
45
49
|
message: String(message)
|
|
46
50
|
} };
|
|
51
|
+
} finally {
|
|
52
|
+
if (id !== void 0) calls.delete(id);
|
|
47
53
|
}
|
|
54
|
+
},
|
|
55
|
+
cancel(id) {
|
|
56
|
+
calls.get(id)?.abort();
|
|
48
57
|
}
|
|
49
58
|
};
|
|
50
59
|
}
|
|
60
|
+
/** The tools that a host gives, after they pass through JSON. The check fails when the value is not a list of tools. */
|
|
61
|
+
function toolsOf(value) {
|
|
62
|
+
const isTool = (tool) => isObject(tool) && typeof tool.name === "string" && typeof tool.description === "string";
|
|
63
|
+
if (!isArrayOf(value, isTool)) throw new Error(`the host must give a list of tools, got ${JSON.stringify(value)}`);
|
|
64
|
+
return value.map((tool) => ({
|
|
65
|
+
name: tool.name,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
inputSchema: tool.inputSchema
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/** A tool call that a host gives, after it passes through JSON. The check fails when the value is not a tool call. */
|
|
71
|
+
function toolCallOf(value) {
|
|
72
|
+
if (isObject(value)) {
|
|
73
|
+
if (value.missing === true) return { missing: true };
|
|
74
|
+
if ("resolved" in value) return { resolved: value.resolved };
|
|
75
|
+
const rejected = value.rejected;
|
|
76
|
+
if (isObject(rejected) && typeof rejected.name === "string" && typeof rejected.message === "string") return { rejected: {
|
|
77
|
+
name: rejected.name,
|
|
78
|
+
kind: rejected.kind,
|
|
79
|
+
message: rejected.message
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`the host must give a tool call, got ${JSON.stringify(value)}`);
|
|
83
|
+
}
|
|
84
|
+
let lastCall = 0;
|
|
85
|
+
function driverOf(host) {
|
|
86
|
+
return {
|
|
87
|
+
tools: (signal) => abortable(host.tools(), signal),
|
|
88
|
+
call: (name, input, { signal, cancel } = {}) => {
|
|
89
|
+
const id = String(++lastCall);
|
|
90
|
+
const stop = () => void host.cancel(id).catch(() => void 0);
|
|
91
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
92
|
+
cancel?.addEventListener("abort", stop, { once: true });
|
|
93
|
+
const call = host.call(name, input, id).finally(() => {
|
|
94
|
+
signal?.removeEventListener("abort", stop);
|
|
95
|
+
cancel?.removeEventListener("abort", stop);
|
|
96
|
+
});
|
|
97
|
+
return signal === void 0 ? call : abortable(call, signal);
|
|
98
|
+
},
|
|
99
|
+
close: () => host.close()
|
|
100
|
+
};
|
|
101
|
+
}
|
|
51
102
|
/** A driver for an implementation in the same process. Values pass through JSON, as they do from a page. */
|
|
52
103
|
async function processDriver(install) {
|
|
53
104
|
const host = createTestHost();
|
|
54
105
|
const remove = await install(host.modelContext);
|
|
55
|
-
const copy = (value) =>
|
|
56
|
-
return {
|
|
57
|
-
tools: async () => copy(host.tools()),
|
|
58
|
-
call: async (name, input,
|
|
106
|
+
const copy = (value) => parseJson(JSON.stringify(value));
|
|
107
|
+
return driverOf({
|
|
108
|
+
tools: async () => toolsOf(copy(host.tools())),
|
|
109
|
+
call: async (name, input, id) => toolCallOf(copy(await host.call(name, input, id))),
|
|
110
|
+
cancel: async (id) => host.cancel(id),
|
|
59
111
|
close: async () => {
|
|
60
112
|
if (typeof remove === "function") await remove();
|
|
61
113
|
}
|
|
62
|
-
};
|
|
114
|
+
});
|
|
63
115
|
}
|
|
64
116
|
/** A driver for a page in Chromium, through Playwright. The page loads with the test host as its `modelContext`. */
|
|
65
117
|
async function browserDriver(url) {
|
|
@@ -80,31 +132,22 @@ async function browserDriver(url) {
|
|
|
80
132
|
}
|
|
81
133
|
`);
|
|
82
134
|
await page.goto(url);
|
|
83
|
-
|
|
135
|
+
const host = (method, ...args) => page.evaluate(`window.__acidCatConform.${method}(${args.map((arg) => JSON.stringify(arg) ?? "undefined").join(", ")})`);
|
|
136
|
+
return driverOf({
|
|
84
137
|
tools: async () => {
|
|
85
138
|
if (errors.length > 0) throw new Error(`The page has errors: ${errors.join("; ")}`);
|
|
86
|
-
return
|
|
139
|
+
return toolsOf(await host("tools"));
|
|
140
|
+
},
|
|
141
|
+
call: async (name, input, id) => toolCallOf(await host("call", name, input, id)),
|
|
142
|
+
cancel: async (id) => {
|
|
143
|
+
await host("cancel", id);
|
|
87
144
|
},
|
|
88
|
-
call: (name, input, cancelAfter) => page.evaluate(([name, input, cancelAfter]) => window.__acidCatConform.call(name, input, cancelAfter), [
|
|
89
|
-
name,
|
|
90
|
-
input,
|
|
91
|
-
cancelAfter
|
|
92
|
-
]),
|
|
93
145
|
close: () => browser.close()
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
/** Wait until `condition` is true. Give up after `timeout` milliseconds. */
|
|
97
|
-
async function eventually(condition, what, timeout = 5e3) {
|
|
98
|
-
const end = Date.now() + timeout;
|
|
99
|
-
for (;;) {
|
|
100
|
-
if (await condition()) return;
|
|
101
|
-
if (Date.now() > end) throw new Error(`after ${timeout} ms: ${what}`);
|
|
102
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
103
|
-
}
|
|
146
|
+
});
|
|
104
147
|
}
|
|
105
148
|
/** True if each `$ref` of a schema refers to a type in its own `$defs` (spec/catalog.md#bundling). */
|
|
106
149
|
function isBundled(schema) {
|
|
107
|
-
const types = schema.$defs
|
|
150
|
+
const types = isObject(schema.$defs) ? schema.$defs : {};
|
|
108
151
|
let bundled = true;
|
|
109
152
|
visitObjects(schema, (node) => {
|
|
110
153
|
if (typeof node.$ref !== "string") return;
|
|
@@ -113,35 +156,45 @@ function isBundled(schema) {
|
|
|
113
156
|
});
|
|
114
157
|
return bundled;
|
|
115
158
|
}
|
|
116
|
-
const names = async (driver) => sorted((await driver.tools()).map((tool) => tool.name));
|
|
117
|
-
|
|
118
|
-
|
|
159
|
+
const names = async (driver, signal) => sorted((await driver.tools(signal)).map((tool) => tool.name));
|
|
160
|
+
/** The keys of the calls that hold now, as fixture.held gives them through the host. */
|
|
161
|
+
async function heldKeys(driver, signal) {
|
|
162
|
+
const call = await driver.call("fixture.held", {}, { signal });
|
|
163
|
+
expect("resolved" in call, `fixture.held gave ${JSON.stringify(call)}`);
|
|
164
|
+
const keys = memberOf(memberOf(call.resolved, "value"), "keys");
|
|
165
|
+
expect(isStringArray(keys), `fixture.held gave ${JSON.stringify(call)}, with no list of keys`);
|
|
166
|
+
return keys;
|
|
167
|
+
}
|
|
168
|
+
async function checkWebMcp(driver, options) {
|
|
169
|
+
const checks = new Checks("webmcp", options.deadline);
|
|
119
170
|
const view = fixture.capabilities;
|
|
120
171
|
const expected = idsOf(TARGET);
|
|
121
|
-
await checks.run("each available capability of the view is a tool", `${RULE}#tools`, async () => {
|
|
122
|
-
await
|
|
172
|
+
await checks.run("each available capability of the view is a tool", `${RULE}#tools`, async (signal) => {
|
|
173
|
+
await until(`the tools are ${expected.join(", ")}`, () => names(driver, signal), (tools) => JSON.stringify(tools) === JSON.stringify(expected), signal);
|
|
123
174
|
});
|
|
124
|
-
await checks.run("a tool has the description and the bundled input schema of its capability", `${RULE}#tools`, async () => {
|
|
125
|
-
for (const tool of await driver.tools()) {
|
|
175
|
+
await checks.run("a tool has the description and the bundled input schema of its capability", `${RULE}#tools`, async (signal) => {
|
|
176
|
+
for (const tool of await driver.tools(signal)) {
|
|
126
177
|
expectEqual(tool.description, view[tool.name]?.description, `description of ${tool.name}`);
|
|
127
178
|
const schema = tool.inputSchema;
|
|
179
|
+
expect(isObject(schema), `the input schema of ${tool.name} must be an object`);
|
|
128
180
|
const input = view[tool.name]?.input ?? {};
|
|
181
|
+
const required = (object) => isStringArray(object.required) ? object.required : [];
|
|
129
182
|
expectEqual(schema.type, "object", `type of the input schema of ${tool.name}`);
|
|
130
183
|
expectEqual(sorted(Object.keys(schema.properties ?? {})), sorted(Object.keys(input.properties ?? {})), `properties of ${tool.name}`);
|
|
131
|
-
expectEqual(sorted(schema
|
|
184
|
+
expectEqual(sorted(required(schema)), sorted(required(input)), `required properties of ${tool.name}`);
|
|
132
185
|
expect(isBundled(schema), `the input schema of ${tool.name} must be bundled`);
|
|
133
186
|
}
|
|
134
187
|
});
|
|
135
|
-
await checks.run("a value resolves as a result", `${RULE}#tools`, async () => {
|
|
136
|
-
expectEqual(await driver.call("fixture.echo", { text: "hello" }), { resolved: {
|
|
188
|
+
await checks.run("a value resolves as a result", `${RULE}#tools`, async (signal) => {
|
|
189
|
+
expectEqual(await driver.call("fixture.echo", { text: "hello" }, { signal }), { resolved: {
|
|
137
190
|
ok: true,
|
|
138
191
|
value: { text: "hello" }
|
|
139
192
|
} }, "outcome");
|
|
140
193
|
});
|
|
141
|
-
await checks.run("a failure resolves as a result", `${RULE}#tools`, async () => {
|
|
142
|
-
const call = await driver.call("fixture.refuse", {});
|
|
194
|
+
await checks.run("a failure resolves as a result", `${RULE}#tools`, async (signal) => {
|
|
195
|
+
const call = await driver.call("fixture.refuse", {}, { signal });
|
|
143
196
|
expect("resolved" in call, `the call must resolve, got ${JSON.stringify(call)}`);
|
|
144
|
-
expectEqual(call.resolved
|
|
197
|
+
expectEqual(memberOf(memberOf(call.resolved, "error"), "_tag"), "Refused", "_tag");
|
|
145
198
|
});
|
|
146
199
|
for (const [name, capability, input, kind, errorName] of [[
|
|
147
200
|
"a defect rejects with the kind internal",
|
|
@@ -155,23 +208,39 @@ async function checkWebMcp(driver) {
|
|
|
155
208
|
{ text: 1 },
|
|
156
209
|
"invalid-input",
|
|
157
210
|
"TypeError"
|
|
158
|
-
]]) await checks.run(name, `${RULE}#tools`, async () => {
|
|
159
|
-
const call = await driver.call(capability, input);
|
|
211
|
+
]]) await checks.run(name, `${RULE}#tools`, async (signal) => {
|
|
212
|
+
const call = await driver.call(capability, input, { signal });
|
|
160
213
|
expect("rejected" in call, `the call must reject, got ${JSON.stringify(call)}`);
|
|
161
214
|
expectEqual(call.rejected.kind, kind, "kind");
|
|
162
215
|
if (errorName !== void 0) expectEqual(call.rejected.name, errorName, "name");
|
|
163
216
|
});
|
|
164
|
-
await checks.run("the signal of the host cancels a call, as an AbortError", `${RULE}#tools`, async () => {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
217
|
+
await checks.run("the signal of the host cancels a call, as an AbortError", `${RULE}#tools`, async (signal) => {
|
|
218
|
+
const key = "cancel";
|
|
219
|
+
const cancel = new AbortController();
|
|
220
|
+
const call = driver.call("fixture.hold", { key }, {
|
|
221
|
+
signal,
|
|
222
|
+
cancel: cancel.signal
|
|
223
|
+
});
|
|
224
|
+
call.catch(() => void 0);
|
|
225
|
+
const read = () => heldKeys(driver, signal);
|
|
226
|
+
try {
|
|
227
|
+
await until(`fixture.held gives ${key}`, read, (held) => held.includes(key), signal);
|
|
228
|
+
cancel.abort();
|
|
229
|
+
const result = await call;
|
|
230
|
+
expect("rejected" in result, `the call must reject, got ${JSON.stringify(result)}`);
|
|
231
|
+
expectEqual(result.rejected.kind, "cancelled", "kind");
|
|
232
|
+
expectEqual(result.rejected.name, "AbortError", "name");
|
|
233
|
+
await until("the cancelled call stops holding", read, (held) => !held.includes(key), signal);
|
|
234
|
+
} finally {
|
|
235
|
+
await driver.call("fixture.release", { key }, { signal: AbortSignal.timeout(options.deadline) }).catch(() => void 0);
|
|
236
|
+
}
|
|
169
237
|
});
|
|
170
|
-
await checks.run("a tool is removed while its capability is unavailable, and registered again after", `${RULE}#tools`, async () => {
|
|
171
|
-
|
|
172
|
-
await
|
|
173
|
-
await
|
|
174
|
-
await
|
|
238
|
+
await checks.run("a tool is removed while its capability is unavailable, and registered again after", `${RULE}#tools`, async (signal) => {
|
|
239
|
+
const toggle = (present) => (tools) => tools.includes("fixture.toggle") === present;
|
|
240
|
+
await driver.call("fixture.switch.set", { on: false }, { signal });
|
|
241
|
+
await until("fixture.toggle is removed", () => names(driver, signal), toggle(false), signal);
|
|
242
|
+
await driver.call("fixture.switch.set", { on: true }, { signal });
|
|
243
|
+
await until("fixture.toggle is registered again", () => names(driver, signal), toggle(true), signal);
|
|
175
244
|
});
|
|
176
245
|
return checks;
|
|
177
246
|
}
|
|
@@ -73,10 +73,50 @@ var fixture_acidcat_default = {
|
|
|
73
73
|
"type": "object"
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
|
+
"fixture.held": {
|
|
77
|
+
"description": "Return the keys of the calls to fixture.hold that hold now.",
|
|
78
|
+
"audience": [
|
|
79
|
+
"app",
|
|
80
|
+
"mcp",
|
|
81
|
+
"cli",
|
|
82
|
+
"api"
|
|
83
|
+
],
|
|
84
|
+
"output": {
|
|
85
|
+
"properties": { "keys": {
|
|
86
|
+
"items": { "type": "string" },
|
|
87
|
+
"type": "array"
|
|
88
|
+
} },
|
|
89
|
+
"required": ["keys"],
|
|
90
|
+
"type": "object"
|
|
91
|
+
},
|
|
92
|
+
"readOnly": true
|
|
93
|
+
},
|
|
76
94
|
"fixture.hidden": {
|
|
77
95
|
"description": "Only the app target can use this capability.",
|
|
78
96
|
"audience": ["app"]
|
|
79
97
|
},
|
|
98
|
+
"fixture.hold": {
|
|
99
|
+
"description": "Hold until fixture.release releases the key, then return the key. While the call holds, fixture.held gives the key. With a marker, the call writes a file at that path when it starts to hold.",
|
|
100
|
+
"audience": [
|
|
101
|
+
"app",
|
|
102
|
+
"mcp",
|
|
103
|
+
"cli",
|
|
104
|
+
"api"
|
|
105
|
+
],
|
|
106
|
+
"input": {
|
|
107
|
+
"properties": {
|
|
108
|
+
"key": { "type": "string" },
|
|
109
|
+
"marker": { "type": "string" }
|
|
110
|
+
},
|
|
111
|
+
"required": ["key"],
|
|
112
|
+
"type": "object"
|
|
113
|
+
},
|
|
114
|
+
"output": {
|
|
115
|
+
"properties": { "key": { "type": "string" } },
|
|
116
|
+
"required": ["key"],
|
|
117
|
+
"type": "object"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
80
120
|
"fixture.refuse": {
|
|
81
121
|
"description": "Always return the failure Refused.",
|
|
82
122
|
"audience": [
|
|
@@ -97,8 +137,8 @@ var fixture_acidcat_default = {
|
|
|
97
137
|
"type": "object"
|
|
98
138
|
} }
|
|
99
139
|
},
|
|
100
|
-
"fixture.
|
|
101
|
-
"description": "
|
|
140
|
+
"fixture.release": {
|
|
141
|
+
"description": "Release the calls to fixture.hold that hold the key.",
|
|
102
142
|
"audience": [
|
|
103
143
|
"app",
|
|
104
144
|
"mcp",
|
|
@@ -106,8 +146,8 @@ var fixture_acidcat_default = {
|
|
|
106
146
|
"api"
|
|
107
147
|
],
|
|
108
148
|
"input": {
|
|
109
|
-
"properties": { "
|
|
110
|
-
"required": ["
|
|
149
|
+
"properties": { "key": { "type": "string" } },
|
|
150
|
+
"required": ["key"],
|
|
111
151
|
"type": "object"
|
|
112
152
|
}
|
|
113
153
|
},
|
package/dist/json.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks of JSON values at run time. A value from a file, a process or a network is not trusted. These checks give it a
|
|
3
|
+
* type only after they examine it.
|
|
4
|
+
*/
|
|
5
|
+
import type { JsonObject } from "@acid-cat/spec-ts";
|
|
6
|
+
export type { JsonObject };
|
|
7
|
+
/** True if the value is a JSON object: not `null`, and not an array. */
|
|
8
|
+
export declare const isObject: (value: unknown) => value is JsonObject;
|
|
9
|
+
/** True if the value is an array of strings. */
|
|
10
|
+
export declare const isStringArray: (value: unknown) => value is readonly string[];
|
|
11
|
+
/** True if the value is a JSON object, and `isMember` is true for each of its member values. */
|
|
12
|
+
export declare const isRecordOf: <T>(value: unknown, isMember: (member: unknown) => member is T) => value is {
|
|
13
|
+
readonly [key: string]: T;
|
|
14
|
+
};
|
|
15
|
+
/** The member `key` of a value, or `undefined` when the value is not an object. */
|
|
16
|
+
export declare const memberOf: (value: unknown, key: string) => unknown;
|
|
17
|
+
/** Parse JSON text. The value is not trusted, so its type is `unknown`. */
|
|
18
|
+
export declare function parseJson(text: string): unknown;
|
|
19
|
+
/** Read the body of a response as JSON. The value is not trusted, so its type is `unknown`. */
|
|
20
|
+
export declare function readJson(response: Response): Promise<unknown>;
|
|
21
|
+
/** True if the value is an array, and `isItem` is true for each of its items. */
|
|
22
|
+
export declare const isArrayOf: <T>(value: unknown, isItem: (item: unknown) => item is T) => value is readonly T[];
|
package/dist/json.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/json.ts
|
|
2
|
+
/** True if the value is a JSON object: not `null`, and not an array. */
|
|
3
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
/** True if the value is an array of strings. */
|
|
5
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
6
|
+
/** True if the value is a JSON object, and `isMember` is true for each of its member values. */
|
|
7
|
+
const isRecordOf = (value, isMember) => isObject(value) && Object.values(value).every(isMember);
|
|
8
|
+
/** The member `key` of a value, or `undefined` when the value is not an object. */
|
|
9
|
+
const memberOf = (value, key) => isObject(value) ? value[key] : void 0;
|
|
10
|
+
/** Parse JSON text. The value is not trusted, so its type is `unknown`. */
|
|
11
|
+
function parseJson(text) {
|
|
12
|
+
return JSON.parse(text);
|
|
13
|
+
}
|
|
14
|
+
/** Read the body of a response as JSON. The value is not trusted, so its type is `unknown`. */
|
|
15
|
+
async function readJson(response) {
|
|
16
|
+
return await response.json();
|
|
17
|
+
}
|
|
18
|
+
/** True if the value is an array, and `isItem` is true for each of its items. */
|
|
19
|
+
const isArrayOf = (value, isItem) => Array.isArray(value) && value.every(isItem);
|
|
20
|
+
//#endregion
|
|
21
|
+
export { isArrayOf, isObject, isRecordOf, isStringArray, memberOf, parseJson, readJson };
|
package/dist/main.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { parseJson } from "./json.js";
|
|
3
|
+
import { check, isCatalog, viewOf } from "./catalog.js";
|
|
4
|
+
import { DEADLINE } from "./conform/check.js";
|
|
3
5
|
import { conform } from "./conform/index.js";
|
|
4
6
|
import { version } from "./json/package.json.js";
|
|
5
7
|
import { canonicalText } from "@acid-cat/catalog";
|
|
@@ -16,7 +18,7 @@ const readCatalog = Effect.fn("readCatalog")(function* (file) {
|
|
|
16
18
|
const text = yield* (yield* FileSystem.FileSystem).readFileString(file);
|
|
17
19
|
return {
|
|
18
20
|
text,
|
|
19
|
-
document:
|
|
21
|
+
document: parseJson(text)
|
|
20
22
|
};
|
|
21
23
|
});
|
|
22
24
|
const fail = Effect.sync(() => {
|
|
@@ -54,8 +56,11 @@ const viewCommand = Command.make("view", {
|
|
|
54
56
|
file: Argument.String("catalog").pipe(Argument.withDescription("A catalog file")),
|
|
55
57
|
target: Argument.String("target").pipe(Argument.withDescription("The name of a target in the catalog"))
|
|
56
58
|
}, Effect.fn(function* ({ file, target }) {
|
|
57
|
-
const { document } = yield* readCatalog(file);
|
|
58
|
-
|
|
59
|
+
const { document: catalog } = yield* readCatalog(file);
|
|
60
|
+
if (!isCatalog(catalog)) {
|
|
61
|
+
yield* Console.error(`${file}: error: the file is not a catalog; run \`acid-cat check ${file}\``);
|
|
62
|
+
return yield* fail;
|
|
63
|
+
}
|
|
59
64
|
if (!(target in catalog.targets)) {
|
|
60
65
|
yield* Console.error(`${file}: error: there is no target ${target}`);
|
|
61
66
|
return yield* fail;
|
|
@@ -68,7 +73,8 @@ const conformCommand = Command.make("conform", {
|
|
|
68
73
|
http: url("http", "The base URL of the HTTP API, before /capabilities"),
|
|
69
74
|
cli: url("cli", "The command that starts the command line, for example \"node fixture-cli.ts\""),
|
|
70
75
|
webmcp: url("webmcp", "The URL of a web page that serves the app target. It runs in Chromium, through Playwright"),
|
|
71
|
-
maximumBytes: Flag.Int("max-bytes").pipe(Flag.withDescription("The body limit of the remote bindings"), Flag.withDefault(1048576))
|
|
76
|
+
maximumBytes: Flag.Int("max-bytes").pipe(Flag.withDescription("The body limit of the remote bindings"), Flag.withDefault(1048576)),
|
|
77
|
+
deadline: Flag.Int("deadline").pipe(Flag.withDescription("The time, in milliseconds, that each check has to settle. A check that does not settle in this time fails"), Flag.withDefault(DEADLINE))
|
|
72
78
|
}, Effect.fn(function* (options) {
|
|
73
79
|
const value = (option) => Option.isSome(option) ? option.value : void 0;
|
|
74
80
|
const command = value(options.cli)?.split(/\s+/).filter((word) => word !== "");
|
|
@@ -77,7 +83,8 @@ const conformCommand = Command.make("conform", {
|
|
|
77
83
|
http: value(options.http),
|
|
78
84
|
cli: command,
|
|
79
85
|
webmcp: value(options.webmcp),
|
|
80
|
-
maximumBytes: options.maximumBytes
|
|
86
|
+
maximumBytes: options.maximumBytes,
|
|
87
|
+
deadline: options.deadline
|
|
81
88
|
}));
|
|
82
89
|
if (results.length === 0) {
|
|
83
90
|
yield* Console.error("Give at least one binding: --mcp, --http, --cli or --webmcp.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The AcidCat command line: check, format and view catalogs, and test implementations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"acidcat",
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@acid-cat/catalog": "0.
|
|
41
|
-
"@acid-cat/spec-ts": "0.
|
|
42
|
-
"@effect/platform-node": "4.0.
|
|
43
|
-
"@effect/platform-node-shared": "4.0.
|
|
40
|
+
"@acid-cat/catalog": "0.5.0",
|
|
41
|
+
"@acid-cat/spec-ts": "0.5.0",
|
|
42
|
+
"@effect/platform-node": "4.0.6",
|
|
43
|
+
"@effect/platform-node-shared": "4.0.6",
|
|
44
44
|
"@modelcontextprotocol/client": "2.3.1",
|
|
45
|
-
"effect": "4.0.
|
|
45
|
+
"effect": "4.0.6",
|
|
46
46
|
"fast-check": "4.10.2"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|