jui-tools-mcp-server 2.13.0 → 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/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/spec_loader.d.ts +25 -0
- package/dist/spec_loader.js +71 -0
- package/dist/tools/jui/jui_generate_converter.js +10 -2
- package/dist/tools/jui/jui_generate_project.js +11 -2
- package/dist/tools/spec/get_data_source.js +5 -2
- package/package.json +1 -1
- package/scripts/fetch-definitions.js +1 -1
- package/scripts/fetch-manifest.js +6 -0
- package/src/cli_runner.ts +7 -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/tests/cli_runner.test.ts +38 -1
- 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 +4 -4
- package/tests/type_synonyms.test.ts +178 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export function register(server, loader) {
|
|
2
|
-
server.tool("get_data_source", "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
|
|
2
|
+
server.tool("get_data_source", "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.", {}, async () => {
|
|
3
3
|
const info = loader.getDataSource();
|
|
4
|
-
// 🚨 THE SAME
|
|
4
|
+
// 🚨 THE SAME FILES THE STALENESS CHECK WATCHES (eight since
|
|
5
|
+
// type_synonyms.json). This list held five
|
|
5
6
|
// while `getChangedSinceLoad` tracked seven, so `staleInMemory` could
|
|
6
7
|
// name a path that appeared in no per-file entry — and a reader who
|
|
7
8
|
// "checked every file reported" had two outside their view. Reported by
|
|
@@ -15,6 +16,7 @@ export function register(server, loader) {
|
|
|
15
16
|
info.attributeSemantics,
|
|
16
17
|
info.platformSemantics,
|
|
17
18
|
info.coverage,
|
|
19
|
+
info.typeSynonyms,
|
|
18
20
|
].filter((f) => f != null);
|
|
19
21
|
const stale = files.some((f) => f.freshness === "stale");
|
|
20
22
|
// The data is read once at construction and cached in memory, so a
|
|
@@ -35,6 +37,7 @@ export function register(server, loader) {
|
|
|
35
37
|
attributeSemantics: info.attributeSemantics,
|
|
36
38
|
platformSemantics: info.platformSemantics,
|
|
37
39
|
coverage: info.coverage,
|
|
40
|
+
typeSynonyms: info.typeSynonyms,
|
|
38
41
|
componentCount: info.componentCount,
|
|
39
42
|
commonAttributeCount: info.commonAttributeCount,
|
|
40
43
|
loadedAt,
|
package/package.json
CHANGED
|
@@ -44,7 +44,7 @@ const DATA_DIR = join(PROJECT_ROOT, "data");
|
|
|
44
44
|
// 3. `npm run fetch-definitions` and commit the data/ diff together with
|
|
45
45
|
// this pin in one commit
|
|
46
46
|
// JSONUI_CLI_RAW_BASE still overrides the whole base URL for manual testing.
|
|
47
|
-
const JSONUI_CLI_TAG = "v1.
|
|
47
|
+
const JSONUI_CLI_TAG = "v1.9.0";
|
|
48
48
|
const BASE_URL =
|
|
49
49
|
process.env.JSONUI_CLI_RAW_BASE ||
|
|
50
50
|
`https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/${JSONUI_CLI_TAG}`;
|
|
@@ -59,6 +59,12 @@ export const FILES = [
|
|
|
59
59
|
// Conformance coverage ledger (declared-but-unimplemented gaps). The loader
|
|
60
60
|
// reads data/coverage.json as its fallback, so it must refresh with the rest
|
|
61
61
|
// — binding_semantics.json already fell into this exact stale-snapshot trap.
|
|
62
|
+
// Type-spelling synonyms (jsonui-cli 1.9.0): lookup_component and
|
|
63
|
+
// search_components resolve ProgressBar, HStack, Text … through it.
|
|
64
|
+
{
|
|
65
|
+
remote: "shared/core/type_synonyms.json",
|
|
66
|
+
local: "type_synonyms.json",
|
|
67
|
+
},
|
|
62
68
|
{
|
|
63
69
|
remote: "conformance/coverage.json",
|
|
64
70
|
local: "coverage.json",
|
package/src/cli_runner.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function runCli(
|
|
|
14
14
|
const timeout = options.timeout ?? 60_000;
|
|
15
15
|
|
|
16
16
|
return new Promise((resolve) => {
|
|
17
|
-
execFile(
|
|
17
|
+
const child = execFile(
|
|
18
18
|
command,
|
|
19
19
|
args,
|
|
20
20
|
{
|
|
@@ -39,6 +39,12 @@ export async function runCli(
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
);
|
|
42
|
+
// Nothing is ever written to the child's stdin: end it now, so a CLI that
|
|
43
|
+
// reads stdin — an overwrite prompt — sees end-of-file instead of waiting
|
|
44
|
+
// for a line that never comes. Through 2.13.1 the pipe stayed open, and such
|
|
45
|
+
// a child waited until the timeout above killed it (`jui g converter` on
|
|
46
|
+
// an existing component, measured 2026-09-26).
|
|
47
|
+
child.stdin?.end();
|
|
42
48
|
});
|
|
43
49
|
}
|
|
44
50
|
|
package/src/spec_loader.ts
CHANGED
|
@@ -90,6 +90,17 @@ export interface ReloadRecord {
|
|
|
90
90
|
error?: string;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/// A spelling the build accepts for a type it is not, as type_synonyms.json
|
|
94
|
+
/// says: its canonical section, the type it is drawn as when that differs,
|
|
95
|
+
/// and the attributes the spelling itself means.
|
|
96
|
+
export interface TypeSynonym {
|
|
97
|
+
spelling: string;
|
|
98
|
+
canonical: string;
|
|
99
|
+
renderAs?: string;
|
|
100
|
+
implies?: Record<string, unknown>;
|
|
101
|
+
source: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
93
104
|
export interface DataSourceInfo {
|
|
94
105
|
attributeDefinitions: FileInfo;
|
|
95
106
|
componentMetadata: FileInfo;
|
|
@@ -111,6 +122,10 @@ export interface DataSourceInfo {
|
|
|
111
122
|
/// attribute, platform) pairs with their recorded reasons. Optional: absent
|
|
112
123
|
/// on older jsonui-cli checkouts.
|
|
113
124
|
coverage: FileInfo | null;
|
|
125
|
+
/// shared/core/type_synonyms.json — type spellings the build accepts for a
|
|
126
|
+
/// section they are not (ProgressBar, HStack, Text …). Optional: absent on
|
|
127
|
+
/// jsonui-cli before 1.9.0.
|
|
128
|
+
typeSynonyms: FileInfo | null;
|
|
114
129
|
componentCount: number;
|
|
115
130
|
commonAttributeCount: number;
|
|
116
131
|
}
|
|
@@ -157,6 +172,10 @@ export class SpecLoader {
|
|
|
157
172
|
private screenIdentity: Record<string, any> | null = null;
|
|
158
173
|
private commonAttributes: any = null;
|
|
159
174
|
private aliasMap: Map<string, string> = new Map();
|
|
175
|
+
/// Lower-cased spelling -> its entry in type_synonyms.json, for the
|
|
176
|
+
/// spellings that resolve through it (not a section, `_alias_of` or
|
|
177
|
+
/// metadata alias of their own).
|
|
178
|
+
private typeSynonyms: Map<string, TypeSynonym> = new Map();
|
|
160
179
|
private metadata: Record<string, ComponentMetadata> = {};
|
|
161
180
|
private dataSource!: DataSourceInfo;
|
|
162
181
|
/** When this process read the files. Data is cached in memory from then
|
|
@@ -223,6 +242,13 @@ export class SpecLoader {
|
|
|
223
242
|
getComponentWithCommon(name: string): any {
|
|
224
243
|
const comp = this.getComponent(name);
|
|
225
244
|
if (!comp) return null;
|
|
245
|
+
// Say which spelling was asked for and why it resolved: the build
|
|
246
|
+
// accepts `ProgressBar` as a Progress, and an HStack is a View that
|
|
247
|
+
// also means `orientation: horizontal`.
|
|
248
|
+
const synonym = this.typeSynonyms.get(name.toLowerCase());
|
|
249
|
+
if (synonym) {
|
|
250
|
+
return { ...comp, typeSynonym: synonym, commonAttributes: this.commonAttributes };
|
|
251
|
+
}
|
|
226
252
|
return { ...comp, commonAttributes: this.commonAttributes };
|
|
227
253
|
}
|
|
228
254
|
|
|
@@ -312,6 +338,15 @@ export class SpecLoader {
|
|
|
312
338
|
}
|
|
313
339
|
}
|
|
314
340
|
|
|
341
|
+
// A spelling the build accepts for this type (type_synonyms.json).
|
|
342
|
+
for (const synonym of this.typeSynonyms.values()) {
|
|
343
|
+
if (this.getComponent(synonym.spelling) !== comp) continue;
|
|
344
|
+
if (synonym.spelling.toLowerCase().includes(q)) {
|
|
345
|
+
score += 8;
|
|
346
|
+
matches.push(`type synonym: ${synonym.spelling}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
315
350
|
if (score > 0) results.push({ component: comp.name, score, matches });
|
|
316
351
|
}
|
|
317
352
|
|
|
@@ -422,6 +457,7 @@ export class SpecLoader {
|
|
|
422
457
|
this.dataSource.attributeSemantics,
|
|
423
458
|
this.dataSource.platformSemantics,
|
|
424
459
|
this.dataSource.coverage,
|
|
460
|
+
this.dataSource.typeSynonyms,
|
|
425
461
|
].filter((f): f is FileInfo => f != null);
|
|
426
462
|
|
|
427
463
|
const changed: string[] = [];
|
|
@@ -635,6 +671,8 @@ export class SpecLoader {
|
|
|
635
671
|
componentCount++;
|
|
636
672
|
}
|
|
637
673
|
|
|
674
|
+
const typeSynonymsResolution = this.loadTypeSynonyms();
|
|
675
|
+
|
|
638
676
|
this.dataSource = {
|
|
639
677
|
attributeDefinitions: attrResolution,
|
|
640
678
|
componentMetadata: metaResolution,
|
|
@@ -643,6 +681,7 @@ export class SpecLoader {
|
|
|
643
681
|
attributeSemantics: attributeSemanticsResolution,
|
|
644
682
|
platformSemantics: platformSemanticsResolution,
|
|
645
683
|
coverage: coverageResolution,
|
|
684
|
+
typeSynonyms: typeSynonymsResolution,
|
|
646
685
|
componentCount,
|
|
647
686
|
commonAttributeCount: Object.keys(this.commonAttributesRaw).filter(
|
|
648
687
|
(k) => !k.startsWith("_")
|
|
@@ -650,6 +689,52 @@ export class SpecLoader {
|
|
|
650
689
|
};
|
|
651
690
|
}
|
|
652
691
|
|
|
692
|
+
/**
|
|
693
|
+
* Type spellings the build accepts for a section they are not
|
|
694
|
+
* (shared/core/type_synonyms.json, jsonui-cli 1.9.0 — the one table the
|
|
695
|
+
* tools and the dynamic runtimes read). Until then lookup_component knew
|
|
696
|
+
* only `_alias_of` sections and metadata aliases, and answered "not found"
|
|
697
|
+
* for `ProgressBar`, which the build draws as a Progress.
|
|
698
|
+
*
|
|
699
|
+
* A section, an `_alias_of` section or a metadata alias of the same
|
|
700
|
+
* spelling wins: the exact match is found first, as in the tools. An entry
|
|
701
|
+
* whose canonical is not a section here is skipped.
|
|
702
|
+
*
|
|
703
|
+
* Absent (older jsonui-cli): no synonyms. Present but not JSON: the load
|
|
704
|
+
* throws, so an automatic reload keeps the last content that parsed rather
|
|
705
|
+
* than swapping in a loader that silently lost every synonym.
|
|
706
|
+
*/
|
|
707
|
+
private loadTypeSynonyms(): FileInfo | null {
|
|
708
|
+
this.typeSynonyms = new Map();
|
|
709
|
+
let resolution: FileInfo;
|
|
710
|
+
try {
|
|
711
|
+
resolution = this.resolveFile("shared/core/type_synonyms.json", "data/type_synonyms.json");
|
|
712
|
+
} catch {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
const doc = JSON.parse(readFileSync(resolution.path, "utf-8")) as {
|
|
716
|
+
synonyms?: Record<string, Record<string, unknown>>;
|
|
717
|
+
};
|
|
718
|
+
for (const [spelling, entry] of Object.entries(doc.synonyms ?? {})) {
|
|
719
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
720
|
+
const { canonical, render_as: renderAs, ...implies } = entry;
|
|
721
|
+
if (typeof canonical !== "string" || canonical.length === 0) continue;
|
|
722
|
+
const key = spelling.toLowerCase();
|
|
723
|
+
if (this.components.has(key) || this.aliasMap.has(key)) continue;
|
|
724
|
+
const target = this.aliasMap.get(canonical.toLowerCase()) ?? canonical.toLowerCase();
|
|
725
|
+
if (!this.components.has(target)) continue;
|
|
726
|
+
this.aliasMap.set(key, target);
|
|
727
|
+
this.typeSynonyms.set(key, {
|
|
728
|
+
spelling,
|
|
729
|
+
canonical,
|
|
730
|
+
...(typeof renderAs === "string" ? { renderAs } : {}),
|
|
731
|
+
...(Object.keys(implies).length > 0 ? { implies } : {}),
|
|
732
|
+
source: "shared/core/type_synonyms.json",
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
return resolution;
|
|
736
|
+
}
|
|
737
|
+
|
|
653
738
|
private normalizeMetadata(raw: Record<string, any>): ComponentMetadata {
|
|
654
739
|
return {
|
|
655
740
|
description: raw.description ?? "",
|
|
@@ -12,7 +12,12 @@ export function register(server: McpServer, config: ServerConfig) {
|
|
|
12
12
|
from_spec: z.string().optional().describe("Generate from component spec file path"),
|
|
13
13
|
all: z.boolean().optional().describe("Generate from all component specs"),
|
|
14
14
|
attributes: z.string().optional().describe("Attributes in key:type format (e.g., 'prop1:String,prop2:Int')"),
|
|
15
|
-
container: z.boolean().optional().describe(
|
|
15
|
+
container: z.boolean().optional().describe(
|
|
16
|
+
"true: a container (it draws its children). false: a leaf (it takes no " +
|
|
17
|
+
"children, and a layout that gives it some is refused by name) — needs " +
|
|
18
|
+
"jsonui-cli 1.9.0 or later (earlier `jui` rejects --no-container). " +
|
|
19
|
+
"Absent: the default form."
|
|
20
|
+
),
|
|
16
21
|
skip_existing: z.boolean().optional().describe(
|
|
17
22
|
"Leave existing converter and scaffold files untouched, without the " +
|
|
18
23
|
"overwrite prompt — for idempotent scaffold runs. Wins over `force` " +
|
|
@@ -34,7 +39,10 @@ export function register(server: McpServer, config: ServerConfig) {
|
|
|
34
39
|
if (params.from_spec) { args.push("--from", params.from_spec); }
|
|
35
40
|
if (params.all) { args.push("--all"); }
|
|
36
41
|
if (params.attributes) { args.push("--attributes", params.attributes); }
|
|
37
|
-
|
|
42
|
+
// `false` asks for a leaf. It used to pass nothing, so the command ran
|
|
43
|
+
// in the default form and the request was dropped without a word.
|
|
44
|
+
if (params.container === true) { args.push("--container"); }
|
|
45
|
+
if (params.container === false) { args.push("--no-container"); }
|
|
38
46
|
if (params.skip_existing) { args.push("--skip-existing"); }
|
|
39
47
|
if (params.force) { args.push("--force"); }
|
|
40
48
|
|
|
@@ -6,10 +6,21 @@ import { runCli, formatResult } from "../../cli_runner.js";
|
|
|
6
6
|
export function register(server: McpServer, config: ServerConfig) {
|
|
7
7
|
server.tool(
|
|
8
8
|
"jui_generate_project",
|
|
9
|
-
"Generate Layout JSON and ViewModel files from screen specs (all or single spec)"
|
|
9
|
+
"Generate Layout JSON and ViewModel files from screen specs (all or single spec). " +
|
|
10
|
+
"A Layout JSON it generates — a screen's, or a Collection cell / header / footer's — is " +
|
|
11
|
+
"written when it is not there, left as it is when it already holds exactly what the spec " +
|
|
12
|
+
"generates, and otherwise KEPT (the Layout JSON is hand-edited after generation) and named " +
|
|
13
|
+
"in the output with \"Kept existing … layout: <file> … --force replaces it\". From " +
|
|
14
|
+
"jsonui-cli 1.9.0; an earlier jui rewrote an existing layout on every run.",
|
|
10
15
|
{
|
|
11
16
|
spec_file: z.string().optional().describe("Single spec file to process (e.g., 'login.spec.json')"),
|
|
12
|
-
force: z.boolean().optional().describe(
|
|
17
|
+
force: z.boolean().optional().describe(
|
|
18
|
+
"Replace the ViewModel declaration files, and each Layout JSON that already exists and " +
|
|
19
|
+
"differs from what the spec generates (kept and named without it). A layout whose data " +
|
|
20
|
+
"section holds entries the spec does not declare is not replaced even with force — the " +
|
|
21
|
+
"output names them. Called from here the command has no terminal, so it never asks: " +
|
|
22
|
+
"without force an existing layout is kept."
|
|
23
|
+
),
|
|
13
24
|
skip_layout: z.boolean().optional().describe("Skip Layout JSON generation"),
|
|
14
25
|
dry_run: z.boolean().optional().describe("Show what would be generated without writing"),
|
|
15
26
|
platform: z.enum(["ios", "android", "web"]).optional().describe("Generate for single platform only"),
|
|
@@ -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,
|
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: "" };
|
|
@@ -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
|
@@ -128,11 +128,11 @@ describe("test_generate_branch_tests", () => {
|
|
|
128
128
|
nextResponse = { stdout: "ok" };
|
|
129
129
|
await harness.call("test_generate_branch_tests", {
|
|
130
130
|
project_dir: projectDir,
|
|
131
|
-
screen: "
|
|
131
|
+
screen: "item_detail",
|
|
132
132
|
platform: "android",
|
|
133
133
|
package: "com.example.app",
|
|
134
134
|
module: "AppModule",
|
|
135
|
-
spec: "docs/screens/json/
|
|
135
|
+
spec: "docs/screens/json/item_detail.spec.json",
|
|
136
136
|
out_dir: "app/src/test/java",
|
|
137
137
|
harness_dir: "app/src/test/java",
|
|
138
138
|
mocks_dir: "tests/mocks",
|
|
@@ -140,7 +140,7 @@ describe("test_generate_branch_tests", () => {
|
|
|
140
140
|
expect(recorded[0].args).toEqual([
|
|
141
141
|
"generate",
|
|
142
142
|
"branch-tests",
|
|
143
|
-
"
|
|
143
|
+
"item_detail",
|
|
144
144
|
"--platform",
|
|
145
145
|
"android",
|
|
146
146
|
"--package",
|
|
@@ -148,7 +148,7 @@ describe("test_generate_branch_tests", () => {
|
|
|
148
148
|
"--module",
|
|
149
149
|
"AppModule",
|
|
150
150
|
"--spec",
|
|
151
|
-
"docs/screens/json/
|
|
151
|
+
"docs/screens/json/item_detail.spec.json",
|
|
152
152
|
"--out-dir",
|
|
153
153
|
"app/src/test/java",
|
|
154
154
|
"--harness-dir",
|