jui-tools-mcp-server 2.15.11 → 2.17.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.
Files changed (63) hide show
  1. package/.github/workflows/test.yml +2 -1
  2. package/README.md +30 -1
  3. package/dist/index.js +30 -2
  4. package/dist/tools/infra/infra_check.d.ts +10 -0
  5. package/dist/tools/infra/infra_check.js +43 -0
  6. package/dist/tools/infra/infra_common.d.ts +40 -0
  7. package/dist/tools/infra/infra_common.js +44 -0
  8. package/dist/tools/infra/infra_cost.d.ts +3 -0
  9. package/dist/tools/infra/infra_cost.js +118 -0
  10. package/dist/tools/infra/infra_load_validate.d.ts +8 -0
  11. package/dist/tools/infra/infra_load_validate.js +39 -0
  12. package/dist/tools/infra/infra_prices_validate.d.ts +7 -0
  13. package/dist/tools/infra/infra_prices_validate.js +34 -0
  14. package/dist/tools/infra/infra_seed.d.ts +27 -0
  15. package/dist/tools/infra/infra_seed.js +81 -0
  16. package/dist/tools/infra/infra_validate.d.ts +8 -0
  17. package/dist/tools/infra/infra_validate.js +37 -0
  18. package/dist/tools/viz/viz_common.d.ts +13 -0
  19. package/dist/tools/viz/viz_common.js +50 -0
  20. package/dist/tools/viz/viz_doctor.d.ts +2 -0
  21. package/dist/tools/viz/viz_doctor.js +14 -0
  22. package/dist/tools/viz/viz_open.d.ts +2 -0
  23. package/dist/tools/viz/viz_open.js +14 -0
  24. package/dist/tools/viz/viz_start.d.ts +2 -0
  25. package/dist/tools/viz/viz_start.js +23 -0
  26. package/dist/tools/viz/viz_status.d.ts +2 -0
  27. package/dist/tools/viz/viz_status.js +21 -0
  28. package/dist/tools/viz/viz_stop.d.ts +2 -0
  29. package/dist/tools/viz/viz_stop.js +13 -0
  30. package/package.json +1 -1
  31. package/scripts/record-infra-fixtures.sh +78 -0
  32. package/src/index.ts +33 -2
  33. package/src/tools/infra/infra_check.ts +49 -0
  34. package/src/tools/infra/infra_common.ts +69 -0
  35. package/src/tools/infra/infra_cost.ts +122 -0
  36. package/src/tools/infra/infra_load_validate.ts +46 -0
  37. package/src/tools/infra/infra_prices_validate.ts +41 -0
  38. package/src/tools/infra/infra_seed.ts +88 -0
  39. package/src/tools/infra/infra_validate.ts +44 -0
  40. package/src/tools/viz/viz_common.ts +61 -0
  41. package/src/tools/viz/viz_doctor.ts +20 -0
  42. package/src/tools/viz/viz_open.ts +20 -0
  43. package/src/tools/viz/viz_start.ts +27 -0
  44. package/src/tools/viz/viz_status.ts +27 -0
  45. package/src/tools/viz/viz_stop.ts +19 -0
  46. package/tests/fixtures/infra/RECORDED_FROM +1 -0
  47. package/tests/fixtures/infra/check-infra.json +101 -0
  48. package/tests/fixtures/infra/cost-infra.day.json +10080 -0
  49. package/tests/fixtures/infra/cost-infra.month-by-node.json +484 -0
  50. package/tests/fixtures/infra/cost-infra.month.json +381 -0
  51. package/tests/fixtures/infra/cost-infra.refused.json +5 -0
  52. package/tests/fixtures/infra/seed-infra.draft.json +233 -0
  53. package/tests/fixtures/infra/seed-infra.report.json +46 -0
  54. package/tests/fixtures/infra/validate-infra-load.errors.json +39 -0
  55. package/tests/fixtures/infra/validate-infra-load.ok.json +29 -0
  56. package/tests/fixtures/infra/validate-infra-load.unreadable.json +6 -0
  57. package/tests/fixtures/infra/validate-infra-prices.ok.json +9 -0
  58. package/tests/fixtures/infra/validate-infra.errors.json +39 -0
  59. package/tests/fixtures/infra/validate-infra.ok.json +30 -0
  60. package/tests/fixtures/infra/validate-infra.unreadable.json +6 -0
  61. package/tests/tool_count.test.ts +53 -0
  62. package/tests/tools_infra.test.ts +332 -0
  63. package/tests/tools_viz.test.ts +154 -0
