pixel-perfect-kit 0.1.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 (53) hide show
  1. package/CLONE-ALOYOGA-HEADER.md +76 -0
  2. package/CLONE-ANY-HEADER.md +106 -0
  3. package/CLONE-ANY-SITE.md +120 -0
  4. package/DEVELOP.md +144 -0
  5. package/LAUNCH-PROMPT.md +66 -0
  6. package/LEARNINGS.md +398 -0
  7. package/LICENSE +21 -0
  8. package/PLAYBOOK.md +260 -0
  9. package/README.md +219 -0
  10. package/WORKFLOW.md +257 -0
  11. package/bin/ppk +4 -0
  12. package/harness/adopt.js +52 -0
  13. package/harness/agent-setup.js +55 -0
  14. package/harness/behavior-selftest.js +243 -0
  15. package/harness/benchmarks/README.md +34 -0
  16. package/harness/benchmarks/battery.js +86 -0
  17. package/harness/benchmarks/detection-power.js +75 -0
  18. package/harness/capture-build-selftest.js +134 -0
  19. package/harness/capture-build.js +323 -0
  20. package/harness/doctor-selftest.js +58 -0
  21. package/harness/doctor.js +85 -0
  22. package/harness/fixtures/01-backdrop-color.js +51 -0
  23. package/harness/fixtures/02-line-strut.js +53 -0
  24. package/harness/fixtures/03-compat-mode.js +44 -0
  25. package/harness/fixtures/README.md +31 -0
  26. package/harness/human-qa-selftest.js +201 -0
  27. package/harness/human-qa.js +406 -0
  28. package/harness/merge-snapshot-selftest.js +56 -0
  29. package/harness/new-target.js +101 -0
  30. package/harness/regression.js +50 -0
  31. package/harness/score.js +58 -0
  32. package/harness/serve.js +149 -0
  33. package/harness/setup-selftest.js +93 -0
  34. package/harness/setup.js +158 -0
  35. package/harness/tunnel-selftest.js +76 -0
  36. package/harness/tunnel.js +241 -0
  37. package/harness/workflow-selftest.js +211 -0
  38. package/harness/workflow.js +739 -0
  39. package/package.json +40 -0
  40. package/skill/ditto-finish/SKILL.md +52 -0
  41. package/skill/pixel-perfect-clone/SKILL.md +49 -0
  42. package/tools/RUNBOOK.md +228 -0
  43. package/tools/behavior-capture.js +307 -0
  44. package/tools/behavior-worksheet.js +82 -0
  45. package/tools/browser-capture.js +240 -0
  46. package/tools/cli-selftest.js +136 -0
  47. package/tools/extract-fonts.js +133 -0
  48. package/tools/extract-icons.js +255 -0
  49. package/tools/merge-snapshot.js +56 -0
  50. package/tools/pixel-diff.js +845 -0
  51. package/tools/reassemble.js +63 -0
  52. package/tools/selftest.js +67 -0
  53. package/tools/sink.js +80 -0
