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.
- package/CLONE-ALOYOGA-HEADER.md +76 -0
- package/CLONE-ANY-HEADER.md +106 -0
- package/CLONE-ANY-SITE.md +120 -0
- package/DEVELOP.md +144 -0
- package/LAUNCH-PROMPT.md +66 -0
- package/LEARNINGS.md +398 -0
- package/LICENSE +21 -0
- package/PLAYBOOK.md +260 -0
- package/README.md +219 -0
- package/WORKFLOW.md +257 -0
- package/bin/ppk +4 -0
- package/harness/adopt.js +52 -0
- package/harness/agent-setup.js +55 -0
- package/harness/behavior-selftest.js +243 -0
- package/harness/benchmarks/README.md +34 -0
- package/harness/benchmarks/battery.js +86 -0
- package/harness/benchmarks/detection-power.js +75 -0
- package/harness/capture-build-selftest.js +134 -0
- package/harness/capture-build.js +323 -0
- package/harness/doctor-selftest.js +58 -0
- package/harness/doctor.js +85 -0
- package/harness/fixtures/01-backdrop-color.js +51 -0
- package/harness/fixtures/02-line-strut.js +53 -0
- package/harness/fixtures/03-compat-mode.js +44 -0
- package/harness/fixtures/README.md +31 -0
- package/harness/human-qa-selftest.js +201 -0
- package/harness/human-qa.js +406 -0
- package/harness/merge-snapshot-selftest.js +56 -0
- package/harness/new-target.js +101 -0
- package/harness/regression.js +50 -0
- package/harness/score.js +58 -0
- package/harness/serve.js +149 -0
- package/harness/setup-selftest.js +93 -0
- package/harness/setup.js +158 -0
- package/harness/tunnel-selftest.js +76 -0
- package/harness/tunnel.js +241 -0
- package/harness/workflow-selftest.js +211 -0
- package/harness/workflow.js +739 -0
- package/package.json +40 -0
- package/skill/ditto-finish/SKILL.md +52 -0
- package/skill/pixel-perfect-clone/SKILL.md +49 -0
- package/tools/RUNBOOK.md +228 -0
- package/tools/behavior-capture.js +307 -0
- package/tools/behavior-worksheet.js +82 -0
- package/tools/browser-capture.js +240 -0
- package/tools/cli-selftest.js +136 -0
- package/tools/extract-fonts.js +133 -0
- package/tools/extract-icons.js +255 -0
- package/tools/merge-snapshot.js +56 -0
- package/tools/pixel-diff.js +845 -0
- package/tools/reassemble.js +63 -0
- package/tools/selftest.js +67 -0
- package/tools/sink.js +80 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// harness/tunnel.js <name> [port=8080] [--check] — a public HTTPS tunnel for the clone,
|
|
2
|
+
// VERIFIED to be serving it before it is ever handed to a human.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. The human phase needs a PUBLIC url (a remote PingHumans worker opens
|
|
5
|
+
// it; localhost is unreachable), and a test filed against a dead or wrong tunnel burns a
|
|
6
|
+
// whole human round — that was hn's QA round 1 verbatim: "(clone unreachable — tunnel
|
|
7
|
+
// died)". So this tool treats "the tunnel serves the clone" as a gate-shaped fact, not an
|
|
8
|
+
// assumption: it spawns cloudflared, parses the public url, FETCHES it, and byte-compares
|
|
9
|
+
// the response to targets/<name>/clone/index.html. Only a verified tunnel is recorded to
|
|
10
|
+
// targets/<name>/tunnel.json — which human-qa.js then uses as the default --draft, and
|
|
11
|
+
// re-checks at file time (a tunnel verified once can still die before filing).
|
|
12
|
+
//
|
|
13
|
+
// USAGE
|
|
14
|
+
// node harness/tunnel.js <name> [port=8080] spawn + verify + record; stays attached
|
|
15
|
+
// (run it as a background task), Ctrl-C stops it
|
|
16
|
+
// node harness/tunnel.js <name> --check re-verify the RECORDED tunnel (exit 0/1)
|
|
17
|
+
// node harness/tunnel.js --sink [port=7799] tunnel the SINK: gives live-page captures a
|
|
18
|
+
// public HTTPS target when the automation
|
|
19
|
+
// environment blocks page→localhost fetch (the
|
|
20
|
+
// ~4s abort, RUNBOOK Step 0) — pxSend/pxSendDom
|
|
21
|
+
// straight through, no stash/chunk fallback.
|
|
22
|
+
// Verified by the sink's own signature (an empty
|
|
23
|
+
// POST provokes its distinctive 400 "empty body"
|
|
24
|
+
// reply); records ./sink-tunnel.json.
|
|
25
|
+
//
|
|
26
|
+
// Needs `cloudflared` on PATH (brew install cloudflared). Quick tunnels need no account.
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const fs = require("fs");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
const crypto = require("crypto");
|
|
32
|
+
const { spawn } = require("child_process");
|
|
33
|
+
|
|
34
|
+
const WORK = process.cwd();
|
|
35
|
+
const targetDir = (name) => path.join(WORK, "targets", name);
|
|
36
|
+
const tunnelPath = (name) => path.join(targetDir(name), "tunnel.json");
|
|
37
|
+
const indexPath = (name) => path.join(targetDir(name), "clone", "index.html");
|
|
38
|
+
const sha = (buf) => crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16);
|
|
39
|
+
|
|
40
|
+
// The quick-tunnel url is printed to cloudflared's stderr. Pure + exported for the selftest.
|
|
41
|
+
function parseTunnelUrl(logText) {
|
|
42
|
+
const m = logText.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
43
|
+
return m ? m[0] : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Does `url` serve exactly the bytes of `filePath`? file:// urls read from disk, so the
|
|
47
|
+
// compare logic is testable offline (same pattern as capture-build's fetchTo).
|
|
48
|
+
// Returns { ok, reason, sha256 }.
|
|
49
|
+
async function verifyServes(url, filePath) {
|
|
50
|
+
const expected = fs.readFileSync(filePath);
|
|
51
|
+
let got;
|
|
52
|
+
try {
|
|
53
|
+
if (url.startsWith("file://")) got = fs.readFileSync(new URL(url));
|
|
54
|
+
else {
|
|
55
|
+
const r = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(15_000), headers: { "cache-control": "no-cache" } });
|
|
56
|
+
if (!r.ok) return { ok: false, reason: `HTTP ${r.status} from ${url}` };
|
|
57
|
+
got = Buffer.from(await r.arrayBuffer());
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return { ok: false, reason: `unreachable: ${url} — ${e.message}` };
|
|
61
|
+
}
|
|
62
|
+
if (!got.equals(expected)) return { ok: false, reason: `${url} responds but the bytes are NOT clone/index.html (${got.length} vs ${expected.length} bytes) — wrong port, stale serve, or another app` };
|
|
63
|
+
return { ok: true, reason: `verified: ${url} serves clone/index.html byte-identically`, sha256: sha(expected) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Quick-tunnel hostnames take a while to become reachable: cloudflared prints the url
|
|
67
|
+
// well before the *.trycloudflare.com DNS record propagates, and the lag is measured in
|
|
68
|
+
// tens of seconds, not the ~20s a 10x2s loop allows (found live: the sink-tunnel probe
|
|
69
|
+
// exhausted its retries on plain "fetch failed" DNS misses while the tunnel was fine).
|
|
70
|
+
// 30 attempts x 3s = 90s budget, with a progress line so a long warm-up isn't silent.
|
|
71
|
+
async function warmUp(probe) {
|
|
72
|
+
let v = { ok: false, reason: "unverified" };
|
|
73
|
+
for (let i = 0; i < 30 && !v.ok; i++) {
|
|
74
|
+
v = await probe();
|
|
75
|
+
if (!v.ok) {
|
|
76
|
+
if (i > 0 && i % 5 === 0) console.error(` … still warming up (attempt ${i}/30): ${v.reason.slice(0, 100)}`);
|
|
77
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return v;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The sink's signature: an empty POST always gets 400 + an "empty body" message
|
|
84
|
+
// (tools/sink.js classifyBody). Proving THAT response comes back through a url proves the
|
|
85
|
+
// real sink answers there — no filesystem coupling, works for local and tunneled checks.
|
|
86
|
+
const looksLikeSink = (status, bodyText) => status === 400 && /empty body/.test(bodyText || "");
|
|
87
|
+
async function probeSink(url) {
|
|
88
|
+
try {
|
|
89
|
+
const r = await fetch(url, { method: "POST", body: "", signal: AbortSignal.timeout(15_000) });
|
|
90
|
+
const text = await r.text();
|
|
91
|
+
if (looksLikeSink(r.status, text)) return { ok: true, reason: `sink answered at ${url}` };
|
|
92
|
+
return { ok: false, reason: `${url} answered (HTTP ${r.status}) but NOT like the sink — wrong port or another app: ${text.slice(0, 80)}` };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { ok: false, reason: `unreachable: ${url} — ${e.message}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function sinkMain(port) {
|
|
99
|
+
const local = await probeSink(`http://localhost:${port}/pxprobe.json`);
|
|
100
|
+
if (!local.ok) { console.error(`❌ no sink on :${port} before tunneling: ${local.reason}\n start it first: node tools/sink.js (PPK_SINK_PORT=${port} if non-default)`); process.exit(1); }
|
|
101
|
+
|
|
102
|
+
const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
103
|
+
child.on("error", (e) => {
|
|
104
|
+
console.error(e.code === "ENOENT" ? "cloudflared not found — install it: brew install cloudflared" : `cloudflared failed: ${e.message}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
|
107
|
+
let log = "", url = null;
|
|
108
|
+
const onChunk = (d) => { if (!url) { log += d; url = parseTunnelUrl(log); } };
|
|
109
|
+
child.stdout.on("data", onChunk);
|
|
110
|
+
child.stderr.on("data", onChunk);
|
|
111
|
+
const deadline = Date.now() + 30_000;
|
|
112
|
+
while (!url && Date.now() < deadline && child.exitCode === null) await new Promise((r) => setTimeout(r, 300));
|
|
113
|
+
if (!url) { console.error(`❌ no tunnel url within 30s — cloudflared output:\n${log.slice(-800)}`); child.kill(); process.exit(1); }
|
|
114
|
+
|
|
115
|
+
const v = await warmUp(() => probeSink(`${url}/pxprobe.json`));
|
|
116
|
+
if (!v.ok) { console.error(`❌ tunnel came up but the sink never answered through it: ${v.reason}`); child.kill(); process.exit(1); }
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(path.join(WORK, "sink-tunnel.json"), JSON.stringify({ url, port, startedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
119
|
+
console.log(`✓ sink tunnel ready: ${url}\n ${v.reason}\n recorded → ./sink-tunnel.json\n live-page delivery (no stash/chunk fallback needed):\n await pxSend('${url}/live.json')\n await pxSendDom('${url}/dom.html')`);
|
|
120
|
+
|
|
121
|
+
const stop = () => { child.kill(); process.exit(0); };
|
|
122
|
+
process.on("SIGINT", stop);
|
|
123
|
+
process.on("SIGTERM", stop);
|
|
124
|
+
child.on("exit", (code) => { console.error(`cloudflared exited (${code}) — sink tunnel is DOWN`); process.exit(code || 1); });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Can `url` serve a real page? For a LIVE DEV SERVER (an adopted external build — ditto's
|
|
128
|
+
// next dev, vite, etc.) byte-identity against a file is meaningless: the check is
|
|
129
|
+
// reachability + a non-trivial HTML body. Weaker than the clone-dir byte check, and the
|
|
130
|
+
// record says so (verified:"reachable").
|
|
131
|
+
async function probeUrl(url) {
|
|
132
|
+
try {
|
|
133
|
+
const r = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(15_000), headers: { "cache-control": "no-cache" } });
|
|
134
|
+
if (!r.ok) return { ok: false, reason: `HTTP ${r.status} from ${url}` };
|
|
135
|
+
const body = await r.text();
|
|
136
|
+
if (body.length < 200) return { ok: false, reason: `${url} answered but the body is ${body.length} bytes — not a page (dev server still starting?)` };
|
|
137
|
+
return { ok: true, reason: `reachable: ${url} serves a ${body.length}-byte page (byte-identity not checkable for a live dev server)` };
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return { ok: false, reason: `unreachable: ${url} — ${e.message}` };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Tunnel an arbitrary local server (adopted builds run on their OWN dev server, not the
|
|
144
|
+
// kit's static serve). Records targets/<name>/tunnel.json so `ppk human <name> file`
|
|
145
|
+
// picks the public url up as the default --draft, same as clone-dir tunnels.
|
|
146
|
+
async function urlMain(name, localUrl) {
|
|
147
|
+
let parsed;
|
|
148
|
+
try { parsed = new URL(localUrl); } catch (e) { console.error(`--url "${localUrl}" is not a valid url`); process.exit(2); }
|
|
149
|
+
if (!fs.existsSync(targetDir(name))) { console.error(`targets/${name} missing — register the build first: ppk adopt ${name} <original-url>`); process.exit(1); }
|
|
150
|
+
|
|
151
|
+
const local = await probeUrl(parsed.href);
|
|
152
|
+
if (!local.ok) { console.error(`❌ local server check failed before tunneling: ${local.reason}\n start your build's dev server first (e.g. npm run dev)`); process.exit(1); }
|
|
153
|
+
|
|
154
|
+
const child = spawn("cloudflared", ["tunnel", "--url", parsed.origin, "--no-autoupdate"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
155
|
+
child.on("error", (e) => {
|
|
156
|
+
console.error(e.code === "ENOENT" ? "cloudflared not found — install it: brew install cloudflared" : `cloudflared failed: ${e.message}`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|
|
159
|
+
let log = "", url = null;
|
|
160
|
+
const onChunk = (d) => { if (!url) { log += d; url = parseTunnelUrl(log); } };
|
|
161
|
+
child.stdout.on("data", onChunk);
|
|
162
|
+
child.stderr.on("data", onChunk);
|
|
163
|
+
const deadline = Date.now() + 30_000;
|
|
164
|
+
while (!url && Date.now() < deadline && child.exitCode === null) await new Promise((r) => setTimeout(r, 300));
|
|
165
|
+
if (!url) { console.error(`❌ no tunnel url within 30s — cloudflared output:\n${log.slice(-800)}`); child.kill(); process.exit(1); }
|
|
166
|
+
|
|
167
|
+
const v = await warmUp(() => probeUrl(url));
|
|
168
|
+
if (!v.ok) { console.error(`❌ tunnel came up but never served the page: ${v.reason}`); child.kill(); process.exit(1); }
|
|
169
|
+
|
|
170
|
+
fs.writeFileSync(tunnelPath(name), JSON.stringify({ url, localUrl: parsed.href, startedAt: new Date().toISOString(), verified: "reachable" }, null, 2) + "\n");
|
|
171
|
+
console.log(`✓ tunnel ready: ${url}\n ${v.reason}\n recorded → targets/${name}/tunnel.json (human-qa uses it as the default --draft)\n next: node harness/human-qa.js file ${name}`);
|
|
172
|
+
|
|
173
|
+
const stop = () => { child.kill(); process.exit(0); };
|
|
174
|
+
process.on("SIGINT", stop);
|
|
175
|
+
process.on("SIGTERM", stop);
|
|
176
|
+
child.on("exit", (code) => { console.error(`cloudflared exited (${code}) — tunnel is DOWN; restart before filing human rounds`); process.exit(code || 1); });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function main() {
|
|
180
|
+
const args = process.argv.slice(2);
|
|
181
|
+
if (args.includes("--sink")) {
|
|
182
|
+
const p = args.filter((a) => !a.startsWith("--"))[0];
|
|
183
|
+
return sinkMain(+(p || process.env.PPK_SINK_PORT || 7799));
|
|
184
|
+
}
|
|
185
|
+
const urlIdx = args.indexOf("--url");
|
|
186
|
+
if (urlIdx >= 0) {
|
|
187
|
+
const name = args.filter((a, i) => !a.startsWith("--") && i !== urlIdx + 1)[0];
|
|
188
|
+
if (!name || !args[urlIdx + 1]) { console.error("usage: node harness/tunnel.js <name> --url <http://localhost:3000>"); process.exit(2); }
|
|
189
|
+
return urlMain(name, args[urlIdx + 1]);
|
|
190
|
+
}
|
|
191
|
+
const [name, portArg] = args.filter((a) => !a.startsWith("--"));
|
|
192
|
+
if (!name) { console.error("usage: node harness/tunnel.js <name> [port=8080] [--check] | <name> --url <local-dev-url> | --sink [port=7799]"); process.exit(2); }
|
|
193
|
+
|
|
194
|
+
if (args.includes("--check")) {
|
|
195
|
+
if (!fs.existsSync(tunnelPath(name))) { console.error(`no tunnel recorded — start one: node harness/tunnel.js ${name} [port]`); process.exit(1); }
|
|
196
|
+
const t = JSON.parse(fs.readFileSync(tunnelPath(name), "utf8"));
|
|
197
|
+
// clone-dir tunnels re-verify byte-identity; adopted-build tunnels (live dev servers)
|
|
198
|
+
// re-verify reachability — the strongest check each mode supports.
|
|
199
|
+
const v = fs.existsSync(indexPath(name)) ? await verifyServes(t.url, indexPath(name)) : await probeUrl(t.url);
|
|
200
|
+
console.log(`${v.ok ? "✓" : "❌"} ${v.reason}`);
|
|
201
|
+
process.exit(v.ok ? 0 : 1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!fs.existsSync(indexPath(name))) { console.error(`targets/${name}/clone/index.html missing — build the clone first (or tunnel an external build's dev server: node harness/tunnel.js ${name} --url <http://localhost:3000>)`); process.exit(1); }
|
|
205
|
+
|
|
206
|
+
const port = +(portArg || 8080);
|
|
207
|
+
// Fail fast if the local serve isn't up — a tunnel to a dead port "works" at the DNS
|
|
208
|
+
// level and still burns the human round.
|
|
209
|
+
const local = await verifyServes(`http://localhost:${port}/`, indexPath(name));
|
|
210
|
+
if (!local.ok) { console.error(`❌ local serve check failed before tunneling: ${local.reason}\n start it: node harness/serve.js ${name} ${port}`); process.exit(1); }
|
|
211
|
+
|
|
212
|
+
const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
213
|
+
child.on("error", (e) => {
|
|
214
|
+
console.error(e.code === "ENOENT" ? "cloudflared not found — install it: brew install cloudflared" : `cloudflared failed: ${e.message}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
let log = "", url = null;
|
|
219
|
+
const onChunk = (d) => { if (!url) { log += d; url = parseTunnelUrl(log); } };
|
|
220
|
+
child.stdout.on("data", onChunk);
|
|
221
|
+
child.stderr.on("data", onChunk);
|
|
222
|
+
|
|
223
|
+
const deadline = Date.now() + 30_000;
|
|
224
|
+
while (!url && Date.now() < deadline && child.exitCode === null) await new Promise((r) => setTimeout(r, 300));
|
|
225
|
+
if (!url) { console.error(`❌ no tunnel url within 30s — cloudflared output:\n${log.slice(-800)}`); child.kill(); process.exit(1); }
|
|
226
|
+
|
|
227
|
+
// DNS/edge warm-up: retry the public fetch before declaring the tunnel usable.
|
|
228
|
+
const v = await warmUp(() => verifyServes(url, indexPath(name)));
|
|
229
|
+
if (!v.ok) { console.error(`❌ tunnel came up but never served the clone: ${v.reason}`); child.kill(); process.exit(1); }
|
|
230
|
+
|
|
231
|
+
fs.writeFileSync(tunnelPath(name), JSON.stringify({ url, port, startedAt: new Date().toISOString(), verifiedSha256: v.sha256 }, null, 2) + "\n");
|
|
232
|
+
console.log(`✓ tunnel ready: ${url}\n ${v.reason}\n recorded → targets/${name}/tunnel.json (human-qa.js uses it as the default --draft)\n next: node harness/human-qa.js file ${name}`);
|
|
233
|
+
|
|
234
|
+
const stop = () => { child.kill(); process.exit(0); };
|
|
235
|
+
process.on("SIGINT", stop);
|
|
236
|
+
process.on("SIGTERM", stop);
|
|
237
|
+
child.on("exit", (code) => { console.error(`cloudflared exited (${code}) — tunnel is DOWN; restart before filing human tests`); process.exit(code || 1); });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (require.main === module) main();
|
|
241
|
+
module.exports = { parseTunnelUrl, verifyServes, looksLikeSink, probeSink };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* workflow-selftest.js — guards the enforced-workflow state machine (harness/workflow.js).
|
|
4
|
+
*
|
|
5
|
+
* The kit's discipline (DEVELOP.md): a lesson goes into a TOOL + a test, never a human
|
|
6
|
+
* checklist. The workflow engine is now a tool, so it gets its own guard. This asserts the
|
|
7
|
+
* two properties that make enforcement real:
|
|
8
|
+
* 1. a gate BLOCKS while its objective condition is unmet, and
|
|
9
|
+
* 2. it PASSES once the condition is met — and out-of-order / undocumented advances are refused.
|
|
10
|
+
*
|
|
11
|
+
* Runs against uniquely-named scratch targets under the repo's targets/ (workflow.js roots
|
|
12
|
+
* targets/ at cwd, which run() pins to the repo), cleaned up on exit. Exit 0 = green.
|
|
13
|
+
*/
|
|
14
|
+
"use strict";
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const cp = require("child_process");
|
|
19
|
+
|
|
20
|
+
const KIT = path.resolve(__dirname, "..");
|
|
21
|
+
const WF = path.join(KIT, "harness", "workflow.js");
|
|
22
|
+
let failed = 0;
|
|
23
|
+
const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
|
|
24
|
+
|
|
25
|
+
// run workflow.js with cwd pinned to KIT, so its cwd-rooted targets/ land under the repo
|
|
26
|
+
// (where `dir` below points) regardless of where regression.js was invoked from.
|
|
27
|
+
// PPK_PINGHUMANS_URL points the human gate at a file:// mock (below) so no test ever
|
|
28
|
+
// touches the network — a socket-blocking sandbox must still run this suite.
|
|
29
|
+
const MOCK = fs.mkdtempSync(path.join(os.tmpdir(), "ppk-hqmock-"));
|
|
30
|
+
process.on("exit", () => { try { fs.rmSync(MOCK, { recursive: true, force: true }); } catch (e) {} });
|
|
31
|
+
const run = (args) => { const r = cp.spawnSync(process.execPath, [WF, ...args], { encoding: "utf8", cwd: KIT, env: { ...process.env, PPK_PINGHUMANS_URL: "file://" + MOCK } }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
|
|
32
|
+
const HQ = path.join(KIT, "harness", "human-qa.js");
|
|
33
|
+
const runHq = (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
|
+
// A uniquely-named scratch target under KIT/targets, cleaned up after. workflow.js resolves
|
|
36
|
+
// targets/<name> from cwd, which we pin to KIT above.
|
|
37
|
+
const NAME = "selftest_scratch_" + process.pid;
|
|
38
|
+
const dir = path.join(KIT, "targets", NAME);
|
|
39
|
+
const cleanup = () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} };
|
|
40
|
+
process.on("exit", cleanup);
|
|
41
|
+
|
|
42
|
+
const writeJson = (f, o) => fs.writeFileSync(path.join(dir, f), JSON.stringify(o, null, 2));
|
|
43
|
+
const snap = (els, width) => ({ viewport: { width }, elements: els });
|
|
44
|
+
const el = (present = true, extra = {}) => ({ present, rect: { x: 0, y: 0, w: 10, h: 10, top: 0, right: 10, bottom: 10, fromRight: 0 }, ...extra });
|
|
45
|
+
|
|
46
|
+
cleanup();
|
|
47
|
+
fs.mkdirSync(path.join(dir, "clone", "assets"), { recursive: true });
|
|
48
|
+
writeJson("target.json", { name: NAME, url: "https://example.com/", width: 1728 });
|
|
49
|
+
|
|
50
|
+
console.log("workflow-selftest — enforced-workflow state machine");
|
|
51
|
+
|
|
52
|
+
// init
|
|
53
|
+
ok(run(["init", NAME]).code === 0, "init creates workflow.json");
|
|
54
|
+
ok(fs.existsSync(path.join(dir, "workflow.json")), "workflow.json written");
|
|
55
|
+
|
|
56
|
+
// target gate should pass (target.json is valid)
|
|
57
|
+
ok(run(["gate", NAME, "target"]).code === 0, "target gate passes with valid target.json");
|
|
58
|
+
|
|
59
|
+
// measure gate BLOCKS before live.json exists
|
|
60
|
+
ok(run(["gate", NAME, "measure"]).code === 1, "measure gate blocks with no live.json");
|
|
61
|
+
|
|
62
|
+
// out-of-order advance is refused (can't advance measure before target/assets)
|
|
63
|
+
ok(run(["advance", NAME, "measure"]).code === 1, "out-of-order advance (measure before target) refused");
|
|
64
|
+
|
|
65
|
+
// advance target (machine phase)
|
|
66
|
+
ok(run(["advance", NAME, "target"]).code === 0, "advance target succeeds");
|
|
67
|
+
|
|
68
|
+
// assets: attested phase → refuses without --evidence
|
|
69
|
+
ok(run(["advance", NAME, "assets"]).code === 1, "attested phase refuses without --evidence");
|
|
70
|
+
// a hand-faked woff2 (wrong magic) must fail the light check even with evidence
|
|
71
|
+
fs.writeFileSync(path.join(dir, "clone", "assets", "fake.woff2"), "NOTAWOFF2FILE");
|
|
72
|
+
ok(run(["advance", NAME, "assets", "--evidence", "x"]).code === 1, "assets gate rejects a woff2 with bad magic bytes");
|
|
73
|
+
fs.rmSync(path.join(dir, "clone", "assets", "fake.woff2"));
|
|
74
|
+
ok(run(["advance", NAME, "assets", "--evidence", "icons+logo captured from live"]).code === 0, "assets advances with evidence + clean assets dir");
|
|
75
|
+
|
|
76
|
+
// measure: width mismatch blocks, correct width passes
|
|
77
|
+
writeJson("live.json", snap({ logo: el(), nav_first: el(true, { text: { x: 0, right: 5, top: 0, bottom: 5, w: 5, h: 5 }, font: { weight: "400", size: 14, line: 20, spacing: "normal", transform: "none", color: "rgb(0,0,0)", decoration: "none", smoothing: "auto" } }) }, 1440));
|
|
78
|
+
ok(run(["gate", NAME, "measure"]).code === 1, "measure gate blocks on width mismatch (1440 vs 1728)");
|
|
79
|
+
writeJson("live.json", snap({ logo: el(), nav_first: el(true, { text: { x: 0, right: 5, top: 0, bottom: 5, w: 5, h: 5 }, font: { weight: "400", size: 14, line: 20, spacing: "normal", transform: "none", color: "rgb(0,0,0)", decoration: "none", smoothing: "auto" } }) }, 1728));
|
|
80
|
+
ok(run(["advance", NAME, "measure"]).code === 0, "measure advances at the fixed width");
|
|
81
|
+
|
|
82
|
+
// build: scaffold TODO blocks; real clone.json passes
|
|
83
|
+
fs.writeFileSync(path.join(dir, "clone", "index.html"), "<header><!-- TODO: build to spec --></header>");
|
|
84
|
+
ok(run(["gate", NAME, "build"]).code === 1, "build gate blocks while scaffold TODO remains");
|
|
85
|
+
fs.writeFileSync(path.join(dir, "clone", "index.html"), "<header><a class=logo></a><a>Shop</a></header>");
|
|
86
|
+
writeJson("clone.json", snap({ logo: el(), nav_first: el(true, { text: { x: 0, right: 5, top: 0, bottom: 5, w: 5, h: 5 }, font: { weight: "400", size: 14, line: 20, spacing: "normal", transform: "none", color: "rgb(0,0,0)", decoration: "none", smoothing: "auto" } }) }, 1728));
|
|
87
|
+
ok(run(["advance", NAME, "build"]).code === 0, "build advances with a real clone.json");
|
|
88
|
+
|
|
89
|
+
// visual: identical snapshots → green
|
|
90
|
+
ok(run(["advance", NAME, "visual"]).code === 0, "visual gate passes when clone matches live");
|
|
91
|
+
|
|
92
|
+
// coverage: blocks without coverage.json, blocks on an uncovered leaf, passes when all covered
|
|
93
|
+
ok(run(["gate", NAME, "coverage"]).code === 1, "coverage gate blocks with no coverage.json");
|
|
94
|
+
writeJson("coverage.json", ["logo", "nav_first", "ghost_leaf"]);
|
|
95
|
+
ok(run(["gate", NAME, "coverage"]).code === 1, "coverage gate blocks on an uncovered painted leaf");
|
|
96
|
+
writeJson("coverage.json", ["logo", "nav_first"]);
|
|
97
|
+
ok(run(["advance", NAME, "coverage"]).code === 0, "coverage advances when every enumerated leaf is targeted");
|
|
98
|
+
|
|
99
|
+
// strict: identical snapshots → 0 deltas → passes
|
|
100
|
+
ok(run(["advance", NAME, "strict"]).code === 0, "strict gate passes with 0 structural deltas");
|
|
101
|
+
|
|
102
|
+
// ── behavior: blocks with no discovery evidence, passes once discovery ran and found nothing ──
|
|
103
|
+
// (behaviorGate itself has a dedicated guard: harness/behavior-selftest.js. Here we only
|
|
104
|
+
// prove the phase is correctly WIRED into the ordered advance sequence between strict/human.)
|
|
105
|
+
ok(run(["gate", NAME, "behavior"]).code === 1, "behavior gate blocks with no behaviors-live.json");
|
|
106
|
+
writeJson("behaviors-live.json", {
|
|
107
|
+
url: "https://example.com/",
|
|
108
|
+
discovery: { elementsScanned: 40, scrollSweep: { from: 0, to: 800, steps: 4 }, observeMs: 1200 },
|
|
109
|
+
behaviors: {},
|
|
110
|
+
});
|
|
111
|
+
ok(run(["advance", NAME, "behavior"]).code === 0, "behavior advances once discovery ran and found no dynamic behaviors");
|
|
112
|
+
|
|
113
|
+
// ── human: blocks with no round, blocks while pending, blocks on rejection, passes on approval ──
|
|
114
|
+
const PING = "00000000-0000-4000-8000-000000000001";
|
|
115
|
+
ok(run(["gate", NAME, "human"]).code === 1, "human gate blocks with no QA round recorded");
|
|
116
|
+
ok(run(["advance", NAME, "done"]).code === 1, "done refused while human phase is pending");
|
|
117
|
+
ok(runHq(["record", NAME, PING, "--approve", "Region identical"]).code === 0, "human-qa record adopts a filed ping");
|
|
118
|
+
fs.writeFileSync(path.join(MOCK, `get_test_results-${PING}.json`), JSON.stringify({ status: "pending", n_received: 0, n_target: 1, responses: [] }));
|
|
119
|
+
ok(run(["gate", NAME, "human"]).code === 1, "human gate blocks while the round is pending");
|
|
120
|
+
fs.writeFileSync(path.join(MOCK, `get_test_results-${PING}.json`), JSON.stringify({ status: "complete", n_received: 1, n_target: 1, responses: [{ verdict: "Region clearly different", notes: "logo sits low" }] }));
|
|
121
|
+
const rej = run(["gate", NAME, "human"]);
|
|
122
|
+
ok(rej.code === 1 && /logo sits low/.test(rej.out), "human gate blocks on rejection and surfaces the human's flag");
|
|
123
|
+
fs.writeFileSync(path.join(MOCK, `get_test_results-${PING}.json`), JSON.stringify({ status: "complete", n_received: 1, n_target: 1, responses: [{ verdict: "Region identical" }] }));
|
|
124
|
+
ok(run(["advance", NAME, "human"]).code === 0, "human advances on an approving verdict");
|
|
125
|
+
|
|
126
|
+
ok(run(["advance", NAME, "done"]).code === 0, "done advances once every phase passed in order");
|
|
127
|
+
|
|
128
|
+
// ledger records the run (refusals are receipted too, so count includes them)
|
|
129
|
+
const readLedger = () => fs.readFileSync(path.join(dir, "workflow.jsonl"), "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
130
|
+
let ledger = readLedger();
|
|
131
|
+
ok(ledger.length >= 7, `ledger recorded ${ledger.length} receipts`);
|
|
132
|
+
ok(ledger.every((r) => r.runId && r.ts && (r.phase || r.event)), "every ledger receipt has runId + ts + phase/event");
|
|
133
|
+
ok(ledger.some((r) => r.gate === "refused"), "refused advances are receipted in the ledger");
|
|
134
|
+
|
|
135
|
+
// ── done is default-FAIL: a recorded pass must not survive edits to what it certified ──
|
|
136
|
+
{
|
|
137
|
+
const clonePath = path.join(dir, "clone.json");
|
|
138
|
+
const original = fs.readFileSync(clonePath, "utf8");
|
|
139
|
+
const tampered = JSON.parse(original);
|
|
140
|
+
tampered.elements.nav_first.font.color = "rgb(255,0,0)"; // paint regression after visual passed
|
|
141
|
+
writeJson("clone.json", tampered);
|
|
142
|
+
ok(run(["gate", NAME, "done"]).code === 1, "done gate FAILS when a certified artifact was edited (stale visual pass)");
|
|
143
|
+
// a PAINT delta can never be documented away in deviations.json
|
|
144
|
+
writeJson("deviations.json", { nav_first: { "font.color": "attempt to excuse a visible mark" } });
|
|
145
|
+
ok(run(["gate", NAME, "strict"]).code === 1, "strict gate refuses to let deviations.json excuse a PAINT delta");
|
|
146
|
+
fs.rmSync(path.join(dir, "deviations.json"));
|
|
147
|
+
fs.writeFileSync(clonePath, original);
|
|
148
|
+
ok(run(["gate", NAME, "done"]).code === 0, "done gate passes again once the artifact is restored");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── --force is never silent: any bypassed enforcement is recorded, and done refuses it ──
|
|
152
|
+
{
|
|
153
|
+
// assets gate itself passes, but --force bypasses the attestation-evidence requirement:
|
|
154
|
+
ok(run(["advance", NAME, "assets", "--force"]).code === 0, "--force advance with passing gate succeeds");
|
|
155
|
+
ledger = readLedger();
|
|
156
|
+
const last = ledger[ledger.length - 1];
|
|
157
|
+
ok(last.forced === true && Array.isArray(last.overrode) && last.overrode.includes("evidence"),
|
|
158
|
+
"forced advance with a PASSING gate still records forced:true + what it overrode");
|
|
159
|
+
ok(run(["gate", NAME, "done"]).code === 1, "done gate refuses a workflow containing a forced phase");
|
|
160
|
+
ok(run(["advance", NAME, "assets", "--evidence", "re-attested cleanly"]).code === 0, "clean re-advance clears the forced flag");
|
|
161
|
+
ok(run(["gate", NAME, "done"]).code === 0, "done gate passes again once no phase is forced");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── --evidence must carry a real value, never a flag ──
|
|
165
|
+
ok(run(["advance", NAME, "assets", "--evidence", "--force"]).code === 2, "--evidence followed by a flag is rejected (exit 2)");
|
|
166
|
+
|
|
167
|
+
// ── schema migration: a pre-`human` workflow.json hydrates as pending, never errors ──
|
|
168
|
+
// (runs while the workflow is fully green, so restoring the saved state must pass done)
|
|
169
|
+
{
|
|
170
|
+
const statePath = path.join(dir, "workflow.json");
|
|
171
|
+
const saved = fs.readFileSync(statePath, "utf8");
|
|
172
|
+
const st = JSON.parse(saved);
|
|
173
|
+
delete st.phases.human; // simulate a workflow seeded before the kit gained the phase
|
|
174
|
+
st.phaseOrder = st.phaseOrder.filter((k) => k !== "human");
|
|
175
|
+
fs.writeFileSync(statePath, JSON.stringify(st, null, 2));
|
|
176
|
+
const s = run(["status", NAME]);
|
|
177
|
+
ok(s.code === 0 && /human/.test(s.out), "old-schema workflow.json hydrates the new phase instead of erroring");
|
|
178
|
+
ok(run(["gate", NAME, "done"]).code === 1, "done gate honestly fails for a migrated workflow until human re-passes");
|
|
179
|
+
fs.writeFileSync(statePath, saved);
|
|
180
|
+
ok(run(["gate", NAME, "done"]).code === 0, "restored state passes done again");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── init never silently wipes state; resets are receipted; corruption recovers cleanly ──
|
|
184
|
+
{
|
|
185
|
+
ok(run(["init", NAME]).code === 1, "re-init on recorded state is refused without --force");
|
|
186
|
+
const statePath = path.join(dir, "workflow.json");
|
|
187
|
+
fs.writeFileSync(statePath, "{ truncated"); // simulate a killed process / manual edit
|
|
188
|
+
const r = run(["status", NAME]);
|
|
189
|
+
ok(r.code === 1 && /corrupt/.test(r.out) && !/at Object\./.test(r.out), "corrupt workflow.json → self-describing error + recovery hint, no raw stack");
|
|
190
|
+
ok(run(["init", NAME, "--force"]).code === 0, "init --force re-seeds corrupt state");
|
|
191
|
+
ledger = readLedger();
|
|
192
|
+
ok(ledger.some((l) => l.event === "reset"), "the forced reset is receipted in the ledger");
|
|
193
|
+
ok(run(["status", NAME]).code === 0, "status works again after recovery");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── directory receipts pin CONTENTS, not just filenames ──
|
|
197
|
+
{
|
|
198
|
+
const { PHASES: P } = require("./workflow.js");
|
|
199
|
+
void P; // (imported to assert the module exports cleanly)
|
|
200
|
+
const sha = require("./workflow.js").sha256OfFile;
|
|
201
|
+
const d = path.join(dir, "clone", "assets");
|
|
202
|
+
fs.writeFileSync(path.join(d, "font.woff2"), "wOF2-original-bytes");
|
|
203
|
+
const before = sha(path.join(dir, "clone"));
|
|
204
|
+
fs.writeFileSync(path.join(d, "font.woff2"), "wOF2-swapped--bytes"); // same name, different bytes
|
|
205
|
+
const after = sha(path.join(dir, "clone"));
|
|
206
|
+
ok(before !== after, "directory sha256 changes when a same-named file's contents change");
|
|
207
|
+
fs.rmSync(path.join(d, "font.woff2"));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(failed ? `\n❌ workflow-selftest: ${failed} assertion(s) failed.` : `\n✓ workflow-selftest: all assertions pass — gates block when unmet and pass when met.`);
|
|
211
|
+
process.exit(failed ? 1 : 0);
|