jui-tools-mcp-server 2.16.0 → 2.18.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/.github/workflows/test.yml +2 -1
- package/README.md +16 -1
- package/dist/index.js +16 -1
- package/dist/tools/infra/infra_check.d.ts +10 -0
- package/dist/tools/infra/infra_check.js +43 -0
- package/dist/tools/infra/infra_common.d.ts +40 -0
- package/dist/tools/infra/infra_common.js +44 -0
- package/dist/tools/infra/infra_cost.d.ts +3 -0
- package/dist/tools/infra/infra_cost.js +181 -0
- package/dist/tools/infra/infra_load_validate.d.ts +8 -0
- package/dist/tools/infra/infra_load_validate.js +39 -0
- package/dist/tools/infra/infra_prices_validate.d.ts +7 -0
- package/dist/tools/infra/infra_prices_validate.js +34 -0
- package/dist/tools/infra/infra_seed.d.ts +27 -0
- package/dist/tools/infra/infra_seed.js +81 -0
- package/dist/tools/infra/infra_validate.d.ts +3 -0
- package/dist/tools/infra/infra_validate.js +74 -0
- package/package.json +1 -1
- package/scripts/record-infra-fixtures.sh +143 -0
- package/src/index.ts +17 -1
- package/src/tools/infra/infra_check.ts +49 -0
- package/src/tools/infra/infra_common.ts +69 -0
- package/src/tools/infra/infra_cost.ts +177 -0
- package/src/tools/infra/infra_load_validate.ts +46 -0
- package/src/tools/infra/infra_prices_validate.ts +41 -0
- package/src/tools/infra/infra_seed.ts +88 -0
- package/src/tools/infra/infra_validate.ts +79 -0
- package/tests/fixtures/infra/RECORDED_FROM +1 -0
- package/tests/fixtures/infra/answers-without-plan.json +9 -0
- package/tests/fixtures/infra/check-infra.json +103 -0
- package/tests/fixtures/infra/cost-infra.day.json +10080 -0
- package/tests/fixtures/infra/cost-infra.month-by-node.json +492 -0
- package/tests/fixtures/infra/cost-infra.month.json +398 -0
- package/tests/fixtures/infra/cost-infra.plan-bad.json +16 -0
- package/tests/fixtures/infra/cost-infra.plan-move-account.json +167 -0
- package/tests/fixtures/infra/cost-infra.plan-move.json +174 -0
- package/tests/fixtures/infra/cost-infra.plan-none.json +16 -0
- package/tests/fixtures/infra/cost-infra.plan-shrink.json +162 -0
- package/tests/fixtures/infra/cost-infra.refused.json +5 -0
- package/tests/fixtures/infra/seed-infra.draft.json +233 -0
- package/tests/fixtures/infra/seed-infra.report.json +46 -0
- package/tests/fixtures/infra/validate-infra-load.errors.json +40 -0
- package/tests/fixtures/infra/validate-infra-load.ok.json +29 -0
- package/tests/fixtures/infra/validate-infra-load.unreadable.json +7 -0
- package/tests/fixtures/infra/validate-infra-prices.ok.json +9 -0
- package/tests/fixtures/infra/validate-infra.errors.json +43 -0
- package/tests/fixtures/infra/validate-infra.ok.json +30 -0
- package/tests/fixtures/infra/validate-infra.plan-both.json +504 -0
- package/tests/fixtures/infra/validate-infra.plan-duplicate.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-edge-end.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-half.json +49 -0
- package/tests/fixtures/infra/validate-infra.plan-move.json +506 -0
- package/tests/fixtures/infra/validate-infra.plan-none.json +49 -0
- package/tests/fixtures/infra/validate-infra.plan-order-unknown.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-remove-missing.json +539 -0
- package/tests/fixtures/infra/validate-infra.plans.json +506 -0
- package/tests/fixtures/infra/validate-infra.unreadable.json +6 -0
- package/tests/tool_count.test.ts +1 -1
- package/tests/tools_infra.test.ts +540 -0
|
@@ -0,0 +1,540 @@
|
|
|
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
|
+
});
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Without `plan`, both tools answer exactly what they answered before plans existed (jsonui-mcp-server 6207831): the
|
|
336
|
+
* whole text, byte for byte, for every recorded output. Recorded once from that commit with RECORD_NO_PLAN=1; a
|
|
337
|
+
* change to these answers is a change to every agent that reads them, so it is made on purpose or not at all.
|
|
338
|
+
*/
|
|
339
|
+
describe("without plan, the answers are the ones before plans", () => {
|
|
340
|
+
const CASES: [string, string, number, Record<string, unknown>][] = [
|
|
341
|
+
["validate ok", "validate-infra.ok.json", 0, {}],
|
|
342
|
+
["validate errors", "validate-infra.errors.json", 1, {}],
|
|
343
|
+
["validate unreadable", "validate-infra.unreadable.json", 2, {}],
|
|
344
|
+
["cost month", "cost-infra.month.json", 0, {}],
|
|
345
|
+
["cost month by node", "cost-infra.month-by-node.json", 0, { scenario: "sale", by: "node" }],
|
|
346
|
+
["cost day", "cost-infra.day.json", 0, { date: "2026-11-03" }],
|
|
347
|
+
["cost refused", "cost-infra.refused.json", 2, { scenario: "nope" }],
|
|
348
|
+
];
|
|
349
|
+
const BEFORE = join(FIX, "answers-without-plan.json");
|
|
350
|
+
|
|
351
|
+
// the CLI's problems now carry short (the page's language) and detail; the summaries keep the English message
|
|
352
|
+
// only, so the answers above stay byte for byte — this arm is not empty only while the fixtures carry them
|
|
353
|
+
it("the recorded problems carry short and detail, which the answers leave out", () => {
|
|
354
|
+
const v = fixture("validate-infra.errors.json").problems;
|
|
355
|
+
expect(v.length).toBeGreaterThan(0);
|
|
356
|
+
expect(v.every((p: any) => typeof p.short === "string" && "detail" in p)).toBe(true);
|
|
357
|
+
expect(fixture("cost-infra.plan-bad.json").problems.every((p: any) => typeof p.short === "string")).toBe(true);
|
|
358
|
+
const before = JSON.parse(readFileSync(BEFORE, "utf-8"));
|
|
359
|
+
expect(before["validate errors"]).not.toMatch(/"short"|"detail"/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it.each(CASES)("%s", async (label, file, code, params) => {
|
|
363
|
+
answer = { code, stdout: raw(file) };
|
|
364
|
+
const tool = label.startsWith("validate") ? "infra_validate" : "infra_cost";
|
|
365
|
+
const got = await harness.call(tool, { project_dir: projectDir, ...params });
|
|
366
|
+
if (process.env.RECORD_NO_PLAN) {
|
|
367
|
+
const all = existsSync(BEFORE) ? JSON.parse(readFileSync(BEFORE, "utf-8")) : {};
|
|
368
|
+
all[label] = got;
|
|
369
|
+
writeFileSync(BEFORE, JSON.stringify(all, null, 2) + "\n");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
expect(got).toBe(JSON.parse(readFileSync(BEFORE, "utf-8"))[label]);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Migration plans (jsonui-cli 2.3.20, --migration): the summaries read the keys the CLI promised, recorded from it —
|
|
378
|
+
* a plan that holds, one plan per migration.* code, and the cost's old / new / difference with and without the
|
|
379
|
+
* account. A key the output lacks is named, never summarised as an empty plan.
|
|
380
|
+
*/
|
|
381
|
+
describe("infra_validate with plan", () => {
|
|
382
|
+
it("a plan folder with nothing in it yet is a plan that is not ok, its read problem said (an empty folder lists none)", async () => {
|
|
383
|
+
answer = { code: 1, stdout: raw("validate-infra.plan-half.json") };
|
|
384
|
+
const out = await call("infra_validate", { plan: "half" });
|
|
385
|
+
expect(out.migrations).toHaveLength(1);
|
|
386
|
+
expect(out.migrations[0]).toMatchObject({ name: "half", ok: false, counts: {}, steps: 0, firstSteps: [] });
|
|
387
|
+
expect(out.migrations[0].problems.byCode).toEqual({ "read.unreadable": 1 });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("passes --migration and summarises the plan: counts, steps in order, edges taken and added", async () => {
|
|
391
|
+
answer = { stdout: raw("validate-infra.plan-move.json") };
|
|
392
|
+
const out = await call("infra_validate", { plan: "move" });
|
|
393
|
+
expect(calls[0].args).toEqual(["validate", "infra", projectDir, "--json", "--migration", "move"]);
|
|
394
|
+
const [m] = fixture("validate-infra.plan-move.json").migrations;
|
|
395
|
+
expect(out.ok).toBe(true);
|
|
396
|
+
expect(out.migrations).toHaveLength(1);
|
|
397
|
+
expect(out.migrations[0]).toMatchObject({ name: "move", ok: true, counts: m.counts, steps: m.steps.length });
|
|
398
|
+
expect(out.migrations[0].problems.count).toBe(0);
|
|
399
|
+
expect(out.migrations[0].firstSteps.map((s: any) => [s.index, s.kind, s.node]))
|
|
400
|
+
.toEqual(m.steps.map((s: any) => [s.index, s.kind, s.node]));
|
|
401
|
+
// a step with no edge moved carries no empty lists
|
|
402
|
+
expect(out.migrations[0].firstSteps[0]).toEqual({ index: 1, kind: "replace", node: "db-prod" });
|
|
403
|
+
expect(out.migrations[0].firstSteps[2].edgesRemoved).toEqual(m.steps[2].edgesRemoved);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("without plan, a room that has plans lists each", async () => {
|
|
407
|
+
answer = { stdout: raw("validate-infra.plans.json") };
|
|
408
|
+
const out = await call("infra_validate");
|
|
409
|
+
expect(calls[0].args).toEqual(["validate", "infra", projectDir, "--json"]);
|
|
410
|
+
expect(out.migrations.map((m: any) => m.name)).toEqual(["move"]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it.each([
|
|
414
|
+
["remove-missing", "migration.remove_missing"],
|
|
415
|
+
["duplicate", "migration.duplicate"],
|
|
416
|
+
["both", "migration.both"],
|
|
417
|
+
["order-unknown", "migration.order_unknown"],
|
|
418
|
+
["edge-end", "migration.edge_end"],
|
|
419
|
+
["none", "migration.no_plan"],
|
|
420
|
+
])("a plan with %s: exit 1 while the model is fine, and the answer shows why (%s)", async (name, code) => {
|
|
421
|
+
answer = { code: 1, stdout: raw(`validate-infra.plan-${name}.json`) };
|
|
422
|
+
const out = await call("infra_validate", { plan: name });
|
|
423
|
+
const rec = fixture(`validate-infra.plan-${name}.json`);
|
|
424
|
+
expect(rec.problems).toEqual([]); // the model's own list is empty: only the plan says it
|
|
425
|
+
expect(out).toMatchObject({ ok: false, exit: 1, readable: true });
|
|
426
|
+
expect(out.problems.count).toBe(0);
|
|
427
|
+
expect(out.migrations[0].ok).toBe(false);
|
|
428
|
+
expect(out.migrations[0].problems.byCode).toEqual({ [code]: 1 });
|
|
429
|
+
const [p] = rec.migrations[0].problems;
|
|
430
|
+
expect(out.migrations[0].problems.first[0]).toEqual(
|
|
431
|
+
Object.fromEntries(["severity", "code", "file", "path", "message"].filter((k) => p[k] != null).map((k) => [k, p[k]])));
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it.each(["name", "dir", "ok", "problems", "steps", "counts"])("names %s when a plan lacks it", async (key) => {
|
|
435
|
+
const data = fixture("validate-infra.plan-move.json");
|
|
436
|
+
delete data.migrations[0][key];
|
|
437
|
+
answer = { stdout: JSON.stringify(data) };
|
|
438
|
+
expect(await harness.call("infra_validate", { project_dir: projectDir, plan: "move" }))
|
|
439
|
+
.toMatch(new RegExp(`^Error: validate infra migration: the JSON has no "${key}"`));
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("with plan, an output with no migrations at all is a CLI these tools do not know", async () => {
|
|
443
|
+
answer = { stdout: raw("validate-infra.ok.json") };
|
|
444
|
+
expect(await harness.call("infra_validate", { project_dir: projectDir, plan: "move" }))
|
|
445
|
+
.toMatch(/^Error: validate infra --migration: the JSON has no "migrations"/);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("infra_cost with plan", () => {
|
|
450
|
+
it("passes --migration and answers old / new / difference, the biggest node changes first, and each step", async () => {
|
|
451
|
+
answer = { stdout: raw("cost-infra.plan-move.json") };
|
|
452
|
+
const out = await call("infra_cost", { plan: "move" });
|
|
453
|
+
expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json", "--migration", "move"]);
|
|
454
|
+
const rec = fixture("cost-infra.plan-move.json");
|
|
455
|
+
expect(out).toMatchObject({ ok: true, exit: 0, migration: "move", scenario: rec.scenario, dir: rec.dir,
|
|
456
|
+
scenarios: rec.scenarios, usd: { old: rec.old.usd, new: rec.new.usd, diff: rec.diff.usd },
|
|
457
|
+
byProvider: rec.byProvider, account: null, loadProblems: rec.problems,
|
|
458
|
+
priceWarnings: rec.prices.warnings, priceWarningCount: rec.prices.warningCount });
|
|
459
|
+
expect(out.newUnpriced).toEqual({ noPrice: rec.new.noPrice, noRule: rec.new.noRule, loadUndefined: rec.new.loadUndefined });
|
|
460
|
+
expect(out.oldUnpriced).toEqual({ noPrice: rec.old.noPrice, noRule: rec.old.noRule, loadUndefined: rec.old.loadUndefined });
|
|
461
|
+
// worker-prod is added with no price: its part is out of the difference, said by name and on its row
|
|
462
|
+
expect(rec.unpricedChanged).toEqual(["worker-prod"]);
|
|
463
|
+
expect(out.unpricedChanged).toEqual(rec.unpricedChanged);
|
|
464
|
+
expect(out.topChanges.find((n: any) => n.node === "worker-prod").unpriced).toBe(true);
|
|
465
|
+
expect(out.topChanges.find((n: any) => n.node === "db-prod").unpriced).toBe(false);
|
|
466
|
+
expect(out.steps).toEqual(rec.steps.map((s: any) => ({ index: s.index, node: s.node, usd: s.usd, diff: s.diff,
|
|
467
|
+
moved: s.moved, movedCount: s.moved.length })));
|
|
468
|
+
expect(out.topChanges[0].node).toBe("db-prod");
|
|
469
|
+
expect(out.topChanges.map((n: any) => n.change).sort()).toEqual(["added", "removed", "replaced"]);
|
|
470
|
+
expect(out.nodes).toMatchObject({ added: 1, replaced: 1, removed: 1 });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("the biggest movement first whichever way, and a node the plan does not touch that moves with it is a change", async () => {
|
|
474
|
+
// shrink: the database shrinks (the biggest, a fall), api-prod goes, and batch-prod — untouched, after api-prod
|
|
475
|
+
// in the model — takes more of the account's free egress: "same", yet its month moves
|
|
476
|
+
answer = { stdout: raw("cost-infra.plan-shrink.json") };
|
|
477
|
+
const out = await call("infra_cost", { plan: "shrink" });
|
|
478
|
+
const rec = fixture("cost-infra.plan-shrink.json");
|
|
479
|
+
const batch = rec.byNode.find((n: any) => n.node === "batch-prod");
|
|
480
|
+
expect(batch.change).toBe("same");
|
|
481
|
+
expect(batch.diff).toBeLessThan(0);
|
|
482
|
+
expect(out.topChanges[0]).toMatchObject({ node: "db-prod", change: "replaced" });
|
|
483
|
+
expect(out.topChanges[0].diff).toBeLessThan(0);
|
|
484
|
+
const sizes = out.topChanges.map((n: any) => Math.abs(n.diff));
|
|
485
|
+
expect(sizes).toEqual([...sizes].sort((x: number, y: number) => y - x));
|
|
486
|
+
expect(out.topChanges.map((n: any) => n.node)).toContain("batch-prod");
|
|
487
|
+
expect(out.changedCount).toBe(rec.byNode.filter((n: any) => n.change !== "same" || n.diff !== 0).length);
|
|
488
|
+
expect(out.changedCount).toBeGreaterThan(rec.byNode.filter((n: any) => n.change !== "same").length);
|
|
489
|
+
// the step that removes api-prod moves batch-prod and the line too, said in its moved
|
|
490
|
+
const step = out.steps.find((x: any) => x.node === "api-prod");
|
|
491
|
+
expect(step.moved.map((m: any) => m.node)).toEqual(expect.arrayContaining(["api-prod", "batch-prod"]));
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("a step's moved is kept whole, a neighbour that moved with the step's node too (the step's diff is not its node's)", async () => {
|
|
495
|
+
const data = fixture("cost-infra.plan-move.json");
|
|
496
|
+
const [first] = data.steps;
|
|
497
|
+
first.moved = [{ node: "db-prod", diff: 20 }, { node: "api-prod>db-prod", diff: 1.6 }]; // a line's transfer moved with it
|
|
498
|
+
answer = { stdout: JSON.stringify(data) };
|
|
499
|
+
const out = await call("infra_cost", { plan: "move" });
|
|
500
|
+
expect(out.steps[0]).toMatchObject({ node: "db-prod", diff: first.diff, movedCount: 2,
|
|
501
|
+
moved: [{ node: "db-prod", diff: 20 }, { node: "api-prod>db-prod", diff: 1.6 }] });
|
|
502
|
+
data.steps[0].moved = Array.from({ length: 30 }, (_, i) => ({ node: `n${i}`, diff: 30 - i }));
|
|
503
|
+
answer = { stdout: JSON.stringify(data) };
|
|
504
|
+
const many = await call("infra_cost", { plan: "move" });
|
|
505
|
+
expect(many.steps[0].moved).toHaveLength(20);
|
|
506
|
+
expect(many.steps[0].movedCount).toBe(30);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("account is a switch: --account, and the account's old / new / difference", async () => {
|
|
510
|
+
answer = { stdout: raw("cost-infra.plan-move-account.json") };
|
|
511
|
+
const out = await call("infra_cost", { plan: "move", account: true, scenario: "sale" });
|
|
512
|
+
expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json", "--migration", "move", "--account", "--scenario", "sale"]);
|
|
513
|
+
expect(out.account).toEqual(fixture("cost-infra.plan-move-account.json").account);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it.each([["bad", "bad_plan"], ["none", "no_plan"]])("a %s plan is refused, priced nothing, the reason and problems kept", async (file, reason) => {
|
|
517
|
+
answer = { code: 2, stdout: raw(`cost-infra.plan-${file}.json`) };
|
|
518
|
+
const rec = fixture(`cost-infra.plan-${file}.json`);
|
|
519
|
+
const out = await call("infra_cost", { plan: "x" });
|
|
520
|
+
expect(out).toMatchObject({ ok: false, exit: 2, reason, message: rec.message });
|
|
521
|
+
expect(out.problems.byCode).toEqual(Object.fromEntries(rec.problems.map((p: any) => [p.code, 1])));
|
|
522
|
+
expect(out.usd).toBeUndefined();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it.each(["migration", "scenario", "old", "new", "diff", "byNode", "byProvider", "steps", "account",
|
|
526
|
+
"dir", "scenarios", "problems", "prices", "new.noPrice", "new.noRule", "new.loadUndefined", "diff.usd", "unpricedChanged",
|
|
527
|
+
"old.noPrice", "old.noRule", "old.loadUndefined", "byNode.0.unpriced", "steps.0.moved", "steps.0.moved.0.node", "steps.0.moved.0.diff"])(
|
|
528
|
+
"names %s when the plan's answer lacks it", async (key) => {
|
|
529
|
+
answer = { stdout: without("cost-infra.plan-move.json", key) };
|
|
530
|
+
expect(await harness.call("infra_cost", { project_dir: projectDir, plan: "move" }))
|
|
531
|
+
.toMatch(new RegExp(`^Error: cost infra[^:]*: the JSON has no "${key.split(".").pop()}"`));
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("refuses date, by and account where they do not apply, without running the CLI", async () => {
|
|
535
|
+
for (const params of [{ plan: "move", date: "2026-11-03" }, { plan: "move", by: "node" }, { account: true }]) {
|
|
536
|
+
expect(await harness.call("infra_cost", { project_dir: projectDir, ...params })).toMatch(/^Error: /);
|
|
537
|
+
}
|
|
538
|
+
expect(calls).toHaveLength(0);
|
|
539
|
+
});
|
|
540
|
+
});
|