@@ -0,0 +1,29 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra/load",
4
+ "files": [
5
+ "ordinary.json",
6
+ "sale.json"
7
+ ],
8
+ "problems": [],
9
+ "scenarios": [
10
+ {
11
+ "id": "ordinary",
12
+ "file": "ordinary.json",
13
+ "environment": "production",
14
+ "month": "2026-11",
15
+ "hours": 720,
16
+ "loads": 6,
17
+ "edges": 1
18
+ },
19
+ {
20
+ "id": "sale",
21
+ "file": "sale.json",
22
+ "environment": "production",
23
+ "month": "2026-11",
24
+ "hours": 720,
25
+ "loads": 3,
26
+ "edges": 0
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "ok": false,
3
+ "reason": "no_infra",
4
+ "message": "the infra model cannot be read: no model files in docs/infra (*.json)",
5
+ "problems": []
6
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra/prices",
4
+ "files": [
5
+ "example.json"
6
+ ],
7
+ "problems": [],
8
+ "count": 5
9
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra",
4
+ "version": 0,
5
+ "files": [
6
+ "external.json",
7
+ "main.json"
8
+ ],
9
+ "environments": [
10
+ {
11
+ "id": "production",
12
+ "label": "本番",
13
+ "kind": "production"
14
+ },
15
+ {
16
+ "id": "staging",
17
+ "label": "ステージング",
18
+ "kind": "staging"
19
+ }
20
+ ],
21
+ "flows": [],
22
+ "problems": [
23
+ {
24
+ "file": "bad.json",
25
+ "path": "/nodes/0/category",
26
+ "code": "schema",
27
+ "message": "must be one of client, compute.vm, compute.container, compute.function, compute.k8s, compute.paas, edge.cdn, edge.dns, edge.waf, load_balancer, gateway.api, gateway.nat, network.ip, database.relational, database.document, database.kv, database.search, cache, storage.object, storage.file, storage.block, queue, stream, scheduler, mail, auth, secrets, monitoring, ci, external.saas, security.iam, security.policy, monitoring.alarm",
28
+ "severity": "error",
29
+ "short": "docs/infra/bad.json: /nodes/0/category がスキーマに合いません"
30
+ }
31
+ ],
32
+ "icons": {},
33
+ "readMs": 2,
34
+ "counts": {
35
+ "groups": 8,
36
+ "nodes": 12,
37
+ "edges": 10
38
+ }
39
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra",
4
+ "version": 0,
5
+ "files": [
6
+ "external.json",
7
+ "main.json"
8
+ ],
9
+ "environments": [
10
+ {
11
+ "id": "production",
12
+ "label": "本番",
13
+ "kind": "production"
14
+ },
15
+ {
16
+ "id": "staging",
17
+ "label": "ステージング",
18
+ "kind": "staging"
19
+ }
20
+ ],
21
+ "flows": [],
22
+ "problems": [],
23
+ "icons": {},
24
+ "readMs": 2,
25
+ "counts": {
26
+ "groups": 8,
27
+ "nodes": 12,
28
+ "edges": 10
29
+ }
30
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "ok": false,
3
+ "reason": "no_infra",
4
+ "message": "no model files in docs/infra (*.json)",
5
+ "short": "docs/infra に infra のファイル(*.json)がありません"
6
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The tool count is written in two places by hand — the startup log in
3
+ * src/index.ts and the README's "Available Tools (N)" with one "(n)" per
4
+ * group — and the log said 41 (8 test) for a while after the server had
5
+ * 46 (10 test). Both are checked against the tools the source registers:
6
+ * one `server.tool(` per file under src/tools/<group>/, and one register call
7
+ * per tool in index.ts.
8
+ */
9
+ import { readdirSync, readFileSync } from "fs";
10
+ import { join } from "path";
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ const ROOT = join(__dirname, "..");
14
+ const GROUPS = ["spec", "context", "jui", "doc", "api", "test", "viz", "infra"]; // README Groups A..H, in order
15
+
16
+ function registeredIn(group: string): string[] {
17
+ const dir = join(ROOT, "src", "tools", group);
18
+ const names: string[] = [];
19
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".ts"))) {
20
+ const text = readFileSync(join(dir, file), "utf-8");
21
+ for (const m of text.matchAll(/server\.tool\(\s*"([a-z_]+)"/g)) names.push(m[1]);
22
+ }
23
+ return names;
24
+ }
25
+
26
+ const counts = Object.fromEntries(GROUPS.map((g) => [g, registeredIn(g).length]));
27
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
28
+
29
+ describe("tool count", () => {
30
+ it("every tool directory is a known group", () => {
31
+ const dirs = readdirSync(join(ROOT, "src", "tools"), { withFileTypes: true })
32
+ .filter((d) => d.isDirectory())
33
+ .map((d) => d.name);
34
+ expect(dirs.sort()).toEqual([...GROUPS].sort());
35
+ });
36
+
37
+ it("index.ts registers each tool once and its log line says how many, per group", () => {
38
+ const index = readFileSync(join(ROOT, "src", "index.ts"), "utf-8");
39
+ expect(index.match(/^register[A-Za-z]+\(server(, (config|loader))?\);$/gm)!.length).toBe(total);
40
+ const line = index.match(/Registered (\d+) tools \(([^)]*)\)/);
41
+ expect(line).not.toBeNull();
42
+ expect(Number(line![1])).toBe(total);
43
+ expect(line![2]).toBe(GROUPS.map((g) => `${counts[g]} ${g}`).join(" + "));
44
+ });
45
+
46
+ it("the README says the same", () => {
47
+ const readme = readFileSync(join(ROOT, "README.md"), "utf-8");
48
+ expect(readme.match(/^## Available Tools \((\d+)\)$/m)![1]).toBe(String(total));
49
+ const headers = [...readme.matchAll(/^### Group ([A-Z]): .*\((\d+)\)$/gm)];
50
+ expect(headers.map((h) => h[1])).toEqual(GROUPS.map((_, i) => String.fromCharCode(65 + i)));
51
+ expect(headers.map((h) => Number(h[2]))).toEqual(GROUPS.map((g) => counts[g]));
52
+ });
53
+ });
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Group H (the server room's infra) tool wrappers. child_process is mocked and answers with what jsonui-doc itself
3
+ * printed: tests/fixtures/infra/*.json, recorded by scripts/record-infra-fixtures.sh from jsonui-cli (RECORDED_FROM
4
+ * names the commit). The summaries read named keys of those outputs, so they are the CLI's contract: record again
5
+ * after the CLI's --json changes, and what no longer holds is red here. A key the tool needs and the output lacks is
6
+ * named in the tool's answer, never summarised as an empty room.
7
+ */
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
+ import { join } from "path";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ const execFileMock = vi.hoisted(() => vi.fn());
13
+ vi.mock("child_process", () => ({ execFile: execFileMock }));
14
+ vi.mock("node:child_process", () => ({ execFile: execFileMock }));
15
+
16
+ import { ServerConfig } from "../src/config.js";
17
+ import { register as registerInfraValidate } from "../src/tools/infra/infra_validate.js";
18
+ import { register as registerInfraLoadValidate } from "../src/tools/infra/infra_load_validate.js";
19
+ import { register as registerInfraPricesValidate } from "../src/tools/infra/infra_prices_validate.js";
20
+ import { register as registerInfraSeed } from "../src/tools/infra/infra_seed.js";
21
+ import { register as registerInfraCheck } from "../src/tools/infra/infra_check.js";
22
+ import { register as registerInfraCost } from "../src/tools/infra/infra_cost.js";
23
+ import { cleanupTempDirs, createToolHarness, makeFixtureProject, makeTempDir, type ToolHarness } from "./helpers.js";
24
+
25
+ const FIX = join(__dirname, "fixtures", "infra");
26
+ const raw = (name: string) => readFileSync(join(FIX, name), "utf-8");
27
+ const fixture = (name: string) => JSON.parse(raw(name));
28
+
29
+ type ExecCallback = (error: any, stdout: string, stderr: string) => void;
30
+ interface Answer { code?: number; stdout?: string; stderr?: string; seed?: boolean }
31
+
32
+ let calls: { command: string; args: string[]; options: any }[];
33
+ let answer: Answer;
34
+ let harness: ToolHarness;
35
+ let projectDir: string;
36
+
37
+ beforeEach(() => {
38
+ delete process.env.JUI_PROJECT_DIR;
39
+ calls = [];
40
+ answer = {};
41
+ execFileMock.mockReset();
42
+ execFileMock.mockImplementation((command: string, args: string[], options: any, callback: ExecCallback) => {
43
+ calls.push({ command, args, options });
44
+ if (answer.seed) { // seed writes its two files where -o says, as the CLI does
45
+ const out = args[args.indexOf("-o") + 1];
46
+ mkdirSync(out, { recursive: true });
47
+ copyFileSync(join(FIX, "seed-infra.draft.json"), join(out, "draft.json"));
48
+ copyFileSync(join(FIX, "seed-infra.report.json"), join(out, "seed-report.json"));
49
+ }
50
+ const error = answer.code ? Object.assign(new Error(`exit ${answer.code}`), { code: answer.code }) : null;
51
+ callback(error, answer.stdout ?? "", answer.stderr ?? "");
52
+ return { stdin: { end() {} } };
53
+ });
54
+ projectDir = makeTempDir("infra");
55
+ makeFixtureProject(projectDir);
56
+ harness = createToolHarness();
57
+ const config = new ServerConfig();
58
+ for (const register of [registerInfraValidate, registerInfraLoadValidate, registerInfraPricesValidate,
59
+ registerInfraSeed, registerInfraCheck, registerInfraCost]) register(harness.server, config);
60
+ });
61
+
62
+ afterEach(() => {
63
+ delete process.env.JUI_PROJECT_DIR;
64
+ cleanupTempDirs();
65
+ });
66
+
67
+ const call = async (name: string, params: Record<string, unknown> = {}) =>
68
+ JSON.parse(await harness.call(name, { project_dir: projectDir, ...params }));
69
+
70
+ /** The recorded output with one key taken out (at a dotted path). */
71
+ function without(name: string, path: string): string {
72
+ const data = fixture(name);
73
+ const keys = path.split(".");
74
+ let at = data;
75
+ for (const k of keys.slice(0, -1)) at = at[k];
76
+ delete at[keys[keys.length - 1]];
77
+ return JSON.stringify(data);
78
+ }
79
+
80
+ describe("the fixtures", () => {
81
+ it("say which jsonui-cli printed them", () => {
82
+ expect(raw("RECORDED_FROM")).toMatch(/^[0-9a-f]{7,} /);
83
+ });
84
+ });
85
+
86
+ describe("infra_validate", () => {
87
+ it("runs validate infra on the project and counts what the model holds", async () => {
88
+ answer = { stdout: raw("validate-infra.ok.json") };
89
+ const out = await call("infra_validate");
90
+ expect(calls[0].command).toBe("jsonui-doc");
91
+ expect(calls[0].args).toEqual(["validate", "infra", projectDir, "--json"]);
92
+ expect(calls[0].options.cwd).toBe(projectDir);
93
+ const rec = fixture("validate-infra.ok.json");
94
+ expect(out).toMatchObject({ ok: true, exit: 0, readable: true, dir: rec.dir, files: rec.files, counts: rec.counts,
95
+ environments: rec.environments.map((e: any) => e.id), flows: rec.flows.length });
96
+ expect(out.problems).toEqual({ count: 0, errors: 0, warnings: 0, byCode: {}, first: [] });
97
+ });
98
+
99
+ it("an error is exit 1, counted by severity and code, its place and message kept", async () => {
100
+ answer = { code: 1, stdout: raw("validate-infra.errors.json") };
101
+ const out = await call("infra_validate");
102
+ const [p] = fixture("validate-infra.errors.json").problems;
103
+ expect(out.ok).toBe(false);
104
+ expect(out.exit).toBe(1);
105
+ expect(out.problems.errors).toBe(1);
106
+ expect(out.problems.byCode).toEqual({ [p.code]: 1 });
107
+ expect(out.problems.first).toEqual([{ severity: p.severity, code: p.code, file: p.file, path: p.path, message: p.message }]);
108
+ });
109
+
110
+ it("a room that cannot be read says why", async () => {
111
+ answer = { code: 2, stdout: raw("validate-infra.unreadable.json") };
112
+ const rec = fixture("validate-infra.unreadable.json");
113
+ expect(await call("infra_validate")).toEqual({ ok: false, exit: 2, readable: false, reason: rec.reason, message: rec.message });
114
+ });
115
+
116
+ it("names a key the CLI's JSON lacks", async () => {
117
+ answer = { stdout: without("validate-infra.ok.json", "problems") };
118
+ expect(await harness.call("infra_validate", { project_dir: projectDir })).toMatch(/^Error: validate infra: the JSON has no "problems"/);
119
+ });
120
+
121
+ it("says so when the CLI prints no JSON (a jsonui-doc before --json)", async () => {
122
+ answer = { code: 2, stdout: "", stderr: "jsonui-doc: error: unrecognized arguments: --json" };
123
+ expect(await harness.call("infra_validate", { project_dir: projectDir }))
124
+ .toMatch(/printed no JSON \(exit 2\): jsonui-doc: error: unrecognized arguments: --json — it needs jsonui-cli 2\.3\.15/);
125
+ });
126
+ });
127
+
128
+ describe("infra_load_validate", () => {
129
+ it("lists the scenarios as the CLI counts them", async () => {
130
+ answer = { stdout: raw("validate-infra-load.ok.json") };
131
+ const out = await call("infra_load_validate");
132
+ expect(calls[0].args).toEqual(["validate", "infra-load", projectDir, "--json"]);
133
+ const rec = fixture("validate-infra-load.ok.json");
134
+ expect(out.scenarios).toEqual(rec.scenarios);
135
+ expect(out).toMatchObject({ ok: true, exit: 0, dir: rec.dir, files: rec.files });
136
+ });
137
+
138
+ it("a file that is not JSON is an error with its line's place", async () => {
139
+ answer = { code: 1, stdout: raw("validate-infra-load.errors.json") };
140
+ const out = await call("infra_load_validate");
141
+ expect(out.exit).toBe(1);
142
+ expect(out.problems.first[0]).toMatchObject({ code: "json", file: "broken.json", severity: "error" });
143
+ expect(out.scenarios.map((s: any) => s.id)).toEqual(fixture("validate-infra-load.errors.json").scenarios.map((s: any) => s.id));
144
+ });
145
+
146
+ it("no model is not readable, with the reason", async () => {
147
+ answer = { code: 2, stdout: raw("validate-infra-load.unreadable.json") };
148
+ expect(await call("infra_load_validate")).toMatchObject({ ok: false, readable: false, reason: "no_infra" });
149
+ });
150
+
151
+ it("names a scenario's key the CLI's JSON lacks", async () => {
152
+ answer = { stdout: without("validate-infra-load.ok.json", "scenarios.0.hours") };
153
+ expect(await harness.call("infra_load_validate", { project_dir: projectDir })).toMatch(/scenario: the JSON has no "hours"/);
154
+ });
155
+ });
156
+
157
+ describe("infra_prices_validate", () => {
158
+ it("counts the room's prices", async () => {
159
+ answer = { stdout: raw("validate-infra-prices.ok.json") };
160
+ const out = await call("infra_prices_validate");
161
+ expect(calls[0].args).toEqual(["validate", "infra-prices", projectDir, "--json"]);
162
+ expect(out).toMatchObject({ ok: true, count: fixture("validate-infra-prices.ok.json").count });
163
+ });
164
+ });
165
+
166
+ describe("infra_seed", () => {
167
+ it("drafts into a temp folder, reads it back and leaves the project as it was", async () => {
168
+ answer = { seed: true };
169
+ const out = await call("infra_seed");
170
+ const args = calls[0].args;
171
+ expect(args.slice(0, 4)).toEqual(["seed", "infra", projectDir, "-o"]);
172
+ expect(args[4].startsWith(projectDir)).toBe(false);
173
+ expect(existsSync(args[4])).toBe(false); // the temp folder is gone
174
+ expect(existsSync(join(projectDir, "docs/infra/seed"))).toBe(false);
175
+ const draft = fixture("seed-infra.draft.json");
176
+ const report = fixture("seed-infra.report.json");
177
+ expect(out.written).toBeNull();
178
+ expect(out.counts).toEqual({ nodes: draft.nodes.length, groups: draft.groups.length, edges: draft.edges.length, flows: 0 });
179
+ expect(out.nodes.map((n: any) => n.id)).toEqual(draft.nodes.map((n: any) => n.id));
180
+ expect(out.unmatched).toEqual(report.unmatched);
181
+ expect(out.inputs).toEqual(report.inputs);
182
+ });
183
+
184
+ it("write: true writes <infra.dir>/seed, and force replaces", async () => {
185
+ answer = { seed: true };
186
+ const out = await call("infra_seed", { write: true, force: true });
187
+ expect(calls[0].args).toEqual(["seed", "infra", projectDir, "-o", join(projectDir, "docs/infra", "seed"), "--force"]);
188
+ expect(out.written).toBe(join(projectDir, "docs/infra", "seed"));
189
+ });
190
+
191
+ it("a refusal is its message", async () => {
192
+ answer = { code: 2, stderr: "seed infra: docs/infra/seed already has a draft (--force to replace it)" };
193
+ expect(await call("infra_seed", { write: true })).toEqual({ ok: false, exit: 2,
194
+ message: "seed infra: docs/infra/seed already has a draft (--force to replace it)" });
195
+ });
196
+ });
197
+
198
+ describe("infra_check", () => {
199
+ it("runs check infra on the project and keeps each check's status and the results not ok", async () => {
200
+ answer = { stdout: raw("check-infra.json") };
201
+ const out = await call("infra_check");
202
+ expect(calls[0].args).toEqual(["check", "infra", "-p", projectDir, "--json"]);
203
+ const rec = fixture("check-infra.json");
204
+ expect(out.ok).toBe(rec.ok);
205
+ expect(out.checks.map((c: any) => [c.name, c.type, c.status])).toEqual(rec.checks.map((c: any) => [c.name, c.type, c.status]));
206
+ expect(out.checks[0].results).toEqual(rec.checks[0].results.slice(0, 20));
207
+ expect(out.checks[0].summary).toEqual(rec.checks[0].summary);
208
+ expect(out.checks[0].report).toBe(rec.checks[0].report);
209
+ });
210
+
211
+ it("no infra check declared says how to declare one", async () => {
212
+ answer = { code: 2, stdout: JSON.stringify({ ok: false, exit: 2, checks: [], declared: [] }),
213
+ stderr: "No declared check matches 'infra'. Declared:" };
214
+ expect(await call("infra_check")).toMatchObject({ ok: false, exit: 2, checks: [], message: "No declared check matches 'infra'. Declared:" });
215
+ });
216
+
217
+ it("names a check's key the CLI's JSON lacks", async () => {
218
+ answer = { stdout: without("check-infra.json", "checks.0.summary") };
219
+ expect(await harness.call("infra_check", { project_dir: projectDir })).toMatch(/check infra check: the JSON has no "summary"/);
220
+ });
221
+ });
222
+
223
+ describe("infra_cost", () => {
224
+ it("the month: the total, the totals by key adding up to it, the top nodes and what has no price", async () => {
225
+ answer = { stdout: raw("cost-infra.month.json") };
226
+ const out = await call("infra_cost");
227
+ expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json"]);
228
+ const rec = fixture("cost-infra.month.json");
229
+ expect(out.month.usd).toBe(rec.month.usd);
230
+ expect(out.month.totals.map((t: any) => t.key)).toEqual(["aws"]);
231
+ expect(out.month.totals.reduce((a: number, t: any) => a + t.usd, 0)).toBeCloseTo(rec.month.usd, 6);
232
+ expect(out.month.days).toBe(rec.month.days.length);
233
+ expect(out.month.noPrice).toEqual(rec.month.noPrice);
234
+ expect(out.month.noRule).toEqual(rec.month.noRule);
235
+ expect(out.month.notes).toEqual(rec.month.warnings.map((w: any) => w.message));
236
+ expect(out.topNodes).toEqual(rec.topNodes);
237
+ expect(out.scenarios.map((s: any) => s.id)).toEqual(["ordinary", "sale"]);
238
+ expect(out.day).toBeUndefined();
239
+ });
240
+
241
+ it("by node and a scenario go to the CLI; the rest is one key", async () => {
242
+ answer = { stdout: raw("cost-infra.month-by-node.json") };
243
+ const out = await call("infra_cost", { scenario: "sale", by: "node" });
244
+ expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json", "--scenario", "sale", "--by", "node"]);
245
+ const rec = fixture("cost-infra.month-by-node.json");
246
+ expect(out.scenario).toBe("sale");
247
+ expect(new Set(out.month.totals.map((t: any) => t.key))).toEqual(
248
+ new Set(rec.month.keys.map((k: any) => (k.key === "__others__" ? "others" : k.key))
249
+ .filter((k: string) => rec.month.days.some((d: any) => (k === "others" ? "__others__" : k) in d.stack))));
250
+ });
251
+
252
+ it("a day: each top node's charge by meter, the price layer, the peak hour — not its 24 hours", async () => {
253
+ answer = { stdout: raw("cost-infra.day.json") };
254
+ const text = await harness.call("infra_cost", { project_dir: projectDir, date: "2026-11-03" });
255
+ expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json", "--date", "2026-11-03"]);
256
+ expect(text.length).toBeLessThan(raw("cost-infra.day.json").length / 10);
257
+ const out = JSON.parse(text);
258
+ const rec = fixture("cost-infra.day.json").day;
259
+ expect(out.day.usd).toBe(rec.usd);
260
+ expect(out.day.nodes.map((n: any) => n.node)).toEqual(rec.nodes.map((n: any) => n.node));
261
+ const db = out.day.nodes.find((n: any) => n.node === "db-prod");
262
+ const recDb = rec.nodes.find((n: any) => n.node === "db-prod");
263
+ const meterSum = Object.values(db.meters).reduce((a: number, m: any) => a + m.usd, 0);
264
+ expect(meterSum).toBeCloseTo(recDb.usd, 6);
265
+ expect(db.meters.instance).toMatchObject({ sku: "db.r6g.large:multi-az", layer: "bundled" });
266
+ expect(out.month).toBeUndefined();
267
+ });
268
+
269
+ it("a scenario that is not there is refused with the reason", async () => {
270
+ answer = { code: 2, stdout: raw("cost-infra.refused.json") };
271
+ const rec = fixture("cost-infra.refused.json");
272
+ expect(await call("infra_cost", { scenario: "nope" })).toEqual({ ok: false, exit: 2, reason: rec.reason, message: rec.message });
273
+ });
274
+
275
+ it("a date that is not YYYY-MM-DD never reaches the CLI", async () => {
276
+ await expect(harness.call("infra_cost", { project_dir: projectDir, date: "11/03" })).rejects.toThrow();
277
+ expect(calls).toEqual([]);
278
+ });
279
+
280
+ it("names a key the month lacks", async () => {
281
+ answer = { stdout: without("cost-infra.month.json", "month.noPrice") };
282
+ expect(await harness.call("infra_cost", { project_dir: projectDir })).toMatch(/cost infra month: the JSON has no "noPrice"/);
283
+ });
284
+ });
285
+
286
+ describe("each tool names a key its summary reads and the CLI's JSON lacks", () => {
287
+ const cases: [string, Record<string, unknown>, string, string, RegExp][] = [
288
+ ["infra_validate", {}, "validate-infra.ok.json", "dir", /validate infra: the JSON has no "dir"/],
289
+ ["infra_load_validate", {}, "validate-infra-load.ok.json", "scenarios", /validate infra-load: the JSON has no "scenarios"/],
290
+ ["infra_prices_validate", {}, "validate-infra-prices.ok.json", "count", /validate infra-prices: the JSON has no "count"/],
291
+ ["infra_check", {}, "check-infra.json", "checks.0.status", /check infra check: the JSON has no "status"/],
292
+ ["infra_check", {}, "check-infra.json", "checks.0.name", /check infra check: the JSON has no "name"/],
293
+ ["infra_cost", {}, "cost-infra.month.json", "topNodes", /cost infra: the JSON has no "topNodes"/],
294
+ ["infra_cost", {}, "cost-infra.month.json", "prices.warningCount", /cost infra prices: the JSON has no "warningCount"/],
295
+ ["infra_cost", { date: "2026-11-03" }, "cost-infra.day.json", "day.nodes.0.hours", /cost infra day node: the JSON has no "hours"/],
296
+ ];
297
+ for (const [tool, params, file, path, message] of cases) {
298
+ it(`${tool}: ${path}`, async () => {
299
+ answer = { stdout: without(file, path) };
300
+ expect(await harness.call(tool, { project_dir: projectDir, ...params })).toMatch(message);
301
+ });
302
+ }
303
+
304
+ it("infra_seed: the report's unmatched", async () => {
305
+ answer = { seed: true };
306
+ const report = fixture("seed-infra.report.json");
307
+ delete report.unmatched;
308
+ execFileMock.mockImplementationOnce((command: string, args: string[], options: any, callback: ExecCallback) => {
309
+ const out = args[args.indexOf("-o") + 1];
310
+ mkdirSync(out, { recursive: true });
311
+ copyFileSync(join(FIX, "seed-infra.draft.json"), join(out, "draft.json"));
312
+ writeFileSync(join(out, "seed-report.json"), JSON.stringify(report));
313
+ callback(null, "", "");
314
+ return { stdin: { end() {} } };
315
+ });
316
+ expect(await harness.call("infra_seed", { project_dir: projectDir })).toMatch(/seed infra report: the JSON has no "unmatched"/);
317
+ });
318
+ });
319
+
320
+ describe("a long problem list", () => {
321
+ it("is counted whole and its first 20 kept", async () => {
322
+ const data = fixture("validate-infra.errors.json");
323
+ const [p] = data.problems;
324
+ data.problems = Array.from({ length: 25 }, (_, i) => ({ ...p, path: `/nodes/${i}/category` }));
325
+ answer = { code: 1, stdout: JSON.stringify(data) };
326
+ const out = await call("infra_validate");
327
+ expect(out.problems.count).toBe(25);
328
+ expect(out.problems.errors).toBe(25);
329
+ expect(out.problems.first).toHaveLength(20);
330
+ expect(out.problems.first[19].path).toBe("/nodes/19/category");
331
+ });
332
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Group G (jui viz) tool wrappers. child_process is mocked: the tests pin the
3
+ * argv each tool builds, that it runs in the home folder (never the project
4
+ * dir), the envelope it returns, and the update hint for a jui that predates
5
+ * viz / start / --with-relay. The stderr samples are what those jui versions
6
+ * print, copied from real runs (2026-10-18), not written from memory.
7
+ */
8
+ import { homedir } from "os";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ const execFileMock = vi.hoisted(() => vi.fn());
12
+ vi.mock("child_process", () => ({ execFile: execFileMock }));
13
+ vi.mock("node:child_process", () => ({ execFile: execFileMock }));
14
+
15
+ import { register as registerVizStatus } from "../src/tools/viz/viz_status.js";
16
+ import { register as registerVizDoctor } from "../src/tools/viz/viz_doctor.js";
17
+ import { register as registerVizStart } from "../src/tools/viz/viz_start.js";
18
+ import { register as registerVizStop } from "../src/tools/viz/viz_stop.js";
19
+ import { register as registerVizOpen } from "../src/tools/viz/viz_open.js";
20
+ import { cleanupTempDirs, createToolHarness, makeTempDir, type ToolHarness } from "./helpers.js";
21
+
22
+ type ExecCallback = (error: any, stdout: string, stderr: string) => void;
23
+
24
+ let recorded: { command: string; args: string[]; options: Record<string, any> }[];
25
+ let nextResponse: { error?: any; stdout?: string; stderr?: string };
26
+ let harness: ToolHarness;
27
+
28
+ const VIZ_TOOLS = ["viz_status", "viz_doctor", "viz_start", "viz_stop", "viz_open"];
29
+
30
+ // jui 1.9.37 (no viz at all)
31
+ const NO_VIZ =
32
+ "usage: jui [-h] [--version]\n" +
33
+ " {init,i,generate,g,build,b,verify,migrate-layouts,lint-generated,lint-strings,ls,sync_tool,hotload,conformance,screens} ...\n" +
34
+ "jui: error: argument command: invalid choice: 'viz' (choose from init, i, generate, g, build, b, verify, migrate-layouts, lint-generated, lint-strings, ls, sync_tool, hotload, conformance, screens)\n";
35
+ // feature/jui-viz b69ac616 (viz, but no start)
36
+ const NO_START =
37
+ "usage: jui viz [-h]\n" +
38
+ " {serve,status,stop,open,doctor,hooks,export-demo,relay} ...\n" +
39
+ "jui viz: error: argument viz_cmd: invalid choice: 'start' (choose from serve, status, stop, open, doctor, hooks, export-demo, relay)\n";
40
+ // the same jui, asked for status --with-relay
41
+ const NO_WITH_RELAY =
42
+ "usage: jui [-h] [--version]\n" +
43
+ " {init,i,generate,g,build,b,verify,migrate-layouts,lint-generated,lint-strings,ls,sync_tool,hotload,conformance,screens,viz} ...\n" +
44
+ "jui: error: unrecognized arguments: --with-relay\n";
45
+
46
+ function failWithCode(code: number | string, stdout = "", stderr = ""): void {
47
+ nextResponse = { error: Object.assign(new Error(`exit ${code}`), { code }), stdout, stderr };
48
+ }
49
+
50
+ beforeEach(() => {
51
+ recorded = [];
52
+ nextResponse = {};
53
+ execFileMock.mockReset();
54
+ execFileMock.mockImplementation((command: string, args: string[], options: any, callback: ExecCallback) => {
55
+ recorded.push({ command, args, options });
56
+ callback(nextResponse.error ?? null, nextResponse.stdout ?? "", nextResponse.stderr ?? "");
57
+ return { stdin: { end() {} } };
58
+ });
59
+ harness = createToolHarness();
60
+ registerVizStatus(harness.server);
61
+ registerVizDoctor(harness.server);
62
+ registerVizStart(harness.server);
63
+ registerVizStop(harness.server);
64
+ registerVizOpen(harness.server);
65
+ });
66
+
67
+ afterEach(() => {
68
+ delete process.env.JUI_PROJECT_DIR;
69
+ cleanupTempDirs();
70
+ });
71
+
72
+ describe("viz tools", () => {
73
+ it("every description says only Claude Code sessions are shown", () => {
74
+ expect([...harness.tools.keys()]).toEqual(VIZ_TOOLS);
75
+ for (const name of VIZ_TOOLS) {
76
+ expect(harness.tools.get(name)!.description).toContain("Claude Code のセッションだけを見せる");
77
+ }
78
+ });
79
+
80
+ it.each([
81
+ ["viz_status", {}, ["viz", "status", "--json", "--with-relay"]],
82
+ ["viz_status", { relay: true }, ["viz", "status", "--json", "--with-relay"]],
83
+ ["viz_status", { relay: false }, ["viz", "status", "--json"]],
84
+ ["viz_doctor", {}, ["viz", "doctor", "--json"]],
85
+ ["viz_start", {}, ["viz", "start", "--json"]],
86
+ ["viz_start", { port: 18011 }, ["viz", "start", "--json", "--port", "18011"]],
87
+ ["viz_stop", {}, ["viz", "stop"]],
88
+ ["viz_open", {}, ["viz", "open"]],
89
+ ])("%s %j runs jui %j in the home folder, not the project", async (tool, params, argv) => {
90
+ process.env.JUI_PROJECT_DIR = makeTempDir("project");
91
+ await harness.call(tool, params);
92
+ expect(recorded).toHaveLength(1);
93
+ expect(recorded[0].command).toBe("jui");
94
+ expect(recorded[0].args).toEqual(argv);
95
+ expect(recorded[0].options.cwd).toBe(homedir());
96
+ expect(recorded[0].options.cwd).not.toBe(process.env.JUI_PROJECT_DIR);
97
+ });
98
+
99
+ it("viz_start waits longer than jui's own 15 s", async () => {
100
+ await harness.call("viz_start");
101
+ expect(recorded[0].options.timeout).toBeGreaterThan(15_000);
102
+ });
103
+
104
+ it("viz_start rejects a port outside 1-65535 before running anything", async () => {
105
+ await expect(harness.call("viz_start", { port: 0 })).rejects.toThrow();
106
+ await expect(harness.call("viz_start", { port: 70000 })).rejects.toThrow();
107
+ expect(execFileMock).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it("passes the CLI's JSON through as result", async () => {
111
+ const started = { running: true, alreadyRunning: false, pid: 4242, url: "http://127.0.0.1:18011/", log: "/h/serve.log" };
112
+ nextResponse = { stdout: JSON.stringify(started) + "\n" };
113
+ expect(JSON.parse(await harness.call("viz_start"))).toEqual({ success: true, result: started });
114
+ });
115
+
116
+ it("a failed start keeps the CLI's JSON and reports failure, with no update hint", async () => {
117
+ const failed = { running: false, error: "jui viz serve が終了しました(終了コード 1)", logTail: "ポート 18011 は使用中です" };
118
+ failWithCode(1, JSON.stringify(failed));
119
+ expect(JSON.parse(await harness.call("viz_start"))).toEqual({ success: false, result: failed });
120
+ });
121
+
122
+ it("a line that is not JSON comes back as output", async () => {
123
+ nextResponse = { stdout: "jui viz: 止めました (pid 4242)\n" };
124
+ expect(JSON.parse(await harness.call("viz_stop"))).toEqual({ success: true, output: "jui viz: 止めました (pid 4242)\n" });
125
+ });
126
+
127
+ it.each([
128
+ ["viz_status", {}, NO_VIZ, "`jui viz`"],
129
+ ["viz_start", {}, NO_VIZ, "`jui viz`"],
130
+ ["viz_start", {}, NO_START, "`jui viz start`"],
131
+ ["viz_status", {}, NO_WITH_RELAY, "`jui viz status --with-relay`"],
132
+ ])("%s on an old jui names what is missing and how to update", async (tool, params, stderr, missing) => {
133
+ failWithCode(2, "", stderr);
134
+ const parsed = JSON.parse(await harness.call(tool, params));
135
+ expect(parsed.success).toBe(false);
136
+ expect(parsed.errors).toBe(stderr);
137
+ expect(parsed.update_jui).toBe(
138
+ `The installed jui has no ${missing}. Update jui, then call this tool again: ` +
139
+ "curl -fsSL https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/main/installer/bootstrap.sh | bash"
140
+ );
141
+ });
142
+
143
+ it("another argparse error is not taken for an old jui", async () => {
144
+ failWithCode(2, "", "jui viz: error: argument --port: invalid int value: 'x'\n");
145
+ expect(JSON.parse(await harness.call("viz_start")).update_jui).toBeUndefined();
146
+ });
147
+
148
+ it("no jui on PATH says so", async () => {
149
+ failWithCode("ENOENT");
150
+ const parsed = JSON.parse(await harness.call("viz_status"));
151
+ expect(parsed.success).toBe(false);
152
+ expect(parsed.update_jui).toMatch(/^jui is not installed \(not found on PATH\)\. Install it: curl /);
153
+ });
154
+ });