@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 CHANGED
@@ -6,7 +6,7 @@ The AcidCat command line. npm package: `@acid-cat/cli`. It gives the `acid-cat`
6
6
  acid-cat check [--strict] <catalog.json ...> # check catalogs
7
7
  acid-cat format [--write] <catalog.json ...> # write catalogs in canonical form
8
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
9
+ acid-cat conform --mcp <url> --http <url> --cli "<command>" --webmcp <url> [--deadline <ms>] # test an implementation
10
10
  ```
11
11
 
12
12
  Install it as a development dependency, and run it with `pnpm exec acid-cat …`:
@@ -35,10 +35,12 @@ The exit code is 0 if there are no errors. It is 1 if there are errors. With `--
35
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
36
 
37
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.
38
+ - **HTTP** and **CLI:** each outcome, each kind of call failure, availability, and rejections. For the CLI, an interrupt (`SIGINT`) during a call that holds gives exit code 130.
39
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
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
41
 
42
+ The checks wait for states that they can observe, not for times. Before a check cancels a call to `fixture.hold`, it waits until `fixture.held` gives the key of the call, or, for the CLI, until the marker file of the call exists. Each check has one deadline: `--deadline`, or `deadline` in code, in milliseconds. The default is 30 000. A check that does not settle before its deadline fails with "did not settle", and the check stops what it started. This cleanup has a deadline of the same length, so `conform` always returns. The deadline never makes a check pass.
43
+
42
44
  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
45
 
44
46
  ## Implementation
@@ -52,5 +54,6 @@ The command line is an [Effect](https://effect.website) program that uses `effec
52
54
  | `src/conform/` | The conformance checks, and `serve`, which serves web handlers on a local port |
53
55
  | `conformance/` | The conformance fixture, in Pkl and in JSON |
54
56
  | `test/` | Tests. Run them with `moon run cli:test` |
57
+ | `test/fixtures/` | The catalog of the tests: a bakery, in canonical form |
55
58
 
56
59
  The canonical writer comes from [`@acid-cat/catalog`](../catalog/typescript).
@@ -133,12 +133,70 @@
133
133
  "type": "object"
134
134
  }
135
135
  },
136
+ "fixture.held": {
137
+ "description": "Return the keys of the calls to fixture.hold that hold now.",
138
+ "audience": [
139
+ "app",
140
+ "mcp",
141
+ "cli",
142
+ "api"
143
+ ],
144
+ "output": {
145
+ "properties": {
146
+ "keys": {
147
+ "items": {
148
+ "type": "string"
149
+ },
150
+ "type": "array"
151
+ }
152
+ },
153
+ "required": [
154
+ "keys"
155
+ ],
156
+ "type": "object"
157
+ },
158
+ "readOnly": true
159
+ },
136
160
  "fixture.hidden": {
137
161
  "description": "Only the app target can use this capability.",
138
162
  "audience": [
139
163
  "app"
140
164
  ]
141
165
  },
166
+ "fixture.hold": {
167
+ "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.",
168
+ "audience": [
169
+ "app",
170
+ "mcp",
171
+ "cli",
172
+ "api"
173
+ ],
174
+ "input": {
175
+ "properties": {
176
+ "key": {
177
+ "type": "string"
178
+ },
179
+ "marker": {
180
+ "type": "string"
181
+ }
182
+ },
183
+ "required": [
184
+ "key"
185
+ ],
186
+ "type": "object"
187
+ },
188
+ "output": {
189
+ "properties": {
190
+ "key": {
191
+ "type": "string"
192
+ }
193
+ },
194
+ "required": [
195
+ "key"
196
+ ],
197
+ "type": "object"
198
+ }
199
+ },
142
200
  "fixture.refuse": {
143
201
  "description": "Always return the failure Refused.",
144
202
  "audience": [
@@ -166,8 +224,8 @@
166
224
  }
167
225
  }
168
226
  },
