@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hebilicious
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # @acid-cat/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The AcidCat command line. npm package: `@acid-cat/cli`. It gives the `acid-cat` command. Use it at build time, in CI or by hand, to check catalogs and to test implementations. Do not use it in an application at run time.
4
+
5
+ ```sh
6
+ acid-cat check [--strict] <catalog.json ...> # check catalogs
7
+ acid-cat format [--write] <catalog.json ...> # write catalogs in canonical form
8
+ acid-cat view <catalog.json> <target> # write the view of one target
9
+ acid-cat conform --mcp <url> --http <url> --cli "<command>" --webmcp <url> # test an implementation
10
+ ```
11
+
12
+ Install it as a development dependency, and run it with `pnpm exec acid-cat …`:
13
+
14
+ ```sh
15
+ pnpm add -D @acid-cat/cli
16
+ ```
17
+
18
+ It needs Node.js 24 or later. `acid-cat conform --webmcp <url>` also needs [Playwright](https://playwright.dev) and Chromium.
19
+
20
+ ## check
21
+
22
+ `check` tests a catalog against [`schemas/catalog.schema.json`](../../schemas/catalog.schema.json) and against the [catalog rules](../../spec/conformance.md#catalog) that the schema cannot express.
23
+
24
+ - An **error** is a broken rule. Example: an audience that names an unknown target.
25
+ - A **warning** is a broken recommendation. Examples: a file that is not in [canonical form](../../spec/catalog.md#canonical-form), or a member with no `x-` prefix.
26
+
27
+ The exit code is 0 if there are no errors. It is 1 if there are errors. With `--strict`, warnings also give the exit code 1.
28
+
29
+ ## format
30
+
31
+ `format` writes a catalog in canonical form. Use it for catalogs that a catalog library did not write: Pkl output, generated files, or files that you edit by hand.
32
+
33
+ ## conform
34
+
35
+ `conform` tests a running implementation of the [conformance fixture](conformance/fixture.pkl) through its bindings (see [Conformance](../../spec/conformance.md#acid-cat-conform)):
36
+
37
+ - **MCP:** the official MCP client, with revision `2026-07-28`, for tools and outcomes; raw requests for rejections.
38
+ - **HTTP** and **CLI:** each outcome, each kind of call failure, availability, and rejections. For the CLI, an interrupt (`SIGINT`) during a call gives exit code 130.
39
+ - **WebMCP:** the checks act as the WebMCP host. `--webmcp <url>` opens a page that serves the `app` target in Chromium, through [Playwright](https://playwright.dev), with a test `modelContext`. Install Playwright to use it: `pnpm add -D playwright`, then `pnpm exec playwright install chromium`. In code, `webmcp` can also be a function that installs the implementation on a host in the same process.
40
+ - **Properties:** with fast-check, through each remote binding: concurrent calls each get exactly their own outcome, and a cancellation changes only its own call.
41
+
42
+ The exit code is 1 if a check fails. Both AcidCat cores run `conform` in their tests. In code, use `conform()` and `serve()` from `@acid-cat/cli`. `createTestHost()` gives the WebMCP host of the checks.
43
+
44
+ ## Implementation
45
+
46
+ The command line is an [Effect](https://effect.website) program that uses `effect/cli`. It validates catalogs with Effect Schema. It imports `schemas/catalog.schema.json` and each schema in the catalog as Effect schemas.
47
+
48
+ | File | Contents |
49
+ | --- | --- |
50
+ | `src/catalog.ts` | The catalog rules, views and bundling |
51
+ | `src/main.ts` | The commands |
52
+ | `src/conform/` | The conformance checks, and `serve`, which serves web handlers on a local port |
53
+ | `conformance/` | The conformance fixture, in Pkl and in JSON |
54
+ | `test/` | Tests. Run them with `moon run cli:test` |
55
+
56
+ The canonical writer comes from [`@acid-cat/catalog`](../catalog/typescript).
@@ -0,0 +1,152 @@
1
+ {
2
+ "acidcat": "0.1",
3
+ "title": "AcidCat conformance fixture",
4
+ "targets": {
5
+ "api": {
6
+ "bindings": [
7
+ "http"
8
+ ]
9
+ },
10
+ "app": {
11
+ "bindings": [
12
+ "typescript",
13
+ "webmcp"
14
+ ]
15
+ },
16
+ "cli": {
17
+ "bindings": [
18
+ "cli"
19
+ ]
20
+ },
21
+ "mcp": {
22
+ "bindings": [
23
+ "mcp"
24
+ ]
25
+ }
26
+ },
27
+ "capabilities": {
28
+ "fixture.crash": {
29
+ "description": "Always fail with a defect.",
30
+ "audience": [
31
+ "app",
32
+ "mcp",
33
+ "cli",
34
+ "api"
35
+ ]
36
+ },
37
+ "fixture.echo": {
38
+ "description": "Return the input text.",
39
+ "audience": [
40
+ "app",
41
+ "mcp",
42
+ "cli",
43
+ "api"
44
+ ],
45
+ "input": {
46
+ "properties": {
47
+ "text": {
48
+ "type": "string"
49
+ }
50
+ },
51
+ "required": [
52
+ "text"
53
+ ],
54
+ "type": "object"
55
+ },
56
+ "output": {
57
+ "properties": {
58
+ "text": {
59
+ "type": "string"
60
+ }
61
+ },
62
+ "required": [
63
+ "text"
64
+ ],
65
+ "type": "object"
66
+ },
67
+ "readOnly": true
68
+ },
69
+ "fixture.hidden": {
70
+ "description": "Only the app target can use this capability.",
71
+ "audience": [
72
+ "app"
73
+ ]
74
+ },
75
+ "fixture.refuse": {
76
+ "description": "Always return the failure Refused.",
77
+ "audience": [
78
+ "app",
79
+ "mcp",
80
+ "cli",
81
+ "api"
82
+ ],
83
+ "failures": {
84
+ "Refused": {
85
+ "properties": {
86
+ "_tag": {
87
+ "const": "Refused",
88
+ "type": "string"
89
+ },
90
+ "reason": {
91
+ "type": "string"
92
+ }
93
+ },
94
+ "required": [
95
+ "_tag",
96
+ "reason"
97
+ ],
98
+ "type": "object"
99
+ }
100
+ }
101
+ },
102
+ "fixture.slow": {
103
+ "description": "Wait for some milliseconds, then return null.",
104
+ "audience": [
105
+ "app",
106
+ "mcp",
107
+ "cli",
108
+ "api"
109
+ ],
110
+ "input": {
111
+ "properties": {
112
+ "milliseconds": {
113
+ "type": "number"
114
+ }
115
+ },
116
+ "required": [
117
+ "milliseconds"
118
+ ],
119
+ "type": "object"
120
+ }
121
+ },
122
+ "fixture.switch.set": {
123
+ "description": "Turn the switch on or off. The switch controls the availability of fixture.toggle.",
124
+ "audience": [
125
+ "app",
126
+ "mcp",
127
+ "cli",
128
+ "api"
129
+ ],
130
+ "input": {
131
+ "properties": {
132
+ "on": {
133
+ "type": "boolean"
134
+ }
135
+ },
136
+ "required": [
137
+ "on"
138
+ ],
139
+ "type": "object"
140
+ }
141
+ },
142
+ "fixture.toggle": {
143
+ "description": "Available only while the switch is on.",
144
+ "audience": [
145
+ "app",
146
+ "mcp",
147
+ "cli",
148
+ "api"
149
+ ]
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,11 @@
1
+ import { bundle, viewOf } from "@acid-cat/spec-ts";
2
+ import type { CatalogDocument as Catalog } from "@acid-cat/spec-ts";
3
+ export { bundle, viewOf };
4
+ export type { Catalog };
5
+ /** The result of a check. An error breaks a rule. A warning breaks a recommendation. */
6
+ export interface Report {
7
+ readonly errors: readonly string[];
8
+ readonly warnings: readonly string[];
9
+ }
10
+ /** Check a catalog. When `text` is given, the file must also be in canonical form. */
11
+ export declare function check(document: unknown, text?: string): Report;
@@ -0,0 +1,111 @@
1
+ import catalog_schema_default from "./json/catalog.schema.json.js";
2
+ import { canonicalText } from "@acid-cat/catalog";
3
+ import { REFERENCE, bundle, schemasOf, viewOf as viewOf$1, visitObjects } from "@acid-cat/spec-ts";
4
+ import { Result, Schema, SchemaIssue, SchemaRepresentation } from "effect";
5
+ //#region src/catalog.ts
6
+ /**
7
+ * The catalog operations that the specification defines: the catalog rules, views and bundling.
8
+ * See spec/catalog.md and spec/conformance.md.
9
+ */
10
+ const KNOWN_MEMBERS = {
11
+ catalog: [
12
+ "acidcat",
13
+ "title",
14
+ "description",
15
+ "targets",
16
+ "$defs",
17
+ "capabilities"
18
+ ],
19
+ target: [
20
+ "bindings",
21
+ "description",
22
+ "options"
23
+ ],
24
+ capability: [
25
+ "description",
26
+ "audience",
27
+ "input",
28
+ "output",
29
+ "failures",
30
+ "readOnly",
31
+ "state"
32
+ ]
33
+ };
34
+ /** The catalog schema (schemas/catalog.schema.json), imported as an Effect schema. */
35
+ const decodeCatalog = (() => {
36
+ const { $defs, $schema: _schema, $id: _id, ...root } = catalog_schema_default;
37
+ const schema = SchemaRepresentation.fromJsonSchemaDocument({
38
+ dialect: "draft-2020-12",
39
+ schema: root,
40
+ definitions: $defs ?? {}
41
+ }, { patterns: "apply" });
42
+ return Schema.decodeUnknownResult(schema);
43
+ })();
44
+ const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
45
+ /** Check a catalog. When `text` is given, the file must also be in canonical form. */
46
+ function check(document, text) {
47
+ const decoded = decodeCatalog(document);
48
+ if (Result.isFailure(decoded)) return {
49
+ errors: formatIssues(decoded.failure.issue).issues.map((issue) => `${(issue.path ?? []).map((key) => `[${JSON.stringify(key)}]`).join("") || "the catalog"}: ${issue.message}`),
50
+ warnings: []
51
+ };
52
+ const catalog = document;
53
+ const errors = ruleErrors(catalog);
54
+ const warnings = [...unknownMembers(catalog), ...errors.length === 0 ? schemaWarnings(catalog) : []];
55
+ if (text !== void 0 && text !== canonicalText(catalog)) warnings.push("the file is not in canonical form: run `acid-cat format --write`");
56
+ return {
57
+ errors,
58
+ warnings
59
+ };
60
+ }
61
+ /** The catalog rules that the catalog schema cannot express. */
62
+ function ruleErrors(catalog) {
63
+ const errors = [];
64
+ const types = catalog.$defs ?? {};
65
+ for (const [name, type] of Object.entries(types)) errors.push(...referenceErrors(type, types).map((error) => `type ${name} ${error}`));
66
+ for (const [id, capability] of Object.entries(catalog.capabilities)) {
67
+ for (const target of capability.audience ?? []) if (!(target in catalog.targets)) errors.push(`${id}: the audience names ${target}, which is not a target`);
68
+ for (const [member, schema] of schemasOf(capability)) {
69
+ const references = referenceErrors(schema, types);
70
+ errors.push(...references.map((error) => `${id} ${member} ${error}`));
71
+ if (member === "input" && references.length === 0 && bundle(schema, types).type !== "object") errors.push(`${id}: the input is not an object`);
72
+ }
73
+ }
74
+ return errors;
75
+ }
76
+ /** Members that the specification does not define and that have no `x-` prefix. */
77
+ function unknownMembers(catalog) {
78
+ const unknown = (where, value, known) => Object.keys(value).filter((key) => !known.includes(key) && !key.startsWith("x-")).map((key) => `${where}: unknown member ${key}; give extensions an x- prefix`);
79
+ return [
80
+ ...unknown("the catalog", catalog, KNOWN_MEMBERS.catalog),
81
+ ...Object.entries(catalog.targets).flatMap(([name, target]) => unknown(`target ${name}`, target, KNOWN_MEMBERS.target)),
82
+ ...Object.entries(catalog.capabilities).flatMap(([id, capability]) => unknown(id, capability, KNOWN_MEMBERS.capability))
83
+ ];
84
+ }
85
+ /** Schemas that cannot be imported as Effect schemas. They can still be valid JSON Schema, so this is a warning. */
86
+ function schemaWarnings(catalog) {
87
+ const definitions = catalog.$defs ?? {};
88
+ const warnings = [];
89
+ for (const [id, capability] of Object.entries(catalog.capabilities)) for (const [member, schema] of schemasOf(capability)) try {
90
+ SchemaRepresentation.fromJsonSchemaMultiDocument({
91
+ dialect: "draft-2020-12",
92
+ schemas: [schema],
93
+ definitions
94
+ }, { patterns: "ignore" });
95
+ } catch (cause) {
96
+ warnings.push(`${id} ${member}: cannot check this schema: ${cause instanceof Error ? cause.message : String(cause)}`);
97
+ }
98
+ return warnings;
99
+ }
100
+ function referenceErrors(schema, types) {
101
+ const errors = [];
102
+ visitObjects(schema, (node) => {
103
+ if ("$defs" in node) errors.push("has its own $defs; put shared types in the catalog's $defs");
104
+ if (node.$ref === void 0) return;
105
+ const name = typeof node.$ref === "string" ? REFERENCE.exec(node.$ref)?.[1] : void 0;
106
+ if (name === void 0 || !(name in types)) errors.push(`references ${String(node.$ref)}, which is not a type`);
107
+ });
108
+ return errors;
109
+ }
110
+ //#endregion
111
+ export { bundle, check, viewOf$1 as viewOf };
@@ -0,0 +1,28 @@
1
+ import type { CatalogDocument } from "@acid-cat/spec-ts";
2
+ export interface Check {
3
+ readonly binding: string;
4
+ readonly name: string;
5
+ /** The part of the specification that the check tests, for example `spec/bindings/mcp.md#tools`. */
6
+ readonly rule: string;
7
+ readonly passed: boolean;
8
+ readonly detail?: string | undefined;
9
+ }
10
+ /** The conformance fixture: the catalog that each implementation serves. */
11
+ export declare const fixture: CatalogDocument;
12
+ /** The ids of the capabilities in the view of a target of the fixture. */
13
+ export declare const idsOf: (target: string) => string[];
14
+ /** Collects checks for one binding. */
15
+ export declare class Checks {
16
+ readonly results: Check[];
17
+ readonly binding: string;
18
+ constructor(binding: string);
19
+ run(name: string, rule: string, body: () => Promise<void> | void): Promise<void>;
20
+ }
21
+ /** Fail a check unless `condition` is true. */
22
+ export declare function expect(condition: unknown, message: string): asserts condition;
23
+ /** Fail a check unless the two values have the same JSON. */
24
+ export declare function expectEqual(actual: unknown, expected: unknown, what: string): void;
25
+ /** A sorted copy of a list. */
26
+ export declare const sorted: (values: Iterable<string>) => string[];
27
+ /** An origin that no deployment allows. */
28
+ export declare const FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
@@ -0,0 +1,51 @@
1
+ import fixture_acidcat_default from "../json/fixture.acidcat.json.js";
2
+ import { viewOf } from "@acid-cat/spec-ts";
3
+ //#region src/conform/check.ts
4
+ /** The result of one conformance check, and helpers to write checks. */
5
+ /** The conformance fixture: the catalog that each implementation serves. */
6
+ const fixture = fixture_acidcat_default;
7
+ /** The ids of the capabilities in the view of a target of the fixture. */
8
+ const idsOf = (target) => Object.keys(viewOf(fixture, target).capabilities).sort();
9
+ /** Collects checks for one binding. */
10
+ var Checks = class {
11
+ results = [];
12
+ binding;
13
+ constructor(binding) {
14
+ this.binding = binding;
15
+ }
16
+ async run(name, rule, body) {
17
+ try {
18
+ await body();
19
+ this.results.push({
20
+ binding: this.binding,
21
+ name,
22
+ rule,
23
+ passed: true
24
+ });
25
+ } catch (error) {
26
+ this.results.push({
27
+ binding: this.binding,
28
+ name,
29
+ rule,
30
+ passed: false,
31
+ detail: error instanceof Error ? error.message : String(error)
32
+ });
33
+ }
34
+ }
35
+ };
36
+ /** Fail a check unless `condition` is true. */
37
+ function expect(condition, message) {
38
+ if (!condition) throw new Error(message);
39
+ }
40
+ /** Fail a check unless the two values have the same JSON. */
41
+ function expectEqual(actual, expected, what) {
42
+ const a = JSON.stringify(actual);
43
+ const b = JSON.stringify(expected);
44
+ if (a !== b) throw new Error(`${what}: expected ${b}, got ${a}`);
45
+ }
46
+ /** A sorted copy of a list. */
47
+ const sorted = (values) => [...values].sort();
48
+ /** An origin that no deployment allows. */
49
+ const FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
50
+ //#endregion
51
+ export { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, fixture, idsOf, sorted };
@@ -0,0 +1,2 @@
1
+ import { Checks } from "./check.js";
2
+ export declare function checkCli(command: readonly string[]): Promise<Checks>;
@@ -0,0 +1,112 @@
1
+ import { Checks, expect, expectEqual, idsOf, sorted } from "./check.js";
2
+ import { spawn } from "node:child_process";
3
+ //#region src/conform/cli.ts
4
+ /** Conformance checks for the CLI binding (spec/bindings/cli.md). Each check runs the command line as a process. */
5
+ const RULE = "spec/bindings/cli.md";
6
+ /** The time that a command line has to start before the interrupt, in milliseconds. */
7
+ const INTERRUPT_AFTER = 2e3;
8
+ /** Run the command line. If `interruptAfter` is given, send `SIGINT` after that many milliseconds. */
9
+ function run(command, args, interruptAfter) {
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn(command[0], [...command.slice(1), ...args], { stdio: [
12
+ "ignore",
13
+ "pipe",
14
+ "pipe"
15
+ ] });
16
+ const timer = interruptAfter === void 0 ? void 0 : setTimeout(() => child.kill("SIGINT"), interruptAfter);
17
+ let stdout = "";
18
+ let stderr = "";
19
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
20
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
21
+ child.on("error", reject);
22
+ child.on("close", (code, signal) => {
23
+ clearTimeout(timer);
24
+ resolve({
25
+ code,
26
+ signal,
27
+ stdout,
28
+ stderr
29
+ });
30
+ });
31
+ });
32
+ }
33
+ async function checkCli(command) {
34
+ const checks = new Checks("cli");
35
+ const cli = (...args) => run(command, args);
36
+ await checks.run("a value goes to standard output with exit code 0", `${RULE}#outcomes`, async () => {
37
+ const result = await cli("fixture", "echo", "--text", "hello");
38
+ expectEqual(result.code, 0, "exit code");
39
+ expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
40
+ });
41
+ await checks.run("a failure goes to standard output with exit code 1", `${RULE}#outcomes`, async () => {
42
+ const result = await cli("fixture", "refuse");
43
+ expectEqual(result.code, 1, "exit code");
44
+ expectEqual(JSON.parse(result.stdout)._tag, "Refused", "_tag");
45
+ });
46
+ await checks.run("a defect goes to standard error with exit code 5", `${RULE}#outcomes`, async () => {
47
+ const result = await cli("fixture", "crash");
48
+ expectEqual(result.code, 5, "exit code");
49
+ expect(/"kind":"internal"/.test(result.stderr), "standard error must contain the call failure");
50
+ });
51
+ for (const [name, args, code] of [
52
+ [
53
+ "an unknown command has exit code 2",
54
+ ["fixture", "nothing"],
55
+ 2
56
+ ],
57
+ [
58
+ "a capability outside the view has exit code 2",
59
+ ["call", "fixture.hidden"],
60
+ 2
61
+ ],
62
+ [
63
+ "a missing required flag has exit code 3",
64
+ ["fixture", "echo"],
65
+ 3
66
+ ],
67
+ [
68
+ "a flag that is not valid has exit code 3",
69
+ [
70
+ "fixture",
71
+ "slow",
72
+ "--milliseconds",
73
+ "soon"
74
+ ],
75
+ 3
76
+ ],
77
+ [
78
+ "invalid input through call has exit code 3",
79
+ [
80
+ "call",
81
+ "fixture.echo",
82
+ "{\"text\":1}"
83
+ ],
84
+ 3
85
+ ]
86
+ ]) await checks.run(name, `${RULE}#outcomes`, async () => {
87
+ expectEqual((await cli(...args)).code, code, "exit code");
88
+ });
89
+ await checks.run("an interrupt cancels the call with exit code 130", `${RULE}#commands`, async () => {
90
+ const result = await run(command, [
91
+ "fixture",
92
+ "slow",
93
+ "--milliseconds",
94
+ "60000"
95
+ ], INTERRUPT_AFTER);
96
+ expect(result.signal === null, `the process must exit, not end by the signal ${result.signal}`);
97
+ expectEqual(result.code, 130, "exit code");
98
+ expectEqual(result.stdout, "", "standard output");
99
+ });
100
+ await checks.run("call gives the value of a capability", `${RULE}#commands`, async () => {
101
+ const result = await cli("call", "fixture.echo", "{\"text\":\"hello\"}");
102
+ expectEqual(result.code, 0, "exit code");
103
+ expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
104
+ });
105
+ await checks.run("list gives the view", `${RULE}#commands`, async () => {
106
+ const result = await cli("list");
107
+ expectEqual(sorted(JSON.parse(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
108
+ });
109
+ return checks;
110
+ }
111
+ //#endregion
112
+ export { checkCli };
@@ -0,0 +1,5 @@
1
+ /** Conformance checks for the HTTP binding (spec/bindings/http.md). */
2
+ import { Checks } from "./check.js";
3
+ export declare function checkHttp(base: string, options: {
4
+ readonly maximumBytes: number;
5
+ }): Promise<Checks>;
@@ -0,0 +1,97 @@
1
+ import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, idsOf, sorted } from "./check.js";
2
+ //#region src/conform/http.ts
3
+ /** Conformance checks for the HTTP binding (spec/bindings/http.md). */
4
+ const RULE = "spec/bindings/http.md";
5
+ async function checkHttp(base, options) {
6
+ const checks = new Checks("http");
7
+ const post = (id, body, headers = {}) => fetch(`${base}/capabilities/${id}`, {
8
+ method: "POST",
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ ...headers
12
+ },
13
+ body: typeof body === "string" ? body : body instanceof Uint8Array ? new Blob([body]) : JSON.stringify(body)
14
+ });
15
+ const json = async (response) => await response.json();
16
+ await checks.run("GET /capabilities lists the view with availability", `${RULE}#requests`, async () => {
17
+ const list = await (await fetch(`${base}/capabilities`)).json();
18
+ expectEqual(sorted(list.map((entry) => entry.id)), idsOf("api"), "ids");
19
+ expect(list.every((entry) => typeof entry.available === "boolean"), "each entry must have available");
20
+ });
21
+ await checks.run("a value has the status 200", `${RULE}#requests`, async () => {
22
+ const response = await post("fixture.echo", { text: "hello" });
23
+ expectEqual(response.status, 200, "status");
24
+ expectEqual(await json(response), {
25
+ ok: true,
26
+ value: { text: "hello" }
27
+ }, "body");
28
+ });
29
+ await checks.run("a failure has the status 200 and ok: false", `${RULE}#requests`, async () => {
30
+ const response = await post("fixture.refuse", {});
31
+ const body = await json(response);
32
+ expectEqual(response.status, 200, "status");
33
+ expect(body.ok === false && body.error?._tag === "Refused", `got ${JSON.stringify(body)}`);
34
+ });
35
+ for (const [name, id, input, status, kind] of [
36
+ [
37
+ "invalid input has the status 422",
38
+ "fixture.echo",
39
+ { text: 1 },
40
+ 422,
41
+ "invalid-input"
42
+ ],
43
+ [
44
+ "a capability outside the view has the status 404",
45
+ "fixture.hidden",
46
+ {},
47
+ 404,
48
+ "unknown-capability"
49
+ ],
50
+ [
51
+ "a defect has the status 500",
52
+ "fixture.crash",
53
+ {},
54
+ 500,
55
+ "internal"
56
+ ]
57
+ ]) await checks.run(name, `${RULE}#requests`, async () => {
58
+ const response = await post(id, input);
59
+ expectEqual(response.status, status, "status");
60
+ expectEqual((await json(response)).kind, kind, "kind");
61
+ });
62
+ await checks.run("an unavailable capability has the status 503", `${RULE}#requests`, async () => {
63
+ await post("fixture.switch.set", { on: false });
64
+ try {
65
+ const toggle = (await (await fetch(`${base}/capabilities`)).json()).find((entry) => entry.id === "fixture.toggle");
66
+ expect(toggle?.available === false && typeof toggle.reason === "string", "fixture.toggle must be unavailable, with a reason");
67
+ const response = await post("fixture.toggle", {});
68
+ expectEqual(response.status, 503, "status");
69
+ expectEqual((await json(response)).kind, "unavailable", "kind");
70
+ } finally {
71
+ await post("fixture.switch.set", { on: true });
72
+ }
73
+ });
74
+ const status = async (response, expected) => expectEqual(response.status, expected, "status");
75
+ await checks.run("a method that is not accepted is rejected with 405", `${RULE}#requests`, async () => {
76
+ await status(await fetch(`${base}/capabilities`, { method: "PUT" }), 405);
77
+ });
78
+ await checks.run("an origin that is not allowed is rejected with 403", `${RULE}#requests`, async () => {
79
+ await status(await post("fixture.echo", { text: "x" }, { Origin: FORBIDDEN_ORIGIN }), 403);
80
+ });
81
+ await checks.run("a media type other than JSON is rejected with 415", `${RULE}#requests`, async () => {
82
+ await status(await post("fixture.echo", { text: "x" }, { "Content-Type": "text/plain" }), 415);
83
+ });
84
+ await checks.run("a body over the limit is rejected with 413", `${RULE}#requests`, async () => {
85
+ await status(await post("fixture.echo", " ".repeat(options.maximumBytes + 1)), 413);
86
+ });
87
+ await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE}#requests`, async () => {
88
+ await status(await post("fixture.echo", new Uint8Array([
89
+ 123,
90
+ 255,
91
+ 125
92
+ ])), 400);
93
+ });
94
+ return checks;
95
+ }
96
+ //#endregion
97
+ export { checkHttp };