renderdiff 0.0.0-stage → 0.2.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/dist/report.js ADDED
@@ -0,0 +1,192 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONTEXT_OPTIONS, name } from "./config.js";
4
+ import { floor4, fmtBand, fmtElements, fmtRegion, pct, signed } from "./format.js";
5
+ /** Sort ascending by score; surface the mean and the worst (lowest-scoring) row. */
6
+ export function stats(rows) {
7
+ const sorted = [...rows].sort((a, b) => a.score - b.score);
8
+ const mean = sorted.reduce((s, r) => s + r.score, 0) / sorted.length;
9
+ return { sorted, mean, worst: sorted[0] };
10
+ }
11
+ /** Scores to 4 decimals, rounded down like `pct`. */
12
+ const round = (key, v) => typeof v === "number" && /^(score|alignedScore|mean)$/.test(key) ? floor4(v) : v;
13
+ export function writeResults(results, meta, outDir) {
14
+ const compared = results.filter((r) => r.status === "compared");
15
+ const { mean, worst } = compared.length ? stats(compared) : { mean: null, worst: null };
16
+ const out = {
17
+ meta,
18
+ results,
19
+ summary: {
20
+ compared: compared.length,
21
+ errors: results.length - compared.length,
22
+ mean,
23
+ worst: worst && { url: worst.url, width: worst.width, score: worst.score },
24
+ belowThreshold: meta.threshold === null ? 0 : compared.filter((r) => r.score < meta.threshold).length,
25
+ },
26
+ };
27
+ const rows = results.map((r) => ` ${JSON.stringify(r, round)}`).join(",\n");
28
+ // One record per line: greppable, and a reader can pull one route without parsing the rest.
29
+ const json = JSON.stringify({ ...out, results: [] }, round, 2).replace('"results": []',
30
+ // A function, so a `$` in a route or text line isn't read as a replacement pattern.
31
+ () => (rows ? `"results": [\n${rows}\n ]` : '"results": []'));
32
+ const file = path.join(outDir, "results.json");
33
+ fs.writeFileSync(file, json + "\n");
34
+ return file;
35
+ }
36
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
37
+ /** Green above 99.5%, amber to 97%, red below. Tuned to be read, not to be precise. */
38
+ const band = (score) => (score >= 0.995 ? "good" : score >= 0.97 ? "warn" : "bad");
39
+ function section(rows) {
40
+ if (!rows.length)
41
+ return "";
42
+ const { sorted, mean, worst } = stats(rows);
43
+ return `
44
+ <section>
45
+ <p class="agg">mean <b class="${band(mean)}">${pct(mean)}</b> &middot;
46
+ worst <b class="${band(worst.score)}">${pct(worst.score)}</b>
47
+ (${esc(name(worst))} @${worst.width}) &middot; ${sorted.length} comparison(s)</p>
48
+ ${sorted.map(card).join("\n")}
49
+ </section>`;
50
+ }
51
+ /** Cap on regions and text lines listed per card; the counts are always full. */
52
+ const LIST_CAP = 5;
53
+ const TEXT_CAP = 20;
54
+ const dom = (els) => els?.length ? `<span class="dom"> — ${esc(fmtElements(els))}</span>` : "";
55
+ function findings(r) {
56
+ const items = [];
57
+ if (r.regions.length) {
58
+ items.push(`<p>${r.regions.length} region(s)</p><ul class="regions">${r.regions
59
+ .slice(0, LIST_CAP)
60
+ .map((g) => `<li>${fmtRegion(g)} — ${g.pixels.toLocaleString("en-US")} px${dom(g.elements)}</li>`)
61
+ .join("")}</ul>`);
62
+ }
63
+ if (r.crops.length) {
64
+ items.push(`<div class="crops">${r.crops
65
+ .map((c) => `<a class="crop" href="${c.path}" target="_blank" title="${c.kind}, left ${c.layout === "stacked" ? "on top" : "on the left"}"><img loading="lazy" src="${c.path}"></a>`)
66
+ .join("")}</div>`);
67
+ }
68
+ if (r.bands?.length) {
69
+ items.push(`<ul class="bands">${r.bands.map((b) => `<li>band ${fmtBand(b)}${dom(b.elements)}</li>`).join("")}</ul>`);
70
+ }
71
+ const { added, removed } = r.text;
72
+ if (added.length || removed.length) {
73
+ const lines = [
74
+ ...added.map((t) => `<li class="add">+ ${esc(t)}</li>`),
75
+ ...removed.map((t) => `<li class="del">- ${esc(t)}</li>`),
76
+ ];
77
+ items.push(`<p>text: ${added.length} added, ${removed.length} removed</p>` +
78
+ `<ul class="text">${lines.slice(0, TEXT_CAP).join("")}</ul>`);
79
+ }
80
+ return items.length ? `<div class="findings">${items.join("")}</div>` : "";
81
+ }
82
+ function card(r) {
83
+ const imgs = r.images;
84
+ return `
85
+ <article class="card">
86
+ <header>
87
+ <span class="score ${band(r.score)}">${pct(r.score)}</span>
88
+ <code>${esc(name(r))}</code>
89
+ <span class="w">@${r.width}px</span>
90
+ ${r.hook ? `<span class="hook">before hook</span>` : ""}
91
+ ${r.heightDelta ? `<span class="delta">height ${signed(r.heightDelta)}px</span>` : ""}
92
+ ${r.widthDelta ? `<span class="delta">width ${signed(r.widthDelta)}px</span>` : ""}
93
+ ${r.unstablePixels ? `<span class="unstable">${r.unstablePixels.toLocaleString("en-US")} px unstable</span>` : ""}
94
+ ${r.alignedScore !== null ? `<span class="aligned">aligned ${pct(r.alignedScore)}</span>` : ""}
95
+ <span class="px">${r.diffPixels.toLocaleString()} / ${r.totalPixels.toLocaleString()} px differ</span>
96
+ </header>
97
+ ${findings(r)}
98
+ <div class="grid">
99
+ <figure><figcaption>left</figcaption><a href="${imgs.left}" target="_blank"><img loading="lazy" src="${imgs.left}"></a></figure>
100
+ <figure><figcaption>right</figcaption><a href="${imgs.right}" target="_blank"><img loading="lazy" src="${imgs.right}"></a></figure>
101
+ <figure><figcaption>diff</figcaption><a href="${imgs.diff}" target="_blank"><img loading="lazy" src="${imgs.diff}"></a></figure>
102
+ </div>
103
+ </article>`;
104
+ }
105
+ export async function writeReport(results, meta, cfg) {
106
+ const compared = results.filter((r) => r.status === "compared");
107
+ const errors = results.filter((r) => r.status === "error");
108
+ const html = `<!doctype html>
109
+ <meta charset="utf-8">
110
+ <title>Visual diff</title>
111
+ <style>
112
+ :root { color-scheme: light dark; --bg:#fff; --fg:#1a1a1a; --mut:#666; --line:#e3e3e3; --card:#fafafa; }
113
+ @media (prefers-color-scheme: dark) { :root { --bg:#16181c; --fg:#e8e8e8; --mut:#999; --line:#2c2f36; --card:#1c1f24; } }
114
+ * { box-sizing: border-box }
115
+ body { margin:0; padding:2rem; background:var(--bg); color:var(--fg);
116
+ font:14px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif; }
117
+ h1 { font-size:1.4rem; margin:0 0 .25rem }
118
+ h2 { font-size:1.05rem; margin:2.5rem 0 .25rem }
119
+ .meta { color:var(--mut); font-size:12px; margin:0 0 2rem }
120
+ .meta code { color:var(--fg) }
121
+ .blurb { color:var(--mut); margin:.25rem 0 .75rem; max-width:70ch }
122
+ .agg { margin:.25rem 0 1rem }
123
+ .good { color:#2e7d32 } .warn { color:#b58900 } .bad { color:#c62828 }
124
+ .card { border:1px solid var(--line); border-radius:8px; background:var(--card);
125
+ margin:0 0 1rem; padding:.75rem 1rem 1rem; }
126
+ .card header { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; margin-bottom:.75rem }
127
+ .score { font-weight:700; font-variant-numeric:tabular-nums; min-width:4.5rem }
128
+ .w, .px, .delta, .unstable, .aligned, .hook { color:var(--mut); font-size:12px }
129
+ .findings { font-size:12px; color:var(--mut); margin:0 0 .75rem }
130
+ .findings p { margin:.25rem 0 0 }
131
+ .findings ul { margin:.25rem 0; font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace }
132
+ .crops { display:flex; flex-wrap:wrap; gap:.5rem; margin:.25rem 0 }
133
+ .crops img { width:auto; max-width:100%; max-height:160px }
134
+ .findings .add { color:#2e7d32 } .findings .del { color:#c62828 }
135
+ .unstable { background:rgba(255,0,255,.12); padding:.1rem .4rem; border-radius:4px }
136
+ .delta { background:rgba(181,137,0,.15); padding:.1rem .4rem; border-radius:4px }
137
+ .grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem }
138
+ @media (max-width:900px) { .grid { grid-template-columns:1fr } }
139
+ figure { margin:0 }
140
+ figcaption { color:var(--mut); font-size:11px; text-transform:uppercase;
141
+ letter-spacing:.05em; margin-bottom:.35rem }
142
+ img { width:100%; height:auto; border:1px solid var(--line); border-radius:4px;
143
+ background:#fff; display:block }
144
+ code { font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace }
145
+ details { margin:1rem 0; color:var(--mut) }
146
+ ul { margin:.5rem 0; padding-left:1.25rem }
147
+ </style>
148
+
149
+ <h1>Visual diff</h1>
150
+ <p class="meta">
151
+ left <code>${esc(meta.left)}</code> vs right <code>${esc(meta.right)}</code><br>
152
+ ${esc(meta.generatedAt)} &middot; ${esc(meta.browser.name)} ${esc(meta.browser.version)}${["left", "right"]
153
+ .filter((s) => meta.storageState[s])
154
+ .map((s) => ` &middot; ${s} storage state <code>${esc(meta.storageState[s])}</code>`)
155
+ .join("")}
156
+ </p>
157
+
158
+ ${section(compared)}
159
+
160
+ ${errors.length
161
+ ? `<section><h2 class="bad">Errors</h2>
162
+ <p class="blurb">These produced no measurement, so they are neither a pass nor a fail.</p>
163
+ <ul>${errors.map((r) => `<li><code>${esc(name(r))}</code> @${r.width}px — ${esc(r.error)}</li>`).join("")}</ul></section>`
164
+ : ""}
165
+
166
+ <details>
167
+ <summary>What was neutralized</summary>
168
+ <p>Blocked on both sides — analytics, consent, and A/B tooling that would otherwise
169
+ rewrite the DOM differently on each load:</p>
170
+ <ul>${cfg.blockUrlPatterns.map((p) => `<li><code>${esc(p)}</code></li>`).join("")}</ul>
171
+ <p>Masked, because they drift — painted over on both sides, so they can neither pass
172
+ nor fail:</p>
173
+ ${cfg.masks.length
174
+ ? `<ul>${cfg.masks.map((m) => `<li>${esc(m.reason)}</li>`).join("")}</ul>`
175
+ : `<p><em>None configured.</em></p>`}
176
+ <p>Pinned on both sides: clock starts at <code>${esc(meta.clock)}</code> on every load, a seeded
177
+ <code>Math.random</code>, timezone <code>${CONTEXT_OPTIONS.timezoneId}</code>, locale
178
+ <code>${CONTEXT_OPTIONS.locale}</code>, <code>${CONTEXT_OPTIONS.colorScheme}</code> colour
179
+ scheme, reduced motion, CSS animations and transitions off, videos paused at frame 0.</p>
180
+ <p>Unstable pixels — ones that changed between two shots of the same load — are painted
181
+ magenta on both sides, counted on each card, and excluded from the measurement. A route
182
+ more than 25% unstable is an error.</p>
183
+ <p>Magenta is also padding where one page is taller or wider than the other. It counts
184
+ against the score on purpose — see the height delta on the row.</p>
185
+ </details>
186
+ `;
187
+ fs.mkdirSync(cfg.outDir, { recursive: true });
188
+ const out = path.join(cfg.outDir, "report.html");
189
+ fs.writeFileSync(out, html);
190
+ return out;
191
+ }
192
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"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"}
package/dist/run.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { type ResolvedConfig, type Route } from "./config.ts";
2
+ import { type RouteResult } from "./report.ts";
3
+ interface RunOpts {
4
+ routes: Route[];
5
+ cfg: ResolvedConfig;
6
+ open: boolean;
7
+ /** A line per shot as it lands; otherwise one compact summary at the end. */
8
+ progress: boolean;
9
+ }
10
+ export declare function run({ routes, cfg, open, progress }: RunOpts): Promise<number>;
11
+ /**
12
+ * The non-TTY summary, sized for an agent's context: clean routes on one line, then each
13
+ * differing route with its widths collapsed where everything but the score agrees, and a
14
+ * one-line verdict that names results.json.
15
+ */
16
+ export declare function compact(results: RouteResult[], routes: string[], resultsPath: string, threshold: number | null): string;
17
+ export {};
package/dist/run.js ADDED
@@ -0,0 +1,390 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { chromium, errors } from "playwright";
6
+ import { cancelUnstable, capture, HookError, prepare } from "./capture.js";
7
+ import { compare, crops } from "./compare.js";
8
+ import { locate } from "./locate.js";
9
+ import { CONTEXT_OPTIONS, label, masksFor, name, selectorText, version, } from "./config.js";
10
+ import { fmtBand, fmtElements, fmtRegion, pct, signed } from "./format.js";
11
+ import { stats, writeReport, writeResults, } from "./report.js";
12
+ /** A busy main thread can stall the probe; the elements are a hint, so give up rather than wait. */
13
+ const LOCATE_TIMEOUT = 5_000;
14
+ /** Past this share of a page unstable, too little was measured for a score to mean anything. */
15
+ const MAX_UNSTABLE_SHARE = 0.25;
16
+ const slug = (url) => url === "/"
17
+ ? "index"
18
+ : url
19
+ .slice(1)
20
+ .toLowerCase()
21
+ .replace(/\//g, "-")
22
+ .replace(/[^a-z0-9-]/g, "_");
23
+ const slugSelector = (s) => s.toLowerCase().replace(/[^a-z0-9-]/g, "_");
24
+ const stem = (r) => ((r.left === r.right ? slug(r.left) : `${slug(r.left)}~${slug(r.right)}`) +
25
+ (r.selector
26
+ ? `@${[...new Set([r.selector.left, r.selector.right])].map(slugSelector).join("~")}`
27
+ : "")).slice(0, 200);
28
+ /** Route name -> file stem, unique across the run. Suffixes follow route order, so reruns agree. */
29
+ function slugs(routes) {
30
+ const out = new Map();
31
+ const used = new Set();
32
+ for (const route of routes) {
33
+ let s = stem(route);
34
+ for (let n = 2; used.has(s); n++)
35
+ s = `${stem(route)}-${n}`;
36
+ used.add(s);
37
+ out.set(name(route), s);
38
+ }
39
+ return out;
40
+ }
41
+ /** Only a directory holding nothing but files renderdiff writes is safe to wipe. */
42
+ const ours = (f) => f === "report.html" ||
43
+ f === "results.json" ||
44
+ /^[a-z0-9_~@-]+\.[\d.]+\.(left|right|diff|c\d+)\.png$/.test(f);
45
+ function wipeable(dir) {
46
+ if (!fs.existsSync(dir))
47
+ return true;
48
+ return fs.statSync(dir).isDirectory() && fs.readdirSync(dir).every(ours);
49
+ }
50
+ export async function run({ routes, cfg, open, progress }) {
51
+ if (!wipeable(cfg.outDir)) {
52
+ console.error(`✗ output directory ${cfg.outDir} holds files renderdiff didn't write — ` +
53
+ `refusing to wipe it. Pass --out with a new or empty directory.`);
54
+ return 2;
55
+ }
56
+ fs.rmSync(cfg.outDir, { recursive: true, force: true });
57
+ fs.mkdirSync(cfg.outDir, { recursive: true });
58
+ const jobs = [];
59
+ for (const route of routes)
60
+ for (const width of cfg.widths)
61
+ jobs.push({ route, width });
62
+ const stems = slugs(routes);
63
+ const results = [];
64
+ const { browser, channel } = await launch();
65
+ const queue = [...jobs];
66
+ let done = 0;
67
+ // Whole minute, so an HH:MM display reads the same on both sides.
68
+ const now = Math.floor(Date.now() / 60_000) * 60_000;
69
+ const worker = async () => {
70
+ // One context per origin so cookies/localStorage never cross between them.
71
+ const leftCtx = await browser.newContext({
72
+ ...CONTEXT_OPTIONS,
73
+ storageState: cfg.storageState.left ?? undefined,
74
+ });
75
+ const rightCtx = await browser.newContext({
76
+ ...CONTEXT_OPTIONS,
77
+ storageState: cfg.storageState.right ?? undefined,
78
+ });
79
+ const leftPage = await leftCtx.newPage();
80
+ const rightPage = await rightCtx.newPage();
81
+ await prepare(leftPage, cfg, now);
82
+ await prepare(rightPage, cfg, now);
83
+ for (let job = queue.shift(); job; job = queue.shift()) {
84
+ const { route, width } = job;
85
+ const key = name(route);
86
+ const { before, ...where } = route;
87
+ const entry = { url: label(route), ...where, hook: before !== null, width };
88
+ const tag = `${label(route)} @${width}`;
89
+ // Its own line: parse.ts reads the route on the row as one \S+ token.
90
+ const sel = route.selector ? `selector ${selectorText(route.selector)}` : "";
91
+ try {
92
+ // allSettled, not all: a page must not start the next job while still loading this one.
93
+ const settled = await Promise.allSettled([
94
+ shoot(leftPage, cfg.left + route.left, route, "left", width, cfg, now),
95
+ shoot(rightPage, cfg.right + route.right, route, "right", width, cfg, now),
96
+ ]);
97
+ const failed = settled.find((r) => r.status === "rejected");
98
+ if (failed)
99
+ throw failed.reason;
100
+ const [leftShot, rightShot] = settled.map((r) => r.value);
101
+ // A blank page matches any other blank page, so refuse rather than score two of
102
+ // them. One blank side is left to the diff, which shows it plainly.
103
+ if (leftShot.blank && rightShot.blank) {
104
+ throw new Error(`page did not render on either side ` +
105
+ `(left: ${leftShot.blank}; right: ${rightShot.blank}) at ${key}`);
106
+ }
107
+ const unstablePixels = await cancelUnstable(leftShot, rightShot);
108
+ const { diffPng, spots, ...metrics } = await compare(leftShot, rightShot);
109
+ if (unstablePixels > MAX_UNSTABLE_SHARE * metrics.totalPixels) {
110
+ throw new Error(`page did not settle: ${unstablePixels}px unstable at ${key}`);
111
+ }
112
+ const measured = metrics.totalPixels - unstablePixels;
113
+ metrics.score = measured > 0 ? 1 - metrics.diffPixels / measured : 0;
114
+ const base = `${stems.get(key)}.${width}`;
115
+ const images = {
116
+ left: `${base}.left.png`,
117
+ right: `${base}.right.png`,
118
+ diff: `${base}.diff.png`,
119
+ };
120
+ const write = (name, buf) => fs.promises.writeFile(path.join(cfg.outDir, name), buf);
121
+ // Both pages still hold this job's document: the next load waits for the loop.
122
+ const [made, named] = await Promise.all([
123
+ crops(leftShot.png, rightShot.png, spots),
124
+ Promise.race([
125
+ locate([leftPage, rightPage], [leftShot, rightShot], spots, [
126
+ masksFor(cfg, leftPage, route),
127
+ masksFor(cfg, rightPage, route),
128
+ ]).catch(() => null),
129
+ sleep(LOCATE_TIMEOUT, null, { ref: false }),
130
+ ]),
131
+ ]);
132
+ named?.forEach((els, i) => (spots[i].target.elements = els));
133
+ const cropped = made.map(({ png, ...c }, i) => ({ path: `${base}.c${i + 1}.png`, ...c }));
134
+ await Promise.all([
135
+ write(images.left, leftShot.png),
136
+ write(images.right, rightShot.png),
137
+ write(images.diff, diffPng),
138
+ ...made.map((c, i) => write(cropped[i].path, c.png)),
139
+ ]);
140
+ results.push({
141
+ ...entry,
142
+ status: "compared",
143
+ ...metrics,
144
+ unstablePixels,
145
+ images,
146
+ crops: cropped,
147
+ });
148
+ const { regions, bands, alignedScore, text } = metrics;
149
+ const dom = elementsOf(metrics);
150
+ const details = [
151
+ sel,
152
+ regions.length
153
+ ? `${regions.length} region${regions.length > 1 ? "s" : ""}, largest ${fmtRegion(regions[0])}`
154
+ : "",
155
+ ...(bands ?? []).map((b) => `band ${fmtBand(b)}`),
156
+ alignedScore !== null ? `aligned ${pct(alignedScore)}` : "",
157
+ bands === null ? "rows too different to align" : "",
158
+ text.added.length || text.removed.length
159
+ ? `text: ${text.added.length} added, ${text.removed.length} removed`
160
+ : "",
161
+ dom.length ? `dom ${fmtElements(dom)}` : "",
162
+ ].filter(Boolean);
163
+ ++done;
164
+ if (!progress)
165
+ continue;
166
+ console.log(` [${done}/${jobs.length}] ${pct(metrics.score)} ${tag}` +
167
+ (metrics.heightDelta ? ` (height ${signed(metrics.heightDelta)}px)` : "") +
168
+ (unstablePixels ? ` (unstable ${unstablePixels.toLocaleString("en-US")}px)` : "") +
169
+ details.map((d) => `\n ${d}`).join(""));
170
+ }
171
+ catch (e) {
172
+ results.push({
173
+ ...entry,
174
+ status: "error",
175
+ error: e instanceof Error ? e.message : String(e),
176
+ });
177
+ ++done;
178
+ if (progress) {
179
+ console.log(` [${done}/${jobs.length}] ERROR ${tag}` + (sel && `\n ${sel}`));
180
+ }
181
+ }
182
+ }
183
+ await leftCtx.close();
184
+ await rightCtx.close();
185
+ };
186
+ if (progress) {
187
+ console.log(`Capturing ${jobs.length} pair(s) with ${cfg.concurrency} worker(s)...\n`);
188
+ }
189
+ await Promise.all(Array.from({ length: cfg.concurrency }, worker));
190
+ const browserMeta = { name: channel ?? "chromium", version: browser.version() };
191
+ await browser.close();
192
+ // Workers finish out of order; job order keeps results.json stable across runs.
193
+ const names = routes.map(name);
194
+ const order = (r) => names.indexOf(name(r)) * cfg.widths.length + cfg.widths.indexOf(r.width);
195
+ results.sort((a, b) => order(a) - order(b));
196
+ const meta = {
197
+ tool: "renderdiff",
198
+ version: version(),
199
+ generatedAt: new Date().toISOString(),
200
+ left: cfg.left,
201
+ right: cfg.right,
202
+ widths: cfg.widths,
203
+ threshold: cfg.threshold,
204
+ clock: new Date(now).toISOString(),
205
+ blockUrlPatterns: cfg.blockUrlPatterns,
206
+ hideSelectors: cfg.hideSelectors,
207
+ masks: cfg.masks.map(({ urlPath, reason }) => ({ urlPath, reason })),
208
+ browser: browserMeta,
209
+ storageState: {
210
+ left: cfg.storageState.left && path.basename(cfg.storageState.left),
211
+ right: cfg.storageState.right && path.basename(cfg.storageState.right),
212
+ },
213
+ };
214
+ const reportPath = await writeReport(results, meta, cfg);
215
+ const resultsPath = writeResults(results, meta, cfg.outDir);
216
+ if (progress)
217
+ summarize(results, reportPath, resultsPath);
218
+ else
219
+ console.log(compact(results, names, resultsPath, cfg.threshold));
220
+ if (open) {
221
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
222
+ spawn(opener, [reportPath], { detached: true, stdio: "ignore" }).unref();
223
+ }
224
+ // A measured failure outranks an unmeasured route, so an error can't hide a threshold failure.
225
+ const below = results.filter((r) => r.status === "compared" && cfg.threshold !== null && r.score < cfg.threshold);
226
+ if (below.length) {
227
+ console.error(`\n✗ ${below.length} route(s) below ${pct(cfg.threshold)}.`);
228
+ }
229
+ return below.length ? 1 : results.some((r) => r.status === "error") ? 3 : 0;
230
+ }
231
+ /**
232
+ * A missing browser binary is the likeliest first-run failure, so fall back to the system
233
+ * Chrome (both sides share it, so the comparison stays symmetric), then name the fix.
234
+ */
235
+ async function launch() {
236
+ try {
237
+ return { browser: await chromium.launch(), channel: null };
238
+ }
239
+ catch (e) {
240
+ try {
241
+ const browser = await chromium.launch({ channel: "chrome" });
242
+ console.log(`Bundled Chromium failed to launch; using the system Chrome ${browser.version()}.`);
243
+ return { browser, channel: "chrome" };
244
+ }
245
+ catch {
246
+ const msg = e instanceof Error ? e.message : String(e);
247
+ throw new Error(`could not launch Chromium, or the system Chrome:\n ${msg}\n\n` +
248
+ ` Install the browser with: npx playwright install chromium`);
249
+ }
250
+ }
251
+ }
252
+ const transient = (e) => !(e instanceof HookError) &&
253
+ (e instanceof errors.TimeoutError ||
254
+ (e instanceof Error && /net::ERR_|Execution context was destroyed/.test(e.message)));
255
+ /** One retry for a transient failure: a live site can time out once on a cold edge. */
256
+ async function shoot(page, url, route, side, width, cfg, now) {
257
+ try {
258
+ return await capture(page, url, route, side, width, cfg, now);
259
+ }
260
+ catch (e) {
261
+ if (!transient(e))
262
+ throw e;
263
+ await page.waitForTimeout(1000);
264
+ return await capture(page, url, route, side, width, cfg, now);
265
+ }
266
+ }
267
+ function summarize(results, reportPath, resultsPath) {
268
+ const compared = results.filter((r) => r.status === "compared");
269
+ if (compared.length) {
270
+ const { sorted, mean, worst } = stats(compared);
271
+ console.log(`\nmean ${pct(mean)} worst ${pct(worst.score)} (${name(worst)} @${worst.width})`);
272
+ for (const r of sorted.slice(0, 10)) {
273
+ console.log(` ${pct(r.score).padStart(7)} ${name(r)}@${r.width}` +
274
+ (r.heightDelta ? ` height ${signed(r.heightDelta)}px` : ""));
275
+ }
276
+ }
277
+ const errors = results.filter((r) => r.status === "error");
278
+ if (errors.length) {
279
+ console.log(`\nErrors (${errors.length}):`);
280
+ for (const e of errors)
281
+ console.log(` ${name(e)}@${e.width}: ${e.error}`);
282
+ }
283
+ console.log(`\nReport: ${reportPath}`);
284
+ console.log(`Results: ${resultsPath}`);
285
+ }
286
+ /** At most this many elements per summary line. */
287
+ const DOM_ELEMENTS = 3;
288
+ /** Located elements in crop order, bands tallest first then regions, one per selector. */
289
+ function elementsOf(r) {
290
+ const all = [...[...(r.bands ?? [])].sort((a, b) => b.h - a.h), ...r.regions].flatMap((g) => g.elements ?? []);
291
+ return all
292
+ .filter((e, i) => all.findIndex((o) => o.selector === e.selector) === i)
293
+ .slice(0, DOM_ELEMENTS);
294
+ }
295
+ /** At most this many text-diff lines per compact row. */
296
+ const TEXT_LINES = 3;
297
+ /**
298
+ * The non-TTY summary, sized for an agent's context: clean routes on one line, then each
299
+ * differing route with its widths collapsed where everything but the score agrees, and a
300
+ * one-line verdict that names results.json.
301
+ */
302
+ export function compact(results, routes, resultsPath, threshold) {
303
+ const out = [];
304
+ const clean = [];
305
+ let differ = 0;
306
+ for (const route of routes) {
307
+ const rows = results.filter((r) => name(r) === route);
308
+ const ok = rows.filter((r) => r.status === "compared");
309
+ // Unstable pixels went unmeasured, so a shot with any is not clean, only unchanged.
310
+ const same = ok.filter((r) => r.diffPixels === 0 && r.unstablePixels === 0);
311
+ const changed = ok.filter((r) => !same.includes(r));
312
+ if (same.length === rows.length) {
313
+ clean.push(route);
314
+ continue;
315
+ }
316
+ if (changed.some((r) => r.diffPixels > 0))
317
+ differ++;
318
+ out.push(route + (same.length ? ` clean @${same.map((r) => r.width).join(",")}` : ""));
319
+ const groups = new Map();
320
+ for (const r of changed) {
321
+ const key = JSON.stringify([
322
+ r.heightDelta,
323
+ r.widthDelta,
324
+ r.bands?.map((b) => [b.side, b.h]) ?? null,
325
+ r.alignedScore === null ? null : pct(r.alignedScore),
326
+ r.text,
327
+ ]);
328
+ groups.set(key, [...(groups.get(key) ?? []), r]);
329
+ }
330
+ for (const g of groups.values()) {
331
+ out.push(` @${g.map((r) => r.width).join(",")} ${row(g)}`);
332
+ const dom = elementsOf(g[0]);
333
+ if (dom.length)
334
+ out.push(` dom ${g.length > 1 ? `@${g[0].width} ` : ""}${fmtElements(dom)}`);
335
+ const paths = g.flatMap((r) => r.crops.map((c) => c.path));
336
+ if (paths.length)
337
+ out.push(` crops ${paths.join(" ")}`);
338
+ }
339
+ const failed = new Map();
340
+ for (const r of rows)
341
+ if (r.status === "error")
342
+ failed.set(r.error, [...(failed.get(r.error) ?? []), r.width]);
343
+ for (const [error, widths] of failed)
344
+ out.push(` @${widths.join(",")} ERROR ${error}`);
345
+ }
346
+ if (clean.length)
347
+ out.unshift(`clean: ${clean.join(" ")}`);
348
+ const compared = results.filter((r) => r.status === "compared");
349
+ const shots = compared.filter((r) => r.diffPixels > 0).length;
350
+ const errors = results.length - compared.length;
351
+ const worst = compared.length ? stats(compared).worst : null;
352
+ out.push(`renderdiff: ${differ} of ${routes.length} routes differ (${shots} of ${results.length} shots), ` +
353
+ `${errors} error${errors === 1 ? "" : "s"}` +
354
+ (worst ? `, worst ${pct(worst.score)} ${name(worst)}@${worst.width}` : "") +
355
+ (threshold === null
356
+ ? ""
357
+ : `, ${compared.filter((r) => r.score < threshold).length} below ${pct(threshold)}`) +
358
+ ` — ${resultsPath}`);
359
+ return out.join("\n");
360
+ }
361
+ /** One compact row for comparisons that agree on everything but score, regions and unstable px. */
362
+ function row(g) {
363
+ const [r] = g;
364
+ const span = (ns, f) => {
365
+ const [lo, hi] = [Math.min(...ns), Math.max(...ns)];
366
+ return lo === hi ? f(lo) : `${f(lo)}–${f(hi)}`;
367
+ };
368
+ const regions = g.map((x) => x.regions.length);
369
+ const unstable = Math.max(...g.map((x) => x.unstablePixels));
370
+ // Band y varies with width; a collapsed row shows the first one's, marked approximate.
371
+ const y = (b) => `@y${g.length > 1 ? "~" : ""}${b.y}`;
372
+ const text = [...r.text.added.map((t) => `+"${t}"`), ...r.text.removed.map((t) => `-"${t}"`)];
373
+ return [
374
+ span(g.map((x) => x.score), pct),
375
+ r.heightDelta ? `h${signed(r.heightDelta)}` : "",
376
+ r.widthDelta ? `w${signed(r.widthDelta)}` : "",
377
+ r.bands === null ? "unaligned" : "",
378
+ ...(r.bands ?? []).map((b) => `band ${b.side === "left" ? "+" : "-"}${b.h}${y(b)}`),
379
+ r.alignedScore !== null ? `aligned ${pct(r.alignedScore)}` : "",
380
+ Math.max(...regions) ? `${span(regions, String)} reg` : "",
381
+ unstable ? `unstable ${unstable.toLocaleString("en-US")}px` : "",
382
+ text.length
383
+ ? `text ${text.slice(0, TEXT_LINES).join(" ")}` +
384
+ (text.length > TEXT_LINES ? ` (+${text.length - TEXT_LINES} more)` : "")
385
+ : "",
386
+ ]
387
+ .filter(Boolean)
388
+ .join(" ");
389
+ }
390
+ //# sourceMappingURL=run.js.map