jui-tools-mcp-server 2.17.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.
Files changed (33) hide show
  1. package/README.md +2 -2
  2. package/dist/tools/infra/infra_cost.js +65 -2
  3. package/dist/tools/infra/infra_validate.d.ts +0 -5
  4. package/dist/tools/infra/infra_validate.js +41 -4
  5. package/package.json +1 -1
  6. package/scripts/record-infra-fixtures.sh +65 -0
  7. package/src/tools/infra/infra_cost.ts +57 -2
  8. package/src/tools/infra/infra_validate.ts +39 -4
  9. package/tests/fixtures/infra/RECORDED_FROM +1 -1
  10. package/tests/fixtures/infra/answers-without-plan.json +9 -0
  11. package/tests/fixtures/infra/check-infra.json +8 -6
  12. package/tests/fixtures/infra/cost-infra.day.json +2 -2
  13. package/tests/fixtures/infra/cost-infra.month-by-node.json +10 -2
  14. package/tests/fixtures/infra/cost-infra.month.json +21 -4
  15. package/tests/fixtures/infra/cost-infra.plan-bad.json +16 -0
  16. package/tests/fixtures/infra/cost-infra.plan-move-account.json +167 -0
  17. package/tests/fixtures/infra/cost-infra.plan-move.json +174 -0
  18. package/tests/fixtures/infra/cost-infra.plan-none.json +16 -0
  19. package/tests/fixtures/infra/cost-infra.plan-shrink.json +162 -0
  20. package/tests/fixtures/infra/validate-infra-load.errors.json +2 -1
  21. package/tests/fixtures/infra/validate-infra-load.unreadable.json +1 -0
  22. package/tests/fixtures/infra/validate-infra.errors.json +6 -2
  23. package/tests/fixtures/infra/validate-infra.ok.json +1 -1
  24. package/tests/fixtures/infra/validate-infra.plan-both.json +504 -0
  25. package/tests/fixtures/infra/validate-infra.plan-duplicate.json +539 -0
  26. package/tests/fixtures/infra/validate-infra.plan-edge-end.json +539 -0
  27. package/tests/fixtures/infra/validate-infra.plan-half.json +49 -0
  28. package/tests/fixtures/infra/validate-infra.plan-move.json +506 -0
  29. package/tests/fixtures/infra/validate-infra.plan-none.json +49 -0
  30. package/tests/fixtures/infra/validate-infra.plan-order-unknown.json +539 -0
  31. package/tests/fixtures/infra/validate-infra.plan-remove-missing.json +539 -0
  32. package/tests/fixtures/infra/validate-infra.plans.json +506 -0
  33. package/tests/tools_infra.test.ts +208 -0
@@ -330,3 +330,211 @@ describe("a long problem list", () => {
330
330
  expect(out.problems.first[19].path).toBe("/nodes/19/category");
331
331
  });
332
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
+ });