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.
- package/README.md +7 -4
- package/data/attribute_definitions.json +124 -74
- package/data/attribute_semantics.json +18 -1
- package/data/component_metadata.json +36 -7
- package/data/type_synonyms.json +167 -0
- package/dist/cli_runner.js +7 -1
- package/dist/index.js +4 -1
- package/dist/spec_loader.d.ts +25 -0
- package/dist/spec_loader.js +71 -0
- package/dist/tools/jui/jui_generate_converter.js +20 -5
- package/dist/tools/jui/jui_generate_project.js +11 -2
- package/dist/tools/spec/get_data_source.js +5 -2
- package/dist/tools/test/test_contracts_coverage.d.ts +10 -0
- package/dist/tools/test/test_contracts_coverage.js +95 -0
- package/dist/version.d.ts +14 -0
- package/dist/version.js +21 -0
- package/package.json +2 -1
- package/scripts/fetch-definitions.js +1 -1
- package/scripts/fetch-manifest.js +6 -0
- package/src/cli_runner.ts +7 -1
- package/src/index.ts +4 -1
- package/src/spec_loader.ts +85 -0
- package/src/tools/jui/jui_generate_converter.ts +10 -2
- package/src/tools/jui/jui_generate_project.ts +13 -2
- package/src/tools/spec/get_data_source.ts +5 -2
- package/src/tools/test/test_contracts_coverage.ts +115 -0
- package/src/version.ts +22 -0
- package/tests/cli_runner.test.ts +38 -1
- package/tests/server_version.test.ts +51 -0
- package/tests/snapshot_completeness.test.ts +1 -0
- package/tests/staleness_is_content_not_mtime.test.ts +1 -1
- package/tests/tools_cli.test.ts +43 -0
- package/tests/tools_test.test.ts +103 -5
- package/tests/type_synonyms.test.ts +178 -0
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/tests/cli_runner.test.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
);
|
package/tests/tools_cli.test.ts
CHANGED
|
@@ -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",
|
package/tests/tools_test.test.ts
CHANGED
|
@@ -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
|
|
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: "
|
|
131
|
+
screen: "item_detail",
|
|
129
132
|
platform: "android",
|
|
130
133
|
package: "com.example.app",
|
|
131
134
|
module: "AppModule",
|
|
132
|
-
spec: "docs/screens/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
|
-
"
|
|
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/
|
|
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
|
+
});
|