Repository navigation
Expand file tree
/
Copy pathweb-shell-visuals-compose.mjs
More file actions
324 lines (308 loc) · 12.9 KB
/
Copy pathweb-shell-visuals-compose.mjs
File metadata and controls
324 lines (308 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Before/after compositor for the web-shell visuals preview.
*
* Given a `before/` (rendered from the PR's merge-base — the PR base) and an
* `after/` (rendered from the PR head) set of `<view>-<theme>.png` screenshots,
* pixel-diff each pair and, for the views that actually CHANGED, stitch a
* single labelled "PR base (before) | this PR (after)" composite into `outDir`
* (reusing the `<view>-<theme>.png` name so the publish step lists it as-is).
*
* Unchanged views are dropped — so a PR with no visual impact produces no
* composites (the comment then says "no visual change") and a feature PR shows
* exactly the surface it moved, with no per-PR understanding required. A view
* present only in `after/` (a scenario the PR adds) is emitted after-only and
* tagged NEW.
*
* The pure helpers (`parseShot`, `isChanged`, `planWork`) are exported and unit
* -tested in web-shell-visuals-compose.test.mjs. The browser diff+stitch runs
* as a CLI:
* node web-shell-visuals-compose.mjs <beforeDir> <afterDir> <outDir>
*/
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { pathToFileURL } from 'node:url';
// % of pixels that must differ for a view to count as "changed". Kept low so a
// small-but-real change (an icon swap ~24×24, a one-word label) still registers
// — this is the exact failure mode the preview exists to catch. The fraction is
// measured AFTER the cluster denoise (see MIN_CLUSTER_NEIGHBORS): base and head
// render in SEPARATE CI jobs, so font anti-aliasing is not bit-identical
// between them and a text-heavy view scatters ~0.1-0.3% of isolated edge pixels
// that would otherwise cross this line; the denoise erodes that scatter to ~0.
// At 1280×800, 0.02% ≈ 205 px.
export const CHANGED_PCT_THRESHOLD = 0.02;
// A pixel "differs" when |ΔR|+|ΔG|+|ΔB| exceeds this (ignores imperceptible AA).
export const PER_PIXEL_DELTA = 30;
// Minimum of the 8 neighbours that must ALSO differ for a differing pixel to be
// counted. Cross-job font-AA leaves a scatter of isolated / 1px-wide differing
// pixels along glyph edges (a 1px-line pixel has 2 differing neighbours, an
// isolated one has 0) — below this cutoff they erode to nothing, while a real
// change (badge, chip, icon, panel) is a solid block whose interior keeps 5-8.
export const MIN_CLUSTER_NEIGHBORS = 4;
// Display width of each panel in the stitched composite.
export const PANEL_WIDTH = 560;
/**
* Count differing pixels that sit in a cluster: (x,y) counts only when at least
* `minNeighbors` of its 8 neighbours also differ. `changed` is a row-major 0/1
* mask of length `width*height`. Pure + deterministic (unit-tested); this is
* what drops cross-job font-AA scatter before the changed fraction is measured.
*/
export function countDenoisedChanges(
changed,
width,
height,
minNeighbors = MIN_CLUSTER_NEIGHBORS,
) {
let count = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!changed[y * width + x]) continue;
let neighbors = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
if (changed[ny * width + nx]) neighbors++;
}
}
if (neighbors >= minNeighbors) count++;
}
}
return count;
}
/** Unpack a base64, LSB-first bit-mask into a 0/1 Uint8Array of length `n`. */
export function unpackBitMask(base64, n) {
const buf = Buffer.from(base64, 'base64');
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) out[i] = (buf[i >> 3] >> (i & 7)) & 1;
return out;
}
/** Parse `<view>-<light|dark>.png` → `{ view, theme }` or null (ignore others). */
export function parseShot(name) {
const m = basename(String(name)).match(/^(.*)-(light|dark)\.png$/i);
return m ? { view: m[1], theme: m[2].toLowerCase() } : null;
}
/**
* Decide whether a view changed. A view with no baseline (added by the PR)
* counts as changed so it is shown (after-only, NEW). Otherwise it is changed
* only when the differing-pixel fraction reaches the threshold.
*/
export function isChanged(
{ hasBefore, changedPct },
threshold = CHANGED_PCT_THRESHOLD,
) {
if (!hasBefore) return true;
return changedPct >= threshold;
}
/**
* Pure plan over filenames: for each real `<view>-<theme>.png` in `after`,
* whether a same-named baseline exists in `before`. Sorted, deterministic.
*/
export function planWork(afterNames, beforeNames) {
const before = new Set(
(beforeNames ?? []).filter((n) => parseShot(n)).map((n) => basename(n)),
);
return (afterNames ?? [])
.filter((n) => parseShot(n))
.map((n) => basename(n))
.sort()
.map((name) => ({ name, hasBefore: before.has(name) }));
}
const dataUri = (path) =>
`data:image/png;base64,${readFileSync(path).toString('base64')}`;
// Minimal HTML-escape for values interpolated into the composite markup. The
// view/theme come from PR-controlled filenames and render only in the capture
// job's own Chromium (which already runs PR code — no boundary crossed), so
// this is purely hygiene, keeping that safety argument obvious.
const esc = (s) =>
String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
// Self-describing composite: the before/after labels and the diff magnitude are
// burned into the image, so it reads correctly even opened on its own.
function compositeHtml({
view,
theme,
hasBefore,
changedPct,
beforeUri,
afterUri,
}) {
const badge = hasBefore
? `${changedPct.toFixed(1)}% changed`
: 'new scenario';
const beforePanel = hasBefore
? `<figure style="margin:0"><figcaption style="font:600 12px system-ui;color:#6b7280;text-align:center;margin-bottom:6px">PR base (before)</figcaption><img src="${beforeUri}" style="display:block;width:${PANEL_WIDTH}px;border:1px solid #cbd5e1;border-radius:6px"></figure>`
: '';
return `<!doctype html><html><body style="margin:0;background:#e5e7eb">
<div id="cap" style="display:inline-block;padding:16px;font-family:-apple-system,system-ui,sans-serif">
<div style="font:600 14px system-ui;color:#111827;margin:0 2px 10px">
${esc(view)} · ${esc(theme)}${hasBefore ? '' : ' · NEW'}
<span style="color:#6b7280;font-weight:400"> — ${badge}</span>
</div>
<div style="display:flex;gap:12px;align-items:flex-start">
${beforePanel}
<figure style="margin:0"><figcaption style="font:600 12px system-ui;color:#2563eb;text-align:center;margin-bottom:6px">this PR (after)</figcaption><img src="${afterUri}" style="display:block;width:${PANEL_WIDTH}px;border:1px solid #93c5fd;border-radius:6px"></figure>
</div>
</div></body></html>`;
}
// Differing-pixel fraction (%) between two PNG data URIs. The browser decodes on
// a canvas (no native image deps) and returns a compact bit-mask of which pixels
// differ by > PER_PIXEL_DELTA; the cluster denoise + count then run here in node
// against the unit-tested `countDenoisedChanges`, so there is a single tested
// implementation of the metric. A size mismatch or an undecodable image
// short-circuits to 100% (both are themselves a visual change, and must be shown
// rather than silently dropped).
async function diffPct(page, beforeUri, afterUri) {
const result = await page.evaluate(
// This arrow runs in the BROWSER (page.evaluate), where Image and document
// are defined; declare them so eslint's node env doesn't flag no-undef.
// (btoa is a shared node+browser global, so it needs no declaration here.)
/* global Image, document */
async ([a, b, delta]) => {
const load = (src) =>
new Promise((res) => {
const img = new Image();
img.onload = () => res(img);
// A truncated/corrupt PNG (a best-effort base render killed
// mid-write) must still settle, or page.evaluate would hang to the
// job timeout and take the artifacts with it.
img.onerror = () => res(null);
img.src = src;
});
const [ia, ib] = await Promise.all([load(a), load(b)]);
// An undecodable image, or a dimension change, IS a visual change: show it
// (comparing only the overlapping rectangle would hide a size change).
if (!ia || !ib) return { full: true };
if (ia.width !== ib.width || ia.height !== ib.height)
return { full: true };
const w = ia.width;
const h = ia.height;
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.drawImage(ia, 0, 0);
const da = ctx.getImageData(0, 0, w, h).data;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(ib, 0, 0);
const db = ctx.getImageData(0, 0, w, h).data;
const n = w * h;
// Bit-pack the differing-pixel mask (LSB-first) so it transfers to node in
// ~n/8 bytes rather than a per-pixel object.
const bytes = new Uint8Array((n + 7) >> 3);
for (let i = 0; i < n; i++) {
const j = i * 4;
if (
Math.abs(da[j] - db[j]) +
Math.abs(da[j + 1] - db[j + 1]) +
Math.abs(da[j + 2] - db[j + 2]) >
delta
)
bytes[i >> 3] |= 1 << (i & 7);
}
let bin = '';
for (let i = 0; i < bytes.length; i++)
bin += String.fromCharCode(bytes[i]);
return { w, h, mask: btoa(bin) };
},
[beforeUri, afterUri, PER_PIXEL_DELTA],
);
if (result.full) return 100;
const n = result.w * result.h;
if (!n) return 0;
const changed = unpackBitMask(result.mask, n);
return (countDenoisedChanges(changed, result.w, result.h) / n) * 100;
}
async function composeCli(beforeDir, afterDir, outDir) {
mkdirSync(outDir, { recursive: true });
const list = (d) => {
try {
return readdirSync(d);
} catch {
return [];
}
};
const work = planWork(list(afterDir), list(beforeDir));
// Lazy import so the pure exports (parseShot / isChanged / planWork) load
// WITHOUT @playwright/test — the dependency-free `github_ci_only` test step
// (no npm ci) imports this module for those helpers and would otherwise die
// with ERR_MODULE_NOT_FOUND on a top-level Playwright import.
const { chromium } = await import('@playwright/test');
const browser = await chromium.launch();
const manifest = [];
// Always close the browser, even if a diff/screenshot rejects mid-loop (an
// evaluate timeout or CDP disconnect on a corrupt/oversized PNG) — otherwise
// a ~200 MB Chromium child would leak for the rest of the CI job.
try {
// deviceScaleFactor:2 keeps the burned-in labels crisp on retina/GitHub zoom.
const page = await browser.newPage({ deviceScaleFactor: 2 });
for (const { name, hasBefore } of work) {
const { view, theme } = parseShot(name);
const afterUri = dataUri(join(afterDir, name));
const beforeUri = hasBefore ? dataUri(join(beforeDir, name)) : null;
const changedPct = hasBefore
? await diffPct(page, beforeUri, afterUri)
: 100;
const changed = isChanged({ hasBefore, changedPct });
manifest.push({
name,
view,
theme,
hasBefore,
changedPct: Number(changedPct.toFixed(2)),
changed,
});
if (!changed) continue;
await page.setContent(
compositeHtml({
view,
theme,
hasBefore,
changedPct,
beforeUri,
afterUri,
}),
);
await page.locator('#cap').screenshot({ path: join(outDir, name) });
}
} finally {
await browser.close();
}
writeFileSync(
join(outDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
);
const composited = manifest.filter((e) => e.changed).length;
for (const e of manifest) {
const tag = e.changed ? (e.hasBefore ? 'CHANGED' : 'NEW ') : 'skip ';
process.stderr.write(` ${tag} ${e.name} (${e.changedPct}% diff)\n`);
}
// stdout = composited count, for logs only. Nothing parses it: the workflow
// re-counts the written composites via `find`, and the post/no-post decision
// lives in the publish workflow.
process.stdout.write(`${composited}\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
const [beforeDir, afterDir, outDir] = process.argv.slice(2);
if (!beforeDir || !afterDir || !outDir) {
process.stderr.write(
'usage: web-shell-visuals-compose.mjs <beforeDir> <afterDir> <outDir>\n',
);
process.exit(2);
}
composeCli(beforeDir, afterDir, outDir).catch((err) => {
// Surface a clean diagnostic + non-zero exit instead of an
// UnhandledPromiseRejectionWarning (e.g. a missing @playwright/test).
process.stderr.write(`${err?.stack ?? err}\n`);
process.exit(1);
});
}