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.
@@ -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 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.", {}, async () => {
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 SEVEN THE STALENESS CHECK WATCHES. This list held five
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "MCP server for JsonUI project management - wraps jui and jsonui-doc CLIs plus component spec lookup",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -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.8.110";
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
 
@@ -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("Mark as container component"),
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
- if (params.container) { args.push("--container"); }
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("Force overwrite declaration files"),
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 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,
@@ -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: "" };
@@ -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",
@@ -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: "bottle_detail",
131
+ screen: "item_detail",
132
132
  platform: "android",
133
133
  package: "com.example.app",
134
134
  module: "AppModule",
135
- spec: "docs/screens/json/bottle_detail.spec.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
- "bottle_detail",
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/bottle_detail.spec.json",
151
+ "docs/screens/json/item_detail.spec.json",
152
152
  "--out-dir",
153
153
  "app/src/test/java",
154
154
  "--harness-dir",