mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-27 17:32:51 +08:00
278 lines
14 KiB
JavaScript
278 lines
14 KiB
JavaScript
/* Prove a CSS change by diffing computed styles, on the gallery instead of a router.
|
|
*
|
|
* The method is docs/css.md's: one DOM, one data set, two stylesheets — load the page, snapshot
|
|
* getComputedStyle over every element, swap the <link>, snapshot again. Any difference was caused
|
|
* by the CSS, because nothing else moved.
|
|
*
|
|
* What is new here is the SURFACE. On a live router the same sheet twice moves 0.5-1.3% of pixels
|
|
* (uptime, DHCP leases, signal) and a real regression weighs 0.19% — the noise buries the signal,
|
|
* which is why screenshots do not work and why a live computed diff still needs a control pass to
|
|
* learn each page's floor. docs/gallery.html has no clock, no poll and no data: `--control` runs the
|
|
* SAME stylesheet on both sides and must report exactly 0 differences. That 0 is the threshold, and
|
|
* it is measured rather than chosen — run `--control` whenever the gallery gains a widget.
|
|
*
|
|
* This does not replace the live run. The gallery has every widget and none of the pages: no menu,
|
|
* no chrome, no third-party sheet. It catches the regression that is IN the stylesheet; owlab
|
|
* catches the one that is in the page.
|
|
*
|
|
* Two viewports run, not one (task 0145): every other gallery gate — this one included, until now —
|
|
* takes Playwright's 1280 default (`a11y`, `export-tier`) or sets it explicitly (line below), so a
|
|
* declared width that only drifts once a flex row is narrower than its own content never shows up.
|
|
* cb1's own bug was exactly that shape: 0 diffs at 1280 with the fix reverted, a `width` diff at 390.
|
|
* A container query answered by 1280 alone was answered by nothing; VIEWPORTS below is the fix.
|
|
*
|
|
* node tools/computed-diff.mjs # worktree vs HEAD, light + dark, both viewports
|
|
* node tools/computed-diff.mjs --control # the same sheet twice: the threshold, must be 0
|
|
* node tools/computed-diff.mjs --against v0.15.0 --full --max 0
|
|
*/
|
|
import { chromium } from 'playwright';
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { serveGallery, applyAppearance, matrix, ROOT } from './lib/gallery.mjs';
|
|
import { buildCss } from './lib/css.mjs';
|
|
|
|
const ARGV = process.argv.slice(2);
|
|
const flag = (n) => ARGV.includes(n);
|
|
const opt = (n, d) => { const i = ARGV.indexOf(n); return i < 0 ? d : ARGV[i + 1]; };
|
|
|
|
const CONTROL = flag('--control');
|
|
const REF = opt('--against', 'HEAD');
|
|
const MAX = opt('--max', null);
|
|
const POINTS = flag('--full')
|
|
? matrix([null])
|
|
: [{ palette: 'footstrap', mode: 'light', tint: null }, { palette: 'footstrap', mode: 'dark', tint: null }];
|
|
|
|
/* Two viewports, not one. 1280 is every page's design width (docs/development.md); 390x844 is the
|
|
* phone point task 0145's own bug was measured against (styles/theme/60-inputs.css) and the one this
|
|
* repo already reaches for on a phone-width claim (owrt2512/390x844 elsewhere in that file). The gap
|
|
* this closes: a caption turns the switch pill into a SECOND, shrinkable flex item only once the row
|
|
* is narrower than the pill + caption's combined natural width, which never happens at 1280 — so a
|
|
* declared `width` sitting at exactly `--sw-w` on both sides of a diff there proves nothing, the flex
|
|
* shrink that broke it never engaged. Measured with the flex-shrink:0 fix reverted in a scratch copy:
|
|
* cb1's used width holds at 40.00px on both sides at 1280 (0 diffs, the bug invisible), and at 390 it
|
|
* splits 40.00px (worktree) vs 26.80px (reverted HEAD) — a 13.20px `width` difference the 1280 pass
|
|
* cannot see at any density. */
|
|
const VIEWPORTS = [
|
|
{ width: 1280, height: 900, label: '1280' },
|
|
{ width: 390, height: 844, label: '390' },
|
|
];
|
|
|
|
/* The properties read on every element. This list IS the contract: a property missing from it is a
|
|
* regression this gate cannot see, so add rather than trim. Layout first, then box, then ink — the
|
|
* four bugs docs/css.md credits to this method were a font-family, a max-width, a height/padding
|
|
* pair from two rules, and a lost text-align, one in each group. */
|
|
const PROPS = [
|
|
'display', 'position', 'visibility', 'opacity', 'z-index', 'float', 'clear',
|
|
'width', 'height', 'max-width', 'max-height', 'min-width', 'min-height',
|
|
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
|
'border-top-style', 'border-top-color', 'border-bottom-color', 'border-radius',
|
|
'box-shadow', 'outline-width', 'outline-color', 'outline-offset',
|
|
'color', 'background-color', 'background-image',
|
|
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing',
|
|
'text-align', 'text-transform', 'text-decoration-line', 'white-space', 'overflow-wrap',
|
|
'overflow-x', 'overflow-y',
|
|
'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
|
|
'align-items', 'justify-content', 'gap',
|
|
'grid-template-columns', 'grid-auto-flow',
|
|
'transform', 'transition-property', 'content',
|
|
];
|
|
|
|
/* Build the reference stylesheet from a git ref. `git archive` gives exactly the two things
|
|
* build-css.sh needs — itself and styles/ beside it — and it reads neither the index nor the
|
|
* worktree, so an uncommitted edit cannot leak into the BEFORE side. */
|
|
function buildCssAt(ref) {
|
|
const dir = mkdtempSync(join(process.env.RUNNER_TEMP || tmpdir(), 'fs-css-ref-'));
|
|
const pkg = 'luci-theme-footstrap';
|
|
execFileSync('sh', ['-c',
|
|
`git archive ${JSON.stringify(ref)} ${pkg}/build-css.sh ${pkg}/styles | tar -x -C ${JSON.stringify(dir)}`],
|
|
{ cwd: ROOT, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
const out = join(dir, 'cascade.css');
|
|
execFileSync(join(dir, pkg, 'build-css.sh'), [out], { stdio: 'inherit' });
|
|
return out;
|
|
}
|
|
|
|
/* Wait for the sheet AND for font matching. A snapshot taken before matching settles measures
|
|
* fallback metrics: every width on the page shifts a pixel or two, and 291 false differences on the
|
|
* firewall page is what that looked like the first time. Neither sheet ships a face now, but a
|
|
* machine with Manrope installed still resolves one. */
|
|
async function settle(page) {
|
|
await page.evaluate(() => document.fonts.ready);
|
|
/* And for every running animation. Measured: with fonts alone, a clean tree still reported one
|
|
* difference against its own HEAD — `span.cbi-tooltip opacity 0.00245647 -> 0`, a fade caught
|
|
* mid-flight by one snapshot and finished by the other. Waiting on the animations rather than
|
|
* disabling them keeps `transition-property` honest, which is itself one of the measured
|
|
* properties. The 2 s cap is for an infinite animation, which never resolves. */
|
|
await page.evaluate(() => Promise.race([
|
|
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
|
|
new Promise((r) => setTimeout(r, 2000)),
|
|
]));
|
|
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
}
|
|
|
|
async function swapSheet(page, href) {
|
|
await page.evaluate((h) => new Promise((resolve, reject) => {
|
|
const link = document.querySelector('link[rel="stylesheet"]');
|
|
if (!link) { reject(new Error('the gallery has no <link rel=stylesheet> to swap')); return; }
|
|
link.addEventListener('load', resolve, { once: true });
|
|
link.addEventListener('error', () => reject(new Error('the swapped stylesheet failed to load')), { once: true });
|
|
link.href = h;
|
|
}), href);
|
|
await settle(page);
|
|
}
|
|
|
|
const snapshot = (props) => {
|
|
const els = document.querySelectorAll('*');
|
|
const out = new Array(els.length);
|
|
for (let i = 0; i < els.length; i++) {
|
|
const cs = getComputedStyle(els[i]);
|
|
const row = new Array(props.length);
|
|
for (let p = 0; p < props.length; p++) row[p] = cs.getPropertyValue(props[p]);
|
|
out[i] = row;
|
|
}
|
|
/* A readable name for each element, computed once so the diff pass carries no DOM work. */
|
|
const names = new Array(els.length);
|
|
for (let i = 0; i < els.length; i++) {
|
|
const e = els[i];
|
|
const cls = (e.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
names[i] = e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + (cls ? '.' + cls : '');
|
|
}
|
|
window.__fsSnap = { out, names };
|
|
return els.length;
|
|
};
|
|
|
|
const compare = ({ props, cap }) => {
|
|
const before = window.__fsSnap;
|
|
const els = document.querySelectorAll('*');
|
|
const diffs = [];
|
|
if (els.length !== before.out.length) {
|
|
return { structural: `element count changed: ${before.out.length} -> ${els.length}`, diffs };
|
|
}
|
|
for (let i = 0; i < els.length && diffs.length < cap; i++) {
|
|
const cs = getComputedStyle(els[i]);
|
|
for (let p = 0; p < props.length; p++) {
|
|
const now = cs.getPropertyValue(props[p]);
|
|
if (now !== before.out[i][p]) {
|
|
diffs.push({ el: before.names[i], i, prop: props[p], from: before.out[i][p], to: now });
|
|
if (diffs.length >= cap) break;
|
|
}
|
|
}
|
|
}
|
|
return { structural: null, diffs };
|
|
};
|
|
|
|
const CAP = 4000;
|
|
|
|
const cssNow = buildCss();
|
|
const cssRef = CONTROL ? cssNow : buildCssAt(REF);
|
|
|
|
const a = await serveGallery(cssRef);
|
|
const b = await serveGallery(cssNow);
|
|
const originB = new URL(b.base).origin;
|
|
|
|
const browser = await chromium.launch();
|
|
const page = await browser.newPage({ viewport: VIEWPORTS[0] });
|
|
|
|
let total = 0;
|
|
const report = [];
|
|
|
|
for (const vp of VIEWPORTS) {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
|
|
for (const point of POINTS) {
|
|
await page.goto(a.base, { waitUntil: 'load' });
|
|
await applyAppearance(page, point);
|
|
await settle(page);
|
|
|
|
/* Swap A onto itself before the FIRST snapshot, so both snapshots are taken in the same state.
|
|
* Measured: without this, a control pass (the same stylesheet on both sides) reported 28
|
|
* differences in light and 0 in dark — every one of them a colour re-serialised across the swap,
|
|
* `oklab(0.539907 -0.0412925 -0.186042 / 0.4)` becoming
|
|
* `color(srgb 0.0352941 0.411765 0.854902 / 0.4)` on border-top-color, border-bottom-color and
|
|
* box-shadow. The values are the same colour; only the serialisation of a computed colour
|
|
* differs between a sheet parsed with the document and one attached afterwards. Symmetry costs
|
|
* one extra load and takes the floor to 0, which is what makes a non-zero diff readable as
|
|
* causal rather than as a number to compare against a remembered baseline. */
|
|
await swapSheet(page, `${new URL(a.base).origin}/cascade.css`);
|
|
const n = await page.evaluate(snapshot, PROPS);
|
|
|
|
await swapSheet(page, `${originB}/cascade.css`);
|
|
/* The swap does not touch :root, so the axes stamped before it are still in force. Re-stamping
|
|
* here would be the bug this gate exists to catch, dressed as a fixture. */
|
|
const { structural, diffs } = await page.evaluate(compare, { props: PROPS, cap: CAP });
|
|
|
|
/* The viewport is part of the label, not just the point: a `width` diff that only exists at
|
|
* 390 and not at 1280 is unreadable as "width: … -> …" alone once two viewports run — task
|
|
* 0145's own fixture is exactly a diff that is silent at 1280 and loud at 390. */
|
|
const label = `${vp.label}/${point.palette}/${point.mode}${point.tint ? `/tint${point.tint}` : ''}`;
|
|
if (structural) { report.push({ label, structural, diffs: [], n }); total += 1; continue; }
|
|
report.push({ label, structural: null, diffs, n });
|
|
total += diffs.length;
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
a.close();
|
|
b.close();
|
|
|
|
const side = CONTROL ? 'the SAME stylesheet on both sides' : `worktree vs ${REF}`;
|
|
console.log(`computed-diff: ${side}, ${report[0]?.n ?? 0} elements x ${PROPS.length} properties, ${POINTS.length} appearance point(s) x ${VIEWPORTS.length} viewport(s) (${VIEWPORTS.map((v) => v.label).join(', ')})`);
|
|
|
|
for (const r of report) {
|
|
if (r.structural) { console.log(` ${r.label}: ${r.structural}`); continue; }
|
|
console.log(` ${r.label}: ${r.diffs.length} difference(s)${r.diffs.length >= CAP ? ` (capped at ${CAP})` : ''}`);
|
|
/* Group by property: a real regression is one property over many elements, and printing the
|
|
* elements one per line buries that shape in a wall of rows. */
|
|
const byProp = new Map();
|
|
for (const d of r.diffs) {
|
|
if (!byProp.has(d.prop)) byProp.set(d.prop, []);
|
|
byProp.get(d.prop).push(d);
|
|
}
|
|
for (const [prop, ds] of [...byProp].sort((x, y) => y[1].length - x[1].length)) {
|
|
const s = ds[0];
|
|
console.log(` ${prop}: ${ds.length}x e.g. ${s.el} ${s.from} -> ${s.to}`);
|
|
}
|
|
}
|
|
|
|
/* Below this line a real diff count is a JUDGEMENT CALL (`--max` makes it one; plain output is a
|
|
* report a human reads) — but "the page never rendered" is not a judgement call, it is a broken
|
|
* harness, and it used to read exactly like a clean pass: a blank gallery snapshots 0 elements on
|
|
* both sides, `els.length === before.out.length` (0 === 0) skips the structural check, the diff
|
|
* loop never runs, and "0 difference(s) total" prints — indistinguishable from the real thing this
|
|
* gate is trusted to prove. Both checks below run UNCONDITIONALLY, independent of --control/--max,
|
|
* because a run that measured nothing is not a report either — it is nothing to report.
|
|
*
|
|
* MEASURED is the current per-point count (853 elements on docs/gallery.html, task-gate-audit);
|
|
* the floor sits far under it so a widget added or removed never trips it, and only a gallery that
|
|
* failed to render — 0, or a handful of elements from a bare error page — does. */
|
|
const MEASURED_ELEMENTS = 853;
|
|
const ELEMENTS_FLOOR = 200;
|
|
const empty = report.filter((r) => (r.n ?? 0) < ELEMENTS_FLOOR);
|
|
if (empty.length) {
|
|
console.error(`\nFAIL: measured only ${empty[0].n} element(s) at ${empty[0].label} (last real run: `
|
|
+ `~${MEASURED_ELEMENTS}) — the gallery did not render. This is not "0 differences"; it is `
|
|
+ `nothing measured, on ${empty.length}/${report.length} point(s).`);
|
|
process.exit(1);
|
|
}
|
|
const structural = report.filter((r) => r.structural);
|
|
if (structural.length) {
|
|
console.error(`\nFAIL: the DOM itself changed shape, which no property list can diff — see the `
|
|
+ `"element count changed" line(s) above.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (CONTROL) {
|
|
if (total === 0) { console.log('control pass: 0 differences. The gallery has no noise floor; any non-zero diff above is causal.'); process.exit(0); }
|
|
console.error(`control pass FAILED with ${total} difference(s): the same stylesheet disagreed with itself, so the harness is unstable and no diff it reports can be read as causal.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (MAX !== null) {
|
|
const max = Number(MAX);
|
|
if (total > max) { console.error(`computed-diff: ${total} difference(s) over --max ${max}.`); process.exit(1); }
|
|
}
|
|
console.log(`computed-diff: ${total} difference(s) total. Read them; this gate reports, it does not judge (pass --max N to make it judge).`);
|