169
- "fixture.slow": {
170
- "description": "Wait for some milliseconds, then return null.",
227
+ "fixture.release": {
228
+ "description": "Release the calls to fixture.hold that hold the key.",
171
229
  "audience": [
172
230
  "app",
173
231
  "mcp",
@@ -176,12 +234,12 @@
176
234
  ],
177
235
  "input": {
178
236
  "properties": {
179
- "milliseconds": {
180
- "type": "number"
237
+ "key": {
238
+ "type": "string"
181
239
  }
182
240
  },
183
241
  "required": [
184
- "milliseconds"
242
+ "key"
185
243
  ],
186
244
  "type": "object"
187
245
  }
package/dist/catalog.d.ts CHANGED
@@ -7,5 +7,10 @@ export interface Report {
7
7
  readonly errors: readonly string[];
8
8
  readonly warnings: readonly string[];
9
9
  }
10
+ /**
11
+ * True if the value has the members and the member types of a catalog. It does not check the catalog rules: `check`
12
+ * does that.
13
+ */
14
+ export declare const isCatalog: (value: unknown) => value is Catalog;
10
15
  /** Check a catalog. When `text` is given, the file must also be in canonical form. */
11
16
  export declare function check(document: unknown, text?: string): Report;
package/dist/catalog.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import catalog_schema_default from "./json/catalog.schema.json.js";
2
+ import { isObject, isRecordOf, isStringArray } from "./json.js";
2
3
  import { canonicalText } from "@acid-cat/catalog";
3
4
  import { REFERENCE, bundle, schemasOf, viewOf as viewOf$1, visitObjects } from "@acid-cat/spec-ts";
4
5
  import { Result, Schema, SchemaIssue, SchemaRepresentation } from "effect";
@@ -34,13 +35,24 @@ const KNOWN_MEMBERS = {
34
35
  /** The catalog schema (schemas/catalog.schema.json), imported as an Effect schema. */
35
36
  const decodeCatalog = (() => {
36
37
  const { $defs, $schema: _schema, $id: _id, ...root } = catalog_schema_default;
38
+ const definitions = $defs;
37
39
  const schema = SchemaRepresentation.fromJsonSchemaDocument({
38
40
  dialect: "draft-2020-12",
39
41
  schema: root,
40
- definitions: $defs ?? {}
42
+ definitions
41
43
  }, { patterns: "apply" });
42
- return Schema.decodeUnknownResult(schema);
44
+ return Schema.decodeUnknownResult(Schema.toType(schema));
43
45
  })();
46
+ /** True if `key` is absent from the object, or if `isMember` is true for its value. */
47
+ const optional = (object, key, isMember) => !(key in object) || isMember(object[key]);
48
+ const isString = (value) => typeof value === "string";
49
+ const isTarget = (value) => isObject(value) && isStringArray(value.bindings);
50
+ const isCapability = (value) => isObject(value) && typeof value.description === "string" && optional(value, "audience", isStringArray) && optional(value, "input", isObject) && optional(value, "output", isObject) && optional(value, "state", isObject) && optional(value, "failures", (failures) => isRecordOf(failures, isObject)) && optional(value, "readOnly", (readOnly) => typeof readOnly === "boolean");
51
+ /**
52
+ * True if the value has the members and the member types of a catalog. It does not check the catalog rules: `check`
53
+ * does that.
54
+ */
55
+ const isCatalog = (value) => isObject(value) && typeof value.acidcat === "string" && optional(value, "title", isString) && optional(value, "description", isString) && isRecordOf(value.targets, isTarget) && optional(value, "$defs", (types) => isRecordOf(types, isObject)) && isRecordOf(value.capabilities, isCapability);
44
56
  const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
45
57
  /** Check a catalog. When `text` is given, the file must also be in canonical form. */
46
58
  function check(document, text) {
@@ -49,6 +61,10 @@ function check(document, text) {
49
61
  errors: formatIssues(decoded.failure.issue).issues.map((issue) => `${(issue.path ?? []).map((key) => `[${JSON.stringify(key)}]`).join("") || "the catalog"}: ${issue.message}`),
50
62
  warnings: []
51
63
  };
64
+ if (!isCatalog(document)) return {
65
+ errors: ["the catalog: the members do not have the types of a catalog"],
66
+ warnings: []
67
+ };
52
68
  const catalog = document;
53
69
  const errors = ruleErrors(catalog);
54
70
  const warnings = [...unknownMembers(catalog), ...errors.length === 0 ? schemaWarnings(catalog) : []];
@@ -103,9 +119,12 @@ function referenceErrors(schema, types) {
103
119
  if ("$defs" in node) errors.push("has its own $defs; put shared types in the catalog's $defs");
104
120
  if (node.$ref === void 0) return;
105
121
  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`);
122
+ if (name === void 0 || !(name in types)) {
123
+ const reference = typeof node.$ref === "string" ? node.$ref : JSON.stringify(node.$ref);
124
+ errors.push(`references ${reference}, which is not a type`);
125
+ }
107
126
  });
108
127
  return errors;
109
128
  }
110
129
  //#endregion
111
- export { bundle, check, viewOf$1 as viewOf };
130
+ export { bundle, check, isCatalog, viewOf$1 as viewOf };
@@ -1,4 +1,4 @@
1
- import type { CatalogDocument } from "@acid-cat/spec-ts";
1
+ import type { CatalogDocument, Outcome } from "@acid-cat/spec-ts";
2
2
  export interface Check {
3
3
  readonly binding: string;
4
4
  readonly name: string;
@@ -9,15 +9,35 @@ export interface Check {
9
9
  }
10
10
  /** The conformance fixture: the catalog that each implementation serves. */
11
11
  export declare const fixture: CatalogDocument;
12
+ /**
13
+ * True if the value is an outcome (spec/invocation.md): `{ ok: true, value }`, `{ ok: false, error }`, or a call
14
+ * failure `{ kind, message }` with no `ok`.
15
+ */
16
+ export declare const isOutcome: (value: unknown) => value is Outcome<unknown, unknown>;
12
17
  /** The ids of the capabilities in the view of a target of the fixture. */
13
18
  export declare const idsOf: (target: string) => string[];
14
- /** Collects checks for one binding. */
19
+ /** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
20
+ export declare const DEADLINE = 30000;
21
+ /**
22
+ * Collects checks for one binding. Each check has one deadline. The check gives its signal to each operation, and the
23
+ * signal aborts at the deadline. Then each operation stops, and the check fails with "did not settle". The deadline
24
+ * never makes a check pass. The cleanup of a check, which releases what the check holds, gets its own signal with the
25
+ * same time, so that `conform` always returns.
26
+ */
15
27
  export declare class Checks {
16
28
  readonly results: Check[];
17
29
  readonly binding: string;
18
- constructor(binding: string);
19
- run(name: string, rule: string, body: () => Promise<void> | void): Promise<void>;
30
+ readonly deadline: number;
31
+ constructor(binding: string, deadline: number);
32
+ run(name: string, rule: string, body: (signal: AbortSignal) => Promise<void> | void): Promise<void>;
20
33
  }
34
+ /**
35
+ * Read a state until `done` is true for it, and give that value. The read and the pause between two reads stop when the
36
+ * signal of the check aborts.
37
+ */
38
+ export declare function until<T>(what: string, read: () => Promise<T>, done: (value: T) => boolean, signal: AbortSignal): Promise<T>;
39
+ /** Settle as the promise does, or reject with the reason of the signal when it aborts first. */
40
+ export declare function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>;
21
41
  /** Fail a check unless `condition` is true. */
22
42
  export declare function expect(condition: unknown, message: string): asserts condition;
23
43
  /** Fail a check unless the two values have the same JSON. */
@@ -1,21 +1,44 @@
1
+ import { isObject } from "../json.js";
1
2
  import fixture_acidcat_default from "../json/fixture.acidcat.json.js";
2
- import { viewOf } from "@acid-cat/spec-ts";
3
+ import { CALL_FAILURE_KINDS, viewOf } from "@acid-cat/spec-ts";
4
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
3
5
  //#region src/conform/check.ts
4
6
  /** The result of one conformance check, and helpers to write checks. */
5
7
  /** The conformance fixture: the catalog that each implementation serves. */
6
8
  const fixture = fixture_acidcat_default;
9
+ /**
10
+ * True if the value is an outcome (spec/invocation.md): `{ ok: true, value }`, `{ ok: false, error }`, or a call
11
+ * failure `{ kind, message }` with no `ok`.
12
+ */
13
+ const isOutcome = (value) => {
14
+ if (!isObject(value)) return false;
15
+ if (value.ok === true) return "value" in value;
16
+ if (value.ok === false) return "error" in value;
17
+ return !("ok" in value) && CALL_FAILURE_KINDS.some((kind) => kind === value.kind) && typeof value.message === "string";
18
+ };
7
19
  /** The ids of the capabilities in the view of a target of the fixture. */
8
20
  const idsOf = (target) => Object.keys(viewOf(fixture, target).capabilities).sort();
9
- /** Collects checks for one binding. */
21
+ /** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
22
+ const DEADLINE = 3e4;
23
+ /**
24
+ * Collects checks for one binding. Each check has one deadline. The check gives its signal to each operation, and the
25
+ * signal aborts at the deadline. Then each operation stops, and the check fails with "did not settle". The deadline
26
+ * never makes a check pass. The cleanup of a check, which releases what the check holds, gets its own signal with the
27
+ * same time, so that `conform` always returns.
28
+ */
10
29
  var Checks = class {
11
30
  results = [];
12
31
  binding;
13
- constructor(binding) {
32
+ deadline;
33
+ constructor(binding, deadline) {
14
34
  this.binding = binding;
35
+ this.deadline = deadline;
15
36
  }
16
37
  async run(name, rule, body) {
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), this.deadline);
17
40
  try {
18
- await body();
41
+ await body(controller.signal);
19
42
  this.results.push({
20
43
  binding: this.binding,
21
44
  name,
@@ -23,16 +46,44 @@ var Checks = class {
23
46
  passed: true
24
47
  });
25
48
  } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ const detail = controller.signal.aborted ? `did not settle within ${this.deadline} ms: ${message}` : message;
26
51
  this.results.push({
27
52
  binding: this.binding,
28
53
  name,
29
54
  rule,
30
55
  passed: false,
31
- detail: error instanceof Error ? error.message : String(error)
56
+ detail
32
57
  });
58
+ } finally {
59
+ clearTimeout(timer);
33
60
  }
34
61
  }
35
62
  };
63
+ /**
64
+ * Read a state until `done` is true for it, and give that value. The read and the pause between two reads stop when the
65
+ * signal of the check aborts.
66
+ */
67
+ async function until(what, read, done, signal) {
68
+ let last;
69
+ try {
70
+ for (;;) {
71
+ last = await read();
72
+ if (done(last)) return last;
73
+ await setTimeout$1(10, void 0, { signal });
74
+ }
75
+ } catch (error) {
76
+ throw signal.aborted ? /* @__PURE__ */ new Error(`waiting until ${what}; the last value was ${JSON.stringify(last)}`) : error;
77
+ }
78
+ }
79
+ /** Settle as the promise does, or reject with the reason of the signal when it aborts first. */
80
+ function abortable(promise, signal) {
81
+ const aborted = new Promise((_, reject) => {
82
+ signal.throwIfAborted();
83
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
84
+ });
85
+ return Promise.race([promise, aborted]);
86
+ }
36
87
  /** Fail a check unless `condition` is true. */
37
88
  function expect(condition, message) {
38
89
  if (!condition) throw new Error(message);
@@ -48,4 +99,4 @@ const sorted = (values) => [...values].sort();
48
99
  /** An origin that no deployment allows. */
49
100
  const FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
50
101
  //#endregion
51
- export { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, fixture, idsOf, sorted };
102
+ export { Checks, DEADLINE, FORBIDDEN_ORIGIN, abortable, expect, expectEqual, fixture, idsOf, isOutcome, sorted, until };
@@ -1,2 +1,4 @@
1
1
  import { Checks } from "./check.js";
2
- export declare function checkCli(command: readonly string[]): Promise<Checks>;
2
+ export declare function checkCli(command: readonly string[], options: {
3
+ readonly deadline: number;
4
+ }): Promise<Checks>;
@@ -1,5 +1,10 @@
1
- import { Checks, expect, expectEqual, idsOf, sorted } from "./check.js";
1
+ import { isArrayOf, isObject, memberOf, parseJson } from "../json.js";
2
+ import { Checks, expect, expectEqual, idsOf, sorted, until } from "./check.js";
2
3
  import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
3
8
  //#region src/conform/cli.ts
4
9
  /** Conformance checks for the CLI binding (spec/bindings/cli.md). Each check runs the command line as a process. */
5
10
  const RULE = "spec/bindings/cli.md";
@@ -8,53 +13,71 @@ const USAGES = {
8
13
  "fixture.crash": "fixture crash",
9
14
  "fixture.echo": "fixture echo --text <string>",
10
15
  "fixture.flags": "fixture flags --mode <fast|slow> [--count <integer>] [--extra <json>] [--label <string>] [--ratio <number>] [--verbose]",
16
+ "fixture.held": "fixture held",
17
+ "fixture.hold": "fixture hold --key <string> [--marker <string>]",
11
18
  "fixture.refuse": "fixture refuse",
12
- "fixture.slow": "fixture slow --milliseconds <number>",
19
+ "fixture.release": "fixture release --key <string>",
13
20
  "fixture.switch.set": "fixture switch set --on",
14
21
  "fixture.toggle": "fixture toggle"
15
22
  };
16
- /** The time that a command line has to start before the interrupt, in milliseconds. */
17
- const INTERRUPT_AFTER = 2e3;
18
- /** Run the command line. If `interruptAfter` is given, send `SIGINT` after that many milliseconds. */
19
- function run(command, args, interruptAfter) {
20
- return new Promise((resolve, reject) => {
21
- const child = spawn(command[0], [...command.slice(1), ...args], { stdio: [
23
+ const isEntry = (value) => isObject(value) && typeof value.id === "string";
24
+ /** The list that the `list` command writes to standard output. The check fails when it is not a list of entries. */
25
+ function entriesOf(stdout) {
26
+ const list = parseJson(stdout);
27
+ if (!isArrayOf(list, isEntry)) throw new Error(`list must give a list of objects with an id, got ${JSON.stringify(list)}`);
28
+ return list;
29
+ }
30
+ /**
31
+ * Start the command line. When the signal aborts, the process is killed, and its run rejects. The rejection is observed
32
+ * here too, so that a check that stops waiting first, for example at the deadline, records a failure and does not end
33
+ * the process with an unhandled rejection.
34
+ */
35
+ function start(command, args, signal) {
36
+ const [program, ...programArgs] = command;
37
+ if (program === void 0) throw new Error("the command that starts the command line is empty");
38
+ const child = spawn(program, [...programArgs, ...args], {
39
+ stdio: [
22
40
  "ignore",
23
41
  "pipe",
24
42
  "pipe"
25
- ] });
26
- const timer = interruptAfter === void 0 ? void 0 : setTimeout(() => child.kill("SIGINT"), interruptAfter);
43
+ ],
44
+ signal,
45
+ killSignal: "SIGKILL"
46
+ });
47
+ const run = new Promise((resolve, reject) => {
27
48
  let stdout = "";
28
49
  let stderr = "";
29
50
  child.stdout.on("data", (chunk) => stdout += chunk.toString());
30
51
  child.stderr.on("data", (chunk) => stderr += chunk.toString());
31
- child.on("error", reject);
32
- child.on("close", (code, signal) => {
33
- clearTimeout(timer);
34
- resolve({
35
- code,
36
- signal,
37
- stdout,
38
- stderr
39
- });
40
- });
52
+ child.on("error", (error) => reject(signal.aborted ? /* @__PURE__ */ new Error(`waiting until the command line exits: ${args.join(" ")}`) : error));
53
+ child.on("close", (code, exitSignal) => resolve({
54
+ code,
55
+ signal: exitSignal,
56
+ stdout,
57
+ stderr
58
+ }));
41
59
  });
60
+ run.catch(() => void 0);
61
+ return {
62
+ child,
63
+ run
64
+ };
42
65
  }
43
- async function checkCli(command) {
44
- const checks = new Checks("cli");
45
- const cli = (...args) => run(command, args);
46
- await checks.run("a value goes to standard output with exit code 0", `${RULE}#outcomes`, async () => {
47
- const result = await cli("fixture", "echo", "--text", "hello");
66
+ async function checkCli(command, options) {
67
+ const checks = new Checks("cli", options.deadline);
68
+ const run = (signal, ...args) => start(command, args, signal).run;
69
+ await checks.run("a value goes to standard output with exit code 0", `${RULE}#outcomes`, async (signal) => {
70
+ const result = await run(signal, "fixture", "echo", "--text", "hello");
48
71
  expectEqual(result.code, 0, "exit code");
49
- expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
72
+ expectEqual(parseJson(result.stdout), { text: "hello" }, "standard output");
50
73
  });
51
- await checks.run("a failure goes to standard output with exit code 1", `${RULE}#outcomes`, async () => {
52
- const result = await cli("fixture", "refuse");
74
+ await checks.run("a failure goes to standard output with exit code 1", `${RULE}#outcomes`, async (signal) => {
75
+ const result = await run(signal, "fixture", "refuse");
53
76
  expectEqual(result.code, 1, "exit code");
54
- expectEqual(JSON.parse(result.stdout)._tag, "Refused", "_tag");
77
+ expectEqual(memberOf(parseJson(result.stdout), "_tag"), "Refused", "_tag");
55
78
  });
56
- await checks.run("a defect goes to standard error with exit code 5", `${RULE}#outcomes`, async () => {
57
- const result = await cli("fixture", "crash");
79
+ await checks.run("a defect goes to standard error with exit code 5", `${RULE}#outcomes`, async (signal) => {
80
+ const result = await run(signal, "fixture", "crash");
58
81
  expectEqual(result.code, 5, "exit code");
59
82
  expect(/"kind":"internal"/.test(result.stderr), "standard error must contain the call failure");
60
83
  });
@@ -78,8 +101,10 @@ async function checkCli(command) {
78
101
  "a flag that is not valid has exit code 3",
79
102
  [
80
103
  "fixture",
81
- "slow",
82
- "--milliseconds",
104
+ "flags",
105
+ "--mode",
106
+ "fast",
107
+ "--ratio",
83
108
  "soon"
84
109
  ],
85
110
  3
@@ -93,31 +118,47 @@ async function checkCli(command) {
93
118
  ],
94
119
  3
95
120
  ]
96
- ]) await checks.run(name, `${RULE}#outcomes`, async () => {
97
- expectEqual((await cli(...args)).code, code, "exit code");
121
+ ]) await checks.run(name, `${RULE}#outcomes`, async (signal) => {
122
+ expectEqual((await run(signal, ...args)).code, code, "exit code");
98
123
  });
99
- await checks.run("an interrupt cancels the call with exit code 130", `${RULE}#commands`, async () => {
100
- const result = await run(command, [
101
- "fixture",
102
- "slow",
103
- "--milliseconds",
104
- "60000"
105
- ], INTERRUPT_AFTER);
106
- expect(result.signal === null, `the process must exit, not end by the signal ${result.signal}`);
107
- expectEqual(result.code, 130, "exit code");
108
- expectEqual(result.stdout, "", "standard output");
124
+ await checks.run("an interrupt cancels the call with exit code 130", `${RULE}#commands`, async (signal) => {
125
+ const directory = await mkdtemp(join(tmpdir(), "acid-cat-conform-"));
126
+ const marker = join(directory, "held");
127
+ try {
128
+ const { child, run } = start(command, [
129
+ "fixture",
130
+ "hold",
131
+ "--key",
132
+ "interrupt",
133
+ "--marker",
134
+ marker
135
+ ], signal);
136
+ const ended = () => child.exitCode !== null || child.signalCode !== null;
137
+ await until("the call writes its marker file", async () => existsSync(marker) || ended(), (ready) => ready, signal);
138
+ if (!ended()) child.kill("SIGINT");
139
+ const result = await run;
140
+ expect(existsSync(marker), `the call must hold before the interrupt, but the command line ended with ${result.code}: ${result.stderr}`);
141
+ expect(result.signal === null, `the process must exit, not end by the signal ${result.signal}`);
142
+ expectEqual(result.code, 130, "exit code");
143
+ expectEqual(result.stdout, "", "standard output");
144
+ } finally {
145
+ await rm(directory, {
146
+ recursive: true,
147
+ force: true
148
+ });
149
+ }
109
150
  });
110
- await checks.run("call gives the value of a capability", `${RULE}#commands`, async () => {
111
- const result = await cli("call", "fixture.echo", "{\"text\":\"hello\"}");
151
+ await checks.run("call gives the value of a capability", `${RULE}#commands`, async (signal) => {
152
+ const result = await run(signal, "call", "fixture.echo", "{\"text\":\"hello\"}");
112
153
  expectEqual(result.code, 0, "exit code");
113
- expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
154
+ expectEqual(parseJson(result.stdout), { text: "hello" }, "standard output");
114
155
  });
115
- await checks.run("list gives the view", `${RULE}#commands`, async () => {
116
- const result = await cli("list");
117
- expectEqual(sorted(JSON.parse(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
156
+ await checks.run("list gives the view", `${RULE}#commands`, async (signal) => {
157
+ const result = await run(signal, "list");
158
+ expectEqual(sorted(entriesOf(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
118
159
  });
119
- await checks.run("list gives the usage of each capability, with its flags", `${RULE}#commands`, async () => {
120
- const entries = JSON.parse((await cli("list")).stdout);
160
+ await checks.run("list gives the usage of each capability, with its flags", `${RULE}#commands`, async (signal) => {
161
+ const entries = entriesOf((await run(signal, "list")).stdout);
121
162
  for (const { id, usage } of entries) {
122
163
  expect(typeof usage === "string", `${id} must have a usage`);
123
164
  const [program, ...rest] = usage.split(" ");
@@ -1,5 +1,5 @@
1
- /** Conformance checks for the HTTP binding (spec/bindings/http.md). */
2
1
  import { Checks } from "./check.js";
3
2
  export declare function checkHttp(base: string, options: {
4
3
  readonly maximumBytes: number;
4
+ readonly deadline: number;
5
5
  }): Promise<Checks>;