jui-tools-mcp-server 2.8.0 → 2.10.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 +2 -1
- package/data/screen_identity.json +1 -1
- package/dist/index.js +8 -2
- package/dist/spec_loader.d.ts +5 -0
- package/dist/spec_loader.js +55 -0
- package/dist/tools/context/list_screen_specs.js +24 -5
- package/dist/tools/context/search_specs.d.ts +3 -0
- package/dist/tools/context/search_specs.js +113 -0
- package/dist/tools/doc/doc_validate_spec.js +1 -1
- package/dist/tools/spec/get_platform_rules.d.ts +3 -0
- package/dist/tools/spec/get_platform_rules.js +18 -0
- package/dist/tools/test/test_generate_branch_tests.d.ts +3 -0
- package/dist/tools/test/test_generate_branch_tests.js +76 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/tools/doc/doc_validate_spec.ts +1 -1
- package/src/tools/test/test_generate_branch_tests.ts +72 -0
- package/tests/tools_test.test.ts +66 -1
package/README.md
CHANGED
|
@@ -107,13 +107,14 @@ bash ~/.jsonui-mcp-server/uninstall.sh
|
|
|
107
107
|
| `list_api_models` | 生成済み DTO/Domain 一覧(orphan 検出付き) | `jui ls api-models --json` |
|
|
108
108
|
| `preview_api_model_sync` | swagger→DTO/Domain 同期の dry-run プレビュー(書き込みなし) | `jui g api --dry-run --json` |
|
|
109
109
|
|
|
110
|
-
### Group F: jsonui-test CLI (
|
|
110
|
+
### Group F: jsonui-test CLI (9)
|
|
111
111
|
|
|
112
112
|
| Tool | Description | CLI |
|
|
113
113
|
|------|-------------|-----|
|
|
114
114
|
| `test_validate` | テストファイル(screen/flow + description)のバリデーション。既定では CLI 同様に flatten-install 副作用あり(`no_install=true` で検証のみ) | `jsonui-test validate [--no-install]` |
|
|
115
115
|
| `test_generate_screen` | screen テストテンプレート生成 | `jsonui-test generate test screen` |
|
|
116
116
|
| `test_generate_flow` | flow テストテンプレート生成 | `jsonui-test generate test flow` |
|
|
117
|
+
| `test_generate_branch_tests` | spec の branchContracts から分岐テスト生成(web/android/ios) | `jsonui-test generate branch-tests` |
|
|
117
118
|
| `test_generate_description` | テストケースの description JSON 生成(screen/flow) | `jsonui-test generate description` |
|
|
118
119
|
| `test_report` | results JSON → JUnit XML / HTML レポート変換 | `jsonui-test report` |
|
|
119
120
|
| `test_mock_generate` | OpenAPI からモック scaffold(`check=true` で drift レポートのみ・書き込みなし) | `jsonui-test mock generate [--check]` |
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
}
|
|
86
86
|
},
|
|
87
87
|
"appOwnedScreens": {
|
|
88
|
-
"description": "Screens the app owns outright — a hand-written page with no JsonUI layout behind it. They are real navigation destinations (a test navigates to them and asserts there), so they must be declarable, otherwise the screen-unknown rule rejects a legitimate id.",
|
|
88
|
+
"description": "Screens the app owns outright — a hand-written page with no JsonUI layout behind it. They are real navigation destinations (a test navigates to them and asserts there), so they must be declarable, otherwise the screen-unknown rule rejects a legitimate id. Having no layout is what they ARE, not an omission: `jui verify`'s spec-coverage check looks for a layout that generates a screen nobody declared, so these fall outside it — requiring a spec would also leave no way to comply, since there is no layout root to carry a `role` and a spec for a screen with nothing to generate describes nothing.",
|
|
89
89
|
"declaration": {
|
|
90
90
|
"location": "jui.config.json",
|
|
91
91
|
"key": "test.appOwnedScreens",
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { register as registerGetBindingRules } from "./tools/spec/get_binding_ru
|
|
|
12
12
|
import { register as registerGetScreenIdentity } from "./tools/spec/get_screen_identity.js";
|
|
13
13
|
import { register as registerGetPlatformMapping } from "./tools/spec/get_platform_mapping.js";
|
|
14
14
|
import { register as registerGetDataSource } from "./tools/spec/get_data_source.js";
|
|
15
|
+
import { register as registerGetPlatformRules } from "./tools/spec/get_platform_rules.js";
|
|
15
16
|
// --- Group B: Project Context ---
|
|
16
17
|
import { register as registerGetProjectConfig } from "./tools/context/get_project_config.js";
|
|
17
18
|
import { register as registerListScreenSpecs } from "./tools/context/list_screen_specs.js";
|
|
@@ -19,6 +20,7 @@ import { register as registerListComponentSpecs } from "./tools/context/list_com
|
|
|
19
20
|
import { register as registerListLayouts } from "./tools/context/list_layouts.js";
|
|
20
21
|
import { register as registerReadSpecFile } from "./tools/context/read_spec_file.js";
|
|
21
22
|
import { register as registerReadLayoutFile } from "./tools/context/read_layout_file.js";
|
|
23
|
+
import { register as registerSearchSpecs } from "./tools/context/search_specs.js";
|
|
22
24
|
// --- Group C: jui CLI ---
|
|
23
25
|
import { register as registerJuiInit } from "./tools/jui/jui_init.js";
|
|
24
26
|
import { register as registerJuiGenerateProject } from "./tools/jui/jui_generate_project.js";
|
|
@@ -46,6 +48,7 @@ import { register as registerDocRulesShow } from "./tools/doc/doc_rules_show.js"
|
|
|
46
48
|
import { register as registerTestValidate } from "./tools/test/test_validate.js";
|
|
47
49
|
import { register as registerTestGenerateScreen } from "./tools/test/test_generate_screen.js";
|
|
48
50
|
import { register as registerTestGenerateFlow } from "./tools/test/test_generate_flow.js";
|
|
51
|
+
import { register as registerTestGenerateBranchTests } from "./tools/test/test_generate_branch_tests.js";
|
|
49
52
|
import { register as registerTestGenerateDescription } from "./tools/test/test_generate_description.js";
|
|
50
53
|
import { register as registerTestReport } from "./tools/test/test_report.js";
|
|
51
54
|
import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
|
|
@@ -70,7 +73,7 @@ log(`component_metadata: [${source.componentMetadata.layer}] ${source.compone
|
|
|
70
73
|
log(`Loaded ${source.componentCount} components, ${source.commonAttributeCount} common attributes`);
|
|
71
74
|
// Initialize server config for Groups B/C/D
|
|
72
75
|
const config = new ServerConfig();
|
|
73
|
-
// Register Group A: Component Spec Lookup (
|
|
76
|
+
// Register Group A: Component Spec Lookup (9 tools)
|
|
74
77
|
registerLookupComponent(server, loader);
|
|
75
78
|
registerLookupAttribute(server, loader);
|
|
76
79
|
registerSearchComponents(server, loader);
|
|
@@ -79,13 +82,15 @@ registerGetBindingRules(server, loader);
|
|
|
79
82
|
registerGetScreenIdentity(server, loader);
|
|
80
83
|
registerGetPlatformMapping(server, loader);
|
|
81
84
|
registerGetDataSource(server, loader);
|
|
82
|
-
|
|
85
|
+
registerGetPlatformRules(server, loader);
|
|
86
|
+
// Register Group B: Project Context (7 tools)
|
|
83
87
|
registerGetProjectConfig(server, config);
|
|
84
88
|
registerListScreenSpecs(server, config);
|
|
85
89
|
registerListComponentSpecs(server, config);
|
|
86
90
|
registerListLayouts(server, config);
|
|
87
91
|
registerReadSpecFile(server, config);
|
|
88
92
|
registerReadLayoutFile(server, config);
|
|
93
|
+
registerSearchSpecs(server, config);
|
|
89
94
|
// Register Group C: jui CLI (8 tools)
|
|
90
95
|
registerJuiInit(server, config);
|
|
91
96
|
registerJuiGenerateProject(server, config);
|
|
@@ -116,6 +121,7 @@ registerPreviewApiModelSync(server, config);
|
|
|
116
121
|
registerTestValidate(server, config);
|
|
117
122
|
registerTestGenerateScreen(server, config);
|
|
118
123
|
registerTestGenerateFlow(server, config);
|
|
124
|
+
registerTestGenerateBranchTests(server, config);
|
|
119
125
|
registerTestGenerateDescription(server, config);
|
|
120
126
|
registerTestReport(server, config);
|
|
121
127
|
registerTestMockGenerate(server, config);
|
package/dist/spec_loader.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface DataSourceInfo {
|
|
|
38
38
|
screenIdentity: FileInfo | null;
|
|
39
39
|
bindingSemantics: FileInfo | null;
|
|
40
40
|
attributeSemantics: FileInfo | null;
|
|
41
|
+
platformSemantics: FileInfo | null;
|
|
41
42
|
coverage: FileInfo | null;
|
|
42
43
|
componentCount: number;
|
|
43
44
|
commonAttributeCount: number;
|
|
@@ -60,6 +61,7 @@ export declare class SpecLoader {
|
|
|
60
61
|
private commonAttributesRaw;
|
|
61
62
|
private bindingSemantics;
|
|
62
63
|
private attributeSemantics;
|
|
64
|
+
private platformSemantics;
|
|
63
65
|
private coverageGaps;
|
|
64
66
|
private screenIdentity;
|
|
65
67
|
private commonAttributes;
|
|
@@ -75,6 +77,8 @@ export declare class SpecLoader {
|
|
|
75
77
|
getAttribute(name: string): any;
|
|
76
78
|
private withImplementationGaps;
|
|
77
79
|
searchComponents(query: string): any[];
|
|
80
|
+
/** Keyword-indexed topical guides, each backed by a dedicated MCP tool. */
|
|
81
|
+
private topicGuides;
|
|
78
82
|
getModifierOrder(platform?: string): any;
|
|
79
83
|
/**
|
|
80
84
|
* The canonical screen-identity asset, or null when this checkout predates
|
|
@@ -93,6 +97,7 @@ export declare class SpecLoader {
|
|
|
93
97
|
getChangedSinceLoad(): string[];
|
|
94
98
|
getScreenIdentity(): any;
|
|
95
99
|
getBindingRules(): any;
|
|
100
|
+
getPlatformRules(): any;
|
|
96
101
|
getPlatformMapping(category?: string): any;
|
|
97
102
|
listComponents(): string[];
|
|
98
103
|
getDataSource(): DataSourceInfo;
|
package/dist/spec_loader.js
CHANGED
|
@@ -35,6 +35,7 @@ export class SpecLoader {
|
|
|
35
35
|
commonAttributesRaw = {};
|
|
36
36
|
bindingSemantics = null;
|
|
37
37
|
attributeSemantics = null;
|
|
38
|
+
platformSemantics = null;
|
|
38
39
|
/// `${component}.${attribute}` -> [{platform, reason, note}] from the
|
|
39
40
|
/// coverage ledger. The ledger tracks pairs that ARE declared (platform
|
|
40
41
|
/// field includes them) but that the platform's codegen does not consume —
|
|
@@ -159,8 +160,40 @@ export class SpecLoader {
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
}
|
|
163
|
+
// Topic-guide index: task vocabulary ("iOSだけ色を変える", "ios only",
|
|
164
|
+
// "platform override") does not contain any component or attribute
|
|
165
|
+
// name, so the loops above can't surface the mechanism. Each guide
|
|
166
|
+
// declares its own keywords (en + ja) in its semantics file; a hit
|
|
167
|
+
// pins a pointer to the dedicated tool at the top of the results.
|
|
168
|
+
for (const guide of this.topicGuides()) {
|
|
169
|
+
const hit = guide.keywords.some((kw) => q.includes(kw) || (q.length >= 3 && kw.includes(q)));
|
|
170
|
+
if (hit) {
|
|
171
|
+
results.push({
|
|
172
|
+
guide: guide.topic,
|
|
173
|
+
score: 100,
|
|
174
|
+
matches: [guide.summary],
|
|
175
|
+
nextTool: guide.tool,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
162
179
|
return results.sort((a, b) => b.score - a.score);
|
|
163
180
|
}
|
|
181
|
+
/** Keyword-indexed topical guides, each backed by a dedicated MCP tool. */
|
|
182
|
+
topicGuides() {
|
|
183
|
+
const guides = [];
|
|
184
|
+
if (this.platformSemantics) {
|
|
185
|
+
const kws = Array.isArray(this.platformSemantics.keywords)
|
|
186
|
+
? this.platformSemantics.keywords
|
|
187
|
+
: [];
|
|
188
|
+
guides.push({
|
|
189
|
+
topic: "platform_rules",
|
|
190
|
+
tool: "get_platform_rules",
|
|
191
|
+
summary: "Per-platform styling/visibility (e.g. change fontColor on iOS only): node-level 'platform' directive (object form = per-platform overrides, string form = platform filter), root 'platforms' whitelist, vs 'responsive'. Call get_platform_rules for the full semantics with examples.",
|
|
192
|
+
keywords: kws.map((k) => k.toLowerCase()),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return guides;
|
|
196
|
+
}
|
|
164
197
|
getModifierOrder(platform) {
|
|
165
198
|
if (platform)
|
|
166
199
|
return MODIFIER_ORDER[platform] || null;
|
|
@@ -189,6 +222,7 @@ export class SpecLoader {
|
|
|
189
222
|
this.dataSource.screenIdentity,
|
|
190
223
|
this.dataSource.bindingSemantics,
|
|
191
224
|
this.dataSource.attributeSemantics,
|
|
225
|
+
this.dataSource.platformSemantics,
|
|
192
226
|
this.dataSource.coverage,
|
|
193
227
|
].filter((f) => f != null);
|
|
194
228
|
const changed = [];
|
|
@@ -222,6 +256,14 @@ export class SpecLoader {
|
|
|
222
256
|
semantics: this.bindingSemantics,
|
|
223
257
|
};
|
|
224
258
|
}
|
|
259
|
+
getPlatformRules() {
|
|
260
|
+
// Canonical platform-scoping semantics (SSoT:
|
|
261
|
+
// shared/core/platform_semantics.json — restates
|
|
262
|
+
// jui_tools/jui_cli/core/platform_resolver.py). Null on jsonui-cli
|
|
263
|
+
// checkouts that predate the file; the tool reports that instead of
|
|
264
|
+
// inventing rules.
|
|
265
|
+
return this.platformSemantics;
|
|
266
|
+
}
|
|
225
267
|
getPlatformMapping(category) {
|
|
226
268
|
if (category)
|
|
227
269
|
return PLATFORM_MAPPING[category] || null;
|
|
@@ -262,6 +304,18 @@ export class SpecLoader {
|
|
|
262
304
|
catch {
|
|
263
305
|
this.attributeSemantics = null;
|
|
264
306
|
}
|
|
307
|
+
// Platform-scoped layout semantics (node 'platform' directive, root
|
|
308
|
+
// 'platforms', vs 'responsive'). Optional for the same backward-compat
|
|
309
|
+
// reason.
|
|
310
|
+
let platformSemanticsResolution = null;
|
|
311
|
+
try {
|
|
312
|
+
const platSemResolution = this.resolveFile("shared/core/platform_semantics.json", "data/platform_semantics.json");
|
|
313
|
+
this.platformSemantics = JSON.parse(readFileSync(platSemResolution.path, "utf-8"));
|
|
314
|
+
platformSemanticsResolution = platSemResolution;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
this.platformSemantics = null;
|
|
318
|
+
}
|
|
265
319
|
// Canonical screen identity (screen-identity track). Optional so older
|
|
266
320
|
// jsonui-cli checkouts without the file still load.
|
|
267
321
|
try {
|
|
@@ -337,6 +391,7 @@ export class SpecLoader {
|
|
|
337
391
|
screenIdentity: screenIdentityResolution,
|
|
338
392
|
bindingSemantics: bindingResolution,
|
|
339
393
|
attributeSemantics: attributeSemanticsResolution,
|
|
394
|
+
platformSemantics: platformSemanticsResolution,
|
|
340
395
|
coverage: coverageResolution,
|
|
341
396
|
componentCount,
|
|
342
397
|
commonAttributeCount: Object.keys(this.commonAttributesRaw).filter((k) => !k.startsWith("_")).length,
|
|
@@ -13,21 +13,40 @@ export function register(server, config) {
|
|
|
13
13
|
return { content: [{ type: "text", text: `Spec directory not found: ${specDir}` }] };
|
|
14
14
|
}
|
|
15
15
|
const files = readdirSync(specDir).filter((f) => f.endsWith(".spec.json"));
|
|
16
|
-
const specs =
|
|
16
|
+
const specs = [];
|
|
17
|
+
for (const file of files) {
|
|
17
18
|
try {
|
|
18
19
|
const data = JSON.parse(readFileSync(join(specDir, file), "utf-8"));
|
|
19
|
-
|
|
20
|
+
specs.push({
|
|
20
21
|
file,
|
|
21
22
|
name: data.metadata?.name || file.replace(".spec.json", ""),
|
|
22
23
|
displayName: data.metadata?.displayName || "",
|
|
23
24
|
type: data.type || "screen_spec",
|
|
24
25
|
layoutFile: data.metadata?.layoutFile || null,
|
|
25
|
-
};
|
|
26
|
+
});
|
|
27
|
+
// A parent spec splits one screen across sub-spec files (its
|
|
28
|
+
// 'subSpecs' array). Surface them inline so a session can see
|
|
29
|
+
// the split and read_spec_file the right part without first
|
|
30
|
+
// opening the parent.
|
|
31
|
+
if (Array.isArray(data.subSpecs)) {
|
|
32
|
+
for (const sub of data.subSpecs) {
|
|
33
|
+
if (!sub?.file)
|
|
34
|
+
continue;
|
|
35
|
+
specs.push({
|
|
36
|
+
file: sub.file,
|
|
37
|
+
name: sub.name || sub.file,
|
|
38
|
+
displayName: sub.description || "",
|
|
39
|
+
type: "screen_sub_spec",
|
|
40
|
+
parent: file,
|
|
41
|
+
layoutFile: data.metadata?.layoutFile || null,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
26
45
|
}
|
|
27
46
|
catch {
|
|
28
|
-
|
|
47
|
+
specs.push({ file, name: file.replace(".spec.json", ""), displayName: "", type: "unknown", layoutFile: null });
|
|
29
48
|
}
|
|
30
|
-
}
|
|
49
|
+
}
|
|
31
50
|
return { content: [{ type: "text", text: JSON.stringify(specs, null, 2) }] };
|
|
32
51
|
}
|
|
33
52
|
catch (e) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "fs";
|
|
3
|
+
import { join, relative } from "path";
|
|
4
|
+
const MAX_RESULTS = 30;
|
|
5
|
+
const SNIPPET_LEN = 200;
|
|
6
|
+
/** Recursively collect *.spec.json / *.component.json under dir. */
|
|
7
|
+
function collectSpecFiles(dir, out = []) {
|
|
8
|
+
for (const entry of readdirSync(dir)) {
|
|
9
|
+
const p = join(dir, entry);
|
|
10
|
+
if (statSync(p).isDirectory()) {
|
|
11
|
+
collectSpecFiles(p, out);
|
|
12
|
+
}
|
|
13
|
+
else if (entry.endsWith(".spec.json") || entry.endsWith(".component.json")) {
|
|
14
|
+
out.push(p);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Walk a parsed spec and report the JSON paths whose keys or string values
|
|
21
|
+
* contain the query. One entry per path, with a short snippet.
|
|
22
|
+
*/
|
|
23
|
+
function findMatches(node, q, path, out) {
|
|
24
|
+
if (out.length >= MAX_RESULTS)
|
|
25
|
+
return;
|
|
26
|
+
if (typeof node === "string") {
|
|
27
|
+
if (node.toLowerCase().includes(q)) {
|
|
28
|
+
out.push({ path, snippet: node.slice(0, SNIPPET_LEN) });
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(node)) {
|
|
33
|
+
node.forEach((v, i) => findMatches(v, q, `${path}[${i}]`, out));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (node && typeof node === "object") {
|
|
37
|
+
for (const [k, v] of Object.entries(node)) {
|
|
38
|
+
const childPath = path ? `${path}.${k}` : k;
|
|
39
|
+
if (k.toLowerCase().includes(q)) {
|
|
40
|
+
const snippet = typeof v === "string"
|
|
41
|
+
? v.slice(0, SNIPPET_LEN)
|
|
42
|
+
: JSON.stringify(v)?.slice(0, SNIPPET_LEN) ?? "";
|
|
43
|
+
out.push({ path: childPath, snippet });
|
|
44
|
+
if (out.length >= MAX_RESULTS)
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
findMatches(v, q, childPath, out);
|
|
48
|
+
if (out.length >= MAX_RESULTS)
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function register(server, config) {
|
|
54
|
+
server.tool("search_specs", "Keyword-search across ALL project spec files (screen specs, sub-specs in subdirectories, component specs). Returns file + JSON path + snippet per match — the fast way to find which spec (or which sub-spec of a split screen) covers a topic", {
|
|
55
|
+
query: z.string().describe("Search keyword (matched case-insensitively against keys and string values)"),
|
|
56
|
+
project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
|
|
57
|
+
}, async ({ query, project_dir }) => {
|
|
58
|
+
try {
|
|
59
|
+
const projectDir = config.resolveProjectDir(project_dir);
|
|
60
|
+
const projectConfig = config.readProjectConfig(projectDir);
|
|
61
|
+
const q = query.toLowerCase();
|
|
62
|
+
if (!q.trim()) {
|
|
63
|
+
return { content: [{ type: "text", text: "Empty query." }] };
|
|
64
|
+
}
|
|
65
|
+
const dirs = new Set();
|
|
66
|
+
for (const field of ["spec_directory", "component_spec_directory"]) {
|
|
67
|
+
try {
|
|
68
|
+
const d = config.resolveDir(projectConfig, field, projectDir);
|
|
69
|
+
if (existsSync(d))
|
|
70
|
+
dirs.add(d);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* directory not configured */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (dirs.size === 0) {
|
|
77
|
+
return { content: [{ type: "text", text: "No spec directories found." }] };
|
|
78
|
+
}
|
|
79
|
+
const results = [];
|
|
80
|
+
for (const dir of dirs) {
|
|
81
|
+
for (const filePath of collectSpecFiles(dir)) {
|
|
82
|
+
if (results.length >= MAX_RESULTS)
|
|
83
|
+
break;
|
|
84
|
+
let matches = [];
|
|
85
|
+
try {
|
|
86
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
87
|
+
findMatches(data, q, "", matches);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (matches.length > 0) {
|
|
93
|
+
results.push({
|
|
94
|
+
file: relative(dir, filePath),
|
|
95
|
+
matchCount: matches.length,
|
|
96
|
+
matches: matches.slice(0, 5),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (results.length === 0) {
|
|
102
|
+
return { content: [{ type: "text", text: `No matches for '${query}'.` }] };
|
|
103
|
+
}
|
|
104
|
+
results.sort((a, b) => b.matchCount - a.matchCount);
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { runCli } from "../../cli_runner.js";
|
|
4
4
|
export function register(server, config) {
|
|
5
|
-
server.tool("doc_validate_spec", "Validate a screen specification JSON file against the schema", {
|
|
5
|
+
server.tool("doc_validate_spec", "Validate a screen specification JSON file against the schema. Also expands `@canonical` marks in dataFlow method params/returnType against the project's OpenAPI documents, so a mark that names no operation is reported here as an error rather than reaching `jui build`.", {
|
|
6
6
|
file: z.string().describe("Spec file path (e.g., 'login.spec.json' or full path)"),
|
|
7
7
|
project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
|
|
8
8
|
}, async (params) => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function register(server, loader) {
|
|
2
|
+
server.tool("get_platform_rules", "Get platform-scoping rules for layouts: how to change an attribute on one platform only (node-level 'platform' object form, e.g. iOS-only fontColor), how to drop a node per platform (string form + language tokens), the layout-root 'platforms' whitelist, precedence, and how this differs from 'responsive'", {}, async () => {
|
|
3
|
+
const result = loader.getPlatformRules();
|
|
4
|
+
if (!result) {
|
|
5
|
+
return {
|
|
6
|
+
content: [
|
|
7
|
+
{
|
|
8
|
+
type: "text",
|
|
9
|
+
text: "platform_semantics.json not found — the connected jsonui-cli checkout predates it. Fall back to lookup_attribute('platform') for the short definition.",
|
|
10
|
+
},
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { runCli, formatResult } from "../../cli_runner.js";
|
|
3
|
+
export function register(server, config) {
|
|
4
|
+
server.tool("test_generate_branch_tests", "Generate unit-level branch tests from a screen spec's branchContracts " +
|
|
5
|
+
"(vitest / Kotlin JUnit4+Robolectric / Swift XCTest). Only the HTTP " +
|
|
6
|
+
"boundary is mocked — ViewModel, UseCase, Repository and response " +
|
|
7
|
+
"decoding all run for real. The generated test file and runtime are " +
|
|
8
|
+
"@generated; the harness skeleton is emitted once and then owned by " +
|
|
9
|
+
"the project. Screens with no branchContracts section are an error, " +
|
|
10
|
+
"not a no-op", {
|
|
11
|
+
screen: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe("Screen name in snake_case (resolves <spec_directory>/<screen>.spec.json)"),
|
|
14
|
+
platform: z
|
|
15
|
+
.enum(["web", "android", "ios"])
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Target platform (default web). android additionally requires " +
|
|
18
|
+
"`package`; ios additionally requires `module`"),
|
|
19
|
+
package: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Kotlin package for the generated sources — required for platform 'android'"),
|
|
23
|
+
module: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("App module name for @testable import — required for platform 'ios'"),
|
|
27
|
+
spec: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Explicit spec file path (overrides spec_directory resolution)"),
|
|
31
|
+
out_dir: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Output directory for the @generated test + runtime files"),
|
|
35
|
+
harness_dir: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Project-owned harness directory (skeleton written only when absent)"),
|
|
39
|
+
mocks_dir: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Directory scanned for *.mock.json scenario files"),
|
|
43
|
+
project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
|
|
44
|
+
}, async (params) => {
|
|
45
|
+
try {
|
|
46
|
+
const projectDir = config.resolveProjectDir(params.project_dir);
|
|
47
|
+
const args = ["generate", "branch-tests", params.screen];
|
|
48
|
+
if (params.platform) {
|
|
49
|
+
args.push("--platform", params.platform);
|
|
50
|
+
}
|
|
51
|
+
if (params.package) {
|
|
52
|
+
args.push("--package", params.package);
|
|
53
|
+
}
|
|
54
|
+
if (params.module) {
|
|
55
|
+
args.push("--module", params.module);
|
|
56
|
+
}
|
|
57
|
+
if (params.spec) {
|
|
58
|
+
args.push("--spec", params.spec);
|
|
59
|
+
}
|
|
60
|
+
if (params.out_dir) {
|
|
61
|
+
args.push("--out-dir", params.out_dir);
|
|
62
|
+
}
|
|
63
|
+
if (params.harness_dir) {
|
|
64
|
+
args.push("--harness-dir", params.harness_dir);
|
|
65
|
+
}
|
|
66
|
+
if (params.mocks_dir) {
|
|
67
|
+
args.push("--mocks-dir", params.mocks_dir);
|
|
68
|
+
}
|
|
69
|
+
const result = await runCli("jsonui-test", args, { cwd: projectDir });
|
|
70
|
+
return { content: [{ type: "text", text: formatResult(result) }] };
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { register as registerDocRulesShow } from "./tools/doc/doc_rules_show.js"
|
|
|
55
55
|
import { register as registerTestValidate } from "./tools/test/test_validate.js";
|
|
56
56
|
import { register as registerTestGenerateScreen } from "./tools/test/test_generate_screen.js";
|
|
57
57
|
import { register as registerTestGenerateFlow } from "./tools/test/test_generate_flow.js";
|
|
58
|
+
import { register as registerTestGenerateBranchTests } from "./tools/test/test_generate_branch_tests.js";
|
|
58
59
|
import { register as registerTestGenerateDescription } from "./tools/test/test_generate_description.js";
|
|
59
60
|
import { register as registerTestReport } from "./tools/test/test_report.js";
|
|
60
61
|
import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
|
|
@@ -144,6 +145,7 @@ registerPreviewApiModelSync(server, config);
|
|
|
144
145
|
registerTestValidate(server, config);
|
|
145
146
|
registerTestGenerateScreen(server, config);
|
|
146
147
|
registerTestGenerateFlow(server, config);
|
|
148
|
+
registerTestGenerateBranchTests(server, config);
|
|
147
149
|
registerTestGenerateDescription(server, config);
|
|
148
150
|
registerTestReport(server, config);
|
|
149
151
|
registerTestMockGenerate(server, config);
|
|
@@ -7,7 +7,7 @@ import { runCli } from "../../cli_runner.js";
|
|
|
7
7
|
export function register(server: McpServer, config: ServerConfig) {
|
|
8
8
|
server.tool(
|
|
9
9
|
"doc_validate_spec",
|
|
10
|
-
"Validate a screen specification JSON file against the schema",
|
|
10
|
+
"Validate a screen specification JSON file against the schema. Also expands `@canonical` marks in dataFlow method params/returnType against the project's OpenAPI documents, so a mark that names no operation is reported here as an error rather than reaching `jui build`.",
|
|
11
11
|
{
|
|
12
12
|
file: z.string().describe("Spec file path (e.g., 'login.spec.json' or full path)"),
|
|
13
13
|
project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ServerConfig } from "../../config.js";
|
|
4
|
+
import { runCli, formatResult } from "../../cli_runner.js";
|
|
5
|
+
|
|
6
|
+
export function register(server: McpServer, config: ServerConfig) {
|
|
7
|
+
server.tool(
|
|
8
|
+
"test_generate_branch_tests",
|
|
9
|
+
"Generate unit-level branch tests from a screen spec's branchContracts " +
|
|
10
|
+
"(vitest / Kotlin JUnit4+Robolectric / Swift XCTest). Only the HTTP " +
|
|
11
|
+
"boundary is mocked — ViewModel, UseCase, Repository and response " +
|
|
12
|
+
"decoding all run for real. The generated test file and runtime are " +
|
|
13
|
+
"@generated; the harness skeleton is emitted once and then owned by " +
|
|
14
|
+
"the project. Screens with no branchContracts section are an error, " +
|
|
15
|
+
"not a no-op",
|
|
16
|
+
{
|
|
17
|
+
screen: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe("Screen name in snake_case (resolves <spec_directory>/<screen>.spec.json)"),
|
|
20
|
+
platform: z
|
|
21
|
+
.enum(["web", "android", "ios"])
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(
|
|
24
|
+
"Target platform (default web). android additionally requires " +
|
|
25
|
+
"`package`; ios additionally requires `module`"
|
|
26
|
+
),
|
|
27
|
+
package: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Kotlin package for the generated sources — required for platform 'android'"),
|
|
31
|
+
module: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("App module name for @testable import — required for platform 'ios'"),
|
|
35
|
+
spec: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Explicit spec file path (overrides spec_directory resolution)"),
|
|
39
|
+
out_dir: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Output directory for the @generated test + runtime files"),
|
|
43
|
+
harness_dir: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Project-owned harness directory (skeleton written only when absent)"),
|
|
47
|
+
mocks_dir: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("Directory scanned for *.mock.json scenario files"),
|
|
51
|
+
project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
|
|
52
|
+
},
|
|
53
|
+
async (params) => {
|
|
54
|
+
try {
|
|
55
|
+
const projectDir = config.resolveProjectDir(params.project_dir);
|
|
56
|
+
const args = ["generate", "branch-tests", params.screen];
|
|
57
|
+
if (params.platform) { args.push("--platform", params.platform); }
|
|
58
|
+
if (params.package) { args.push("--package", params.package); }
|
|
59
|
+
if (params.module) { args.push("--module", params.module); }
|
|
60
|
+
if (params.spec) { args.push("--spec", params.spec); }
|
|
61
|
+
if (params.out_dir) { args.push("--out-dir", params.out_dir); }
|
|
62
|
+
if (params.harness_dir) { args.push("--harness-dir", params.harness_dir); }
|
|
63
|
+
if (params.mocks_dir) { args.push("--mocks-dir", params.mocks_dir); }
|
|
64
|
+
|
|
65
|
+
const result = await runCli("jsonui-test", args, { cwd: projectDir });
|
|
66
|
+
return { content: [{ type: "text", text: formatResult(result) }] };
|
|
67
|
+
} catch (e: any) {
|
|
68
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
}
|
package/tests/tools_test.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { ServerConfig } from "../src/config.js";
|
|
|
14
14
|
import { register as registerTestValidate } from "../src/tools/test/test_validate.js";
|
|
15
15
|
import { register as registerTestGenerateScreen } from "../src/tools/test/test_generate_screen.js";
|
|
16
16
|
import { register as registerTestGenerateFlow } from "../src/tools/test/test_generate_flow.js";
|
|
17
|
+
import { register as registerTestGenerateBranchTests } from "../src/tools/test/test_generate_branch_tests.js";
|
|
17
18
|
import { register as registerTestGenerateDescription } from "../src/tools/test/test_generate_description.js";
|
|
18
19
|
import { register as registerTestReport } from "../src/tools/test/test_report.js";
|
|
19
20
|
import { register as registerTestMockGenerate } from "../src/tools/test/test_mock_generate.js";
|
|
@@ -65,6 +66,7 @@ beforeEach(() => {
|
|
|
65
66
|
registerTestValidate(harness.server, config);
|
|
66
67
|
registerTestGenerateScreen(harness.server, config);
|
|
67
68
|
registerTestGenerateFlow(harness.server, config);
|
|
69
|
+
registerTestGenerateBranchTests(harness.server, config);
|
|
68
70
|
registerTestGenerateDescription(harness.server, config);
|
|
69
71
|
registerTestReport(harness.server, config);
|
|
70
72
|
registerTestMockGenerate(harness.server, config);
|
|
@@ -86,11 +88,12 @@ function failWithCode(code: number, stdout = "", stderr = ""): void {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
describe("Group F registration", () => {
|
|
89
|
-
it("registers all
|
|
91
|
+
it("registers all 9 jsonui-test tools", () => {
|
|
90
92
|
expect([...harness.tools.keys()].sort()).toEqual(
|
|
91
93
|
[
|
|
92
94
|
"test_artifacts_pull",
|
|
93
95
|
"test_artifacts_status",
|
|
96
|
+
"test_generate_branch_tests",
|
|
94
97
|
"test_generate_description",
|
|
95
98
|
"test_generate_flow",
|
|
96
99
|
"test_generate_screen",
|
|
@@ -102,6 +105,68 @@ describe("Group F registration", () => {
|
|
|
102
105
|
});
|
|
103
106
|
});
|
|
104
107
|
|
|
108
|
+
describe("test_generate_branch_tests", () => {
|
|
109
|
+
it("builds the base argv and runs in the project dir", async () => {
|
|
110
|
+
nextResponse = { stdout: "generated 5 branch(es)" };
|
|
111
|
+
await harness.call("test_generate_branch_tests", {
|
|
112
|
+
project_dir: projectDir,
|
|
113
|
+
screen: "booking_confirm",
|
|
114
|
+
});
|
|
115
|
+
expect(recorded).toEqual([
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
command: "jsonui-test",
|
|
118
|
+
args: ["generate", "branch-tests", "booking_confirm"],
|
|
119
|
+
options: expect.objectContaining({ cwd: projectDir }),
|
|
120
|
+
}),
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("maps every optional flag", async () => {
|
|
125
|
+
nextResponse = { stdout: "ok" };
|
|
126
|
+
await harness.call("test_generate_branch_tests", {
|
|
127
|
+
project_dir: projectDir,
|
|
128
|
+
screen: "bottle_detail",
|
|
129
|
+
platform: "android",
|
|
130
|
+
package: "com.example.app",
|
|
131
|
+
module: "AppModule",
|
|
132
|
+
spec: "docs/screens/json/bottle_detail.spec.json",
|
|
133
|
+
out_dir: "app/src/test/java",
|
|
134
|
+
harness_dir: "app/src/test/java",
|
|
135
|
+
mocks_dir: "tests/mocks",
|
|
136
|
+
});
|
|
137
|
+
expect(recorded[0].args).toEqual([
|
|
138
|
+
"generate",
|
|
139
|
+
"branch-tests",
|
|
140
|
+
"bottle_detail",
|
|
141
|
+
"--platform",
|
|
142
|
+
"android",
|
|
143
|
+
"--package",
|
|
144
|
+
"com.example.app",
|
|
145
|
+
"--module",
|
|
146
|
+
"AppModule",
|
|
147
|
+
"--spec",
|
|
148
|
+
"docs/screens/json/bottle_detail.spec.json",
|
|
149
|
+
"--out-dir",
|
|
150
|
+
"app/src/test/java",
|
|
151
|
+
"--harness-dir",
|
|
152
|
+
"app/src/test/java",
|
|
153
|
+
"--mocks-dir",
|
|
154
|
+
"tests/mocks",
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("surfaces a generation failure instead of reporting success", async () => {
|
|
159
|
+
// Unbindable declarations are hard errors in the generator, and the
|
|
160
|
+
// wrapper must not flatten them into a bland OK.
|
|
161
|
+
failWithCode(1, "", "branches[0].when.arg.status: no parameter");
|
|
162
|
+
const text = await harness.call("test_generate_branch_tests", {
|
|
163
|
+
project_dir: projectDir,
|
|
164
|
+
screen: "home",
|
|
165
|
+
});
|
|
166
|
+
expect(text).toContain("no parameter");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
105
170
|
describe("test_validate", () => {
|
|
106
171
|
it("builds the base argv and runs in the project dir", async () => {
|
|
107
172
|
nextResponse = { stdout: "All valid" };
|