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,406 @@
|
|
|
1
|
+
// harness/human-qa.js — the HUMAN phase: PingHumans side-by-side QA as a workflow gate.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The gates prove every property the tool measures; they cannot prove
|
|
4
|
+
// the two things LEARNINGS reserves for eyes — that the *measured set* is what a person
|
|
5
|
+
// actually sees, and that techniques rasterise identically (the gate-vs-eyes split). The
|
|
6
|
+
// kit's end state is a clone the designer receives after "all the human iteration rounds"
|
|
7
|
+
// — unattended — so the human round must be a PHASE with a machine-checkable gate, not an
|
|
8
|
+
// operator ritual. A PingHumans verdict IS machine-checkable: this tool files the test,
|
|
9
|
+
// polls the verdict over the same authenticated JSON-RPC endpoint the cpyany CLI uses,
|
|
10
|
+
// and exits 0 only when the LATEST round is human-approved. Receipts pin ping_id+verdict.
|
|
11
|
+
//
|
|
12
|
+
// The test template encodes the lessons stripe's 8 unconverged rounds paid for:
|
|
13
|
+
// - SCOPE-PIN: the reviewer judges ONLY the cloned region (an open-ended side-by-side
|
|
14
|
+
// of a partial clone cannot converge — stripe lesson 1).
|
|
15
|
+
// - Behavior is INFORMATIONAL: a static clone strips JS; hover/animation differences
|
|
16
|
+
// are noted, never failed on (stripe lesson 2 — acceptance criteria for
|
|
17
|
+
// animated/generative content must be agreed up front).
|
|
18
|
+
// - Per-leaf compare steps are generated from coverage.json, so the human is pointed
|
|
19
|
+
// at exactly the marks the gates certified — detection stays human, scope stays pinned.
|
|
20
|
+
//
|
|
21
|
+
// USAGE
|
|
22
|
+
// node harness/human-qa.js template <name> --draft <public-url> [--region "the header"]
|
|
23
|
+
// print the test spec JSON (to file manually via the PingHumans MCP)
|
|
24
|
+
// node harness/human-qa.js file <name> --draft <public-url> [--region "…"]
|
|
25
|
+
// generate the spec AND file it; records the round in targets/<name>/human-qa.json
|
|
26
|
+
// node harness/human-qa.js record <name> <ping_id> [--approve "Verdict A,Verdict B"]
|
|
27
|
+
// adopt a test filed elsewhere (e.g. via the MCP) as the current round
|
|
28
|
+
// node harness/human-qa.js verify <name>
|
|
29
|
+
// fetch the LATEST round's verdict; exit 0 = approved (the workflow gate runs this)
|
|
30
|
+
// node harness/human-qa.js poll <name> "question" [--choices "A,B,C"]
|
|
31
|
+
// a MICRO-CHECK (~$0.05, blocks up to ~300s): put one small question in front of a
|
|
32
|
+
// human MID-ROUND — "do these 3 tiles look right now? <urls>" — before spending a
|
|
33
|
+
// full test round on it. With a responsive reviewer this collapses a whole
|
|
34
|
+
// flag→refile→wait cycle into minutes (astryx burned 4 rounds on one contested cell
|
|
35
|
+
// treatment). ADVISORY ONLY: polls never satisfy the human gate — the gate still
|
|
36
|
+
// requires an approving verdict on a full scope-pinned round. The draft/original
|
|
37
|
+
// urls are appended automatically when tunnel.json/target.json exist.
|
|
38
|
+
// node harness/human-qa.js poll-result <name> <ping_id>
|
|
39
|
+
// free re-fetch of a pending poll's answers
|
|
40
|
+
//
|
|
41
|
+
// AUTH: the designer's existing cpyany/PingHumans login — ~/.config/cpyany/credentials.json,
|
|
42
|
+
// else the Bearer header in ~/.claude.json's cpyany/pinghumans MCP entry, else
|
|
43
|
+
// PPK_PINGHUMANS_TOKEN. PPK_PINGHUMANS_URL overrides the API base; a file:// value serves
|
|
44
|
+
// canned responses from disk (offline selftests; sandboxes that block sockets).
|
|
45
|
+
"use strict";
|
|
46
|
+
|
|
47
|
+
const fs = require("fs");
|
|
48
|
+
const os = require("os");
|
|
49
|
+
const path = require("path");
|
|
50
|
+
const { fileURLToPath } = require("url");
|
|
51
|
+
|
|
52
|
+
const WORK = process.cwd();
|
|
53
|
+
const targetDir = (name) => path.join(WORK, "targets", name);
|
|
54
|
+
const hqPath = (name) => path.join(targetDir(name), "human-qa.json");
|
|
55
|
+
const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
|
56
|
+
const BASE = process.env.PPK_PINGHUMANS_URL || process.env.PINGHUMANS_APP_URL || "https://pinghumans.com";
|
|
57
|
+
|
|
58
|
+
function resolveToken() {
|
|
59
|
+
if (process.env.PPK_PINGHUMANS_TOKEN) return process.env.PPK_PINGHUMANS_TOKEN;
|
|
60
|
+
try {
|
|
61
|
+
const t = readJson(path.join(os.homedir(), ".config", "cpyany", "credentials.json")).token;
|
|
62
|
+
if (t) return t;
|
|
63
|
+
} catch (e) {}
|
|
64
|
+
try {
|
|
65
|
+
const cfg = readJson(path.join(os.homedir(), ".claude.json"));
|
|
66
|
+
const entry = (cfg.mcpServers || {}).cpyany || (cfg.mcpServers || {}).pinghumans;
|
|
67
|
+
const m = /Bearer\s+(\S+)/.exec((entry && entry.headers && (entry.headers.Authorization || entry.headers.authorization)) || "");
|
|
68
|
+
if (m) return m[1];
|
|
69
|
+
} catch (e) {}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One JSON-RPC tools/call against the PingHumans MCP-over-HTTP endpoint (the same
|
|
74
|
+
// transport `npx cpyany wait` uses). file:// base → canned responses from disk:
|
|
75
|
+
// get_test_results-<ping_id>.json / request_human_test.json
|
|
76
|
+
//
|
|
77
|
+
// The LIVE api/mcp endpoint's tools/list exposes these under the "cpyany" namespace
|
|
78
|
+
// (`cpyany_test`, `cpyany_test_results`), not the generic names this file uses
|
|
79
|
+
// internally — confirmed empirically (astryx run): a live call with the generic name
|
|
80
|
+
// fails with "Tool request_human_test not found" even with a valid token, while
|
|
81
|
+
// `tools/list` on the same endpoint returns `cpyany_test`/`cpyany_test_results`/
|
|
82
|
+
// `cpyany_wait`/`cpyany_poll`/`cpyany_poll_results`/`cpyany_check_source`. Kept the
|
|
83
|
+
// internal names (and the file:// fixture filenames / selftest) unchanged — only the
|
|
84
|
+
// wire method name sent to the LIVE endpoint is remapped, right before the fetch.
|
|
85
|
+
const LIVE_TOOL_NAME = { request_human_test: "cpyany_test", get_test_results: "cpyany_test_results", ping_humans: "cpyany_poll", get_ping: "cpyany_poll_results" };
|
|
86
|
+
async function rpc(name, args, timeoutMs) {
|
|
87
|
+
if (BASE.startsWith("file://")) {
|
|
88
|
+
const dir = fileURLToPath(BASE);
|
|
89
|
+
const f = name === "get_test_results" ? `get_test_results-${args.ping_id}.json`
|
|
90
|
+
: name === "get_ping" ? `get_ping-${args.ping_id}.json`
|
|
91
|
+
: `${name}.json`;
|
|
92
|
+
return readJson(path.join(dir, f));
|
|
93
|
+
}
|
|
94
|
+
const token = resolveToken();
|
|
95
|
+
if (!token) throw new Error("no PingHumans token — run `npx cpyany setup`, or set PPK_PINGHUMANS_TOKEN");
|
|
96
|
+
const wireName = LIVE_TOOL_NAME[name] || name;
|
|
97
|
+
const res = await fetch(`${BASE}/api/mcp`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
|
|
100
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: wireName, arguments: args } }),
|
|
101
|
+
// ping_humans blocks server-side while a human answers (up to ~300s) — callers pass a
|
|
102
|
+
// matching timeout; everything else keeps the snappy default.
|
|
103
|
+
signal: AbortSignal.timeout(timeoutMs || 20_000),
|
|
104
|
+
});
|
|
105
|
+
const raw = await res.text();
|
|
106
|
+
const m = raw.match(/data: (.*)/);
|
|
107
|
+
const payload = JSON.parse(m ? m[1] : raw);
|
|
108
|
+
if (payload.error) throw new Error(payload.error.message || "MCP error");
|
|
109
|
+
const r = payload.result || {};
|
|
110
|
+
if (r.isError) throw new Error((r.content && r.content[0] && r.content[0].text) || "PingHumans returned an error");
|
|
111
|
+
if (r.structuredContent) return r.structuredContent;
|
|
112
|
+
try { return JSON.parse(r.content[0].text); } catch (e) { throw new Error("unexpected RPC response shape"); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── the scope-pinned test template ───────────────────────────────────────────
|
|
116
|
+
const slugToWords = (s) => s.replace(/[_-]+/g, " ").trim();
|
|
117
|
+
|
|
118
|
+
function buildSpec(name, draftUrl, region, changelog) {
|
|
119
|
+
const target = readJson(path.join(targetDir(name), "target.json"));
|
|
120
|
+
let leaves = [];
|
|
121
|
+
try {
|
|
122
|
+
const cov = readJson(path.join(targetDir(name), "coverage.json"));
|
|
123
|
+
leaves = (Array.isArray(cov) ? cov : cov.leaves || []).map(slugToWords);
|
|
124
|
+
} catch (e) {}
|
|
125
|
+
const R = region || "the cloned region";
|
|
126
|
+
const stripped = R.replace(/^the\s+/i, "");
|
|
127
|
+
const cap = stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
|
128
|
+
const verdicts = [`${cap} identical`, `${cap} slightly off`, `${cap} clearly different`];
|
|
129
|
+
const steps = [
|
|
130
|
+
{ text: `Open both pages at the same window size. This test is ONLY about ${R}. Everything outside it is out of scope — ignore it for your verdict.`, check: null },
|
|
131
|
+
];
|
|
132
|
+
// Documented deviations are surfaced TO THE REVIEWER, or the gate's escape hatch and the
|
|
133
|
+
// human's expectations diverge forever: astryx round 3 re-flagged bento cells that
|
|
134
|
+
// behavior-deviations.json had honestly excused — the reviewer had no way to know.
|
|
135
|
+
let deviationNote = "";
|
|
136
|
+
try {
|
|
137
|
+
const dev = readJson(path.join(targetDir(name), "behavior-deviations.json"));
|
|
138
|
+
// Cross-reference entries ("See <other-key> — same phenomenon") exist for the GATE's
|
|
139
|
+
// key-by-key bookkeeping, not for humans: astryx round 5 surfaced 20 entries of which
|
|
140
|
+
// 14 were "See mutation:… same phenomenon" fragments, and the 300-char budget rendered
|
|
141
|
+
// the whole step as unreadable mush. Only PRIMARY entries face the reviewer.
|
|
142
|
+
const primary = Object.fromEntries(Object.entries(dev).filter(([, v]) => !/^\s*See\s/i.test(String((v && v.reason) || ""))));
|
|
143
|
+
const keys = Object.keys(primary);
|
|
144
|
+
if (keys.length) {
|
|
145
|
+
// PingHumans caps a step's `text` at 300 chars. As the number of documented
|
|
146
|
+
// deviations grows (astryx round 4: 5, up from round 3's 3), joining a per-entry
|
|
147
|
+
// reason snippet can blow past that even after truncating each one — the step
|
|
148
|
+
// silently failed to file (Zod "too_big") instead of degrading. Budget the PREFIX
|
|
149
|
+
// first, then divide what's left evenly across entries so this scales to any count
|
|
150
|
+
// instead of hard-failing past ~2-3 deviations (kit-change candidate — flagged here
|
|
151
|
+
// since human-qa.js is shared kit code, fixed minimally rather than reworked).
|
|
152
|
+
const PREFIX = "KNOWN, INTENTIONAL differences — do NOT flag these (documented exclusions): ";
|
|
153
|
+
const BUDGET = 300 - PREFIX.length;
|
|
154
|
+
const perEntry = Math.max(20, Math.floor((BUDGET - (keys.length - 1) * 3) / keys.length));
|
|
155
|
+
const lines = Object.entries(primary).map(([k, v]) => {
|
|
156
|
+
const label = k.replace(/^[a-z]+:/, "");
|
|
157
|
+
const reason = String((v && v.reason) || "");
|
|
158
|
+
const snippet = `${label} — ${reason}`;
|
|
159
|
+
return snippet.length > perEntry ? snippet.slice(0, Math.max(0, perEntry - 1)) + "…" : snippet;
|
|
160
|
+
});
|
|
161
|
+
steps.push({ text: (PREFIX + lines.join(" | ")).slice(0, 300), check: null });
|
|
162
|
+
// `instructions` is a SEPARATE field with its own hard cap (observed: 1000 chars
|
|
163
|
+
// total, shared with the fixed scope/verdict text below) — it is NOT the same budget
|
|
164
|
+
// as the step's 300-char cap. Duplicating the full per-entry list here (as an earlier
|
|
165
|
+
// version did) silently blew past 1000 once deviation count grew past ~15 (iphone17
|
|
166
|
+
// round 5: 34 entries -> 1152 chars -> PingHumans rejected the whole filing with a
|
|
167
|
+
// Zod "too_big" error, not a graceful truncation). Fix: `instructions` gets a SHORT,
|
|
168
|
+
// fixed-size pointer to the dedicated step above (which already carries the full,
|
|
169
|
+
// properly-budgeted list) instead of re-deriving its own copy from `keys.length`.
|
|
170
|
+
deviationNote = ` See the "KNOWN, INTENTIONAL differences" step below (${keys.length} documented) — you should NOT flag those; they're already excused.`;
|
|
171
|
+
}
|
|
172
|
+
} catch (e) {}
|
|
173
|
+
// one compare step per ~5 leaves, so the reviewer is pointed at exactly the
|
|
174
|
+
// certified marks without a 20-step checklist
|
|
175
|
+
for (let i = 0; i < leaves.length; i += 5) {
|
|
176
|
+
steps.push({
|
|
177
|
+
text: `Compare these elements between clone and original — position, size, color, font, spacing: ${leaves.slice(i, i + 5).join(", ")}.`,
|
|
178
|
+
options: ["Identical", "Slightly off", "Clearly different"],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
steps.push(
|
|
182
|
+
{ text: `Squint test: at a glance, could you tell which is the clone? Compare overall font weight/sharpness in ${R}.`, options: ["Could not tell apart", "Subtle difference", "Obvious difference"] },
|
|
183
|
+
{ text: "INFORMATIONAL (does not affect your verdict): the clone is a static snapshot — hover menus and animations may not run. Note any such difference; do not fail the test for it.", options: ["Same on both", "Different", "Didn't check"] },
|
|
184
|
+
{ text: `If anything in ${R} looked off, describe exactly what and where in your notes. Attach a screenshot showing both.`, check: null },
|
|
185
|
+
// Workers use the comment-pin flow and skip the verdict buttons unless told — 3 of the
|
|
186
|
+
// first 3 real responses were comment-only (choice:null), which the gate rightly refuses
|
|
187
|
+
// but which stalls the loop. The pick is therefore an explicit, REQUIRED final step.
|
|
188
|
+
{ text: `FINAL REQUIRED STEP: pick one of the verdict buttons ("${verdicts[0]}" / "${verdicts[1]}" / "${verdicts[2]}"). A review without a verdict pick cannot be accepted — comments alone don't count.`, check: null }
|
|
189
|
+
);
|
|
190
|
+
// What changed since the reviewer's last round, stated UP FRONT — iphone17 round 10's
|
|
191
|
+
// verdict was literally "did you fix anything?": the round's substantive change was
|
|
192
|
+
// invisible without being told where to look. A refile that doesn't say what changed
|
|
193
|
+
// wastes the reviewer's pass.
|
|
194
|
+
let changeNote = "";
|
|
195
|
+
if (changelog && changelog.trim()) {
|
|
196
|
+
changeNote = ` CHANGED SINCE YOUR LAST REVIEW: ${changelog.trim().slice(0, 200)}`;
|
|
197
|
+
// step text caps at 300 chars API-side: budget = 300 − prefix, never prefix + 250
|
|
198
|
+
// (52 + 250 = 302 failed a live filing — iphone17 round 13)
|
|
199
|
+
const CHG_PREFIX = "Changed since the last review — check these first: ";
|
|
200
|
+
steps.splice(1, 0, { text: CHG_PREFIX + changelog.trim().slice(0, 300 - CHG_PREFIX.length), check: null });
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
url: target.url,
|
|
204
|
+
draft_url: draftUrl,
|
|
205
|
+
title: `Compare ${R}: clone vs original`,
|
|
206
|
+
instructions: `Compare ONLY ${R} between the original site and the clone, side by side at the same width. Everything outside ${R} is out of scope.${changeNote}${deviationNote} When done, you MUST pick a verdict button — a comment-only review cannot be accepted.`,
|
|
207
|
+
steps,
|
|
208
|
+
verdict_options: verdicts,
|
|
209
|
+
approve_verdicts: [verdicts[0]],
|
|
210
|
+
n_target: 1,
|
|
211
|
+
deadline_seconds: 86400,
|
|
212
|
+
require_evidence: "screenshot",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── round state ───────────────────────────────────────────────────────────────
|
|
217
|
+
const loadHq = (name) => (fs.existsSync(hqPath(name)) ? readJson(hqPath(name)) : { rounds: [] });
|
|
218
|
+
const saveHq = (name, hq) => fs.writeFileSync(hqPath(name), JSON.stringify(hq, null, 2) + "\n");
|
|
219
|
+
|
|
220
|
+
function pushRound(name, ping_id, spec, approve) {
|
|
221
|
+
const hq = loadHq(name);
|
|
222
|
+
hq.rounds.push({ ping_id, draft_url: (spec && spec.draft_url) || null, region: (spec && spec.title) || null, approve_verdicts: approve, filed_at: new Date().toISOString(), last: null, checked_at: null });
|
|
223
|
+
saveHq(name, hq);
|
|
224
|
+
return hq.rounds.length;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── commands ──────────────────────────────────────────────────────────────────
|
|
228
|
+
async function main() {
|
|
229
|
+
const args = process.argv.slice(2);
|
|
230
|
+
const opt = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : null; };
|
|
231
|
+
const [cmd, name, extra] = args.filter((a, i) => !a.startsWith("--") && (i === 0 || !args[i - 1].startsWith("--")));
|
|
232
|
+
if (!cmd || !name) { console.error("usage: human-qa.js template|file|record|verify <name> … (see file header)"); process.exit(2); }
|
|
233
|
+
if (!fs.existsSync(path.join(targetDir(name), "target.json"))) { console.error(`targets/${name}/target.json missing`); process.exit(1); }
|
|
234
|
+
|
|
235
|
+
if (cmd === "template" || cmd === "file") {
|
|
236
|
+
// A test filed while earlier gates are still red reviews an UNFINISHED clone — the
|
|
237
|
+
// worker returns flags you already knew about and a credit is burned (paid for on
|
|
238
|
+
// astryx round 2: filed before the behavior phase ran, racing the worker's claim).
|
|
239
|
+
// Filing isn't a phase advance, so the state machine can't refuse it — this check is
|
|
240
|
+
// that refusal. Canonical phase list comes from workflow.js (a workflow.json seeded
|
|
241
|
+
// before a phase existed simply lacks its key — that phase is PENDING, not exempt);
|
|
242
|
+
// no workflow.json at all = standalone usage, guard skipped.
|
|
243
|
+
if (cmd === "file" && !args.includes("--anyway")) {
|
|
244
|
+
const wfPath = path.join(targetDir(name), "workflow.json");
|
|
245
|
+
if (fs.existsSync(wfPath)) {
|
|
246
|
+
const { PHASES } = require("./workflow.js");
|
|
247
|
+
const st = readJson(wfPath);
|
|
248
|
+
const beforeHuman = PHASES.slice(0, PHASES.findIndex((p) => p.key === "human")).map((p) => p.key);
|
|
249
|
+
const pending = beforeHuman.filter((k) => !(st.phases && st.phases[k] && st.phases[k].status === "pass"));
|
|
250
|
+
if (pending.length) {
|
|
251
|
+
console.error(`❌ refusing to file — earlier phase(s) not passed: ${pending.join(", ")}. A worker reviewing an unfinished clone returns flags you already know about and burns a credit.\n finish the gates first (node harness/workflow.js status ${name}), or override with --anyway if this round is deliberately out-of-band.`);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} else if (cmd === "file" && args.includes("--anyway")) {
|
|
256
|
+
console.error("⚠ --anyway: filing without the pre-human gates all green — deliberate out-of-band round");
|
|
257
|
+
}
|
|
258
|
+
// LOCAL review mode (--local): no PingHumans account, no tunnel, no credits — the
|
|
259
|
+
// kit's own serve hosts the review page at /__review (clone in an iframe, click-to-
|
|
260
|
+
// pin, the SAME verdict-button contract), and the submission lands in human-qa.json
|
|
261
|
+
// where verify reads it. The trust model is explicit: a local verdict is
|
|
262
|
+
// OPERATOR-trusted (an agent with browser control could forge it — PingHumans is the
|
|
263
|
+
// independent option), so the round records provider:"local" and every receipt built
|
|
264
|
+
// on it says so. Agents must never open or submit /__review themselves.
|
|
265
|
+
const local = args.includes("--local");
|
|
266
|
+
let draft = opt("--draft");
|
|
267
|
+
if (!local) {
|
|
268
|
+
// Default --draft to the tunnel this target RECORDED (harness/tunnel.js writes it
|
|
269
|
+
// only after byte-verifying it serves the clone).
|
|
270
|
+
const tp = path.join(targetDir(name), "tunnel.json");
|
|
271
|
+
if (!draft && fs.existsSync(tp)) draft = readJson(tp).url;
|
|
272
|
+
if (!draft || /localhost|127\.0\.0\.1/.test(draft)) {
|
|
273
|
+
console.error("need a PUBLIC draft url — a remote human opens it. Start a verified tunnel first:\n node harness/tunnel.js " + name + " [port] (records targets/" + name + "/tunnel.json, used as the default)\nor pass --draft <url> explicitly.\n(no PingHumans account? review locally instead: node harness/human-qa.js file " + name + " --local)");
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const spec = buildSpec(name, draft || "local review — served by ppk serve at /__review", opt("--region"), opt("--changelog"));
|
|
278
|
+
if (cmd === "template") { console.log(JSON.stringify(spec, null, 2)); return; }
|
|
279
|
+
if (local) {
|
|
280
|
+
const pingId = "local-" + require("crypto").randomBytes(6).toString("hex");
|
|
281
|
+
const hq = loadHq(name);
|
|
282
|
+
hq.rounds.push({ ping_id: pingId, provider: "local", draft_url: null, region: spec.title, approve_verdicts: spec.approve_verdicts, filed_at: new Date().toISOString(), spec: { title: spec.title, instructions: spec.instructions, steps: spec.steps, verdict_options: spec.verdict_options }, raw_response: null, last: null, checked_at: null });
|
|
283
|
+
saveHq(name, hq);
|
|
284
|
+
console.log(`✓ filed round ${hq.rounds.length} (LOCAL review) — ${pingId}\n approve verdict: "${spec.approve_verdicts[0]}"\n review it in a browser: http://localhost:8080/__review (ppk serve ${name} must be running)\n the reviewer pins what looks wrong and MUST pick a verdict button; then:\n node harness/human-qa.js verify ${name}\n ⚠ local verdicts are operator-trusted (recorded as provider:"local"); agents never open or submit /__review themselves.`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// A test filed against a dead/wrong tunnel burns the whole human round (hn round 1).
|
|
288
|
+
// Re-verify AT FILE TIME: reachable is required; byte-identical to clone/index.html is
|
|
289
|
+
// expected for a standalone clone (warn-only — a component served by an app dev server
|
|
290
|
+
// legitimately differs).
|
|
291
|
+
const idx = path.join(targetDir(name), "clone", "index.html");
|
|
292
|
+
if (fs.existsSync(idx)) {
|
|
293
|
+
const v = await require("./tunnel.js").verifyServes(draft, idx);
|
|
294
|
+
if (!v.ok && /unreachable|HTTP \d+/.test(v.reason)) { console.error(`❌ refusing to file — draft url is not serving: ${v.reason}`); process.exit(1); }
|
|
295
|
+
if (!v.ok) console.error(`⚠ ${v.reason} — filing anyway (expected only when the draft is not the standalone clone)`);
|
|
296
|
+
if (!/pinghumans\.com\/qa-toolbar\.js/.test(fs.readFileSync(idx, "utf8"))) console.error("⚠ clone/index.html has no PingHumans qa-toolbar <script> — workers can still review, but pinned comments/align data will be limited (capture-build --qa-toolbar adds it)");
|
|
297
|
+
}
|
|
298
|
+
const { approve_verdicts, ...toolArgs } = spec;
|
|
299
|
+
const r = await rpc("request_human_test", toolArgs);
|
|
300
|
+
if (!r.ping_id) throw new Error("filing returned no ping_id");
|
|
301
|
+
const round = pushRound(name, r.ping_id, spec, approve_verdicts);
|
|
302
|
+
console.log(`✓ filed round ${round} — ping ${r.ping_id}\n approve verdict: "${approve_verdicts[0]}"\n gate: node harness/workflow.js gate ${name} human (or: node harness/human-qa.js verify ${name})\n wake on result: npx cpyany wait ${r.ping_id}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (cmd === "record") {
|
|
307
|
+
if (!extra || !/^[0-9a-f-]{36}$/i.test(extra)) { console.error("usage: human-qa.js record <name> <ping_id> [--approve \"Verdict A,Verdict B\"]"); process.exit(2); }
|
|
308
|
+
const approve = (opt("--approve") || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
309
|
+
if (!approve.length) { console.error('record needs --approve "…" — the verdict string(s) that count as approval for the test you filed'); process.exit(2); }
|
|
310
|
+
const round = pushRound(name, extra, null, approve);
|
|
311
|
+
console.log(`✓ recorded round ${round} — ping ${extra} (approve: ${approve.join(" | ")})`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (cmd === "verify") {
|
|
316
|
+
const hq = loadHq(name);
|
|
317
|
+
if (!hq.rounds.length) { console.error(`no human QA round recorded — file one: node harness/human-qa.js file ${name} --draft <public-url>`); process.exit(1); }
|
|
318
|
+
const round = hq.rounds[hq.rounds.length - 1];
|
|
319
|
+
// Local rounds: the review page's submission (written by serve.js's /__review/submit)
|
|
320
|
+
// IS the result — same shape the API returns, same classification below, and the
|
|
321
|
+
// provider travels into the receipt so a local verdict is never mistaken for an
|
|
322
|
+
// independent one.
|
|
323
|
+
const sc = round.provider === "local"
|
|
324
|
+
? (round.raw_response || { status: "pending", n_received: 0, n_target: 1, responses: [] })
|
|
325
|
+
: await rpc("get_test_results", { ping_id: round.ping_id });
|
|
326
|
+
// Real response schema (verified empirically): the verdict pick is `choice`, prose is
|
|
327
|
+
// `free_text`; older/other shapes may use `verdict`/`notes` — accept both.
|
|
328
|
+
round.last = { status: sc.status, n_received: sc.n_received, n_target: sc.n_target, responses: (sc.responses || []).map((r) => ({ verdict: r.choice != null ? r.choice : (r.verdict != null ? r.verdict : null), notes: r.free_text || r.notes || r.comment || null })) };
|
|
329
|
+
round.checked_at = new Date().toISOString();
|
|
330
|
+
saveHq(name, hq);
|
|
331
|
+
const n = hq.rounds.length;
|
|
332
|
+
const resp = round.last.responses;
|
|
333
|
+
if (!resp.length) {
|
|
334
|
+
console.error(sc.status === "expired" ? `round ${n} EXPIRED unanswered (ping ${round.ping_id}) — refile` : `round ${n} pending — ${sc.n_received}/${sc.n_target} responses (ping ${round.ping_id})`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
// A response with NO verdict pick (comment-only) can never pass: inferring approval
|
|
338
|
+
// from prose is the exact hole the gates exist to close — "a pass is an explicit
|
|
339
|
+
// machine-checkable fact" applies to humans too. Surface what they wrote instead.
|
|
340
|
+
const unpicked = resp.filter((r) => r.verdict == null);
|
|
341
|
+
if (unpicked.length) {
|
|
342
|
+
console.error(`round ${n} has ${unpicked.length} response(s) with NO verdict pick — comments alone can't pass the gate${unpicked[0].notes ? `; reviewer wrote: "${unpicked[0].notes}"` : ""}\nask the reviewer to pick a verdict option, or refile: node harness/human-qa.js file ${name} --draft <url>`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
const rejected = resp.filter((r) => !round.approve_verdicts.includes(r.verdict));
|
|
346
|
+
if (rejected.length) {
|
|
347
|
+
console.error(`round ${n} NOT approved — ${rejected.map((r) => `"${r.verdict}"${r.notes ? ` — ${r.notes}` : ""}`).join("; ")}\nfix the flags (PLAYBOOK Phase 6: --inspect the flagged marks), redeploy, then refile: node harness/human-qa.js file ${name} --draft <url>`);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
console.log(`round ${n} approved by ${resp.length} human(s): "${resp[0].verdict}" (${round.provider === "local" ? "LOCAL review — operator-trusted, " : ""}ping ${round.ping_id})`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (cmd === "poll" || cmd === "poll-result") {
|
|
355
|
+
const hq = loadHq(name);
|
|
356
|
+
hq.polls = hq.polls || [];
|
|
357
|
+
// shared printer: exit 0 once ≥1 answer exists, 1 while pending/expired
|
|
358
|
+
const report = (sc, entry) => {
|
|
359
|
+
entry.last = { status: sc.status, n_received: sc.n_received, responses: (sc.responses || []).map((r) => ({ choice: r.choice != null ? r.choice : null, text: r.free_text || r.text || null })) };
|
|
360
|
+
entry.checked_at = new Date().toISOString();
|
|
361
|
+
saveHq(name, hq);
|
|
362
|
+
const resp = entry.last.responses;
|
|
363
|
+
if (resp.length) {
|
|
364
|
+
console.log(`poll answered (${resp.length}):` + resp.map((r) => `\n ${r.choice != null ? `[${r.choice}] ` : ""}${r.text || ""}`).join(""));
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
console.error(`poll ${entry.ping_id} ${sc.status || "pending"} — 0 answers yet; re-check (free): node harness/human-qa.js poll-result ${name} ${entry.ping_id}`);
|
|
368
|
+
return 1;
|
|
369
|
+
};
|
|
370
|
+
if (cmd === "poll-result") {
|
|
371
|
+
if (!extra) { console.error("usage: human-qa.js poll-result <name> <ping_id>"); process.exit(2); }
|
|
372
|
+
const entry = hq.polls.find((p) => p.ping_id === extra) || (() => { const e = { ping_id: extra, question: null, asked_at: null }; hq.polls.push(e); return e; })();
|
|
373
|
+
process.exit(report(await rpc("get_ping", { ping_id: extra }), entry));
|
|
374
|
+
}
|
|
375
|
+
const question = extra;
|
|
376
|
+
if (!question) { console.error('usage: human-qa.js poll <name> "question" [--choices "A,B,C"]'); process.exit(2); }
|
|
377
|
+
// A comparison-shaped question ("does the clone match the real page?") through the
|
|
378
|
+
// text-poll channel strips the reviewer of the side-by-side compare UI — flagged by
|
|
379
|
+
// the reviewer TWICE on the iphone17 run. Polls may reference ONE side; a question
|
|
380
|
+
// naming both sides is a comparison and belongs in a filed test (file --region "…"
|
|
381
|
+
// scopes a quick compare round). Refusal, not advice — prose rules kept being skipped.
|
|
382
|
+
const namesTheClone = /\b(clone|draft|replica|copy|ours?|our (page|site|version))\b/i.test(question);
|
|
383
|
+
const namesTheLive = /\b(original|real|live|reference|apple'?s?|actual)\b[^.]{0,40}\b(page|site|version|one)\b|\bthe (original|real thing)\b/i.test(question);
|
|
384
|
+
if (namesTheClone && namesTheLive && !args.includes("--allow-comparison")) {
|
|
385
|
+
console.error(`❌ refusing to poll — this question names BOTH the clone and the live page, which makes it a COMPARISON: the reviewer needs the side-by-side compare UI, not a text question.\n file a scoped compare round instead: node harness/human-qa.js file ${name} --region "<the section>"\n (or, if this genuinely isn't a comparison, re-run with --allow-comparison)`);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
// context the human needs to answer a visual question: the verified draft + original
|
|
389
|
+
let ctx = "";
|
|
390
|
+
try { ctx += `\nDraft: ${readJson(path.join(targetDir(name), "tunnel.json")).url}`; } catch (e) {}
|
|
391
|
+
try { ctx += `\nOriginal: ${readJson(path.join(targetDir(name), "target.json")).url}`; } catch (e) {}
|
|
392
|
+
const choices = (opt("--choices") || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
393
|
+
const args_ = { question: question + ctx, n_target: 1, deadline_seconds: 3600 };
|
|
394
|
+
if (choices.length) args_.choices = choices;
|
|
395
|
+
const sc = await rpc("ping_humans", args_, 320_000); // server blocks up to ~300s while a human answers
|
|
396
|
+
const entry = { ping_id: sc.ping_id || null, question, asked_at: new Date().toISOString(), last: null, checked_at: null };
|
|
397
|
+
hq.polls.push(entry);
|
|
398
|
+
process.exit(report(sc, entry));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
console.error(`unknown command "${cmd}" — template | file | record | verify | poll | poll-result`);
|
|
402
|
+
process.exit(2);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (require.main === module) main().catch((e) => { console.error(`human-qa: ${e.message}`); process.exit(1); });
|
|
406
|
+
module.exports = { buildSpec, resolveToken };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// harness/merge-snapshot-selftest.js — guards the incremental-capture merge
|
|
2
|
+
// (tools/merge-snapshot.js) and its honesty contract with the done gate. Offline.
|
|
3
|
+
// - partial elements overwrite; untouched elements carry over; merged stamp accumulates keys
|
|
4
|
+
// - width / compat-mode mismatch refused (would poison x-positions / #18)
|
|
5
|
+
// - the done gate refuses a merged (stamped) snapshot; a full re-capture (no stamp) clears it
|
|
6
|
+
// Run: node harness/merge-snapshot-selftest.js (regression.js runs it too)
|
|
7
|
+
"use strict";
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const cp = require("child_process");
|
|
11
|
+
const { mergeSnapshots } = require("../tools/merge-snapshot.js");
|
|
12
|
+
|
|
13
|
+
const KIT = path.resolve(__dirname, "..");
|
|
14
|
+
let failed = 0;
|
|
15
|
+
const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
|
|
16
|
+
|
|
17
|
+
console.log("merge-snapshot-selftest — incremental capture merge + done-gate honesty");
|
|
18
|
+
|
|
19
|
+
// ── merge semantics (pure) ────────────────────────────────────────────────────
|
|
20
|
+
const snap = (els, width, mode) => ({ viewport: { width }, mode: mode || "CSS1Compat", elements: els });
|
|
21
|
+
{
|
|
22
|
+
const full = snap({ a: { present: true, v: 1 }, b: { present: true, v: 1 }, c: { present: true, v: 1 } }, 1512);
|
|
23
|
+
const r = mergeSnapshots(full, snap({ b: { present: true, v: 2 } }, 1512));
|
|
24
|
+
ok(!r.error && full.elements.b.v === 2 && full.elements.a.v === 1 && r.carried === 2, "partial overwrites its keys; others carry over");
|
|
25
|
+
ok(full.merged && full.merged.keys.join() === "b", "merge stamps merged:{at,keys}");
|
|
26
|
+
mergeSnapshots(full, snap({ c: { present: true, v: 2 } }, 1512));
|
|
27
|
+
ok(full.merged.keys.sort().join() === "b,c", "successive merges ACCUMULATE stamped keys");
|
|
28
|
+
ok(mergeSnapshots(full, snap({ a: {} }, 1280)).error.includes("width"), "viewport-width mismatch refused");
|
|
29
|
+
ok(mergeSnapshots(full, snap({ a: {} }, 1512, "BackCompat")).error.includes("modes differ"), "compat-mode mismatch refused (#18)");
|
|
30
|
+
ok(mergeSnapshots(full, snap({}, 1512)).error.includes("no elements"), "empty partial refused");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── done-gate refusal of merged snapshots (through the real workflow) ────────
|
|
34
|
+
{
|
|
35
|
+
const run = (args) => { const r = cp.spawnSync(process.execPath, [path.join(KIT, "harness", "workflow.js"), ...args], { encoding: "utf8", cwd: KIT }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
|
|
36
|
+
const NAME = "mergeselftest_" + process.pid;
|
|
37
|
+
const dir = path.join(KIT, "targets", NAME);
|
|
38
|
+
process.on("exit", () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} });
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
fs.mkdirSync(path.join(dir, "clone"), { recursive: true });
|
|
41
|
+
fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name: NAME, url: "https://example.com/", width: 1512 }));
|
|
42
|
+
run(["init", NAME]);
|
|
43
|
+
const el = { present: true, rect: { x: 0, y: 0, w: 10, h: 10, top: 0, right: 10, bottom: 10, fromRight: 0 } };
|
|
44
|
+
const clean = snap({ logo: el }, 1512);
|
|
45
|
+
fs.writeFileSync(path.join(dir, "clone.json"), JSON.stringify(clean));
|
|
46
|
+
const stamped = { ...clean, merged: { at: "2026-07-05T00:00:00.000Z", keys: ["logo"] } };
|
|
47
|
+
fs.writeFileSync(path.join(dir, "live.json"), JSON.stringify(stamped));
|
|
48
|
+
const refused = run(["gate", NAME, "done"]);
|
|
49
|
+
ok(refused.code === 1 && /MERGED iteration snapshot/.test(refused.out) && /live\.json/.test(refused.out), "done gate refuses a merged-stamped snapshot, naming the file");
|
|
50
|
+
fs.writeFileSync(path.join(dir, "live.json"), JSON.stringify(clean));
|
|
51
|
+
const after = run(["gate", NAME, "done"]);
|
|
52
|
+
ok(!/MERGED iteration snapshot/.test(after.out), "a full re-capture (stamp gone) clears the refusal (done then blocks on ordinary pending phases only)");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(failed ? `\n❌ merge-snapshot-selftest: ${failed} assertion(s) failed.` : "\n✓ merge-snapshot-selftest: all assertions pass.");
|
|
56
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// harness/new-target.js <name> <url> [width=1728] — scaffold a fresh clone workspace.
|
|
2
|
+
//
|
|
3
|
+
// A "target" is a disposable instance under targets/<name>/. The KIT (tools + the .md
|
|
4
|
+
// instructions) is the product; targets are where you exercise it on a real site. The
|
|
5
|
+
// scaffold is plain HTML/CSS (no build step) so the pixel loop is instant: edit
|
|
6
|
+
// clone/ → refresh → capture → `node harness/score.js <name>`.
|
|
7
|
+
const fs = require("fs"), path = require("path");
|
|
8
|
+
const [, , name, url, widthArg] = process.argv;
|
|
9
|
+
if (!name || !url) { console.error("usage: node harness/new-target.js <name> <url> [width=1728]"); process.exit(1); }
|
|
10
|
+
const width = +(widthArg || 1728);
|
|
11
|
+
// targets/ live in the USER's current directory (WORK), not inside the installed kit (PKG).
|
|
12
|
+
const WORK = process.cwd();
|
|
13
|
+
const dir = path.join(WORK, "targets", name);
|
|
14
|
+
if (fs.existsSync(dir)) { console.error(`targets/${name} already exists — delete it first or pick another name`); process.exit(1); }
|
|
15
|
+
fs.mkdirSync(path.join(dir, "clone", "assets"), { recursive: true });
|
|
16
|
+
|
|
17
|
+
fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name, url, width }, null, 2) + "\n");
|
|
18
|
+
|
|
19
|
+
// Seed the ENFORCED workflow state machine (the kit's `gjc`). From here on the clone must
|
|
20
|
+
// pass each phase gate IN ORDER — nothing can claim "pixel-perfect" without the receipts.
|
|
21
|
+
require("./workflow.js").initWorkflow(name, url, width);
|
|
22
|
+
|
|
23
|
+
fs.writeFileSync(path.join(dir, "clone", "index.html"), `<!doctype html>
|
|
24
|
+
<!-- CHECK LIVE'S DOCTYPE FIRST: if the live page ships no doctype it renders in QUIRKS
|
|
25
|
+
mode (document.compatMode === "BackCompat") and this standards-mode doctype will
|
|
26
|
+
shift table-cell line boxes with every computed style identical — delete it to
|
|
27
|
+
match (LEARNINGS #18). The capture records \`mode\` and the gate fails a mismatch. -->
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8" />
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
32
|
+
<title>${name} — header clone</title>
|
|
33
|
+
<link rel="stylesheet" href="styles.css" />
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<!-- DEFAULT BUILD STRATEGY: capture, don't reconstruct (LEARNINGS #19). Capture the
|
|
37
|
+
live page's post-hydration DOM (pxSendDom → dom.html) and run
|
|
38
|
+
\`ppk capture-build <name>\` — it REPLACES this whole scaffold with the captured
|
|
39
|
+
markup + self-hosted CSS/fonts, inheriting live's doctype and drawing techniques
|
|
40
|
+
by construction. Hand-rebuild here (to MEASUREMENTS, never guesses) only when the
|
|
41
|
+
deliverable is a component in your own stack. See ../../PLAYBOOK.md Phase 4. -->
|
|
42
|
+
<header class="site-header">
|
|
43
|
+
<!-- TODO: build to spec -->
|
|
44
|
+
</header>
|
|
45
|
+
|
|
46
|
+
<!-- Capture (with \`node tools/sink.js\` running on :7799):
|
|
47
|
+
const s = await fetch('/tools/browser-capture.js').then(r => r.text()); (0, eval)(s);
|
|
48
|
+
window.pxRegion = { maxY: 135 };
|
|
49
|
+
window.pxTargets = [ ["logo", () => document.querySelector('.logo'), false], ... ];
|
|
50
|
+
await pxSend('http://localhost:7799/clone.json'); -->
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
fs.writeFileSync(path.join(dir, "clone", "styles.css"), `/* ${name} clone — build to measured values */
|
|
56
|
+
* { box-sizing: border-box; }
|
|
57
|
+
html, body { margin: 0; padding: 0; }
|
|
58
|
+
body {
|
|
59
|
+
/* Learned default (LEARNINGS #13): most design systems ship antialiased smoothing —
|
|
60
|
+
matching it up front avoids a "looks thicker" miss the gate would otherwise flag. */
|
|
61
|
+
-webkit-font-smoothing: antialiased;
|
|
62
|
+
-moz-osx-font-smoothing: grayscale;
|
|
63
|
+
}
|
|
64
|
+
`);
|
|
65
|
+
|
|
66
|
+
fs.writeFileSync(path.join(dir, "scores.jsonl"), "");
|
|
67
|
+
fs.writeFileSync(path.join(dir, "NOTES.md"), `# ${name} — ${url} @ ${width}px
|
|
68
|
+
|
|
69
|
+
## Iteration log
|
|
70
|
+
Record each loop: what a human/you flagged, whether the **gate** caught it, and the fix.
|
|
71
|
+
A flag the gate MISSED is the valuable one — it becomes a tool check + fixture (see DEVELOP.md).
|
|
72
|
+
|
|
73
|
+
| round | flagged | gate caught it? | fix | kit change (tool/fixture/instruction) |
|
|
74
|
+
|-------|---------|-----------------|-----|----------------------------------------|
|
|
75
|
+
| | | | | |
|
|
76
|
+
|
|
77
|
+
## Site-specific findings
|
|
78
|
+
(structure quirks, faux-bold, etc. — generalize the durable ones into LEARNINGS.md)
|
|
79
|
+
`);
|
|
80
|
+
|
|
81
|
+
// Print next steps that are RUNNABLE in the invoking context: `ppk …` when launched via the
|
|
82
|
+
// installed ppk entrypoint (PPK_ENTRY set by its delegate), `node harness/…` when someone in
|
|
83
|
+
// the repo ran this script directly (they have no ppk on PATH).
|
|
84
|
+
const viaPpk = process.env.PPK_ENTRY === "1";
|
|
85
|
+
const c = (ppkForm, nodeForm) => (viaPpk ? ppkForm : nodeForm);
|
|
86
|
+
console.log(`created targets/${name}/ (url=${url}, width=${width})
|
|
87
|
+
|
|
88
|
+
next:
|
|
89
|
+
${c("ppk sink &", "node tools/sink.js &")} # snapshot receiver on :7799
|
|
90
|
+
${c(`ppk serve ${name}`, `node harness/serve.js ${name}`)} # serves clone + /tools on :8080
|
|
91
|
+
# 1. open the live site at ${width}px, measure it (RUNBOOK), POST live.json into targets/${name}/
|
|
92
|
+
# 2. build clone/index.html — DEFAULT: capture it (RUNBOOK "Build by capture"):
|
|
93
|
+
# live tab: await pxSendDom('http://localhost:7799/dom.html')
|
|
94
|
+
# then: ${c(`ppk capture-build ${name}`, `node harness/capture-build.js ${name}`)}
|
|
95
|
+
# (hand-rebuild to measurements only when the deliverable is a component in your stack)
|
|
96
|
+
# 3. capture clone.json, then:
|
|
97
|
+
${c(`ppk score ${name}`, `node harness/score.js ${name}`)} # scores this run vs the last (is it better?)
|
|
98
|
+
|
|
99
|
+
the enforced workflow tracks your phases (target → assets → measure → build → visual → coverage → strict → done):
|
|
100
|
+
${c(`ppk status ${name}`, `node harness/workflow.js status ${name}`)} # what's done, what's next, whether its gate passes
|
|
101
|
+
${c(`ppk advance ${name} <phase>`, `node harness/workflow.js advance ${name} <phase>`)} # record a phase — refuses unless its gate exits 0`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// harness/regression.js — the kit's guardrail. Runs the gate-guard (tools/selftest.js)
|
|
2
|
+
// plus every harness/fixtures/*.js. Each class of miss found while cloning a real site
|
|
3
|
+
// becomes a fixture here, so it can never silently come back (that's the whole point:
|
|
4
|
+
// a lesson goes into the TOOL + a fixture, not into a human checklist). Exit nonzero if
|
|
5
|
+
// any fail — wire this into CI or run it before shipping a pixel-diff.js change.
|
|
6
|
+
const { execFileSync } = require("child_process");
|
|
7
|
+
const fs = require("fs"), path = require("path");
|
|
8
|
+
const KIT = path.resolve(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
const tests = [path.join(KIT, "tools", "selftest.js"), path.join(KIT, "tools", "cli-selftest.js"), path.join(KIT, "harness", "workflow-selftest.js"), path.join(KIT, "harness", "capture-build-selftest.js"), path.join(KIT, "harness", "human-qa-selftest.js"), path.join(KIT, "harness", "tunnel-selftest.js"), path.join(KIT, "harness", "behavior-selftest.js"), path.join(KIT, "harness", "merge-snapshot-selftest.js"), path.join(KIT, "harness", "doctor-selftest.js"), path.join(KIT, "harness", "setup-selftest.js")];
|
|
11
|
+
const fixDir = path.join(__dirname, "fixtures");
|
|
12
|
+
if (fs.existsSync(fixDir))
|
|
13
|
+
for (const f of fs.readdirSync(fixDir).filter((f) => f.endsWith(".js")).sort()) tests.push(path.join(fixDir, f));
|
|
14
|
+
// the detection-power battery (absolute mode) — asserts the current gate catches every
|
|
15
|
+
// known defect class and flags no control. A broader guard than the per-class fixtures.
|
|
16
|
+
const bench = path.join(__dirname, "benchmarks", "detection-power.js");
|
|
17
|
+
if (fs.existsSync(bench)) tests.push(bench);
|
|
18
|
+
|
|
19
|
+
let failed = 0;
|
|
20
|
+
|
|
21
|
+
// SYNTAX GUARD — a kit script that doesn't parse is a broken kit even when no test
|
|
22
|
+
// imports it. Paid for: a template-literal typo in new-target.js shipped while this
|
|
23
|
+
// suite was green, because the scaffold is only ever run by users, never by a test
|
|
24
|
+
// (caught by an agent mid-clone when `ppk new` wouldn't start). `node --check` every
|
|
25
|
+
// kit script so the whole surface at least parses.
|
|
26
|
+
const syntaxFiles = [path.join(KIT, "bin", "ppk")];
|
|
27
|
+
for (const root of ["tools", "harness"].map((d) => path.join(KIT, d))) {
|
|
28
|
+
const walk = (d) => {
|
|
29
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
30
|
+
const fp = path.join(d, e.name);
|
|
31
|
+
if (e.isDirectory()) walk(fp);
|
|
32
|
+
else if (e.name.endsWith(".js")) syntaxFiles.push(fp);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
if (fs.existsSync(root)) walk(root);
|
|
36
|
+
}
|
|
37
|
+
let syntaxFailed = 0;
|
|
38
|
+
for (const f of syntaxFiles) {
|
|
39
|
+
try { execFileSync("node", ["--check", f], { stdio: "pipe" }); }
|
|
40
|
+
catch (e) { syntaxFailed++; failed++; console.log(`✗ syntax ${path.relative(KIT, f)}`); process.stdout.write((e.stderr || "").toString()); }
|
|
41
|
+
}
|
|
42
|
+
if (!syntaxFailed) console.log(`✓ syntax — all ${syntaxFiles.length} kit scripts parse (node --check)`);
|
|
43
|
+
|
|
44
|
+
for (const t of tests) {
|
|
45
|
+
const label = path.relative(KIT, t);
|
|
46
|
+
try { execFileSync("node", [t], { stdio: "pipe" }); console.log(`✓ ${label}`); }
|
|
47
|
+
catch (e) { failed++; console.log(`✗ ${label}`); process.stdout.write((e.stdout || "").toString() + (e.stderr || "").toString()); }
|
|
48
|
+
}
|
|
49
|
+
console.log(failed ? `\n❌ regression: ${failed} of ${tests.length} file(s) failed.` : `\n✓ regression: all ${tests.length} file(s) pass — the gate still catches every known class of miss.`);
|
|
50
|
+
process.exit(failed ? 1 : 0);
|