@@ -0,0 +1,31 @@
1
+ # fixtures — one file per class of miss, so it can never come back
2
+
3
+ When cloning a real site surfaces a defect that a **green `--visual` didn't catch**, the
4
+ fix is two-part (see `../../DEVELOP.md` → the miss protocol):
5
+
6
+ 1. Teach the **tool** to measure it (extend `browser-capture.js` + `pixel-diff.js`).
7
+ 2. Add a **fixture here** that fails *without* that tool change — locking it in forever.
8
+
9
+ `node harness/regression.js` runs `tools/selftest.js` + every `*.js` in this folder.
10
+
11
+ ## Template
12
+
13
+ ```js
14
+ // fixtures/NN-short-name.js — <one line: the class of miss, and which target found it>
15
+ const { diffSnapshots } = require("../../tools/pixel-diff.js");
16
+ let bad = 0; const check = (n, c) => { console.log(`${c ? "✓" : "✗"} ${n}`); if (!c) bad++; };
17
+
18
+ // A minimal snapshot pair. The "bad" side reproduces the real defect; assert the gate
19
+ // FAILS on the named property (i.e. the property you just taught it to compare).
20
+ const el = (over = {}) => ({ present: true, rect: {}, font: {}, /* ...the fields that matter... */ ...over });
21
+ const snap = (e) => ({ viewport: { width: 1728 }, elements: { x: e } });
22
+
23
+ const res = diffSnapshots(snap(el()), snap(el({ /* the defect */ })), { visual: true });
24
+ check("gate catches <the miss>", !res.ok && res.rows.some((r) => !r.pass && r.prop === "<the.prop>"));
25
+
26
+ process.exit(bad ? 1 : 0);
27
+ ```
28
+
29
+ Keep fixtures tiny and synthetic (no browser, no network) — they guard the *diff
30
+ engine's guarantees*, not any one site. The underline-box and font-smoothing guarantees
31
+ already live in `tools/selftest.js`; add the *next* lesson here.
@@ -0,0 +1,201 @@
1
+ // harness/human-qa-selftest.js — guards the human phase tooling (harness/human-qa.js).
2
+ // Offline + socket-free: the API is mocked via PPK_PINGHUMANS_URL=file://… (canned
3
+ // get_test_results-<ping>.json / request_human_test.json responses on disk).
4
+ // Asserts the contracts that make the human gate trustworthy:
5
+ // - the generated test spec is SCOPE-PINNED, per-leaf from coverage.json, with the
6
+ // behavior step marked informational (stripe lessons encoded, not tribal)
7
+ // - localhost draft urls are refused (a remote human can't open them)
8
+ // - file → records the round; verify → pending/rejected exit 1 (with the human's flag
9
+ // surfaced), approved exit 0; a REFILE makes verify judge the LATEST round only
10
+ // - verify re-fetches every time (a cached approval is never trusted — the state file
11
+ // is updated from the API on each call)
12
+ "use strict";
13
+
14
+ const fs = require("fs");
15
+ const os = require("os");
16
+ const path = require("path");
17
+ const cp = require("child_process");
18
+
19
+ const KIT = path.resolve(__dirname, "..");
20
+ const HQ = path.join(KIT, "harness", "human-qa.js");
21
+ let failed = 0;
22
+ const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
23
+
24
+ const MOCK = fs.mkdtempSync(path.join(os.tmpdir(), "ppk-hq-"));
25
+ const NAME = "hqselftest_" + process.pid;
26
+ const dir = path.join(KIT, "targets", NAME);
27
+ process.on("exit", () => { try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(MOCK, { recursive: true, force: true }); } catch (e) {} });
28
+ fs.rmSync(dir, { recursive: true, force: true }); // pre-clean a stale target dir only — MOCK was just created
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name: NAME, url: "https://example.com/", width: 1512 }));
31
+ fs.writeFileSync(path.join(dir, "coverage.json"), JSON.stringify(["logo", "nav_product", "nav_pricing", "download_btn", "signin_btn", "locale_caret"]));
32
+
33
+ const run = (args) => { const r = cp.spawnSync(process.execPath, [HQ, ...args], { encoding: "utf8", cwd: KIT, env: { ...process.env, PPK_PINGHUMANS_URL: "file://" + MOCK } }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
34
+
35
+ console.log("human-qa-selftest — the human phase tooling");
36
+
37
+ // ── template: scope-pinned, coverage-driven, behavior informational ───────────
38
+ const t = run(["template", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the top navigation header"]);
39
+ ok(t.code === 0, "template generates");
40
+ const spec = JSON.parse(t.out);
41
+ ok(/ONLY about the top navigation header/.test(spec.steps[0].text), "step 1 scope-pins the comparison region");
42
+ ok(spec.steps.some((s) => /logo, nav product/.test(s.text) && s.options), "per-leaf compare steps generated from coverage.json (slugs → words)");
43
+ ok(spec.steps.filter((s) => /Compare these elements/.test(s.text)).length === 2, "6 leaves chunk into 2 compare steps (≤5 each)");
44
+ ok(spec.steps.some((s) => /INFORMATIONAL/.test(s.text) && /does not affect your verdict/.test(s.text)), "behavior step is informational, never a fail criterion");
45
+ ok(spec.verdict_options.length === 3 && spec.approve_verdicts[0] === spec.verdict_options[0], "approve verdict = first verdict option");
46
+ ok(spec.require_evidence === "screenshot" && spec.url === "https://example.com/", "screenshot required; original url from target.json");
47
+ // 3 of the first 3 real worker responses were comment-only (choice:null) — the verdict pick
48
+ // must be an explicit REQUIRED final step, or the human gate never passes (astryx round 3)
49
+ const lastStep = spec.steps[spec.steps.length - 1];
50
+ ok(/FINAL REQUIRED STEP/.test(lastStep.text) && lastStep.text.includes(spec.verdict_options[0]) && /pick a verdict|comment-only/i.test(spec.instructions), "verdict pick is a REQUIRED final step + demanded in instructions (workers default to comment-only)");
51
+
52
+ // documented deviations are surfaced TO THE REVIEWER (astryx round 3 re-flagged an excused cell)
53
+ // — but only PRIMARY entries: "See <key> — same phenomenon" cross-references are gate
54
+ // bookkeeping, and 14 of them once rendered the whole step as unreadable mush (round 5)
55
+ fs.writeFileSync(path.join(dir, "behavior-deviations.json"), JSON.stringify({
56
+ "mutation:div.bento": { reason: "3 cells mount a nested miniature app preview — excluded" },
57
+ "mutation:_r_7q_": { reason: "See mutation:div.bento — same phenomenon" },
58
+ "mutation:_r_94_": { reason: "See mutation:div.bento — same phenomenon" },
59
+ }));
60
+ const tDev = JSON.parse(run(["template", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header"]).out);
61
+ const devStep = tDev.steps.find((s) => /KNOWN, INTENTIONAL/.test(s.text));
62
+ ok(devStep && /nested miniature app preview/.test(devStep.text) && /should NOT flag/.test(tDev.instructions), "behavior-deviations.json entries appear as a do-not-flag step + in instructions");
63
+ ok(devStep && !/_r_7q_|_r_94_|See mutation/.test(devStep.text), "cross-reference deviation entries are hidden from the reviewer-facing step (primaries only)");
64
+ fs.rmSync(path.join(dir, "behavior-deviations.json"));
65
+
66
+ // A LARGE number of primary deviation entries (iphone17 round 5 hit 34) must never blow
67
+ // `instructions` past PingHumans' hard cap (observed: 1000 chars) — instructions gets a
68
+ // short, FIXED-SIZE pointer to the dedicated step regardless of entry count; only the
69
+ // step itself (already 300-char-budgeted per entry) scales with count. Before the fix,
70
+ // `instructions` duplicated the full per-entry list and grew unboundedly with entry count,
71
+ // silently exceeding 1000 chars past ~15 entries and failing the whole filing.
72
+ const manyDev = {};
73
+ for (let i = 0; i < 40; i++) manyDev[`mutation:item_${i}`] = { reason: `Deviation number ${i} — a reasonably detailed excuse explaining why this one is fine to skip and not worth flagging in the review.` };
74
+ fs.writeFileSync(path.join(dir, "behavior-deviations.json"), JSON.stringify(manyDev));
75
+ const tManyDev = JSON.parse(run(["template", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header"]).out);
76
+ ok(tManyDev.instructions.length <= 1000, `instructions stays under the 1000-char API cap with 40 deviation entries (got ${tManyDev.instructions.length})`);
77
+ ok(/should NOT flag/.test(tManyDev.instructions), "instructions still points the reviewer at the do-not-flag step with 40 deviation entries");
78
+ fs.rmSync(path.join(dir, "behavior-deviations.json"));
79
+
80
+ // --changelog surfaces "what changed since your last review" up front (iphone17 round 10's
81
+ // verdict was "did you fix anything?" — the substantive change was invisible untold)
82
+ const tChg = JSON.parse(run(["template", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header", "--changelog", "the camera intro now scrubs with scroll — scroll slowly through it"]).out);
83
+ ok(/CHANGED SINCE YOUR LAST REVIEW: the camera intro now scrubs/.test(tChg.instructions) && /check these first/.test(tChg.steps[1].text), "--changelog lands in instructions AND as the second step");
84
+ const tNoChg = JSON.parse(run(["template", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header"]).out);
85
+ ok(!/CHANGED SINCE/.test(tNoChg.instructions), "no --changelog → no change note (first rounds stay clean)");
86
+ // the changelog STEP must never exceed the 300-char API cap (52-prefix + slice(250) = 302
87
+ // failed a live filing — iphone17 round 13)
88
+ const tLongChg = JSON.parse(run(["template", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header", "--changelog", "x".repeat(400)]).out);
89
+ const chgStep = tLongChg.steps.find((s) => /check these first/.test(s.text));
90
+ ok(chgStep && chgStep.text.length <= 300, `changelog step stays within the 300-char cap (got ${chgStep ? chgStep.text.length : "none"})`);
91
+
92
+ // localhost drafts refused
93
+ ok(run(["template", NAME, "--draft", "http://localhost:8199"]).code === 1, "localhost draft url refused (remote humans can't open it)");
94
+
95
+ // ── file: records a round via the (mocked) API ───────────────────────────────
96
+ const PING1 = "00000000-0000-4000-8000-0000000000a1";
97
+ fs.writeFileSync(path.join(MOCK, "request_human_test.json"), JSON.stringify({ ping_id: PING1, status: "pending" }));
98
+ const f = run(["file", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header"]);
99
+ ok(f.code === 0 && f.out.includes(PING1), "file records round 1 with the returned ping_id");
100
+ const hq1 = JSON.parse(fs.readFileSync(path.join(dir, "human-qa.json"), "utf8"));
101
+ ok(hq1.rounds.length === 1 && hq1.rounds[0].ping_id === PING1 && hq1.rounds[0].approve_verdicts.length === 1, "human-qa.json round 1 shape correct");
102
+
103
+ // ── verify: pending → 1, comment-only → 1, rejected → 1 with flag, approved → 0 ──
104
+ // Mocks use the REAL PingHumans response schema (verified empirically 2026-07-02):
105
+ // the verdict pick is `choice`, prose is `free_text`. (workflow-selftest's mocks use
106
+ // the legacy `verdict`/`notes` keys, covering the fallback mapping.)
107
+ fs.writeFileSync(path.join(MOCK, `get_test_results-${PING1}.json`), JSON.stringify({ status: "pending", n_received: 0, n_target: 1, responses: [] }));
108
+ ok(run(["verify", NAME]).code === 1, "verify exits 1 while pending");
109
+ // comment-only response (choice:null) — approval must NEVER be inferred from prose,
110
+ // even prose that contains the approve verdict verbatim (paid for on opendesign round 2)
111
+ fs.writeFileSync(path.join(MOCK, `get_test_results-${PING1}.json`), JSON.stringify({ status: "complete", n_received: 1, n_target: 1, responses: [{ choice: null, free_text: "1 comment(s): <div> — Header identical" }] }));
112
+ const unpicked = run(["verify", NAME]);
113
+ ok(unpicked.code === 1 && /NO verdict pick/.test(unpicked.out) && /Header identical/.test(unpicked.out), "a comment-only response (choice:null) fails the gate even when the prose says the approve verdict");
114
+ fs.writeFileSync(path.join(MOCK, `get_test_results-${PING1}.json`), JSON.stringify({ status: "complete", n_received: 1, n_target: 1, responses: [{ choice: "Header clearly different", free_text: "sign in button color off" }] }));
115
+ const rej = run(["verify", NAME]);
116
+ ok(rej.code === 1 && /sign in button color off/.test(rej.out), "verify exits 1 on rejection and surfaces the human's notes (the fix list)");
117
+ fs.writeFileSync(path.join(MOCK, `get_test_results-${PING1}.json`), JSON.stringify({ status: "complete", n_received: 1, n_target: 1, responses: [{ choice: "Header identical", free_text: null }] }));
118
+ ok(run(["verify", NAME]).code === 0, "verify exits 0 on approval");
119
+ const cached = JSON.parse(fs.readFileSync(path.join(dir, "human-qa.json"), "utf8"));
120
+ ok(cached.rounds[0].last.responses[0].verdict === "Header identical" && cached.rounds[0].checked_at, "verify caches the fetched result + timestamp (receipt content)");
121
+
122
+ // ── refile: verify judges the LATEST round, not a stale approval ─────────────
123
+ const PING2 = "00000000-0000-4000-8000-0000000000a2";
124
+ fs.writeFileSync(path.join(MOCK, "request_human_test.json"), JSON.stringify({ ping_id: PING2, status: "pending" }));
125
+ ok(run(["file", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header"]).code === 0, "refile records round 2");
126
+ fs.writeFileSync(path.join(MOCK, `get_test_results-${PING2}.json`), JSON.stringify({ status: "pending", n_received: 0, n_target: 1, responses: [] }));
127
+ ok(run(["verify", NAME]).code === 1, "after a refile, verify judges round 2 — round 1's approval no longer passes");
128
+
129
+ // ── record: adopting an externally-filed ping requires the approve verdicts ──
130
+ ok(run(["record", NAME, PING2]).code === 2, "record without --approve refused (approval set must be explicit)");
131
+ ok(run(["record", NAME, "not-a-uuid", "--approve", "Pass"]).code === 2, "record with a malformed ping_id refused");
132
+
133
+ // ── expired round is a refile, not a pass ─────────────────────────────────────
134
+ fs.writeFileSync(path.join(MOCK, `get_test_results-${PING2}.json`), JSON.stringify({ status: "expired", n_received: 0, n_target: 1, responses: [] }));
135
+ const exp = run(["verify", NAME]);
136
+ ok(exp.code === 1 && /EXPIRED/.test(exp.out), "an expired unanswered round fails verify with a refile hint");
137
+
138
+ // ── poll: mid-round micro-checks — advisory, recorded, never a gate input ────
139
+ {
140
+ fs.writeFileSync(path.join(MOCK, "ping_humans.json"), JSON.stringify({ ping_id: "00000000-0000-4000-8000-00000000p0ll", status: "complete", n_received: 1, n_target: 1, responses: [{ choice: "Yes", free_text: "tiles look right now" }] }));
141
+ const p1 = run(["poll", NAME, "do the 3 tiles look right now?", "--choices", "Yes,No"]);
142
+ ok(p1.code === 0 && /\[Yes\]/.test(p1.out) && /tiles look right now/.test(p1.out), "poll prints the human's answer and exits 0 when answered");
143
+ const hqAfter = JSON.parse(fs.readFileSync(path.join(dir, "human-qa.json"), "utf8"));
144
+ ok(hqAfter.polls && hqAfter.polls.length === 1 && hqAfter.polls[0].question.startsWith("do the 3 tiles"), "poll recorded in human-qa.json (audit trail), separate from rounds");
145
+ ok(hqAfter.rounds.length === 2, "polls do NOT create rounds — the human gate still judges full rounds only");
146
+ fs.writeFileSync(path.join(MOCK, "ping_humans.json"), JSON.stringify({ ping_id: "00000000-0000-4000-8000-00000000pend", status: "pending", n_received: 0, n_target: 1, responses: [] }));
147
+ const p2 = run(["poll", NAME, "second question"]);
148
+ ok(p2.code === 1 && /0 answers yet/.test(p2.out) && /poll-result/.test(p2.out), "unanswered poll exits 1 with the free re-check command");
149
+ fs.writeFileSync(path.join(MOCK, "get_ping-00000000-0000-4000-8000-00000000pend.json"), JSON.stringify({ status: "complete", n_received: 1, responses: [{ choice: null, free_text: "late answer" }] }));
150
+ const p3 = run(["poll-result", NAME, "00000000-0000-4000-8000-00000000pend"]);
151
+ ok(p3.code === 0 && /late answer/.test(p3.out), "poll-result picks up a late answer and exits 0");
152
+
153
+ // comparison-shaped questions are REFUSED from the poll channel (reviewer flagged twice:
154
+ // "does the clone match the real page" needs the side-by-side UI, not a text ping)
155
+ const cmp = run(["poll", NAME, "Do the highlight cards on the clone scroll and auto-advance like the real page?"]);
156
+ ok(cmp.code === 1 && /COMPARISON/.test(cmp.out) && /file --region|--region/.test(cmp.out), "a question naming both the clone and the live page is refused with the file --region hint");
157
+ const oneSided = run(["poll", NAME, "On the real page, does the camera video scrub as you scroll?"]);
158
+ ok(oneSided.code === 0 || /pending|0 answers/.test(oneSided.out), "a one-sided live-observation question still polls fine");
159
+ const overridden = run(["poll", NAME, "Compare our draft against the original page price note — acceptable?", "--allow-comparison"]);
160
+ ok(overridden.code === 0 || /pending|0 answers/.test(overridden.out), "--allow-comparison consciously overrides the refusal");
161
+ }
162
+
163
+ // ── LOCAL review mode: file --local → serve's submission → verify, no network at all ──
164
+ {
165
+ const { applySubmission } = require("./serve.js");
166
+ const fLocal = run(["file", NAME, "--local", "--region", "the header"]);
167
+ ok(fLocal.code === 0 && /LOCAL review/.test(fLocal.out) && /__review/.test(fLocal.out) && /operator-trusted/.test(fLocal.out), "file --local records a local round (no draft, no tunnel, no API) and states the trust model");
168
+ const hqL = JSON.parse(fs.readFileSync(path.join(dir, "human-qa.json"), "utf8"));
169
+ const lr = hqL.rounds[hqL.rounds.length - 1];
170
+ ok(lr.provider === "local" && /^local-/.test(lr.ping_id) && lr.spec && lr.spec.steps.length > 0, "local round carries provider:local + the full spec for the review page");
171
+ ok(run(["verify", NAME]).code === 1, "verify: local round pending until the review page submits");
172
+ // comment-only submission → recorded, but the gate still refuses (same contract as marketplace)
173
+ ok(applySubmission(hqL, { comments: [{ selector: "#logo", text: "sits low" }] }).ok, "review page accepts a submission");
174
+ fs.writeFileSync(path.join(dir, "human-qa.json"), JSON.stringify(hqL, null, 2));
175
+ const vCommentOnly = run(["verify", NAME]);
176
+ ok(vCommentOnly.code === 1 && /NO verdict pick/.test(vCommentOnly.out) && /sits low/.test(vCommentOnly.out), "a comment-only local review is refused exactly like a marketplace one");
177
+ // approving submission on a FRESH round → verify passes, marked operator-trusted
178
+ run(["file", NAME, "--local", "--region", "the header"]);
179
+ const hqL2 = JSON.parse(fs.readFileSync(path.join(dir, "human-qa.json"), "utf8"));
180
+ ok(applySubmission(hqL2, { choice: hqL2.rounds[hqL2.rounds.length - 1].approve_verdicts[0], free_text: "looks right" }).ok, "approving submission accepted");
181
+ ok(!applySubmission(hqL2, { choice: "again" }).ok, "double-submission on an answered round refused");
182
+ fs.writeFileSync(path.join(dir, "human-qa.json"), JSON.stringify(hqL2, null, 2));
183
+ const vOk = run(["verify", NAME]);
184
+ ok(vOk.code === 0 && /LOCAL review — operator-trusted/.test(vOk.out), "verify approves the local round and the output carries the operator-trusted marker");
185
+ }
186
+
187
+ // ── filing while pre-human gates are red is refused (astryx round-2 process miss) ──
188
+ // A workflow.json whose phases predate `behavior` counts the missing key as PENDING —
189
+ // exactly the astryx state when the premature round was filed.
190
+ fs.writeFileSync(path.join(dir, "workflow.json"), JSON.stringify({
191
+ name: NAME, phaseOrder: ["target", "assets", "measure", "build", "visual", "coverage", "strict", "human", "done"],
192
+ phases: Object.fromEntries(["target", "assets", "measure", "build", "visual", "coverage", "strict"].map((k) => [k, { status: "pass" }])),
193
+ }));
194
+ const premature = run(["file", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header"]);
195
+ ok(premature.code === 1 && /behavior/.test(premature.out) && /refusing to file/.test(premature.out), "file refuses while a pre-human phase is pending — names it (missing key = pending, not exempt)");
196
+ const anyway = run(["file", NAME, "--draft", "https://x.trycloudflare.com", "--region", "the header", "--anyway"]);
197
+ ok(anyway.code === 0 && /--anyway/.test(anyway.out), "--anyway overrides with a printed warning (deliberate out-of-band round)");
198
+ fs.rmSync(path.join(dir, "workflow.json")); // standalone usage (no workflow.json) keeps filing freely — proven by every earlier case above
199
+
200
+ console.log(failed ? `\n❌ human-qa-selftest: ${failed} assertion(s) failed.` : `\n✓ human-qa-selftest: all assertions pass.`);
201
+ process.exit(failed ? 1 : 0);