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