@diviops/mcp-server 1.5.10 → 1.5.12
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 +34 -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 +330 -0
- package/dist/preset-cli/__tests__/heading-font-emitter.test.d.ts +12 -0
- package/dist/preset-cli/__tests__/heading-font-emitter.test.js +249 -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 +149 -0
- package/dist/preset-cli/__tests__/write-path.test.d.ts +8 -0
- package/dist/preset-cli/__tests__/write-path.test.js +174 -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 +62 -0
- package/dist/preset-cli/cli.js +429 -0
- package/dist/preset-cli/heading-font-emitter.d.ts +128 -0
- package/dist/preset-cli/heading-font-emitter.js +166 -0
- package/dist/preset-cli/registry.d.ts +121 -0
- package/dist/preset-cli/registry.js +192 -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 +74 -0
- package/dist/preset-cli/write-path.js +110 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -80,6 +80,40 @@ 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. Current commands:
|
|
89
|
+
|
|
90
|
+
| Command | Emits |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `diviops-preset button [options]` | `divi/button` group preset |
|
|
93
|
+
| `diviops-preset heading-font [options]` | `divi/font` group preset for `divi/heading` (Pattern A — Google Fonts — or Pattern B — local-hosted) |
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
diviops-preset button --name "Primary" --bg-color gcid-primary-color \
|
|
97
|
+
--bg-color-hover gcid-secondary-color --radius 8px \
|
|
98
|
+
--font-family Inter --font-weight 600 --font-color gcid-body-color
|
|
99
|
+
|
|
100
|
+
diviops-preset heading-font --name "Heading H1" --pattern google \
|
|
101
|
+
--font-family Inter --font-weight 700 \
|
|
102
|
+
--font-color gcid-heading-color --font-size 48px
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--dry-run` (the default) composes and prints the canonical JSON with no
|
|
106
|
+
credentials and no network. `--apply` posts to the existing `/preset/create`
|
|
107
|
+
REST route, reusing the same `WP_URL` / `WP_USER` / `WP_APP_PASSWORD` env vars.
|
|
108
|
+
|
|
109
|
+
The CLI's coverage is intentionally narrow: only the (module, group, variant)
|
|
110
|
+
combinations whose canonical shape is VB-verified in the registry are
|
|
111
|
+
emittable. It is **not** an all-module or all-font-family emitter — each
|
|
112
|
+
additional vertical slice lands with its own verified evidence. See the
|
|
113
|
+
[preset-cli reference](https://github.com/oaris-dev/diviops/blob/main/diviops-server/src/preset-cli/README.md)
|
|
114
|
+
for the full command reference (the `src/` tree is not part of the published
|
|
115
|
+
npm package — this link resolves on the repository).
|
|
116
|
+
|
|
83
117
|
## Response contract
|
|
84
118
|
|
|
85
119
|
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,330 @@
|
|
|
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("--help advertises the heading-font command", async () => {
|
|
43
|
+
const io = capture();
|
|
44
|
+
const code = await run(["--help"], io);
|
|
45
|
+
assert.equal(code, EXIT.OK);
|
|
46
|
+
const help = io.stdout.join("\n");
|
|
47
|
+
assert.match(help, /heading-font/);
|
|
48
|
+
assert.match(help, /--pattern <google\|local>/);
|
|
49
|
+
});
|
|
50
|
+
test("unknown flag exits 1 (invalid input)", async () => {
|
|
51
|
+
const io = capture();
|
|
52
|
+
const code = await run(["button", "--name", "X", "--bogus", "y"], io);
|
|
53
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
54
|
+
assert.match(io.stderr.join("\n"), /Unknown flag/);
|
|
55
|
+
});
|
|
56
|
+
test("button without --name exits 1", async () => {
|
|
57
|
+
const io = capture();
|
|
58
|
+
const code = await run(["button", "--bg-color", "#111"], io);
|
|
59
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
60
|
+
assert.match(io.stderr.join("\n"), /requires --name/);
|
|
61
|
+
});
|
|
62
|
+
test("dry-run is the default and prints canonical JSON, exit 0", async () => {
|
|
63
|
+
const io = capture();
|
|
64
|
+
const code = await run(["button", "--name", "Primary", "--bg-color", "gcid-primary-color"], io);
|
|
65
|
+
assert.equal(code, EXIT.OK);
|
|
66
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
67
|
+
assert.equal(parsed.type, "group");
|
|
68
|
+
assert.equal(parsed.dry_run, true);
|
|
69
|
+
assert.equal(parsed.module_name, "divi/button");
|
|
70
|
+
assert.equal(parsed.attrs.button.decoration.background.desktop.value.color, '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$');
|
|
71
|
+
});
|
|
72
|
+
test("explicit --dry-run behaves the same as the default", async () => {
|
|
73
|
+
const io = capture();
|
|
74
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111", "--dry-run"], io);
|
|
75
|
+
assert.equal(code, EXIT.OK);
|
|
76
|
+
assert.equal(JSON.parse(io.stdout.join("\n")).dry_run, true);
|
|
77
|
+
});
|
|
78
|
+
test("dry-run requires no credentials and no network", async () => {
|
|
79
|
+
// No WP_* env vars set in this assertion's expectation: a clean dry-run
|
|
80
|
+
// run must not throw a CredentialsMissingError.
|
|
81
|
+
const io = capture();
|
|
82
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111"], io);
|
|
83
|
+
assert.equal(code, EXIT.OK);
|
|
84
|
+
assert.equal(io.stderr.length, 0, "no error output on a credential-free dry-run");
|
|
85
|
+
});
|
|
86
|
+
test("--apply without credentials exits 1 with a credentials hint", async () => {
|
|
87
|
+
const saved = {
|
|
88
|
+
WP_URL: process.env.WP_URL,
|
|
89
|
+
WP_USER: process.env.WP_USER,
|
|
90
|
+
WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
|
|
91
|
+
};
|
|
92
|
+
delete process.env.WP_URL;
|
|
93
|
+
delete process.env.WP_USER;
|
|
94
|
+
delete process.env.WP_APP_PASSWORD;
|
|
95
|
+
try {
|
|
96
|
+
const io = capture();
|
|
97
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111", "--apply"], io);
|
|
98
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
99
|
+
assert.match(io.stderr.join("\n"), /requires WordPress credentials/);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (saved.WP_URL !== undefined)
|
|
103
|
+
process.env.WP_URL = saved.WP_URL;
|
|
104
|
+
if (saved.WP_USER !== undefined)
|
|
105
|
+
process.env.WP_USER = saved.WP_USER;
|
|
106
|
+
if (saved.WP_APP_PASSWORD !== undefined)
|
|
107
|
+
process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
test("--apply and --dry-run together exit 1 (mutually exclusive)", async () => {
|
|
111
|
+
const io = capture();
|
|
112
|
+
const code = await run(["button", "--name", "P", "--apply", "--dry-run"], io);
|
|
113
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
114
|
+
assert.match(io.stderr.join("\n"), /mutually exclusive/);
|
|
115
|
+
});
|
|
116
|
+
test("parseArgs: --radius shorthand and per-corner overrides", () => {
|
|
117
|
+
const parsed = parseArgs([
|
|
118
|
+
"button",
|
|
119
|
+
"--name",
|
|
120
|
+
"P",
|
|
121
|
+
"--radius",
|
|
122
|
+
"8px",
|
|
123
|
+
"--radius-top-left",
|
|
124
|
+
"12px",
|
|
125
|
+
]);
|
|
126
|
+
const input = buildButtonInput(parsed);
|
|
127
|
+
assert.deepEqual(input.radius, {
|
|
128
|
+
topLeft: "12px",
|
|
129
|
+
topRight: "8px",
|
|
130
|
+
bottomLeft: "8px",
|
|
131
|
+
bottomRight: "8px",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
test("parseArgs: --bypass-hover-padding-gate sets the opt-in flag", () => {
|
|
135
|
+
const parsed = parseArgs(["button", "--name", "P", "--bypass-hover-padding-gate"]);
|
|
136
|
+
const input = buildButtonInput(parsed);
|
|
137
|
+
assert.equal(input.bypass_hover_padding_gate, true);
|
|
138
|
+
});
|
|
139
|
+
test("parseArgs: value flag without a value throws UsageError", () => {
|
|
140
|
+
assert.throws(() => parseArgs(["button", "--name"]), (err) => {
|
|
141
|
+
assert.ok(err instanceof UsageError);
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
test("parseArgs: --radius-sync rejects values outside on|off", () => {
|
|
146
|
+
const parsed = parseArgs(["button", "--name", "P", "--radius-sync", "maybe"]);
|
|
147
|
+
assert.throws(() => buildButtonInput(parsed), /radius-sync must be/);
|
|
148
|
+
});
|
|
149
|
+
// ------------------------------------------------------------------
|
|
150
|
+
// heading-font command — CLI integration (parse → emit → dry-run JSON)
|
|
151
|
+
// ------------------------------------------------------------------
|
|
152
|
+
test("heading-font dry-run (Pattern A) emits canonical JSON, exit 0", async () => {
|
|
153
|
+
const io = capture();
|
|
154
|
+
const code = await run([
|
|
155
|
+
"heading-font",
|
|
156
|
+
"--name",
|
|
157
|
+
"H1",
|
|
158
|
+
"--pattern",
|
|
159
|
+
"google",
|
|
160
|
+
"--font-family",
|
|
161
|
+
"Inter",
|
|
162
|
+
"--font-weight",
|
|
163
|
+
"700",
|
|
164
|
+
"--font-color",
|
|
165
|
+
"gcid-heading-color",
|
|
166
|
+
"--font-size",
|
|
167
|
+
"48px",
|
|
168
|
+
], io);
|
|
169
|
+
assert.equal(code, EXIT.OK);
|
|
170
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
171
|
+
assert.equal(parsed.type, "group");
|
|
172
|
+
assert.equal(parsed.dry_run, true);
|
|
173
|
+
assert.equal(parsed.module_name, "divi/heading");
|
|
174
|
+
assert.equal(parsed.group_name, "divi/font");
|
|
175
|
+
assert.equal(parsed.group_id, "designTitleText");
|
|
176
|
+
const value = parsed.attrs.title.decoration.font.font.desktop.value;
|
|
177
|
+
assert.equal(value.family, "Inter");
|
|
178
|
+
assert.equal(value.weight, "700");
|
|
179
|
+
assert.equal(value.size, "48px");
|
|
180
|
+
assert.equal(value.color, '$variable({"type":"color","value":{"name":"gcid-heading-color","settings":{}}})$');
|
|
181
|
+
});
|
|
182
|
+
test("heading-font dry-run (Pattern B) emits no `weight` key", async () => {
|
|
183
|
+
const io = capture();
|
|
184
|
+
const code = await run([
|
|
185
|
+
"heading-font",
|
|
186
|
+
"--name",
|
|
187
|
+
"H1-local",
|
|
188
|
+
"--pattern",
|
|
189
|
+
"local",
|
|
190
|
+
"--font-family",
|
|
191
|
+
"Sora 700",
|
|
192
|
+
"--font-color",
|
|
193
|
+
"gcid-heading-color",
|
|
194
|
+
"--font-size",
|
|
195
|
+
"48px",
|
|
196
|
+
], io);
|
|
197
|
+
assert.equal(code, EXIT.OK);
|
|
198
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
199
|
+
const value = parsed.attrs.title.decoration.font.font.desktop.value;
|
|
200
|
+
assert.equal(value.family, "Sora 700");
|
|
201
|
+
assert.equal("weight" in value, false, "Pattern B emits no weight key");
|
|
202
|
+
});
|
|
203
|
+
test("heading-font without --pattern exits 1 (invalid input)", async () => {
|
|
204
|
+
const io = capture();
|
|
205
|
+
const code = await run([
|
|
206
|
+
"heading-font",
|
|
207
|
+
"--name",
|
|
208
|
+
"H1",
|
|
209
|
+
"--font-family",
|
|
210
|
+
"Inter",
|
|
211
|
+
"--font-weight",
|
|
212
|
+
"700",
|
|
213
|
+
], io);
|
|
214
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
215
|
+
assert.match(io.stderr.join("\n"), /--pattern/);
|
|
216
|
+
assert.match(io.stderr.join("\n"), /google\|local/);
|
|
217
|
+
});
|
|
218
|
+
test("heading-font without --name exits 1", async () => {
|
|
219
|
+
const io = capture();
|
|
220
|
+
const code = await run(["heading-font", "--pattern", "google", "--font-family", "Inter"], io);
|
|
221
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
222
|
+
assert.match(io.stderr.join("\n"), /requires --name/);
|
|
223
|
+
});
|
|
224
|
+
test("heading-font --pattern local + --font-weight is refused (exit 1)", async () => {
|
|
225
|
+
const io = capture();
|
|
226
|
+
const code = await run([
|
|
227
|
+
"heading-font",
|
|
228
|
+
"--name",
|
|
229
|
+
"H1-bad",
|
|
230
|
+
"--pattern",
|
|
231
|
+
"local",
|
|
232
|
+
"--font-family",
|
|
233
|
+
"Sora 700",
|
|
234
|
+
"--font-weight",
|
|
235
|
+
"700",
|
|
236
|
+
], io);
|
|
237
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
238
|
+
assert.match(io.stderr.join("\n"), /Pattern B/);
|
|
239
|
+
});
|
|
240
|
+
test("heading-font --pattern with an invalid value exits 1", async () => {
|
|
241
|
+
const io = capture();
|
|
242
|
+
const code = await run([
|
|
243
|
+
"heading-font",
|
|
244
|
+
"--name",
|
|
245
|
+
"H1",
|
|
246
|
+
"--pattern",
|
|
247
|
+
"auto",
|
|
248
|
+
"--font-family",
|
|
249
|
+
"Inter",
|
|
250
|
+
], io);
|
|
251
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
252
|
+
assert.match(io.stderr.join("\n"), /--pattern must be/);
|
|
253
|
+
});
|
|
254
|
+
test("heading-font dry-run requires no credentials and no network", async () => {
|
|
255
|
+
// Mirrors the dry-run-no-creds button assertion: a heading-font dry-run
|
|
256
|
+
// must not throw a CredentialsMissingError. Exercises AC: --apply is
|
|
257
|
+
// the only path that touches credentials/handshake/network.
|
|
258
|
+
const saved = {
|
|
259
|
+
WP_URL: process.env.WP_URL,
|
|
260
|
+
WP_USER: process.env.WP_USER,
|
|
261
|
+
WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
|
|
262
|
+
};
|
|
263
|
+
delete process.env.WP_URL;
|
|
264
|
+
delete process.env.WP_USER;
|
|
265
|
+
delete process.env.WP_APP_PASSWORD;
|
|
266
|
+
try {
|
|
267
|
+
const io = capture();
|
|
268
|
+
const code = await run([
|
|
269
|
+
"heading-font",
|
|
270
|
+
"--name",
|
|
271
|
+
"H1",
|
|
272
|
+
"--pattern",
|
|
273
|
+
"google",
|
|
274
|
+
"--font-family",
|
|
275
|
+
"Inter",
|
|
276
|
+
], io);
|
|
277
|
+
assert.equal(code, EXIT.OK);
|
|
278
|
+
assert.equal(io.stderr.length, 0, "no error output on credential-free dry-run");
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
if (saved.WP_URL !== undefined)
|
|
282
|
+
process.env.WP_URL = saved.WP_URL;
|
|
283
|
+
if (saved.WP_USER !== undefined)
|
|
284
|
+
process.env.WP_USER = saved.WP_USER;
|
|
285
|
+
if (saved.WP_APP_PASSWORD !== undefined)
|
|
286
|
+
process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
test("heading-font --apply without credentials exits 1 with a credentials hint", async () => {
|
|
290
|
+
const saved = {
|
|
291
|
+
WP_URL: process.env.WP_URL,
|
|
292
|
+
WP_USER: process.env.WP_USER,
|
|
293
|
+
WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
|
|
294
|
+
};
|
|
295
|
+
delete process.env.WP_URL;
|
|
296
|
+
delete process.env.WP_USER;
|
|
297
|
+
delete process.env.WP_APP_PASSWORD;
|
|
298
|
+
try {
|
|
299
|
+
const io = capture();
|
|
300
|
+
const code = await run([
|
|
301
|
+
"heading-font",
|
|
302
|
+
"--name",
|
|
303
|
+
"H1",
|
|
304
|
+
"--pattern",
|
|
305
|
+
"google",
|
|
306
|
+
"--font-family",
|
|
307
|
+
"Inter",
|
|
308
|
+
"--apply",
|
|
309
|
+
], io);
|
|
310
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
311
|
+
assert.match(io.stderr.join("\n"), /requires WordPress credentials/);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
if (saved.WP_URL !== undefined)
|
|
315
|
+
process.env.WP_URL = saved.WP_URL;
|
|
316
|
+
if (saved.WP_USER !== undefined)
|
|
317
|
+
process.env.WP_USER = saved.WP_USER;
|
|
318
|
+
if (saved.WP_APP_PASSWORD !== undefined)
|
|
319
|
+
process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
test("dry-run output includes the bypass corner when requested", async () => {
|
|
323
|
+
const io = capture();
|
|
324
|
+
const code = await run(["button", "--name", "P", "--bg-color", "#111", "--bypass-hover-padding-gate"], io);
|
|
325
|
+
assert.equal(code, EXIT.OK);
|
|
326
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
327
|
+
assert.deepEqual(parsed.attrs.button.decoration.button, {
|
|
328
|
+
desktop: { value: { padding: { top: "0px" } } },
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `divi/font` heading emitter shape + gating coverage:
|
|
3
|
+
* - fixture-based shape assertion against round-1a (Pattern A) and
|
|
4
|
+
* round-1b (Pattern B) canonical captures;
|
|
5
|
+
* - Pattern B no-weight discriminator: omitted `weight` produces no key;
|
|
6
|
+
* - Pattern B + explicit weight refused as unverified/out-of-scope;
|
|
7
|
+
* - variant-aware registry gating: missing applicability or under-
|
|
8
|
+
* verified evidence on the chosen variant throws with attr / pattern /
|
|
9
|
+
* effective-level / source in the message; Pattern A evidence does NOT
|
|
10
|
+
* vouch for Pattern B and vice versa.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|