@diviops/mcp-server 1.5.10 → 1.5.11
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 +21 -0
- package/dist/preset-cli/__tests__/button-emitter.test.d.ts +8 -0
- package/dist/preset-cli/__tests__/button-emitter.test.js +188 -0
- package/dist/preset-cli/__tests__/cli.test.d.ts +9 -0
- package/dist/preset-cli/__tests__/cli.test.js +149 -0
- package/dist/preset-cli/__tests__/preset-create-unchanged.test.d.ts +13 -0
- package/dist/preset-cli/__tests__/preset-create-unchanged.test.js +64 -0
- package/dist/preset-cli/__tests__/registry.test.d.ts +5 -0
- package/dist/preset-cli/__tests__/registry.test.js +134 -0
- package/dist/preset-cli/__tests__/write-path.test.d.ts +8 -0
- package/dist/preset-cli/__tests__/write-path.test.js +120 -0
- package/dist/preset-cli/bin.d.ts +8 -0
- package/dist/preset-cli/bin.js +32 -0
- package/dist/preset-cli/button-emitter.d.ts +117 -0
- package/dist/preset-cli/button-emitter.js +218 -0
- package/dist/preset-cli/cli.d.ts +59 -0
- package/dist/preset-cli/cli.js +326 -0
- package/dist/preset-cli/registry.d.ts +107 -0
- package/dist/preset-cli/registry.js +168 -0
- package/dist/preset-cli/variable-token.d.ts +42 -0
- package/dist/preset-cli/variable-token.js +70 -0
- package/dist/preset-cli/write-path.d.ts +59 -0
- package/dist/preset-cli/write-path.js +89 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -80,6 +80,27 @@ The server exposes **73 tools** across the categories below. Each category links
|
|
|
80
80
|
|
|
81
81
|
See [server-reference.md](../docs/server-reference.md) for per-tool descriptions.
|
|
82
82
|
|
|
83
|
+
## Bundled CLI — `diviops-preset`
|
|
84
|
+
|
|
85
|
+
The package also ships a standalone command-line preset emitter, `diviops-preset`,
|
|
86
|
+
that produces byte-canonical Divi 5.5.x preset JSON gated by the verified-attrs
|
|
87
|
+
registry (`data/verified-attrs.json`). It is independent of the MCP stdio server —
|
|
88
|
+
run it directly:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
diviops-preset button --name "Primary" --bg-color gcid-primary-color \
|
|
92
|
+
--bg-color-hover gcid-secondary-color --radius 8px \
|
|
93
|
+
--font-family Inter --font-weight 600 --font-color gcid-body-color
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--dry-run` (the default) composes and prints the canonical JSON with no
|
|
97
|
+
credentials and no network. `--apply` posts to the existing `/preset/create`
|
|
98
|
+
REST route, reusing the same `WP_URL` / `WP_USER` / `WP_APP_PASSWORD` env vars.
|
|
99
|
+
The current scope is one emitter — `divi/button` group presets. See the
|
|
100
|
+
[preset-cli reference](https://github.com/oaris-dev/diviops/blob/main/diviops-server/src/preset-cli/README.md)
|
|
101
|
+
for the full command reference (the `src/` tree is not part of the published
|
|
102
|
+
npm package — this link resolves on the repository).
|
|
103
|
+
|
|
83
104
|
## Response contract
|
|
84
105
|
|
|
85
106
|
Tools return a standardized envelope. The shape lets clients branch on `ok` and machine-readable `error.code` without parsing freeform messages.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `divi/button` emitter shape coverage:
|
|
3
|
+
* - fixture-based shape assertion against round-3-button-canonical-complete.json
|
|
4
|
+
* - emit-on-specification discriminator (with vs without font.weight)
|
|
5
|
+
* - hover shape (NO `value` wrapper), radius vocabulary, variable tokens,
|
|
6
|
+
* the do-not-emit list, and the opt-in bypass corner.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `divi/button` emitter shape coverage:
|
|
3
|
+
* - fixture-based shape assertion against round-3-button-canonical-complete.json
|
|
4
|
+
* - emit-on-specification discriminator (with vs without font.weight)
|
|
5
|
+
* - hover shape (NO `value` wrapper), radius vocabulary, variable tokens,
|
|
6
|
+
* the do-not-emit list, and the opt-in bypass corner.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { emitButtonGroupPreset, composeButtonAttrs, buildPresetCreateBody, } from "../button-emitter.js";
|
|
14
|
+
import { loadRegistry } from "../registry.js";
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const REPO_ROOT = join(__dirname, "..", "..", "..", "..");
|
|
17
|
+
const FIXTURE = join(REPO_ROOT, "docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-3-button-canonical-complete.json");
|
|
18
|
+
const registry = loadRegistry();
|
|
19
|
+
test("emitter byte-matches the round-3 canonical button fixture attrs", () => {
|
|
20
|
+
const fixture = JSON.parse(readFileSync(FIXTURE, "utf8"));
|
|
21
|
+
const canonicalAttrs = fixture.canonical_button_primary_preset.attrs;
|
|
22
|
+
const entry = emitButtonGroupPreset({
|
|
23
|
+
name: "canonical-button-primary-vb-2026-05-18",
|
|
24
|
+
bg_color: "gcid-primary-color",
|
|
25
|
+
bg_color_hover: "gcid-secondary-color",
|
|
26
|
+
radius: {
|
|
27
|
+
topLeft: "8px",
|
|
28
|
+
topRight: "8px",
|
|
29
|
+
bottomLeft: "8px",
|
|
30
|
+
bottomRight: "8px",
|
|
31
|
+
},
|
|
32
|
+
font: {
|
|
33
|
+
family: "Inter",
|
|
34
|
+
weight: "600",
|
|
35
|
+
color: "gcid-body-color",
|
|
36
|
+
},
|
|
37
|
+
}, registry);
|
|
38
|
+
assert.deepEqual(entry.attrs, canonicalAttrs, "emitted attrs must byte-match the canonical capture");
|
|
39
|
+
assert.equal(entry.type, "group");
|
|
40
|
+
assert.equal(entry.group_name, "divi/button");
|
|
41
|
+
assert.equal(entry.group_id, "button");
|
|
42
|
+
assert.equal(entry.module_name, "divi/button");
|
|
43
|
+
});
|
|
44
|
+
test("hover bg color emits at desktop.hover.color with NO value wrapper", () => {
|
|
45
|
+
const attrs = composeButtonAttrs({
|
|
46
|
+
name: "hover-only",
|
|
47
|
+
bg_color: "#111111",
|
|
48
|
+
bg_color_hover: "#222222",
|
|
49
|
+
});
|
|
50
|
+
const bg = attrs.button.decoration.background;
|
|
51
|
+
assert.equal(bg.desktop.value.color, "#111111", "desktop is 3-level (value.color)");
|
|
52
|
+
assert.equal(bg.desktop.hover.color, "#222222", "hover is 2-level (hover.color)");
|
|
53
|
+
assert.equal("value" in bg.desktop.hover, false, "hover must NOT carry a `value` wrapper");
|
|
54
|
+
});
|
|
55
|
+
test("emit-on-specification discriminator: font.weight present vs absent", () => {
|
|
56
|
+
const withWeight = composeButtonAttrs({
|
|
57
|
+
name: "with-weight",
|
|
58
|
+
font: { family: "Inter", weight: "600" },
|
|
59
|
+
});
|
|
60
|
+
const withoutWeight = composeButtonAttrs({
|
|
61
|
+
name: "without-weight",
|
|
62
|
+
font: { family: "Inter" },
|
|
63
|
+
});
|
|
64
|
+
const wv = withWeight.button.decoration.font.font.desktop.value;
|
|
65
|
+
const wov = withoutWeight.button.decoration.font.font.desktop.value;
|
|
66
|
+
assert.equal(wv.weight, "600", "weight emitted when specified");
|
|
67
|
+
assert.equal("weight" in wv, true);
|
|
68
|
+
assert.equal("weight" in wov, false, "weight absent when not specified");
|
|
69
|
+
assert.deepEqual(Object.keys(wov), ["family"], "ONLY the touched key emitted");
|
|
70
|
+
});
|
|
71
|
+
test("radius vocabulary is {topLeft,topRight,bottomLeft,bottomRight,sync}", () => {
|
|
72
|
+
const attrs = composeButtonAttrs({
|
|
73
|
+
name: "r",
|
|
74
|
+
radius: { topLeft: "8px", topRight: "8px", bottomLeft: "8px", bottomRight: "8px" },
|
|
75
|
+
});
|
|
76
|
+
const radius = attrs.button.decoration.border.desktop.value.radius;
|
|
77
|
+
assert.deepEqual(Object.keys(radius).sort(), [
|
|
78
|
+
"bottomLeft",
|
|
79
|
+
"bottomRight",
|
|
80
|
+
"sync",
|
|
81
|
+
"topLeft",
|
|
82
|
+
"topRight",
|
|
83
|
+
]);
|
|
84
|
+
assert.equal(radius.sync, "on", "equal corners derive sync=on");
|
|
85
|
+
});
|
|
86
|
+
test("radius sync derives off when corners differ; partial corners stay absent", () => {
|
|
87
|
+
const attrs = composeButtonAttrs({
|
|
88
|
+
name: "r",
|
|
89
|
+
radius: { topLeft: "8px", bottomRight: "4px" },
|
|
90
|
+
});
|
|
91
|
+
const radius = attrs.button.decoration.border.desktop.value.radius;
|
|
92
|
+
assert.equal(radius.sync, "off");
|
|
93
|
+
assert.equal("topRight" in radius, false, "untouched corner absent");
|
|
94
|
+
assert.equal("bottomLeft" in radius, false, "untouched corner absent");
|
|
95
|
+
});
|
|
96
|
+
test("radius sync derives off for a single specified corner (Rule 1a)", () => {
|
|
97
|
+
// Per Rule 1a in canonical-preset-shapes: a partial corner set must derive
|
|
98
|
+
// sync="off". A 1-element corner array is trivially "all equal" — auto-
|
|
99
|
+
// deriving "on" is only correct when all 4 corners are present and identical.
|
|
100
|
+
const attrs = composeButtonAttrs({
|
|
101
|
+
name: "r",
|
|
102
|
+
radius: { topLeft: "8px" },
|
|
103
|
+
});
|
|
104
|
+
const radius = attrs.button.decoration.border.desktop.value.radius;
|
|
105
|
+
assert.equal(radius.sync, "off", "single corner must NOT derive sync=on");
|
|
106
|
+
assert.equal("topRight" in radius, false, "untouched corner absent");
|
|
107
|
+
});
|
|
108
|
+
test("radius sync stays off for 3 identical corners; on only with all 4", () => {
|
|
109
|
+
const three = composeButtonAttrs({
|
|
110
|
+
name: "r",
|
|
111
|
+
radius: { topLeft: "8px", topRight: "8px", bottomLeft: "8px" },
|
|
112
|
+
});
|
|
113
|
+
assert.equal(three.button.decoration.border.desktop.value.radius.sync, "off", "3 identical corners is still a partial set — sync=off");
|
|
114
|
+
const all = composeButtonAttrs({
|
|
115
|
+
name: "r",
|
|
116
|
+
radius: { topLeft: "8px", topRight: "8px", bottomLeft: "8px", bottomRight: "8px" },
|
|
117
|
+
});
|
|
118
|
+
assert.equal(all.button.decoration.border.desktop.value.radius.sync, "on", "all 4 identical corners derive sync=on");
|
|
119
|
+
});
|
|
120
|
+
test("explicit radius.sync wins over derivation", () => {
|
|
121
|
+
const attrs = composeButtonAttrs({
|
|
122
|
+
name: "r",
|
|
123
|
+
radius: { topLeft: "8px", sync: "on" },
|
|
124
|
+
});
|
|
125
|
+
assert.equal(attrs.button.decoration.border.desktop.value.radius.sync, "on", "an explicitly-passed sync flag is honored regardless of corner count");
|
|
126
|
+
});
|
|
127
|
+
test("variable tokens get the {name,settings} object shape + trailing )$", () => {
|
|
128
|
+
const attrs = composeButtonAttrs({
|
|
129
|
+
name: "v",
|
|
130
|
+
bg_color: "gcid-primary-color",
|
|
131
|
+
});
|
|
132
|
+
const color = attrs.button.decoration.background.desktop.value.color;
|
|
133
|
+
assert.ok(color.endsWith(")$"), "token must end with )$");
|
|
134
|
+
assert.match(color, /^\$variable\(/);
|
|
135
|
+
const payload = JSON.parse(color.slice("$variable(".length, -2));
|
|
136
|
+
assert.deepEqual(payload, {
|
|
137
|
+
type: "color",
|
|
138
|
+
value: { name: "gcid-primary-color", settings: {} },
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
test("literal hex color is emitted verbatim, not wrapped", () => {
|
|
142
|
+
const attrs = composeButtonAttrs({ name: "h", bg_color: "#2563eb" });
|
|
143
|
+
assert.equal(attrs.button.decoration.background.desktop.value.color, "#2563eb");
|
|
144
|
+
});
|
|
145
|
+
test("do-not-emit list: no attrs.font/spacing top-level, no renderAttrs, no button slot", () => {
|
|
146
|
+
const entry = emitButtonGroupPreset({
|
|
147
|
+
name: "clean",
|
|
148
|
+
bg_color: "#111",
|
|
149
|
+
font: { family: "Inter" },
|
|
150
|
+
}, registry);
|
|
151
|
+
assert.equal("font" in entry.attrs, false, "no top-level attrs.font");
|
|
152
|
+
assert.equal("spacing" in entry.attrs, false, "no top-level attrs.spacing");
|
|
153
|
+
assert.equal("renderAttrs" in entry, false, "no renderAttrs");
|
|
154
|
+
const decoration = entry.attrs.button.decoration;
|
|
155
|
+
assert.equal("button" in decoration, false, "no button.decoration.button slot by default");
|
|
156
|
+
});
|
|
157
|
+
test("bypass_hover_padding_gate emits ONLY the padding.top corner", () => {
|
|
158
|
+
const attrs = composeButtonAttrs({
|
|
159
|
+
name: "bypass",
|
|
160
|
+
bg_color: "#111",
|
|
161
|
+
bypass_hover_padding_gate: true,
|
|
162
|
+
});
|
|
163
|
+
const buttonSlot = attrs.button.decoration.button;
|
|
164
|
+
assert.deepEqual(buttonSlot, {
|
|
165
|
+
desktop: { value: { padding: { top: "0px" } } },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
test("emitter rejects an empty preset (no styling specified)", () => {
|
|
169
|
+
assert.throws(() => emitButtonGroupPreset({ name: "empty" }, registry), /empty preset/);
|
|
170
|
+
});
|
|
171
|
+
test("emitter rejects a missing name", () => {
|
|
172
|
+
assert.throws(() => emitButtonGroupPreset({ name: "" }, registry), /requires a non-empty `name`/);
|
|
173
|
+
});
|
|
174
|
+
test("buildPresetCreateBody mirrors the diviops_preset_create body shape", () => {
|
|
175
|
+
const entry = emitButtonGroupPreset({ name: "Primary", bg_color: "#111" }, registry);
|
|
176
|
+
const body = buildPresetCreateBody(entry, { dry_run: true });
|
|
177
|
+
assert.deepEqual(body, {
|
|
178
|
+
module_name: "divi/button",
|
|
179
|
+
name: "Primary",
|
|
180
|
+
attrs: entry.attrs,
|
|
181
|
+
type: "group",
|
|
182
|
+
group_name: "divi/button",
|
|
183
|
+
group_id: "button",
|
|
184
|
+
dry_run: true,
|
|
185
|
+
});
|
|
186
|
+
const noDry = buildPresetCreateBody(entry);
|
|
187
|
+
assert.equal("dry_run" in noDry, false, "dry_run omitted when not requested");
|
|
188
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI integration coverage: --help, arg parsing, structured exit codes,
|
|
3
|
+
* dry-run output, evidence-gate exit, capability-missing exit.
|
|
4
|
+
*
|
|
5
|
+
* Apply-mode here is exercised through the credential-missing path only
|
|
6
|
+
* (no live write — #725 AC #8). The mocked write-path proper lives in
|
|
7
|
+
* write-path.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI integration coverage: --help, arg parsing, structured exit codes,
|
|
3
|
+
* dry-run output, evidence-gate exit, capability-missing exit.
|
|
4
|
+
*
|
|
5
|
+
* Apply-mode here is exercised through the credential-missing path only
|
|
6
|
+
* (no live write — #725 AC #8). The mocked write-path proper lives in
|
|
7
|
+
* write-path.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { run, parseArgs, buildButtonInput, EXIT, UsageError } from "../cli.js";
|
|
12
|
+
/** Capture CLI output without touching real stdio. */
|
|
13
|
+
function capture() {
|
|
14
|
+
const stdout = [];
|
|
15
|
+
const stderr = [];
|
|
16
|
+
return {
|
|
17
|
+
stdout,
|
|
18
|
+
stderr,
|
|
19
|
+
out: (t) => stdout.push(t),
|
|
20
|
+
err: (t) => stderr.push(t),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
test("--help prints usage and exits 0", async () => {
|
|
24
|
+
const io = capture();
|
|
25
|
+
const code = await run(["--help"], io);
|
|
26
|
+
assert.equal(code, EXIT.OK);
|
|
27
|
+
assert.match(io.stdout.join("\n"), /diviops-preset/);
|
|
28
|
+
assert.match(io.stdout.join("\n"), /EXIT CODES/);
|
|
29
|
+
});
|
|
30
|
+
test("no args prints help and exits 0", async () => {
|
|
31
|
+
const io = capture();
|
|
32
|
+
const code = await run([], io);
|
|
33
|
+
assert.equal(code, EXIT.OK);
|
|
34
|
+
assert.match(io.stdout.join("\n"), /USAGE/);
|
|
35
|
+
});
|
|
36
|
+
test("unknown command exits 1 (invalid input)", async () => {
|
|
37
|
+
const io = capture();
|
|
38
|
+
const code = await run(["section", "--name", "X"], io);
|
|
39
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
40
|
+
assert.match(io.stderr.join("\n"), /Unknown command/);
|
|
41
|
+
});
|
|
42
|
+
test("unknown flag exits 1 (invalid input)", async () => {
|
|
43
|
+
const io = capture();
|
|
44
|
+
const code = await run(["button", "--name", "X", "--bogus", "y"], io);
|
|
45
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
46
|
+
assert.match(io.stderr.join("\n"), /Unknown flag/);
|
|
47
|
+
});
|
|
48
|
+
test("button without --name exits 1", async () => {
|
|
49
|
+
const io = capture();
|
|
50
|
+
const code = await run(["button", "--bg-color", "#111"], io);
|
|
51
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
52
|
+
assert.match(io.stderr.join("\n"), /requires --name/);
|
|
53
|
+
});
|
|
54
|
+
test("dry-run is the default and prints canonical JSON, exit 0", async () => {
|
|
55
|
+
const io = capture();
|
|
56
|
+
const code = await run(["button", "--name", "Primary", "--bg-color", "gcid-primary-color"], io);
|
|
57
|
+
assert.equal(code, EXIT.OK);
|
|
58
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
59
|
+
assert.equal(parsed.type, "group");
|
|
60
|
+
assert.equal(parsed.dry_run, true);
|
|
61
|
+
assert.equal(parsed.module_name, "divi/button");
|
|
62
|
+
assert.equal(parsed.attrs.button.decoration.background.desktop.value.color, '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$');
|
|
63
|
+
});
|
|
64
|
+
test("explicit --dry-run behaves the same as the default", async () => {
|
|
65
|
+
const io = capture();
|
|
66
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111", "--dry-run"], io);
|
|
67
|
+
assert.equal(code, EXIT.OK);
|
|
68
|
+
assert.equal(JSON.parse(io.stdout.join("\n")).dry_run, true);
|
|
69
|
+
});
|
|
70
|
+
test("dry-run requires no credentials and no network", async () => {
|
|
71
|
+
// No WP_* env vars set in this assertion's expectation: a clean dry-run
|
|
72
|
+
// run must not throw a CredentialsMissingError.
|
|
73
|
+
const io = capture();
|
|
74
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111"], io);
|
|
75
|
+
assert.equal(code, EXIT.OK);
|
|
76
|
+
assert.equal(io.stderr.length, 0, "no error output on a credential-free dry-run");
|
|
77
|
+
});
|
|
78
|
+
test("--apply without credentials exits 1 with a credentials hint", async () => {
|
|
79
|
+
const saved = {
|
|
80
|
+
WP_URL: process.env.WP_URL,
|
|
81
|
+
WP_USER: process.env.WP_USER,
|
|
82
|
+
WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
|
|
83
|
+
};
|
|
84
|
+
delete process.env.WP_URL;
|
|
85
|
+
delete process.env.WP_USER;
|
|
86
|
+
delete process.env.WP_APP_PASSWORD;
|
|
87
|
+
try {
|
|
88
|
+
const io = capture();
|
|
89
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111", "--apply"], io);
|
|
90
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
91
|
+
assert.match(io.stderr.join("\n"), /requires WordPress credentials/);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
if (saved.WP_URL !== undefined)
|
|
95
|
+
process.env.WP_URL = saved.WP_URL;
|
|
96
|
+
if (saved.WP_USER !== undefined)
|
|
97
|
+
process.env.WP_USER = saved.WP_USER;
|
|
98
|
+
if (saved.WP_APP_PASSWORD !== undefined)
|
|
99
|
+
process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test("--apply and --dry-run together exit 1 (mutually exclusive)", async () => {
|
|
103
|
+
const io = capture();
|
|
104
|
+
const code = await run(["button", "--name", "P", "--apply", "--dry-run"], io);
|
|
105
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
106
|
+
assert.match(io.stderr.join("\n"), /mutually exclusive/);
|
|
107
|
+
});
|
|
108
|
+
test("parseArgs: --radius shorthand and per-corner overrides", () => {
|
|
109
|
+
const parsed = parseArgs([
|
|
110
|
+
"button",
|
|
111
|
+
"--name",
|
|
112
|
+
"P",
|
|
113
|
+
"--radius",
|
|
114
|
+
"8px",
|
|
115
|
+
"--radius-top-left",
|
|
116
|
+
"12px",
|
|
117
|
+
]);
|
|
118
|
+
const input = buildButtonInput(parsed);
|
|
119
|
+
assert.deepEqual(input.radius, {
|
|
120
|
+
topLeft: "12px",
|
|
121
|
+
topRight: "8px",
|
|
122
|
+
bottomLeft: "8px",
|
|
123
|
+
bottomRight: "8px",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
test("parseArgs: --bypass-hover-padding-gate sets the opt-in flag", () => {
|
|
127
|
+
const parsed = parseArgs(["button", "--name", "P", "--bypass-hover-padding-gate"]);
|
|
128
|
+
const input = buildButtonInput(parsed);
|
|
129
|
+
assert.equal(input.bypass_hover_padding_gate, true);
|
|
130
|
+
});
|
|
131
|
+
test("parseArgs: value flag without a value throws UsageError", () => {
|
|
132
|
+
assert.throws(() => parseArgs(["button", "--name"]), (err) => {
|
|
133
|
+
assert.ok(err instanceof UsageError);
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
test("parseArgs: --radius-sync rejects values outside on|off", () => {
|
|
138
|
+
const parsed = parseArgs(["button", "--name", "P", "--radius-sync", "maybe"]);
|
|
139
|
+
assert.throws(() => buildButtonInput(parsed), /radius-sync must be/);
|
|
140
|
+
});
|
|
141
|
+
test("dry-run output includes the bypass corner when requested", async () => {
|
|
142
|
+
const io = capture();
|
|
143
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111", "--bypass-hover-padding-gate"], io);
|
|
144
|
+
assert.equal(code, EXIT.OK);
|
|
145
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
146
|
+
assert.deepEqual(parsed.attrs.button.decoration.button, {
|
|
147
|
+
desktop: { value: { padding: { top: "0px" } } },
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AC #9 — `diviops_preset_create` MCP tool behavior is unchanged.
|
|
3
|
+
*
|
|
4
|
+
* Track 4 adds a standalone CLI; it must NOT modify the existing MCP tool.
|
|
5
|
+
* This test pins two contracts:
|
|
6
|
+
* 1. The CLI posts to the SAME route the MCP tool uses (`/preset/create`)
|
|
7
|
+
* with a body whose keys are a subset of the MCP tool's body keys —
|
|
8
|
+
* i.e. the CLI is a peer consumer of the existing route, not a fork.
|
|
9
|
+
* 2. The `diviops_preset_create` registration block in `src/index.ts` is
|
|
10
|
+
* byte-identical to the committed baseline on `main` (a guard against
|
|
11
|
+
* an accidental edit landing in this PR).
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AC #9 — `diviops_preset_create` MCP tool behavior is unchanged.
|
|
3
|
+
*
|
|
4
|
+
* Track 4 adds a standalone CLI; it must NOT modify the existing MCP tool.
|
|
5
|
+
* This test pins two contracts:
|
|
6
|
+
* 1. The CLI posts to the SAME route the MCP tool uses (`/preset/create`)
|
|
7
|
+
* with a body whose keys are a subset of the MCP tool's body keys —
|
|
8
|
+
* i.e. the CLI is a peer consumer of the existing route, not a fork.
|
|
9
|
+
* 2. The `diviops_preset_create` registration block in `src/index.ts` is
|
|
10
|
+
* byte-identical to the committed baseline on `main` (a guard against
|
|
11
|
+
* an accidental edit landing in this PR).
|
|
12
|
+
*/
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { PRESET_CREATE_ROUTE } from "../write-path.js";
|
|
20
|
+
import { buildPresetCreateBody, emitButtonGroupPreset } from "../button-emitter.js";
|
|
21
|
+
import { loadRegistry } from "../registry.js";
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const SERVER_ROOT = join(__dirname, "..", "..", "..");
|
|
24
|
+
test("CLI posts to the same /preset/create route as diviops_preset_create", () => {
|
|
25
|
+
assert.equal(PRESET_CREATE_ROUTE, "/preset/create");
|
|
26
|
+
});
|
|
27
|
+
test("CLI write body keys are a subset of the diviops_preset_create contract", () => {
|
|
28
|
+
// diviops_preset_create accepts: module_name, name, attrs, type,
|
|
29
|
+
// group_name, group_id, primary_attr_name, make_default, priority, dry_run.
|
|
30
|
+
const allowed = new Set([
|
|
31
|
+
"module_name",
|
|
32
|
+
"name",
|
|
33
|
+
"attrs",
|
|
34
|
+
"type",
|
|
35
|
+
"group_name",
|
|
36
|
+
"group_id",
|
|
37
|
+
"primary_attr_name",
|
|
38
|
+
"make_default",
|
|
39
|
+
"priority",
|
|
40
|
+
"dry_run",
|
|
41
|
+
]);
|
|
42
|
+
const entry = emitButtonGroupPreset({ name: "P", bg_color: "#111" }, loadRegistry());
|
|
43
|
+
const body = buildPresetCreateBody(entry, { dry_run: true });
|
|
44
|
+
for (const key of Object.keys(body)) {
|
|
45
|
+
assert.ok(allowed.has(key), `CLI body key "${key}" is not part of the diviops_preset_create contract`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
test("diviops_preset_create registration in src/index.ts is unchanged vs main", () => {
|
|
49
|
+
// Diff src/index.ts against main; the preset_create handler region must
|
|
50
|
+
// not appear in the diff. If `main` is unavailable (detached CI checkout)
|
|
51
|
+
// the test is skipped rather than failing spuriously.
|
|
52
|
+
let diff = "";
|
|
53
|
+
try {
|
|
54
|
+
diff = execFileSync("git", ["diff", "main", "--", "src/index.ts"], { cwd: SERVER_ROOT, encoding: "utf8" });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// git/main not resolvable in this environment — fall back to a
|
|
58
|
+
// presence check that the tool is still registered.
|
|
59
|
+
const src = readFileSync(join(SERVER_ROOT, "src", "index.ts"), "utf8");
|
|
60
|
+
assert.match(src, /"diviops_preset_create"/);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
assert.equal(diff.includes("diviops_preset_create"), false, "src/index.ts diff vs main must not touch the diviops_preset_create tool");
|
|
64
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry-gating coverage: the min() rule, missing-applicability → 0,
|
|
3
|
+
* and threshold refusal with attr + level + source in the error.
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { loadRegistry, resolveEvidence, gateWriteAttr, EvidenceGateError, writeThresholdNumber, } from "../registry.js";
|
|
8
|
+
/** A minimal synthetic registry exercising every branch of the min() rule. */
|
|
9
|
+
function syntheticRegistry() {
|
|
10
|
+
return {
|
|
11
|
+
evidence_level_ordering: {
|
|
12
|
+
UNVERIFIED: 0,
|
|
13
|
+
SCHEMA_OBSERVED: 1,
|
|
14
|
+
RUNTIME_VERIFIED: 2,
|
|
15
|
+
VB_ROUNDTRIP_VERIFIED: 3,
|
|
16
|
+
VB_PRESET_STORAGE_VERIFIED: 4,
|
|
17
|
+
CROSS_VERSION_STABLE: 5,
|
|
18
|
+
},
|
|
19
|
+
tier2: [
|
|
20
|
+
{
|
|
21
|
+
// pattern 4, cell 4 -> effective 4 (clears threshold)
|
|
22
|
+
pattern_family: "synthetic/clears",
|
|
23
|
+
pattern_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
24
|
+
pattern_evidence_source: "docs/synthetic/clears.json",
|
|
25
|
+
applicability: {
|
|
26
|
+
"divi/widget": {
|
|
27
|
+
cell_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
28
|
+
source: "docs/synthetic/clears-cell.json",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
// pattern 4, cell 1 -> effective 1 (min picks the cell, below threshold)
|
|
34
|
+
pattern_family: "synthetic/cell-low",
|
|
35
|
+
pattern_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
36
|
+
pattern_evidence_source: "docs/synthetic/cell-low-pattern.json",
|
|
37
|
+
applicability: {
|
|
38
|
+
"divi/widget": {
|
|
39
|
+
cell_evidence_level: "SCHEMA_OBSERVED",
|
|
40
|
+
source: "docs/synthetic/cell-low-cell.json",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// pattern 1, cell 4 -> effective 1 (min picks the pattern, below threshold)
|
|
46
|
+
pattern_family: "synthetic/pattern-low",
|
|
47
|
+
pattern_evidence_level: "SCHEMA_OBSERVED",
|
|
48
|
+
pattern_evidence_source: "docs/synthetic/pattern-low-pattern.json",
|
|
49
|
+
applicability: {
|
|
50
|
+
"divi/widget": {
|
|
51
|
+
cell_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
52
|
+
source: "docs/synthetic/pattern-low-cell.json",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
// pattern 4, NO applicability cell for divi/widget -> effective 0
|
|
58
|
+
pattern_family: "synthetic/no-cell",
|
|
59
|
+
pattern_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
60
|
+
pattern_evidence_source: "docs/synthetic/no-cell.json",
|
|
61
|
+
applicability: {
|
|
62
|
+
"divi/other": {
|
|
63
|
+
cell_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
64
|
+
source: "docs/synthetic/no-cell-other.json",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
test("min() rule picks the smaller of pattern and cell", () => {
|
|
72
|
+
const reg = syntheticRegistry();
|
|
73
|
+
const clears = resolveEvidence(reg, "divi/widget", "synthetic/clears");
|
|
74
|
+
assert.equal(clears.effectiveLevel, 4);
|
|
75
|
+
assert.equal(clears.effectiveLevelName, "VB_PRESET_STORAGE_VERIFIED");
|
|
76
|
+
const cellLow = resolveEvidence(reg, "divi/widget", "synthetic/cell-low");
|
|
77
|
+
assert.equal(cellLow.patternLevel, 4);
|
|
78
|
+
assert.equal(cellLow.cellLevel, 1);
|
|
79
|
+
assert.equal(cellLow.effectiveLevel, 1, "min picks the cell level");
|
|
80
|
+
const patternLow = resolveEvidence(reg, "divi/widget", "synthetic/pattern-low");
|
|
81
|
+
assert.equal(patternLow.patternLevel, 1);
|
|
82
|
+
assert.equal(patternLow.cellLevel, 4);
|
|
83
|
+
assert.equal(patternLow.effectiveLevel, 1, "min picks the pattern level");
|
|
84
|
+
});
|
|
85
|
+
test("missing applicability[<module>] resolves to UNVERIFIED (0)", () => {
|
|
86
|
+
const reg = syntheticRegistry();
|
|
87
|
+
const r = resolveEvidence(reg, "divi/widget", "synthetic/no-cell");
|
|
88
|
+
assert.equal(r.applicabilityMissing, true);
|
|
89
|
+
assert.equal(r.cellLevel, 0, "no invisible inheritance from the pattern");
|
|
90
|
+
assert.equal(r.effectiveLevel, 0);
|
|
91
|
+
assert.equal(r.effectiveLevelName, "UNVERIFIED");
|
|
92
|
+
});
|
|
93
|
+
test("gateWriteAttr passes when effective evidence clears the threshold", () => {
|
|
94
|
+
const reg = syntheticRegistry();
|
|
95
|
+
const r = gateWriteAttr(reg, "divi/widget", "synthetic/clears");
|
|
96
|
+
assert.equal(r.effectiveLevel, 4);
|
|
97
|
+
});
|
|
98
|
+
test("gateWriteAttr refuses below threshold, naming attr + level + source", () => {
|
|
99
|
+
const reg = syntheticRegistry();
|
|
100
|
+
assert.throws(() => gateWriteAttr(reg, "divi/widget", "synthetic/cell-low"), (err) => {
|
|
101
|
+
assert.ok(err instanceof EvidenceGateError);
|
|
102
|
+
assert.match(err.message, /synthetic\/cell-low/, "names the attr family");
|
|
103
|
+
assert.match(err.message, /SCHEMA_OBSERVED/, "names the effective level");
|
|
104
|
+
assert.match(err.message, /docs\/synthetic\/cell-low-cell\.json/, "names the registry source");
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
test("gateWriteAttr refusal on missing applicability cites UNVERIFIED + absent cell", () => {
|
|
109
|
+
const reg = syntheticRegistry();
|
|
110
|
+
assert.throws(() => gateWriteAttr(reg, "divi/widget", "synthetic/no-cell"), (err) => {
|
|
111
|
+
assert.ok(err instanceof EvidenceGateError);
|
|
112
|
+
assert.match(err.message, /UNVERIFIED \(0\)/);
|
|
113
|
+
assert.match(err.message, /absent from the registry entry/);
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
test("resolveEvidence throws for an entirely-absent pattern family", () => {
|
|
118
|
+
const reg = syntheticRegistry();
|
|
119
|
+
assert.throws(() => resolveEvidence(reg, "divi/widget", "synthetic/does-not-exist"), /absent from verified-attrs\.json/);
|
|
120
|
+
});
|
|
121
|
+
test("real registry: divi/button styling families clear the write threshold", () => {
|
|
122
|
+
const reg = loadRegistry();
|
|
123
|
+
assert.equal(writeThresholdNumber(reg), 4);
|
|
124
|
+
for (const fam of [
|
|
125
|
+
"divi/button.background",
|
|
126
|
+
"divi/button.border",
|
|
127
|
+
"divi/button.font",
|
|
128
|
+
]) {
|
|
129
|
+
const r = resolveEvidence(reg, "divi/button", fam);
|
|
130
|
+
assert.equal(r.effectiveLevel, 4, `${fam} must be at effective level 4 (VB_PRESET_STORAGE_VERIFIED)`);
|
|
131
|
+
// gateWriteAttr must not throw.
|
|
132
|
+
gateWriteAttr(reg, "divi/button", fam);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply-mode coverage — mocked only (no live substrate write, per #725 AC #8).
|
|
3
|
+
*
|
|
4
|
+
* Asserts: the capability gate (storage_multipath_probe_v1 present → proceed,
|
|
5
|
+
* absent → fail fast), the POST route + request body, and credential
|
|
6
|
+
* handling. The HTTP client is a stub; nothing touches the network.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|