jui-tools-mcp-server 2.12.10 → 2.14.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.
@@ -4,11 +4,12 @@ import { SpecLoader } from "../../spec_loader.js";
4
4
  export function register(server: McpServer, loader: SpecLoader) {
5
5
  server.tool(
6
6
  "get_data_source",
7
- "Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json and binding_semantics.json. Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon: since 2.12.0 every tool call re-reads a file whose CONTENT changed (sha256, not mtime), so a restart is not needed after a jsonui-cli distribution; `lastReload` says when that last happened and whether it succeeded, and `staleInMemory` is non-empty only when a reload FAILED (a file did not parse) and the server is still serving the previous content.",
7
+ "Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json, binding_semantics.json and the rest, down to type_synonyms.json (the type spellings the build accepts). Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon: since 2.12.0 every tool call re-reads a file whose CONTENT changed (sha256, not mtime), so a restart is not needed after a jsonui-cli distribution; `lastReload` says when that last happened and whether it succeeded, and `staleInMemory` is non-empty only when a reload FAILED (a file did not parse) and the server is still serving the previous content.",
8
8
  {},
9
9
  async () => {
10
10
  const info = loader.getDataSource();
11
- // 🚨 THE SAME SEVEN THE STALENESS CHECK WATCHES. This list held five
11
+ // 🚨 THE SAME FILES THE STALENESS CHECK WATCHES (eight since
12
+ // type_synonyms.json). This list held five
12
13
  // while `getChangedSinceLoad` tracked seven, so `staleInMemory` could
13
14
  // name a path that appeared in no per-file entry — and a reader who
14
15
  // "checked every file reported" had two outside their view. Reported by
@@ -22,6 +23,7 @@ export function register(server: McpServer, loader: SpecLoader) {
22
23
  info.attributeSemantics,
23
24
  info.platformSemantics,
24
25
  info.coverage,
26
+ info.typeSynonyms,
25
27
  ].filter((f): f is NonNullable<typeof f> => f != null);
26
28
  const stale = files.some((f) => f.freshness === "stale");
27
29
  // The data is read once at construction and cached in memory, so a
@@ -43,6 +45,7 @@ export function register(server: McpServer, loader: SpecLoader) {
43
45
  attributeSemantics: info.attributeSemantics,
44
46
  platformSemantics: info.platformSemantics,
45
47
  coverage: info.coverage,
48
+ typeSynonyms: info.typeSynonyms,
46
49
  componentCount: info.componentCount,
47
50
  commonAttributeCount: info.commonAttributeCount,
48
51
  loadedAt,
@@ -0,0 +1,115 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { runCli, CliResult } from "../../cli_runner.js";
5
+
6
+ // The CLI decides the verdict; this tool only carries it. Its exit is the
7
+ // verdict itself — 0 pass (or empty), 1 uncovered or a declaration error,
8
+ // 2 cannot start, 3 something could not be evaluated — so a failure of the
9
+ // TOOL must never be reported as one of those numbers. A timeout reported as
10
+ // exit 1 would read as "uncovered".
11
+ const MIN_CLI = "1.8.116";
12
+
13
+ export function register(server: McpServer, config: ServerConfig) {
14
+ server.tool(
15
+ "test_contracts_coverage",
16
+ "For every screen and platform, bucket every response status the OpenAPI " +
17
+ "declares for an operation the screen's dataFlow names: answered by a " +
18
+ "branch row, excluded with a reason (unit / unreachable / unexpressible), " +
19
+ "not evaluated, or uncovered (partial / default-only / unattributed). " +
20
+ "Endpoints listed only in dataFlow.apiEndpoints are named as unbound. " +
21
+ "Read-only, and not a gate yet. Returns the CLI's JSON report; its `exit` " +
22
+ "is the verdict: 0 pass (or empty), 1 uncovered or a declaration error, " +
23
+ "2 cannot start, 3 something could not be evaluated and nothing is " +
24
+ `uncovered. Needs jsonui-cli ${MIN_CLI} or later`,
25
+ {
26
+ screen: z
27
+ .string()
28
+ .optional()
29
+ .describe("One screen (the spec's snake_case name); every screen when omitted"),
30
+ platform: z
31
+ .array(z.enum(["web", "android", "ios"]))
32
+ .optional()
33
+ .describe("Platforms to evaluate (default: the platforms jui.config.json declares)"),
34
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
35
+ },
36
+ async (params) => {
37
+ try {
38
+ const projectDir = config.resolveProjectDir(params.project_dir);
39
+ const args = ["contracts", "coverage"];
40
+ if (params.screen) { args.push(params.screen); }
41
+ for (const p of params.platform ?? []) { args.push("--platform", p); }
42
+ args.push("--json");
43
+ const result = await runCli("jsonui-test", args, { cwd: projectDir, timeout: 180_000 });
44
+ return { content: [{ type: "text", text: coverageEnvelope(result) }] };
45
+ } catch (e: any) {
46
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
47
+ }
48
+ }
49
+ );
50
+ }
51
+
52
+ /**
53
+ * The CLI's JSON verbatim when there is one and its `exit` is the process's.
54
+ * Otherwise an envelope that says which of the two is missing — with `exit`
55
+ * set only where the CLI's own meaning applies (2: it could not start).
56
+ */
57
+ export function coverageEnvelope(result: CliResult): string {
58
+ const text = result.stdout.trim();
59
+ let report: any;
60
+ try {
61
+ report = JSON.parse(text);
62
+ } catch {
63
+ report = undefined;
64
+ }
65
+ if (report && typeof report === "object" && typeof report.exit === "number") {
66
+ if (report.exit === result.exitCode) {
67
+ return text;
68
+ }
69
+ return JSON.stringify(
70
+ {
71
+ exit: null,
72
+ verdict: "no_report",
73
+ error: `the report says exit ${report.exit} but the process exited ${String(result.exitCode)}`,
74
+ report,
75
+ },
76
+ null,
77
+ 2
78
+ );
79
+ }
80
+ const errors = result.stderr || undefined;
81
+ if (result.exitCode === ("ENOENT" as any)) {
82
+ return JSON.stringify(
83
+ {
84
+ exit: 2,
85
+ verdict: "cannot_start",
86
+ error: `jsonui-test is not on PATH — install jsonui-cli ${MIN_CLI} or later`,
87
+ },
88
+ null,
89
+ 2
90
+ );
91
+ }
92
+ if (result.exitCode === 2 && /invalid choice: 'contracts'/.test(result.stderr)) {
93
+ return JSON.stringify(
94
+ {
95
+ exit: 2,
96
+ verdict: "cannot_start",
97
+ error: `this jsonui-test has no \`contracts coverage\` — it arrived in jsonui-cli ${MIN_CLI}`,
98
+ errors,
99
+ },
100
+ null,
101
+ 2
102
+ );
103
+ }
104
+ return JSON.stringify(
105
+ {
106
+ exit: null,
107
+ verdict: "no_report",
108
+ error: `no JSON report (process exit ${String(result.exitCode)})`,
109
+ output: result.stdout || undefined,
110
+ errors,
111
+ },
112
+ null,
113
+ 2
114
+ );
115
+ }
package/src/version.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "fs";
2
+
3
+ /**
4
+ * The version this server reports to MCP clients, read from package.json.
5
+ *
6
+ * The server used to hand McpServer a literal "2.0.0" — written in the
7
+ * initial commit and never moved, so every release from 2.0.0 to 2.12.x
8
+ * told clients it was 2.0.0. A literal beside a version that is bumped
9
+ * elsewhere drifts silently; reading the one file the release bumps does
10
+ * not.
11
+ *
12
+ * `../package.json` resolves from both places this module runs: src/ under
13
+ * the test runner and dist/ at runtime (npm package and deploy checkout
14
+ * alike keep package.json at the root, one level up).
15
+ */
16
+ export function packageVersion(moduleUrl: string = import.meta.url): string {
17
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", moduleUrl), "utf8"));
18
+ if (typeof pkg.version !== "string" || pkg.version === "") {
19
+ throw new Error("package.json has no version");
20
+ }
21
+ return pkg.version;
22
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * cli_runner — execFile wrapper. child_process is mocked; no real CLI runs.
2
+ * cli_runner — execFile wrapper. child_process is mocked; no real CLI runs,
3
+ * except in "the child's stdin", which calls through to the real execFile.
3
4
  */
4
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
6
 
@@ -19,10 +20,14 @@ interface RecordedCall {
19
20
 
20
21
  let recorded: RecordedCall[];
21
22
  let nextResponse: { error?: any; stdout?: string; stderr?: string };
23
+ // The mocked child's stdin: execFile returns a ChildProcess, and runCli ends
24
+ // its stdin.
25
+ let stdinEnd: ReturnType<typeof vi.fn>;
22
26
 
23
27
  beforeEach(() => {
24
28
  recorded = [];
25
29
  nextResponse = {};
30
+ stdinEnd = vi.fn();
26
31
  execFileMock.mockReset();
27
32
  execFileMock.mockImplementation(
28
33
  (command: string, args: string[], options: any, callback: ExecCallback) => {
@@ -32,6 +37,7 @@ beforeEach(() => {
32
37
  nextResponse.stdout ?? "",
33
38
  nextResponse.stderr ?? ""
34
39
  );
40
+ return { stdin: { end: stdinEnd } };
35
41
  }
36
42
  );
37
43
  });
@@ -101,6 +107,37 @@ describe("runCli", () => {
101
107
  });
102
108
  });
103
109
 
110
+ // execFile gives the child a stdin pipe, and through 2.13.1 nothing wrote to it
111
+ // or closed it: a CLI that read stdin — `sjui / kjui / rjui g converter` at
112
+ // its overwrite prompt, reached through `jui` — waited for a line that never
113
+ // came, until this runner's timeout killed it (60 s by default; measured
114
+ // 2026-09-26 on jsonui-cli, ticket
115
+ // generate-commands-overwrite-edited-files-and-ignore-their-flags). The
116
+ // child's stdin is ended at once: a reader sees end-of-file.
117
+ describe("the child's stdin", () => {
118
+ it("is ended at once: a child that reads stdin to end-of-file finishes", async () => {
119
+ const actual = await vi.importActual<typeof import("child_process")>("child_process");
120
+ execFileMock.mockImplementation((...args: any[]) => (actual.execFile as any)(...args));
121
+ const reader =
122
+ "let n = 0; process.stdin.on('data', (c) => { n += c.length; });" +
123
+ " process.stdin.on('end', () => { console.log('eof after ' + n + ' bytes'); });";
124
+ const started = Date.now();
125
+ // runCli's own limit is 5 s: a child left waiting on stdin comes back as
126
+ // "Command timed out after 5s", and this test's limit (10 s) stops the
127
+ // suite from waiting longer than that.
128
+ const result = await runCli(process.execPath, ["-e", reader], { cwd: process.cwd(), timeout: 5_000 });
129
+ const elapsed = Date.now() - started;
130
+ expect(result.stderr).not.toMatch(/timed out/);
131
+ expect(result).toMatchObject({ exitCode: 0, stdout: "eof after 0 bytes\n" });
132
+ expect(elapsed).toBeLessThan(2_500);
133
+ }, 10_000);
134
+
135
+ it("ends the stdin of the process execFile returns", async () => {
136
+ await runCli("jui", ["build"], { cwd: "/proj" });
137
+ expect(stdinEnd).toHaveBeenCalledTimes(1);
138
+ });
139
+ });
140
+
104
141
  describe("formatResult", () => {
105
142
  it("marks exitCode 0 as success and omits empty errors", () => {
106
143
  const result: CliResult = { exitCode: 0, stdout: "ok", stderr: "" };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The server reports package.json's version, not a literal.
3
+ *
4
+ * Until 2.13.0 the McpServer was built with version "2.0.0" (a literal from
5
+ * the initial commit), so every release told MCP clients it was 2.0.0.
6
+ */
7
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
8
+ import { tmpdir } from "os";
9
+ import { join } from "path";
10
+ import { pathToFileURL } from "url";
11
+ import { afterEach, describe, expect, it } from "vitest";
12
+ import { packageVersion } from "../src/version.js";
13
+
14
+ const repoRoot = join(__dirname, "..");
15
+ const tmpDirs: string[] = [];
16
+
17
+ function fakeModuleUrl(pkg: unknown): string {
18
+ // A root with package.json and a src/ one level down, like the real layout.
19
+ const root = mkdtempSync(join(tmpdir(), "jui-version-"));
20
+ tmpDirs.push(root);
21
+ mkdirSync(join(root, "src"));
22
+ writeFileSync(join(root, "package.json"), JSON.stringify(pkg));
23
+ return pathToFileURL(join(root, "src", "version.js")).href;
24
+ }
25
+
26
+ afterEach(() => {
27
+ while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("server version", () => {
31
+ it("is the repository's package.json version", () => {
32
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
33
+ expect(packageVersion()).toBe(pkg.version);
34
+ });
35
+
36
+ it("follows whatever package.json says (a literal could not)", () => {
37
+ expect(packageVersion(fakeModuleUrl({ version: "9.8.7" }))).toBe("9.8.7");
38
+ expect(packageVersion(fakeModuleUrl({ version: "0.0.1-rc.2" }))).toBe("0.0.1-rc.2");
39
+ });
40
+
41
+ it("refuses a package.json without a version", () => {
42
+ expect(() => packageVersion(fakeModuleUrl({ name: "x" }))).toThrow(/no version/);
43
+ expect(() => packageVersion(fakeModuleUrl({ version: "" }))).toThrow(/no version/);
44
+ });
45
+
46
+ it("is what index.ts hands McpServer — no version literal left there", () => {
47
+ const src = readFileSync(join(repoRoot, "src", "index.ts"), "utf8");
48
+ expect(src).toMatch(/new McpServer\(\{[^}]*version:\s*packageVersion\(\)/s);
49
+ expect(src).not.toMatch(/version:\s*["'`]\d+\.\d+\.\d+/);
50
+ });
51
+ });
@@ -47,6 +47,7 @@ function reportedFiles(source: DataSourceInfo) {
47
47
  source.attributeSemantics,
48
48
  source.platformSemantics,
49
49
  source.coverage,
50
+ source.typeSynonyms,
50
51
  ].filter((f): f is NonNullable<typeof f> => f != null);
51
52
  }
52
53
 
@@ -156,7 +156,7 @@ describe("the per-file report covers what the check tracks", () => {
156
156
 
157
157
  const reported = new Set(
158
158
  ["attributeDefinitions", "componentMetadata", "screenIdentity",
159
- "bindingSemantics", "attributeSemantics", "platformSemantics", "coverage"]
159
+ "bindingSemantics", "attributeSemantics", "platformSemantics", "coverage", "typeSynonyms"]
160
160
  .map((k) => out[k]?.path)
161
161
  .filter(Boolean)
162
162
  );
@@ -218,6 +218,32 @@ describe("jui_generate_project", () => {
218
218
  ]);
219
219
  expect(recorded[0].options.timeout).toBe(120_000);
220
220
  });
221
+
222
+ // jsonui-cli 1.9.0: `jui g project` keeps a Layout JSON that exists and
223
+ // differs from what the spec generates, names it, and replaces it only with
224
+ // --force (never one whose data section the spec does not declare). From
225
+ // MCP it has no terminal, so it never asks. The description says the same,
226
+ // the flag reaches the CLI only when asked, and the CLI's line naming a kept
227
+ // file reaches the agent.
228
+ it("says what happens to a layout that is already there, as the CLI does", async () => {
229
+ const tool = harness.tools.get("jui_generate_project")!;
230
+ expect(tool.description).toContain("written when it is not there");
231
+ expect(tool.description).toContain("left as it is when it already holds exactly what the spec generates");
232
+ expect(tool.description).toContain("KEPT");
233
+ expect(tool.description).toContain("--force replaces it");
234
+ const force = tool.schema.force.description!;
235
+ expect(force).not.toBe("Force overwrite declaration files");
236
+ expect(force).toContain("not replaced even with force");
237
+ expect(force).toContain("never asks");
238
+
239
+ nextResponse = {
240
+ stdout: "Processing: home.spec.json (Home)\n Kept existing layout: docs/screens/layouts/home.json " +
241
+ "(it differs from what the spec generates; --force replaces it)\n",
242
+ };
243
+ const text = await harness.call("jui_generate_project", { project_dir: projectDir });
244
+ expect(recorded[0].args).toEqual(["generate", "project"]);
245
+ expect(text).toContain("Kept existing layout: docs/screens/layouts/home.json");
246
+ });
221
247
  });
222
248
 
223
249
  describe("jui_generate_screen", () => {
@@ -270,6 +296,23 @@ describe("jui_generate_converter", () => {
270
296
  ]);
271
297
  });
272
298
 
299
+ // `container: false` is a leaf (`jui g converter --no-container`, jsonui-cli
300
+ // 1.9.0). It passed nothing before, so a leaf request scaffolded the
301
+ // default form.
302
+ it("passes --no-container for container: false, and neither flag when it is absent", async () => {
303
+ await harness.call("jui_generate_converter", {
304
+ name: "LeafCard",
305
+ container: false,
306
+ project_dir: projectDir,
307
+ });
308
+ await harness.call("jui_generate_converter", {
309
+ name: "PlainCard",
310
+ project_dir: projectDir,
311
+ });
312
+ expect(recorded[0].args).toEqual(["generate", "converter", "LeafCard", "--no-container"]);
313
+ expect(recorded[1].args).toEqual(["generate", "converter", "PlainCard"]);
314
+ });
315
+
273
316
  it("maps spec-mode params to flags", async () => {
274
317
  await harness.call("jui_generate_converter", {
275
318
  from_spec: "docs/components/json/example_card.component.json",
@@ -20,6 +20,7 @@ import { register as registerTestReport } from "../src/tools/test/test_report.js
20
20
  import { register as registerTestMockGenerate } from "../src/tools/test/test_mock_generate.js";
21
21
  import { register as registerTestArtifactsPull } from "../src/tools/test/test_artifacts_pull.js";
22
22
  import { register as registerTestArtifactsStatus } from "../src/tools/test/test_artifacts_status.js";
23
+ import { register as registerTestContractsCoverage } from "../src/tools/test/test_contracts_coverage.js";
23
24
 
24
25
  import {
25
26
  cleanupTempDirs,
@@ -72,6 +73,7 @@ beforeEach(() => {
72
73
  registerTestMockGenerate(harness.server, config);
73
74
  registerTestArtifactsPull(harness.server, config);
74
75
  registerTestArtifactsStatus(harness.server, config);
76
+ registerTestContractsCoverage(harness.server, config);
75
77
  });
76
78
 
77
79
  afterEach(() => {
@@ -88,11 +90,12 @@ function failWithCode(code: number, stdout = "", stderr = ""): void {
88
90
  }
89
91
 
90
92
  describe("Group F registration", () => {
91
- it("registers all 9 jsonui-test tools", () => {
93
+ it("registers all 10 jsonui-test tools", () => {
92
94
  expect([...harness.tools.keys()].sort()).toEqual(
93
95
  [
94
96
  "test_artifacts_pull",
95
97
  "test_artifacts_status",
98
+ "test_contracts_coverage",
96
99
  "test_generate_branch_tests",
97
100
  "test_generate_description",
98
101
  "test_generate_flow",
@@ -125,11 +128,11 @@ describe("test_generate_branch_tests", () => {
125
128
  nextResponse = { stdout: "ok" };
126
129
  await harness.call("test_generate_branch_tests", {
127
130
  project_dir: projectDir,
128
- screen: "bottle_detail",
131
+ screen: "item_detail",
129
132
  platform: "android",
130
133
  package: "com.example.app",
131
134
  module: "AppModule",
132
- spec: "docs/screens/json/bottle_detail.spec.json",
135
+ spec: "docs/screens/json/item_detail.spec.json",
133
136
  out_dir: "app/src/test/java",
134
137
  harness_dir: "app/src/test/java",
135
138
  mocks_dir: "tests/mocks",
@@ -137,7 +140,7 @@ describe("test_generate_branch_tests", () => {
137
140
  expect(recorded[0].args).toEqual([
138
141
  "generate",
139
142
  "branch-tests",
140
- "bottle_detail",
143
+ "item_detail",
141
144
  "--platform",
142
145
  "android",
143
146
  "--package",
@@ -145,7 +148,7 @@ describe("test_generate_branch_tests", () => {
145
148
  "--module",
146
149
  "AppModule",
147
150
  "--spec",
148
- "docs/screens/json/bottle_detail.spec.json",
151
+ "docs/screens/json/item_detail.spec.json",
149
152
  "--out-dir",
150
153
  "app/src/test/java",
151
154
  "--harness-dir",
@@ -347,3 +350,98 @@ describe("test_artifacts_status", () => {
347
350
  expect(execFileMock).not.toHaveBeenCalled();
348
351
  });
349
352
  });
353
+
354
+ describe("test_contracts_coverage", () => {
355
+ const report = (exit: number) =>
356
+ JSON.stringify({ app: { spec_file: null, rules: 0, declaration_errors: [] }, platforms: [], exit }, null, 2);
357
+
358
+ it("runs `jsonui-test contracts coverage --json` in the project dir", async () => {
359
+ nextResponse = { stdout: report(0) };
360
+ await harness.call("test_contracts_coverage", { project_dir: projectDir });
361
+ expect(recorded).toEqual([
362
+ expect.objectContaining({
363
+ command: "jsonui-test",
364
+ args: ["contracts", "coverage", "--json"],
365
+ options: expect.objectContaining({ cwd: projectDir, timeout: 180_000 }),
366
+ }),
367
+ ]);
368
+ });
369
+
370
+ it("maps the screen and every platform, in order", async () => {
371
+ nextResponse = { stdout: report(0) };
372
+ await harness.call("test_contracts_coverage", {
373
+ project_dir: projectDir,
374
+ screen: "detail",
375
+ platform: ["web", "ios"],
376
+ });
377
+ expect(recorded[0].args).toEqual([
378
+ "contracts", "coverage", "detail", "--platform", "web", "--platform", "ios", "--json",
379
+ ]);
380
+ });
381
+
382
+ it("resolves jsonui-test the way the other jsonui-test tools do", async () => {
383
+ // The same bare command name and the same environment as
384
+ // test_artifacts_status: the installed jsonui-cli on PATH, never a
385
+ // development tree put in front of it by this tool.
386
+ nextResponse = { stdout: "{}" };
387
+ await harness.call("test_artifacts_status", { project_dir: projectDir });
388
+ nextResponse = { stdout: report(0) };
389
+ await harness.call("test_contracts_coverage", { project_dir: projectDir });
390
+ const [sibling, coverage] = recorded;
391
+ expect(coverage.command).toBe(sibling.command);
392
+ expect(coverage.options.env).toEqual(sibling.options.env);
393
+ expect(coverage.options.env.PYTHONPATH).toBe(process.env.PYTHONPATH);
394
+ expect(coverage.options.env.JSONUI_CLI_PATH).toBeUndefined();
395
+ });
396
+
397
+ // The exit IS the verdict, so each of the four has to arrive as itself —
398
+ // not flattened into success/failure the way formatResult would.
399
+ it.each([0, 1, 2, 3])("passes the report through verbatim when it and the process both say exit %i", async (code) => {
400
+ if (code === 0) {
401
+ nextResponse = { stdout: report(0) };
402
+ } else {
403
+ failWithCode(code, report(code));
404
+ }
405
+ const text = await harness.call("test_contracts_coverage", { project_dir: projectDir });
406
+ expect(text).toBe(report(code));
407
+ expect(JSON.parse(text).exit).toBe(code);
408
+ });
409
+
410
+ it("says so when the report and the process disagree about the exit", async () => {
411
+ failWithCode(1, report(0));
412
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
413
+ expect(parsed.exit).toBeNull();
414
+ expect(parsed.verdict).toBe("no_report");
415
+ expect(parsed.error).toContain("exit 0");
416
+ expect(parsed.report.exit).toBe(0);
417
+ });
418
+
419
+ it("names the version when the installed jsonui-test has no contracts command", async () => {
420
+ failWithCode(2, "", "jsonui-test: error: argument command: invalid choice: 'contracts'");
421
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
422
+ expect(parsed).toMatchObject({ exit: 2, verdict: "cannot_start" });
423
+ expect(parsed.error).toContain("1.8.116");
424
+ });
425
+
426
+ it("says jsonui-test is missing when it is not on PATH", async () => {
427
+ nextResponse = { error: Object.assign(new Error("spawn jsonui-test ENOENT"), { code: "ENOENT" }) };
428
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
429
+ expect(parsed).toMatchObject({ exit: 2, verdict: "cannot_start" });
430
+ expect(parsed.error).toContain("not on PATH");
431
+ });
432
+
433
+ it("does not report a timeout as a coverage verdict", async () => {
434
+ // runCli turns a timeout into exit 1 — which is "uncovered" here.
435
+ nextResponse = { error: Object.assign(new Error("killed"), { killed: true, code: null }), stdout: '{"plat' };
436
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
437
+ expect(parsed.exit).toBeNull();
438
+ expect(parsed.verdict).toBe("no_report");
439
+ expect(parsed.errors).toContain("timed out");
440
+ });
441
+
442
+ it("does not spawn anything when no project dir is configured", async () => {
443
+ const text = await harness.call("test_contracts_coverage");
444
+ expect(text).toMatch(/^Error: /);
445
+ expect(execFileMock).not.toHaveBeenCalled();
446
+ });
447
+ });