@acid-cat/cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `acid-cat conform`: test a running implementation through its bindings (spec/conformance.md). The implementation
3
+ * serves the conformance fixture (conformance/fixture.acidcat.json), with its switch on.
4
+ */
5
+ import type { Check } from "./check.js";
6
+ import type { WebMcpInstall } from "./webmcp.js";
7
+ export type { Check } from "./check.js";
8
+ export { fixture } from "./check.js";
9
+ export { serve } from "./serve.js";
10
+ export type { Served, WebHandler } from "./serve.js";
11
+ export { createTestHost } from "./webmcp.js";
12
+ export type { TestHost, WebMcpInstall } from "./webmcp.js";
13
+ export interface ConformOptions {
14
+ /** The URL of the MCP server, for example `http://localhost:8787/mcp`. */
15
+ readonly mcp?: string | undefined;
16
+ /** The base URL of the HTTP API, before `/capabilities`. */
17
+ readonly http?: string | undefined;
18
+ /** The command that starts the command line, for example `["node", "fixture-cli.ts"]`. */
19
+ readonly cli?: readonly string[] | undefined;
20
+ /**
21
+ * The WebMCP binding: the URL of a page that serves the `app` target, or a function that installs the implementation
22
+ * on a host in the same process. A page runs in Chromium, through Playwright.
23
+ */
24
+ readonly webmcp?: string | WebMcpInstall | undefined;
25
+ /** The body limit of the remote bindings, in bytes. The default is 1 MiB. */
26
+ readonly maximumBytes?: number | undefined;
27
+ }
28
+ /** Run all checks for the bindings that the options give. */
29
+ export declare function conform(options: ConformOptions): Promise<readonly Check[]>;
@@ -0,0 +1,37 @@
1
+ import { fixture } from "./check.js";
2
+ import { checkCli } from "./cli.js";
3
+ import { checkHttp } from "./http.js";
4
+ import { checkMcp } from "./mcp.js";
5
+ import { checkProperties, httpInvoke, mcpInvoke } from "./properties.js";
6
+ import { browserDriver, checkWebMcp, processDriver } from "./webmcp.js";
7
+ import { serve } from "./serve.js";
8
+ //#region src/conform/index.ts
9
+ /** Run all checks for the bindings that the options give. */
10
+ async function conform(options) {
11
+ const maximumBytes = options.maximumBytes ?? 1048576;
12
+ const results = [];
13
+ if (options.mcp !== void 0) {
14
+ results.push(...(await checkMcp(options.mcp, { maximumBytes })).results);
15
+ const mcp = await mcpInvoke(options.mcp).catch(() => void 0);
16
+ if (mcp !== void 0) {
17
+ results.push(...(await checkProperties("mcp", mcp.invoke)).results);
18
+ await mcp.close();
19
+ }
20
+ }
21
+ if (options.http !== void 0) {
22
+ results.push(...(await checkHttp(options.http, { maximumBytes })).results);
23
+ results.push(...(await checkProperties("http", httpInvoke(options.http))).results);
24
+ }
25
+ if (options.cli !== void 0) results.push(...(await checkCli(options.cli)).results);
26
+ if (options.webmcp !== void 0) {
27
+ const driver = typeof options.webmcp === "string" ? await browserDriver(options.webmcp) : await processDriver(options.webmcp);
28
+ try {
29
+ results.push(...(await checkWebMcp(driver)).results);
30
+ } finally {
31
+ await driver.close();
32
+ }
33
+ }
34
+ return results;
35
+ }
36
+ //#endregion
37
+ export { conform, fixture, serve };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Conformance checks for the MCP binding (spec/bindings/mcp.md). The protocol checks use the official MCP client,
3
+ * with revision 2026-07-28. The rejection checks send requests that a client never sends.
4
+ */
5
+ import { Client } from "@modelcontextprotocol/client";
6
+ import { Checks } from "./check.js";
7
+ /** A connected MCP client. */
8
+ export declare function mcpClient(url: string): Promise<Client>;
9
+ export declare function checkMcp(url: string, options: {
10
+ readonly maximumBytes: number;
11
+ }): Promise<Checks>;
@@ -0,0 +1,127 @@
1
+ import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, idsOf, sorted } from "./check.js";
2
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3
+ //#region src/conform/mcp.ts
4
+ /**
5
+ * Conformance checks for the MCP binding (spec/bindings/mcp.md). The protocol checks use the official MCP client,
6
+ * with revision 2026-07-28. The rejection checks send requests that a client never sends.
7
+ */
8
+ const RULE = "spec/bindings/mcp.md";
9
+ /** A connected MCP client. */
10
+ async function mcpClient(url) {
11
+ const client = new Client({
12
+ name: "acid-cat-conform",
13
+ version: "0.1.0"
14
+ }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
15
+ await client.connect(new StreamableHTTPClientTransport(new URL(url)));
16
+ return client;
17
+ }
18
+ const textOf = (result) => JSON.parse(result.content?.[0]?.text ?? "null");
19
+ async function checkMcp(url, options) {
20
+ const checks = new Checks("mcp");
21
+ let client;
22
+ await checks.run("an MCP client connects with revision 2026-07-28", `${RULE}#server`, async () => {
23
+ client = await mcpClient(url);
24
+ });
25
+ if (client === void 0) return checks;
26
+ const mcp = client;
27
+ const call = (name, args) => mcp.callTool({
28
+ name,
29
+ arguments: args
30
+ });
31
+ await checks.run("tools/list gives each available capability of the view", `${RULE}#tools`, async () => {
32
+ const { tools } = await mcp.listTools();
33
+ expectEqual(sorted(tools.map((tool) => tool.name)), idsOf("mcp"), "tools");
34
+ });
35
+ await checks.run("tools have their hints and output schemas", `${RULE}#tools`, async () => {
36
+ const { tools } = await mcp.listTools();
37
+ const echo = tools.find((tool) => tool.name === "fixture.echo");
38
+ const refuse = tools.find((tool) => tool.name === "fixture.refuse");
39
+ expect(echo?.annotations?.readOnlyHint === true, "fixture.echo must have readOnlyHint: true");
40
+ expect(refuse?.annotations?.readOnlyHint !== true, "fixture.refuse must not have readOnlyHint: true");
41
+ expect((echo?.outputSchema)?.type === "object", "fixture.echo must have an object outputSchema");
42
+ expect(refuse?.outputSchema === void 0, "fixture.refuse has no object output, so it must have no outputSchema");
43
+ expect((echo?.inputSchema).type === "object", "an inputSchema must be an object");
44
+ });
45
+ await checks.run("a value is a tool result with structured content", `${RULE}#tools`, async () => {
46
+ const result = await call("fixture.echo", { text: "hello" });
47
+ expect(result.isError !== true, "a value must not be an error result");
48
+ expectEqual(result.structuredContent, { text: "hello" }, "structuredContent");
49
+ expectEqual(textOf(result), { text: "hello" }, "text content");
50
+ });
51
+ await checks.run("a failure is an error result with ok: false", `${RULE}#tools`, async () => {
52
+ const result = await call("fixture.refuse", {});
53
+ expect(result.isError === true, "a failure must be an error result");
54
+ const body = textOf(result);
55
+ expect(body.ok === false && body.error?._tag === "Refused", `expected { ok: false, error: { _tag: "Refused" } }, got ${JSON.stringify(body)}`);
56
+ });
57
+ await checks.run("a defect is an internal call failure with no internal details", `${RULE}#tools`, async () => {
58
+ const result = await call("fixture.crash", {});
59
+ const body = textOf(result);
60
+ expect(result.isError === true && body.kind === "internal" && !("ok" in body), `expected { kind: "internal" }, got ${JSON.stringify(body)}`);
61
+ expect(!/\n\s+at /.test(body.message ?? ""), "the message must not contain a stack trace");
62
+ });
63
+ await checks.run("an input that does not agree with its schema is invalid-input", `${RULE}#tools`, async () => {
64
+ const result = await call("fixture.echo", { text: 1 });
65
+ expectEqual(textOf(result).kind, "invalid-input", "kind");
66
+ });
67
+ await checks.run("a capability outside the view is an unknown tool", `${RULE}#tools`, async () => {
68
+ const error = await call("fixture.hidden", {}).then(() => void 0, (caught) => caught);
69
+ expect(error?.code === -32602, `expected the JSON-RPC error -32602, got ${JSON.stringify(error)}`);
70
+ });
71
+ await checks.run("an unavailable capability is not listed", `${RULE}#tools`, async () => {
72
+ await call("fixture.switch.set", { on: false });
73
+ try {
74
+ const { tools } = await mcp.listTools();
75
+ expect(!tools.some((tool) => tool.name === "fixture.toggle"), "fixture.toggle must not be listed while it is unavailable");
76
+ } finally {
77
+ await call("fixture.switch.set", { on: true });
78
+ }
79
+ });
80
+ await mcp.close();
81
+ const post = (init) => fetch(url, {
82
+ method: "POST",
83
+ ...init,
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ ...init.headers
87
+ }
88
+ });
89
+ const rejection = async (response, status, code) => {
90
+ expectEqual(response.status, status, "status");
91
+ expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
92
+ if (code !== void 0) expectEqual((await response.json()).error?.code, code, "JSON-RPC error code");
93
+ };
94
+ await checks.run("a method other than POST is rejected with 405", `${RULE}#server`, async () => {
95
+ const response = await fetch(url);
96
+ await rejection(response, 405);
97
+ expectEqual(response.headers.get("Allow"), "POST", "Allow");
98
+ });
99
+ await checks.run("an origin that is not allowed is rejected with 403", `${RULE}#server`, async () => {
100
+ await rejection(await post({
101
+ headers: { Origin: FORBIDDEN_ORIGIN },
102
+ body: "{}"
103
+ }), 403);
104
+ });
105
+ await checks.run("a media type other than JSON is rejected with 415", `${RULE}#server`, async () => {
106
+ await rejection(await post({
107
+ headers: { "Content-Type": "text/plain" },
108
+ body: "{}"
109
+ }), 415);
110
+ });
111
+ await checks.run("a body over the limit is rejected with 413 and -32600", `${RULE}#server`, async () => {
112
+ await rejection(await post({ body: " ".repeat(options.maximumBytes + 1) }), 413, -32600);
113
+ });
114
+ await checks.run("a body that is not JSON is rejected with 400 and -32700", `${RULE}#server`, async () => {
115
+ await rejection(await post({ body: "{" }), 400, -32700);
116
+ });
117
+ await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE}#server`, async () => {
118
+ await rejection(await post({ body: new Uint8Array([
119
+ 123,
120
+ 255,
121
+ 125
122
+ ]) }), 400);
123
+ });
124
+ return checks;
125
+ }
126
+ //#endregion
127
+ export { checkMcp, mcpClient };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Property-based checks for the guarantees of spec/invocation.md#cancellation-and-concurrency, through each remote
3
+ * binding. fast-check makes random sets of concurrent calls and cancellations.
4
+ */
5
+ import type { Outcome } from "@acid-cat/spec-ts";
6
+ import { Checks } from "./check.js";
7
+ /** A call through one binding. A cancelled call can reject. */
8
+ export type Invoke = (id: string, input: object, signal?: AbortSignal) => Promise<Outcome<unknown, unknown>>;
9
+ export declare function httpInvoke(base: string): Invoke;
10
+ export declare function mcpInvoke(url: string): Promise<{
11
+ readonly invoke: Invoke;
12
+ close(): Promise<void>;
13
+ }>;
14
+ export declare function checkProperties(binding: string, invoke: Invoke): Promise<Checks>;
@@ -0,0 +1,98 @@
1
+ import { Checks, expect } from "./check.js";
2
+ import { mcpClient } from "./mcp.js";
3
+ import fc from "fast-check";
4
+ //#region src/conform/properties.ts
5
+ const RULE = "spec/invocation.md#cancellation-and-concurrency";
6
+ function httpInvoke(base) {
7
+ return async (id, input, signal) => {
8
+ return await (await fetch(`${base}/capabilities/${id}`, {
9
+ method: "POST",
10
+ headers: { "Content-Type": "application/json" },
11
+ body: JSON.stringify(input),
12
+ ...signal ? { signal } : {}
13
+ })).json();
14
+ };
15
+ }
16
+ async function mcpInvoke(url) {
17
+ const client = await mcpClient(url);
18
+ return {
19
+ invoke: async (id, input, signal) => {
20
+ try {
21
+ const result = await client.callTool({
22
+ name: id,
23
+ arguments: input
24
+ }, signal ? { signal } : void 0);
25
+ const body = JSON.parse(result.content[0]?.text ?? "null");
26
+ if (result.isError !== true) return {
27
+ ok: true,
28
+ value: result.structuredContent ?? body
29
+ };
30
+ return body;
31
+ } catch (error) {
32
+ if (error.code === -32602) return {
33
+ kind: "unknown-capability",
34
+ message: String(error)
35
+ };
36
+ throw error;
37
+ }
38
+ },
39
+ close: () => client.close()
40
+ };
41
+ }
42
+ const operation = fc.oneof(fc.string({ maxLength: 40 }).map((text) => ({
43
+ kind: "echo",
44
+ text
45
+ })), fc.constant({ kind: "refuse" }), fc.constant({ kind: "crash" }), fc.constant({ kind: "invalid" }));
46
+ /** The outcome that an operation must have. */
47
+ function agrees(operation, outcome) {
48
+ switch (operation.kind) {
49
+ case "echo": return "ok" in outcome && outcome.ok && JSON.stringify(outcome.value) === JSON.stringify({ text: operation.text });
50
+ case "refuse": return "ok" in outcome && !outcome.ok && outcome.error._tag === "Refused";
51
+ case "crash": return "kind" in outcome && outcome.kind === "internal";
52
+ case "invalid": return "kind" in outcome && outcome.kind === "invalid-input";
53
+ }
54
+ }
55
+ const callFor = (invoke, operation) => {
56
+ switch (operation.kind) {
57
+ case "echo": return invoke("fixture.echo", { text: operation.text });
58
+ case "refuse": return invoke("fixture.refuse", {});
59
+ case "crash": return invoke("fixture.crash", {});
60
+ case "invalid": return invoke("fixture.echo", { text: 0 });
61
+ }
62
+ };
63
+ async function checkProperties(binding, invoke) {
64
+ const checks = new Checks(`${binding} properties`);
65
+ await checks.run("concurrent calls each get exactly their own outcome", RULE, async () => {
66
+ await fc.assert(fc.asyncProperty(fc.array(operation, {
67
+ minLength: 1,
68
+ maxLength: 12
69
+ }), async (operations) => {
70
+ const outcomes = await Promise.all(operations.map((op) => callFor(invoke, op)));
71
+ operations.forEach((op, index) => expect(agrees(op, outcomes[index]), `${op.kind} gave ${JSON.stringify(outcomes[index])}`));
72
+ }), { numRuns: 25 });
73
+ });
74
+ await checks.run("cancelling a call affects only that call", RULE, async () => {
75
+ await fc.assert(fc.asyncProperty(fc.array(fc.boolean(), {
76
+ minLength: 2,
77
+ maxLength: 5
78
+ }), async (cancel) => {
79
+ const calls = cancel.map((cancelled) => {
80
+ const controller = new AbortController();
81
+ const outcome = invoke("fixture.slow", { milliseconds: 200 }, controller.signal).catch(() => "aborted");
82
+ if (cancelled) setTimeout(() => controller.abort(), 20);
83
+ return {
84
+ cancelled,
85
+ outcome
86
+ };
87
+ });
88
+ for (const call of calls) {
89
+ const outcome = await call.outcome;
90
+ if (!call.cancelled) expect(outcome !== "aborted" && "ok" in outcome && outcome.ok, `a call that was not cancelled gave ${JSON.stringify(outcome)}`);
91
+ else expect(outcome === "aborted" || "kind" in outcome && outcome.kind === "cancelled", `a cancelled call gave ${JSON.stringify(outcome)}`);
92
+ }
93
+ }), { numRuns: 5 });
94
+ });
95
+ return checks;
96
+ }
97
+ //#endregion
98
+ export { checkProperties, httpInvoke, mcpInvoke };
@@ -0,0 +1,10 @@
1
+ export interface Served {
2
+ /** The base URL, for example `http://127.0.0.1:40123`. */
3
+ readonly url: string;
4
+ close(): Promise<void>;
5
+ }
6
+ export type WebHandler = (request: Request) => Promise<Response>;
7
+ /** Serve handlers by path prefix, for example `{ "/mcp": mcp, "/api": http }`. */
8
+ export declare function serve(routes: {
9
+ readonly [prefix: string]: WebHandler;
10
+ }): Promise<Served>;
@@ -0,0 +1,62 @@
1
+ import { createServer } from "node:http";
2
+ //#region src/conform/serve.ts
3
+ /**
4
+ * Serve a web request handler, `(request: Request) => Promise<Response>`, on a local port. Implementations use it
5
+ * to run `acid-cat conform` against their bindings in a test.
6
+ */
7
+ /** Serve handlers by path prefix, for example `{ "/mcp": mcp, "/api": http }`. */
8
+ function serve(routes) {
9
+ const prefixes = Object.keys(routes).sort((a, b) => b.length - a.length);
10
+ const server = createServer((incoming, outgoing) => {
11
+ answer(incoming, outgoing, prefixes, routes);
12
+ });
13
+ return new Promise((resolve) => {
14
+ server.listen(0, "127.0.0.1", () => {
15
+ const address = server.address();
16
+ resolve({
17
+ url: `http://127.0.0.1:${typeof address === "object" && address !== null ? address.port : 0}`,
18
+ close: () => new Promise((done) => server.close(() => done()))
19
+ });
20
+ });
21
+ });
22
+ }
23
+ async function answer(incoming, outgoing, prefixes, routes) {
24
+ const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "127.0.0.1"}`);
25
+ const prefix = prefixes.find((candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`));
26
+ if (prefix === void 0) {
27
+ outgoing.writeHead(404).end();
28
+ return;
29
+ }
30
+ const abort = new AbortController();
31
+ outgoing.on("close", () => {
32
+ if (!outgoing.writableFinished) abort.abort();
33
+ });
34
+ const headers = new Headers();
35
+ for (const [name, value] of Object.entries(incoming.headers)) if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
36
+ else if (value !== void 0) headers.set(name, value);
37
+ const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
38
+ const request = new Request(url, {
39
+ method: incoming.method ?? "GET",
40
+ headers,
41
+ signal: abort.signal,
42
+ ...hasBody ? {
43
+ body: incoming,
44
+ duplex: "half"
45
+ } : {}
46
+ });
47
+ try {
48
+ const response = await routes[prefix](request);
49
+ outgoing.writeHead(response.status, Object.fromEntries(response.headers));
50
+ if (response.body === null) {
51
+ outgoing.end();
52
+ return;
53
+ }
54
+ for await (const chunk of response.body) outgoing.write(chunk);
55
+ outgoing.end();
56
+ } catch {
57
+ if (!outgoing.headersSent) outgoing.writeHead(500);
58
+ outgoing.end();
59
+ }
60
+ }
61
+ //#endregion
62
+ export { serve };
@@ -0,0 +1,51 @@
1
+ import { Checks } from "./check.js";
2
+ /** A tool as the host records it. */
3
+ export interface RecordedTool {
4
+ readonly name: string;
5
+ readonly description: string;
6
+ readonly inputSchema: unknown;
7
+ }
8
+ /** What a call to a tool gives: the value that `execute` resolves with, or the error that it rejects with. */
9
+ export type ToolCall = {
10
+ readonly resolved: unknown;
11
+ } | {
12
+ readonly rejected: {
13
+ readonly name: string;
14
+ readonly kind: unknown;
15
+ readonly message: string;
16
+ };
17
+ } | {
18
+ readonly missing: true;
19
+ };
20
+ /** The host that the checks give to the page. */
21
+ export interface TestHost {
22
+ readonly modelContext: {
23
+ registerTool(tool: object, options: {
24
+ readonly signal: AbortSignal;
25
+ }): void;
26
+ };
27
+ tools(): RecordedTool[];
28
+ /** Call a tool. If `cancelAfter` is given, abort the signal of the call after that many milliseconds. */
29
+ call(name: string, input: unknown, cancelAfter?: number): Promise<ToolCall>;
30
+ }
31
+ /**
32
+ * Make a test host. The function uses no imports and no outer variables, because the browser driver sends its
33
+ * source text to the page.
34
+ */
35
+ export declare function createTestHost(): TestHost;
36
+ /** The view from the checks: the tools of the page, and calls to them. */
37
+ export interface WebMcpDriver {
38
+ tools(): Promise<RecordedTool[]>;
39
+ call(name: string, input: unknown, cancelAfter?: number): Promise<ToolCall>;
40
+ close(): Promise<void>;
41
+ }
42
+ /**
43
+ * Install the implementation on a host in the same process. Return a function that removes it, or nothing. The
44
+ * implementation serves the `app` target of the fixture, with its switch on.
45
+ */
46
+ export type WebMcpInstall = (modelContext: TestHost["modelContext"]) => void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;
47
+ /** A driver for an implementation in the same process. Values pass through JSON, as they do from a page. */
48
+ export declare function processDriver(install: WebMcpInstall): Promise<WebMcpDriver>;
49
+ /** A driver for a page in Chromium, through Playwright. The page loads with the test host as its `modelContext`. */
50
+ export declare function browserDriver(url: string): Promise<WebMcpDriver>;
51
+ export declare function checkWebMcp(driver: WebMcpDriver): Promise<Checks>;
@@ -0,0 +1,179 @@
1
+ import { Checks, expect, expectEqual, fixture, idsOf, sorted } from "./check.js";
2
+ import { REFERENCE, visitObjects } from "@acid-cat/spec-ts";
3
+ //#region src/conform/webmcp.ts
4
+ /**
5
+ * Conformance checks for the WebMCP binding (spec/bindings/webmcp.md).
6
+ *
7
+ * The checks act as the WebMCP host: they give the page a `modelContext`, record the tools that the binding registers,
8
+ * and call them. The page can run in a browser, or the implementation can install itself on the host in the same
9
+ * process.
10
+ */
11
+ const RULE = "spec/bindings/webmcp.md";
12
+ /** The target of the fixture that has the WebMCP binding. */
13
+ const TARGET = "app";
14
+ /**
15
+ * Make a test host. The function uses no imports and no outer variables, because the browser driver sends its
16
+ * source text to the page.
17
+ */
18
+ function createTestHost() {
19
+ const registered = /* @__PURE__ */ new Map();
20
+ return {
21
+ modelContext: { registerTool(tool, options) {
22
+ const recorded = tool;
23
+ registered.set(recorded.name, recorded);
24
+ options.signal.addEventListener("abort", () => {
25
+ if (registered.get(recorded.name) === recorded) registered.delete(recorded.name);
26
+ });
27
+ } },
28
+ tools: () => [...registered.values()].map((tool) => ({
29
+ name: tool.name,
30
+ description: tool.description,
31
+ inputSchema: tool.inputSchema
32
+ })),
33
+ async call(name, input, cancelAfter) {
34
+ const tool = registered.get(name);
35
+ if (tool === void 0) return { missing: true };
36
+ const controller = new AbortController();
37
+ if (cancelAfter !== void 0) setTimeout(() => controller.abort(), cancelAfter);
38
+ try {
39
+ return { resolved: await tool.execute(input, { signal: controller.signal }) };
40
+ } catch (error) {
41
+ const { name: errorName, kind, message } = error;
42
+ return { rejected: {
43
+ name: String(errorName),
44
+ kind,
45
+ message: String(message)
46
+ } };
47
+ }
48
+ }
49
+ };
50
+ }
51
+ /** A driver for an implementation in the same process. Values pass through JSON, as they do from a page. */
52
+ async function processDriver(install) {
53
+ const host = createTestHost();
54
+ const remove = await install(host.modelContext);
55
+ const copy = (value) => JSON.parse(JSON.stringify(value));
56
+ return {
57
+ tools: async () => copy(host.tools()),
58
+ call: async (name, input, cancelAfter) => copy(await host.call(name, input, cancelAfter)),
59
+ close: async () => {
60
+ if (typeof remove === "function") await remove();
61
+ }
62
+ };
63
+ }
64
+ /** A driver for a page in Chromium, through Playwright. The page loads with the test host as its `modelContext`. */
65
+ async function browserDriver(url) {
66
+ let playwright;
67
+ try {
68
+ playwright = await import("playwright");
69
+ } catch {
70
+ throw new Error("acid-cat conform --webmcp needs Playwright and Chromium: `pnpm add -D playwright`, then `pnpm exec playwright install chromium`.");
71
+ }
72
+ const browser = await playwright.chromium.launch();
73
+ const page = await browser.newPage();
74
+ const errors = [];
75
+ page.on("pageerror", (error) => errors.push(error.message));
76
+ await page.addInitScript(`
77
+ window.__acidCatConform = (${createTestHost.toString()})();
78
+ for (const scope of [document, navigator]) {
79
+ Object.defineProperty(scope, "modelContext", { value: window.__acidCatConform.modelContext, configurable: true });
80
+ }
81
+ `);
82
+ await page.goto(url);
83
+ return {
84
+ tools: async () => {
85
+ if (errors.length > 0) throw new Error(`The page has errors: ${errors.join("; ")}`);
86
+ return page.evaluate(() => window.__acidCatConform.tools());
87
+ },
88
+ call: (name, input, cancelAfter) => page.evaluate(([name, input, cancelAfter]) => window.__acidCatConform.call(name, input, cancelAfter), [
89
+ name,
90
+ input,
91
+ cancelAfter
92
+ ]),
93
+ 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
+ }
104
+ }
105
+ /** True if each `$ref` of a schema refers to a type in its own `$defs` (spec/catalog.md#bundling). */
106
+ function isBundled(schema) {
107
+ const types = schema.$defs ?? {};
108
+ let bundled = true;
109
+ visitObjects(schema, (node) => {
110
+ if (typeof node.$ref !== "string") return;
111
+ const name = REFERENCE.exec(node.$ref)?.[1];
112
+ if (name === void 0 || !(name in types)) bundled = false;
113
+ });
114
+ return bundled;
115
+ }
116
+ const names = async (driver) => sorted((await driver.tools()).map((tool) => tool.name));
117
+ async function checkWebMcp(driver) {
118
+ const checks = new Checks("webmcp");
119
+ const view = fixture.capabilities;
120
+ const expected = idsOf(TARGET);
121
+ await checks.run("each available capability of the view is a tool", `${RULE}#tools`, async () => {
122
+ await eventually(async () => JSON.stringify(await names(driver)) === JSON.stringify(expected), `the tools must be ${expected.join(", ")}`);
123
+ });
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()) {
126
+ expectEqual(tool.description, view[tool.name]?.description, `description of ${tool.name}`);
127
+ const schema = tool.inputSchema;
128
+ const input = view[tool.name]?.input ?? {};
129
+ expectEqual(schema.type, "object", `type of the input schema of ${tool.name}`);
130
+ expectEqual(sorted(Object.keys(schema.properties ?? {})), sorted(Object.keys(input.properties ?? {})), `properties of ${tool.name}`);
131
+ expectEqual(sorted(schema.required ?? []), sorted(input.required ?? []), `required properties of ${tool.name}`);
132
+ expect(isBundled(schema), `the input schema of ${tool.name} must be bundled`);
133
+ }
134
+ });
135
+ await checks.run("a value resolves as a result", `${RULE}#tools`, async () => {
136
+ expectEqual(await driver.call("fixture.echo", { text: "hello" }), { resolved: {
137
+ ok: true,
138
+ value: { text: "hello" }
139
+ } }, "outcome");
140
+ });
141
+ await checks.run("a failure resolves as a result", `${RULE}#tools`, async () => {
142
+ const call = await driver.call("fixture.refuse", {});
143
+ expect("resolved" in call, `the call must resolve, got ${JSON.stringify(call)}`);
144
+ expectEqual(call.resolved.error?._tag, "Refused", "_tag");
145
+ });
146
+ for (const [name, capability, input, kind, errorName] of [[
147
+ "a defect rejects with the kind internal",
148
+ "fixture.crash",
149
+ {},
150
+ "internal",
151
+ void 0
152
+ ], [
153
+ "invalid input rejects with the kind invalid-input, as a TypeError",
154
+ "fixture.echo",
155
+ { text: 1 },
156
+ "invalid-input",
157
+ "TypeError"
158
+ ]]) await checks.run(name, `${RULE}#tools`, async () => {
159
+ const call = await driver.call(capability, input);
160
+ expect("rejected" in call, `the call must reject, got ${JSON.stringify(call)}`);
161
+ expectEqual(call.rejected.kind, kind, "kind");
162
+ if (errorName !== void 0) expectEqual(call.rejected.name, errorName, "name");
163
+ });
164
+ await checks.run("the signal of the host cancels a call, as an AbortError", `${RULE}#tools`, async () => {
165
+ const call = await driver.call("fixture.slow", { milliseconds: 6e4 }, 50);
166
+ expect("rejected" in call, `the call must reject, got ${JSON.stringify(call)}`);
167
+ expectEqual(call.rejected.kind, "cancelled", "kind");
168
+ expectEqual(call.rejected.name, "AbortError", "name");
169
+ });
170
+ await checks.run("a tool is removed while its capability is unavailable, and registered again after", `${RULE}#tools`, async () => {
171
+ await driver.call("fixture.switch.set", { on: false });
172
+ await eventually(async () => !(await names(driver)).includes("fixture.toggle"), "fixture.toggle must be removed");
173
+ await driver.call("fixture.switch.set", { on: true });
174
+ await eventually(async () => (await names(driver)).includes("fixture.toggle"), "fixture.toggle must be registered again");
175
+ });
176
+ return checks;
177
+ }
178
+ //#endregion
179
+ export { browserDriver, checkWebMcp, createTestHost, processDriver };
@@ -0,0 +1,5 @@
1
+ /** The library of `@acid-cat/cli`: catalog checks, and the conformance tests. */
2
+ export { bundle, check, viewOf } from "./catalog.js";
3
+ export type { Catalog, Report } from "./catalog.js";
4
+ export { conform, fixture, serve } from "./conform/index.js";
5
+ export type { Check, ConformOptions, Served, WebHandler } from "./conform/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { bundle, check, viewOf } from "./catalog.js";
2
+ import { fixture } from "./conform/check.js";
3
+ import { serve } from "./conform/serve.js";
4
+ import { conform } from "./conform/index.js";
5
+ export { bundle, check, conform, fixture, serve, viewOf };