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,34 @@
|
|
|
1
|
+
# benchmarks — score the gate's detection power (the scientific instrument)
|
|
2
|
+
|
|
3
|
+
Fixtures lock in *one* past miss each. This battery scores the gate across *all* known
|
|
4
|
+
defect classes at once, so a proposed change to the gate can be judged objectively:
|
|
5
|
+
**did it catch more without inventing false positives?**
|
|
6
|
+
|
|
7
|
+
- **`battery.js`** — a fixed, labelled corpus of snapshot pairs: DEFECTS (a correct gate
|
|
8
|
+
must FAIL) and CONTROLS incl. adversarial false-positive hunters (a correct gate must
|
|
9
|
+
PASS). Each case traces to a `LEARNINGS.md` entry. Extend it whenever a new class of
|
|
10
|
+
miss — or a new false-positive risk — is found.
|
|
11
|
+
- **`detection-power.js`** — the runner, two modes:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
# ABSOLUTE (CI guard, run by harness/regression.js): the current gate must catch every
|
|
15
|
+
# defect and flag no control. Exit 0 = clean.
|
|
16
|
+
node harness/benchmarks/detection-power.js
|
|
17
|
+
|
|
18
|
+
# A/B: compare a baseline gate to the working-tree gate, case by case. Run this BEFORE
|
|
19
|
+
# adopting a gate change — it isolates exactly what the change adds or breaks.
|
|
20
|
+
node harness/benchmarks/detection-power.js --vs HEAD
|
|
21
|
+
node harness/benchmarks/detection-power.js --vs path/to/old/pixel-diff.js
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The rule for changing the gate (the method)
|
|
25
|
+
|
|
26
|
+
Never adopt a gate change on "looks better." Score it:
|
|
27
|
+
|
|
28
|
+
1. Add the new defect (and any false-positive risk it introduces) as cases in `battery.js`.
|
|
29
|
+
2. `--vs HEAD` — the bar is **+N defect classes gained, 0 regressions** (0 missed defects,
|
|
30
|
+
0 new false positives). A single-variable diff is ideal.
|
|
31
|
+
3. If it shows a regression (e.g. an adversarial control now flags), fix the gate and
|
|
32
|
+
re-run until the A/B is clean — *then* commit. The backdrop-colour gate (`bg`, #16) was
|
|
33
|
+
adopted this way: the naive version false-positived on translucent/whitespace controls;
|
|
34
|
+
the battery caught it before commit.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// battery.js — the fixed set of known visual DEFECTS + negative CONTROLS the gate is
|
|
2
|
+
// scored against. This is the kit's detection-power *instrument*: a labelled corpus,
|
|
3
|
+
// separate from any one target, so a proposed gate change can be measured objectively
|
|
4
|
+
// (see detection-power.js). Each case is a pair of snapshots differing by exactly one
|
|
5
|
+
// thing:
|
|
6
|
+
// • DEFECT → a visibly wrong clone; a correct gate must FAIL it (catch it). A miss is
|
|
7
|
+
// a defect that would slip a green run and cost a human round.
|
|
8
|
+
// • CONTROL → visually identical; a correct gate must PASS it. A flag here is a FALSE
|
|
9
|
+
// POSITIVE — friction the gate invented, which can regress a previously-green clone.
|
|
10
|
+
// Add a case whenever a new class of miss (or a new false-positive risk) is found — the
|
|
11
|
+
// battery is how "did this change improve detection without adding noise?" becomes a
|
|
12
|
+
// number instead of a vibe. Zero deps, no DOM, fully deterministic.
|
|
13
|
+
|
|
14
|
+
const textEl = (over = {}) => ({
|
|
15
|
+
present: true,
|
|
16
|
+
rect: { x: 100, y: 80, w: 180, h: 17, top: 80, right: 280, bottom: 97, fromRight: 200 },
|
|
17
|
+
font: { family: "proxima-nova", weight: "600", size: 14, line: 18.2, spacing: 0.7,
|
|
18
|
+
transform: "uppercase", color: "rgb(0,0,0)", decoration: "none", smoothing: "antialiased",
|
|
19
|
+
underline: false, ...(over.font || {}) },
|
|
20
|
+
box: { padT: 0, padB: 0 }, layout: { display: "flex", position: "static" }, parent: { display: "flex", gap: 8 },
|
|
21
|
+
text: { x: 100, right: 280, top: 80, bottom: 97, w: 180, h: 17, ...(over.text || {}) },
|
|
22
|
+
underline: over.underline !== undefined ? over.underline : { present: false },
|
|
23
|
+
bg: over.bg !== undefined ? over.bg : "rgb(255,255,255)",
|
|
24
|
+
});
|
|
25
|
+
const gfxEl = (over = {}) => ({
|
|
26
|
+
present: true,
|
|
27
|
+
rect: { x: 100, y: 79, w: 20, h: 20, top: 79, right: 120, bottom: 99, fromRight: 200 },
|
|
28
|
+
font: {}, box: {}, layout: { display: "inline-block", position: "static" }, parent: { display: "flex", gap: 8 },
|
|
29
|
+
glyph: { cx: 110, cy: 89, w: 20, h: 20, bgPos: "0% 0%", ...(over.glyph || {}) },
|
|
30
|
+
bg: over.bg !== undefined ? over.bg : "rgb(255,255,255)",
|
|
31
|
+
});
|
|
32
|
+
const snap = (el) => ({ viewport: { width: 1728 }, elements: { t: el } });
|
|
33
|
+
|
|
34
|
+
const UL = { present: true, thickness: 2, x: 90, right: 280, w: 190, top: 99, bottom: 101 };
|
|
35
|
+
|
|
36
|
+
// [name, kind, liveEl, cloneEl, note, mods?]. Each name should trace to a LEARNINGS entry.
|
|
37
|
+
// mods = optional snapshot-level overrides for page-level props: { liveMode, cloneMode }.
|
|
38
|
+
const battery = [
|
|
39
|
+
// ── DEFECTS — a correct gate FAILS each ──
|
|
40
|
+
["backdrop-color", "defect", textEl({ bg: "rgb(113,198,235)" }), textEl({ bg: "rgb(255,0,0)" }), "blue bar vs red bar (#16)"],
|
|
41
|
+
["text-color", "defect", textEl(), textEl({ font: { color: "rgb(255,255,255)" } }), "black vs white text (#10)"],
|
|
42
|
+
["underline-missing", "defect", textEl({ underline: UL, font: { underline: true } }), textEl({ underline: { present: false }, font: { underline: false } }), "underline present vs gone (#11)"],
|
|
43
|
+
["underline-thin", "defect", textEl({ underline: UL }), textEl({ underline: { ...UL, thickness: 1 } }), "2px vs 1px (#12)"],
|
|
44
|
+
["underline-short", "defect", textEl({ underline: UL }), textEl({ underline: { ...UL, w: 150, right: 240 } }), "190 vs 150 wide (#12)"],
|
|
45
|
+
["underline-y", "defect", textEl({ underline: UL }), textEl({ underline: { ...UL, top: 103, bottom: 105 } }), "shifted 4px down (#12)"],
|
|
46
|
+
["smoothing", "defect", textEl(), textEl({ font: { smoothing: "auto" } }), "antialiased vs auto (#13)"],
|
|
47
|
+
["glyph-cy", "defect", gfxEl(), gfxEl({ glyph: { cy: 92 } }), "icon 3px low (#9)"],
|
|
48
|
+
["glyph-cx", "defect", gfxEl(), gfxEl({ glyph: { cx: 114 } }), "icon 4px right (#1/#9)"],
|
|
49
|
+
["line-height", "defect", textEl(), textEl({ font: { line: 14 } }), "18.2 vs 14 (#2)"],
|
|
50
|
+
["line-strut", "defect", textEl({ font: { strut: 16 } }), textEl({ font: { strut: "normal" } }), "container 16px vs normal, leaf matches (#17)"],
|
|
51
|
+
["line-strut-numeric", "defect", textEl({ font: { strut: 16 } }), textEl({ font: { strut: 18 } }), "container 16 vs 18 (#17)"],
|
|
52
|
+
["compat-mode", "defect", textEl(), textEl(), "quirks vs standards doc mode (#18)", { liveMode: "BackCompat", cloneMode: "CSS1Compat" }],
|
|
53
|
+
["letter-spacing", "defect", textEl(), textEl({ font: { spacing: 2 } }), "0.7 vs 2 (#2)"],
|
|
54
|
+
["font-size", "defect", textEl(), textEl({ font: { size: 16 } }), "14 vs 16 (#3)"],
|
|
55
|
+
// ── CONTROLS — a correct gate PASSES each (no false positive) ──
|
|
56
|
+
["identical-text", "control", textEl(), textEl(), "same in every paint prop"],
|
|
57
|
+
["identical-gfx", "control", gfxEl(), gfxEl(), "same glyph"],
|
|
58
|
+
["structural-only", "control", textEl(), textEl({ box: { padT: 24, padB: 24 }, layout: { display: "block", position: "relative" }, parent: { display: "block", gap: 0 }, font: { family: "proxima" } }), "flex/pad/alias differ, pixels same (#6)"],
|
|
59
|
+
["backdrop-transparent","control", textEl({ bg: "rgba(0, 0, 0, 0)" }), textEl({ bg: "rgba(0, 0, 0, 0)" }), "both transparent → skipped"],
|
|
60
|
+
// adversarial controls — false-positive hunters for the backdrop gate (#16)
|
|
61
|
+
["adv-same-translucent","control", textEl({ bg: "rgba(0, 0, 0, 0.5)" }), textEl({ bg: "rgba(0, 0, 0, 0.5)" }), "same translucent → pass"],
|
|
62
|
+
["adv-translucent-vs-solid","control", textEl({ bg: "rgba(0, 0, 0, 0.5)" }), textEl({ bg: "rgb(128,128,128)" }), "translucent vs solid — out of scope, no flag"],
|
|
63
|
+
["adv-whitespace", "control", textEl({ bg: "rgb(113,198,235)" }), textEl({ bg: "rgb(113, 198, 235)" }), "same colour, different spacing"],
|
|
64
|
+
// adversarial controls — false-positive hunters for the strut gate (#17)
|
|
65
|
+
["adv-strut-same-normal","control", textEl({ font: { strut: "normal" } }), textEl({ font: { strut: "normal" } }), "same technique on both → pass"],
|
|
66
|
+
["adv-strut-old-schema","control", textEl(), textEl({ font: { strut: 16 } }), "one capture predates strut → skipped, no flag"],
|
|
67
|
+
// adversarial controls — false-positive hunters for the compat-mode gate (#18)
|
|
68
|
+
["adv-mode-same", "control", textEl(), textEl(), "both quirks → pass", { liveMode: "BackCompat", cloneMode: "BackCompat" }],
|
|
69
|
+
["adv-mode-old-schema","control", textEl(), textEl(), "one capture predates mode → skipped, no flag", { liveMode: "BackCompat" }],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// Score a gate (a diffSnapshots fn) over the battery. Returns per-case rows + totals.
|
|
73
|
+
function scoreGate(diffSnapshots) {
|
|
74
|
+
let caught = 0, defects = 0, falsePos = 0, controls = 0;
|
|
75
|
+
const rows = battery.map(([name, kind, live, clone, note, mods]) => {
|
|
76
|
+
const L = snap(live), C = snap(clone);
|
|
77
|
+
if (mods) { if (mods.liveMode) L.mode = mods.liveMode; if (mods.cloneMode) C.mode = mods.cloneMode; }
|
|
78
|
+
const pass = diffSnapshots(L, C, { visual: true }).ok;
|
|
79
|
+
if (kind === "defect") { defects++; if (!pass) caught++; }
|
|
80
|
+
else { controls++; if (!pass) falsePos++; }
|
|
81
|
+
return { name, kind, pass, note, correct: kind === "defect" ? !pass : pass };
|
|
82
|
+
});
|
|
83
|
+
return { rows, caught, defects, falsePos, controls };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { battery, scoreGate, snap };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// detection-power.js — score the gate's detection power against the fixed battery.
|
|
2
|
+
//
|
|
3
|
+
// This is the kit's SCIENTIFIC instrument for gate changes: "did this change catch more
|
|
4
|
+
// defects without inventing false positives?" as a number, not a vibe. Two modes:
|
|
5
|
+
//
|
|
6
|
+
// node harness/benchmarks/detection-power.js
|
|
7
|
+
// ABSOLUTE (CI guard): score the CURRENT gate. Exit 0 only if every DEFECT is
|
|
8
|
+
// caught and NO control is flagged. Wired into harness/regression.js.
|
|
9
|
+
//
|
|
10
|
+
// node harness/benchmarks/detection-power.js --vs HEAD
|
|
11
|
+
// node harness/benchmarks/detection-power.js --vs <path/to/old/pixel-diff.js>
|
|
12
|
+
// A/B: compare a BASELINE gate (a git ref's tools/pixel-diff.js, or a file) to the
|
|
13
|
+
// current working-tree gate, case by case. Use this BEFORE adopting a gate change —
|
|
14
|
+
// it isolates exactly what the change adds (caught) or breaks (false positive). A
|
|
15
|
+
// single-variable diff (one new row of ← DIFFERENCE, zero new FALSE+) is the bar.
|
|
16
|
+
//
|
|
17
|
+
// Zero deps, deterministic, no DOM. Extend the corpus in battery.js.
|
|
18
|
+
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { scoreGate } = require("./battery.js");
|
|
21
|
+
const NEW = require("../../tools/pixel-diff.js");
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const vsIdx = args.indexOf("--vs");
|
|
25
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
26
|
+
|
|
27
|
+
if (vsIdx === -1) {
|
|
28
|
+
// ── ABSOLUTE mode ── current gate must catch all defects and flag no controls.
|
|
29
|
+
const r = scoreGate(NEW.diffSnapshots);
|
|
30
|
+
console.log(pad("case", 24) + pad("kind", 9) + "result");
|
|
31
|
+
console.log("─".repeat(52));
|
|
32
|
+
for (const row of r.rows)
|
|
33
|
+
console.log(pad(row.name, 24) + pad(row.kind, 9) +
|
|
34
|
+
(row.correct ? (row.kind === "defect" ? "caught ✓" : "pass ✓") : (row.kind === "defect" ? "MISS ✗" : "FALSE+ ✗")));
|
|
35
|
+
console.log("─".repeat(52));
|
|
36
|
+
console.log(`defects caught: ${r.caught}/${r.defects}`);
|
|
37
|
+
console.log(`false positives: ${r.falsePos}/${r.controls}`);
|
|
38
|
+
const ok = r.caught === r.defects && r.falsePos === 0;
|
|
39
|
+
console.log(ok
|
|
40
|
+
? `\n✓ detection-power: all ${r.defects} defect classes caught, 0 false positives.`
|
|
41
|
+
: `\n❌ detection-power: ${r.defects - r.caught} missed, ${r.falsePos} false positive(s).`);
|
|
42
|
+
process.exit(ok ? 0 : 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── A/B mode ── load a baseline gate and compare it to the current one.
|
|
46
|
+
const ref = args[vsIdx + 1];
|
|
47
|
+
if (!ref) { console.error("usage: --vs <git-ref | path/to/pixel-diff.js>"); process.exit(2); }
|
|
48
|
+
let baselinePath = ref;
|
|
49
|
+
if (!ref.includes("/") && !ref.endsWith(".js")) {
|
|
50
|
+
// treat as a git ref → materialise its tools/pixel-diff.js to a temp file we can require
|
|
51
|
+
const { execFileSync } = require("child_process");
|
|
52
|
+
const os = require("os"), fs = require("fs");
|
|
53
|
+
const src = execFileSync("git", ["show", `${ref}:tools/pixel-diff.js`], { cwd: path.resolve(__dirname, "..", "..") });
|
|
54
|
+
baselinePath = path.join(os.tmpdir(), `pixel-diff.${ref.replace(/[^a-z0-9]/gi, "_")}.js`);
|
|
55
|
+
fs.writeFileSync(baselinePath, src);
|
|
56
|
+
}
|
|
57
|
+
const OLD = require(path.resolve(baselinePath));
|
|
58
|
+
const a = scoreGate(OLD.diffSnapshots), b = scoreGate(NEW.diffSnapshots);
|
|
59
|
+
|
|
60
|
+
console.log(pad("case", 24) + pad("kind", 9) + pad(`OLD (${ref})`, 16) + pad("NEW (worktree)", 16) + "note");
|
|
61
|
+
console.log("─".repeat(92));
|
|
62
|
+
let gained = 0, broke = 0;
|
|
63
|
+
for (let i = 0; i < a.rows.length; i++) {
|
|
64
|
+
const ra = a.rows[i], rb = b.rows[i];
|
|
65
|
+
const mark = (row) => row.kind === "defect" ? (row.pass ? "MISS" : "caught") : (row.pass ? "pass" : "FALSE+");
|
|
66
|
+
let flag = "";
|
|
67
|
+
if (ra.kind === "defect" && !ra.pass !== !rb.pass) { flag = rb.correct ? " ← GAINED" : " ← LOST"; rb.correct ? gained++ : broke++; }
|
|
68
|
+
if (ra.kind === "control" && ra.pass && !rb.pass) { flag = " ← NEW FALSE+"; broke++; }
|
|
69
|
+
console.log(pad(ra.name, 24) + pad(ra.kind, 9) + pad(mark(ra), 16) + pad(mark(rb), 16) + ra.note + flag);
|
|
70
|
+
}
|
|
71
|
+
console.log("─".repeat(92));
|
|
72
|
+
console.log(`defects caught: OLD ${a.caught}/${a.defects} NEW ${b.caught}/${b.defects}`);
|
|
73
|
+
console.log(`false positives: OLD ${a.falsePos}/${a.controls} NEW ${b.falsePos}/${b.controls}`);
|
|
74
|
+
console.log(`\nverdict: +${gained} defect class(es) gained, ${broke} regression(s) (missed defect or new false positive).`);
|
|
75
|
+
process.exit(broke ? 1 : 0);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// harness/capture-build-selftest.js — guards the capture-build tool (the default build
|
|
2
|
+
// strategy, LEARNINGS #19). Runs capture-build end-to-end against a file:// "live site"
|
|
3
|
+
// (offline, socket-free — sandboxes that block loopback connects can still run it) and
|
|
4
|
+
// asserts every contract:
|
|
5
|
+
// - doctype PRESERVED byte-for-byte — absent stays absent (quirks, #18), present stays present
|
|
6
|
+
// - <script>/script-preloads/CSP <meta>/<base> stripped
|
|
7
|
+
// - stylesheets downloaded + self-hosted; SRI/crossorigin dropped from rewritten links
|
|
8
|
+
// - fonts downloaded to /assets/fonts/ with REAL bytes (the assets gate checks wOF2 magic)
|
|
9
|
+
// - every other ref absolutized (src/srcset/poster/inline-style url()); data: untouched;
|
|
10
|
+
// anchor hrefs untouched; loading=lazy → eager
|
|
11
|
+
// - a failed stylesheet download exits nonzero (pixel-determining, never silent)
|
|
12
|
+
// Run: node harness/capture-build-selftest.js (regression.js runs it too)
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const { execFileSync } = require("child_process");
|
|
19
|
+
const { pathToFileURL } = require("url");
|
|
20
|
+
|
|
21
|
+
const TOOL = path.join(__dirname, "capture-build.js");
|
|
22
|
+
let failed = 0;
|
|
23
|
+
const check = (label, ok, detail) => {
|
|
24
|
+
console.log(`${ok ? "✓" : "✗"} ${label}${ok || !detail ? "" : ` — ${detail}`}`);
|
|
25
|
+
if (!ok) failed++;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "ppk-capbuild-"));
|
|
29
|
+
|
|
30
|
+
// ── a tiny live-site stand-in on disk (served via file:// — no sockets) ───────
|
|
31
|
+
const site = path.join(work, "site");
|
|
32
|
+
fs.mkdirSync(path.join(site, "assets"), { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(site, "assets", "brand.woff2"), Buffer.concat([Buffer.from("wOF2"), Buffer.alloc(28, 7)]));
|
|
34
|
+
fs.writeFileSync(path.join(site, "assets", "site.css"), [
|
|
35
|
+
'@font-face{font-family:X;src:url(./brand.woff2) format("woff2")}',
|
|
36
|
+
"@import url(./extra.css);",
|
|
37
|
+
".rel{background:url(./img/rel.png)}",
|
|
38
|
+
".abs{background:url(https://cdn.example.com/pic.png)}",
|
|
39
|
+
".data{background:url(data:image/png;base64,AAAA)}",
|
|
40
|
+
].join("\n"));
|
|
41
|
+
const origin = pathToFileURL(site).href; // file:///…/site — target.json url = `${origin}/`
|
|
42
|
+
|
|
43
|
+
function domHtml(doctype, cssHref) {
|
|
44
|
+
return `${doctype}<html><head>
|
|
45
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
|
|
46
|
+
<base href="https://example.com/">
|
|
47
|
+
<link rel="preload" href="./app.js" as="script">
|
|
48
|
+
<link rel="modulepreload" href="./chunk.mjs">
|
|
49
|
+
<link rel="stylesheet" href="${cssHref}" integrity="sha384-bogus" crossorigin="anonymous">
|
|
50
|
+
<link rel="preconnect" href="https://cdn.example.com/css2?family=A&family=B&display=swap">
|
|
51
|
+
<style>.inline{background:url(/img/inline.png)}</style>
|
|
52
|
+
<script src="./app.js"></script>
|
|
53
|
+
<script>window.hydrate()</script>
|
|
54
|
+
</head><body>
|
|
55
|
+
<img loading="lazy" src="./img/a.png" srcset="./img/a.png 1x, ./img/a@2x.png 2x">
|
|
56
|
+
<video poster="./img/poster.jpg"><source src="./vid/v.mp4"></video>
|
|
57
|
+
<a href="/pricing">Pricing</a>
|
|
58
|
+
</body></html>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Run capture-build for one synthetic target; returns { status, out, cloneDir }.
|
|
62
|
+
function run(name, doctype, cssHref, extraFlags) {
|
|
63
|
+
const dir = path.join(work, "targets", name);
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name, url: `${origin}/`, width: 1280 }));
|
|
66
|
+
fs.writeFileSync(path.join(dir, "dom.html"), domHtml(doctype, cssHref));
|
|
67
|
+
let out = "", status = 0;
|
|
68
|
+
try { out = execFileSync("node", [TOOL, name, ...(extraFlags || [])], { cwd: work, stdio: "pipe" }).toString(); }
|
|
69
|
+
catch (e) { status = e.status; out = (e.stdout || "").toString() + (e.stderr || "").toString(); }
|
|
70
|
+
return { status, out, cloneDir: path.join(dir, "clone") };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── case 1: quirks-mode capture (no doctype) — the full contract ──────────────
|
|
74
|
+
const r1 = run("quirks", "", "./assets/site.css");
|
|
75
|
+
check("quirks build exits 0", r1.status === 0, r1.out);
|
|
76
|
+
const idx = fs.readFileSync(path.join(r1.cloneDir, "index.html"), "utf8");
|
|
77
|
+
check("absent doctype PRESERVED (quirks stays quirks, #18)", /^<html/.test(idx));
|
|
78
|
+
check("no <script> survives", !/<script/i.test(idx));
|
|
79
|
+
check("CSP <meta> stripped", !/content-security-policy/i.test(idx));
|
|
80
|
+
check("<base> stripped", !/<base\b/i.test(idx));
|
|
81
|
+
check("script preload + modulepreload stripped", !/app\.js|chunk\.mjs/.test(idx));
|
|
82
|
+
check("stylesheet href rewritten to self-hosted path", idx.includes('href="/assets/css/00-site.css"'));
|
|
83
|
+
check("SRI + crossorigin dropped from rewritten link", !/integrity=|crossorigin=/i.test(idx));
|
|
84
|
+
check("inline <style> root-relative url() absolutized", idx.includes("url(file:///img/inline.png)"));
|
|
85
|
+
check("img src absolutized", idx.includes(`src="${origin}/img/a.png"`));
|
|
86
|
+
check("srcset entries absolutized", idx.includes(`srcset="${origin}/img/a.png 1x, ${origin}/img/a@2x.png 2x"`));
|
|
87
|
+
// Link hrefs are entity-decoded BEFORE url-parsing — without the decode, "&" inside a
|
|
88
|
+
// multi-param href becomes a bogus "amp;family" param and every family after the first is
|
|
89
|
+
// silently dropped (astryx: 1 of 15 Google Fonts families loaded, ~74 visual fails from a
|
|
90
|
+
// system-font fallback — the gate caught it live; this locks the fix in)
|
|
91
|
+
check("&-encoded link href decoded before parsing (astryx Google Fonts miss)", idx.includes('href="https://cdn.example.com/css2?family=A&family=B&display=swap"'));
|
|
92
|
+
check("video poster + source absolutized", idx.includes(`poster="${origin}/img/poster.jpg"`) && idx.includes(`src="${origin}/vid/v.mp4"`));
|
|
93
|
+
check("anchor href untouched (doesn't paint)", idx.includes('href="/pricing"'));
|
|
94
|
+
check('loading="lazy" forced to eager', idx.includes('loading="eager"') && !/loading\s*=\s*"lazy"/i.test(idx));
|
|
95
|
+
check("no scaffold TODO (build gate would pass)", !/TODO: build to spec/.test(idx));
|
|
96
|
+
|
|
97
|
+
const css = fs.readFileSync(path.join(r1.cloneDir, "assets", "css", "00-site.css"), "utf8");
|
|
98
|
+
check("font ref rewritten to self-hosted path", css.includes("url(/assets/fonts/brand.woff2)"));
|
|
99
|
+
check("relative css ref absolutized against the CSS URL", css.includes(`url(${origin}/assets/img/rel.png)`));
|
|
100
|
+
check("already-absolute css ref untouched", css.includes("url(https://cdn.example.com/pic.png)"));
|
|
101
|
+
check("data: uri untouched", css.includes("url(data:image/png;base64,AAAA)"));
|
|
102
|
+
check("@import absolutized + reported (not recursed)", css.includes(`${origin}/assets/extra.css`) && r1.out.includes("@import"));
|
|
103
|
+
const font = fs.readFileSync(path.join(r1.cloneDir, "assets", "fonts", "brand.woff2"));
|
|
104
|
+
check("font self-hosted with REAL wOF2 magic (assets gate)", font.subarray(0, 4).toString("latin1") === "wOF2");
|
|
105
|
+
check("report names the quirks mode", /QUIRKS/i.test(r1.out));
|
|
106
|
+
|
|
107
|
+
// ── case 2: standards-mode capture — doctype preserved verbatim ───────────────
|
|
108
|
+
const r2 = run("standards", "<!DOCTYPE html>\n", "./assets/site.css");
|
|
109
|
+
const idx2 = fs.readFileSync(path.join(r2.cloneDir, "index.html"), "utf8");
|
|
110
|
+
check("present doctype PRESERVED verbatim", r2.status === 0 && /^<!DOCTYPE html>/i.test(idx2));
|
|
111
|
+
|
|
112
|
+
// ── case 3: a failing stylesheet download is LOUD (exit 1), never silent ──────
|
|
113
|
+
const r3 = run("broken", "", "./assets/missing.css");
|
|
114
|
+
check("missing stylesheet → exit 1 + named in the report", r3.status === 1 && r3.out.includes("missing.css"));
|
|
115
|
+
|
|
116
|
+
// ── case 4: --fixes wires clone/fixes.js and reports it; without the flag it's absent ──
|
|
117
|
+
const r4a = run("nofixes", "", "./assets/site.css");
|
|
118
|
+
const idx4a = fs.readFileSync(path.join(r4a.cloneDir, "index.html"), "utf8");
|
|
119
|
+
check("without --fixes: no fixes.js script tag, no scaffold file", !/fixes\.js/i.test(idx4a) && !fs.existsSync(path.join(r4a.cloneDir, "fixes.js")));
|
|
120
|
+
const r4b = run("withfixes", "", "./assets/site.css", ["--fixes"]);
|
|
121
|
+
const idx4b = fs.readFileSync(path.join(r4b.cloneDir, "index.html"), "utf8");
|
|
122
|
+
check("--fixes wires <script src=\"fixes.js\" defer> before </body>", /<script src="fixes\.js" defer><\/script><\/body>/i.test(idx4b));
|
|
123
|
+
check("--fixes scaffolds a starter clone/fixes.js when none exists", fs.existsSync(path.join(r4b.cloneDir, "fixes.js")));
|
|
124
|
+
check("report confirms fixes.js wired", /fixes\.js:\s+wired/.test(r4b.out));
|
|
125
|
+
// re-running --fixes must be idempotent: no duplicate tag, and a hand-edited fixes.js survives
|
|
126
|
+
fs.writeFileSync(path.join(r4b.cloneDir, "fixes.js"), "// hand-written behavior code\n");
|
|
127
|
+
const r4c = run("withfixes", "", "./assets/site.css", ["--fixes"]);
|
|
128
|
+
const idx4c = fs.readFileSync(path.join(r4c.cloneDir, "index.html"), "utf8");
|
|
129
|
+
check("re-running --fixes doesn't duplicate the script tag", (idx4c.match(/src="fixes\.js"/g) || []).length === 1);
|
|
130
|
+
check("re-running --fixes never overwrites a hand-edited fixes.js", fs.readFileSync(path.join(r4c.cloneDir, "fixes.js"), "utf8").includes("hand-written behavior code"));
|
|
131
|
+
|
|
132
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
133
|
+
console.log(failed ? `\n❌ capture-build selftest: ${failed} check(s) failed.` : "\n✓ capture-build selftest: all checks pass.");
|
|
134
|
+
process.exit(failed ? 1 : 0);
|