@trueclone/cli 0.3.0 → 0.4.0-rc.1

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.
@@ -1,52 +1,86 @@
1
- // Hover inventory + comparison by position. Hovers every interactive element on the reference (links, buttons,
2
- // anything with cursor:pointer), records which visual properties change on it and its first descendants, then
3
- // hovers the same page point on the clone and checks it reacts the same way. Catches missing hover states,
4
- // which screenshots at rest never show.
1
+ // Hovers: does hovering do the same thing on the clone as on the reference? Every element a visitor can hover is
2
+ // found two ways: interactive elements (links, buttons, anything with cursor:pointer) and every element that has a
3
+ // mouse or pointer enter/over/move listener (read through the DevTools protocol, so a card that opens a panel from
4
+ // script is found even without a pointer cursor). Each one is hovered on the reference; the same page point is
5
+ // hovered on the clone. Two things are compared:
6
+ // - which visual properties change on the element and its first descendants (colour, background, transform…);
7
+ // - the outcome: the region around the element after the hover, as pixels, reference vs clone (text and media masked
8
+ // in template mode). A clone that opens a different panel, or opens nothing, fails even if "something changed".
5
9
  //
6
- // usage: node scripts/hovers.mjs <name> [--viewport desktop] [--url <clone url>] [--max 40]
10
+ // usage: node scripts/hovers.mjs <name> [--viewport desktop] [--url <clone url>] [--max 60] [--refresh-ref]
7
11
  import fs from 'node:fs/promises';
8
12
  import path from 'node:path';
9
- import { VIEWPORTS, parseArgs, openBrowser, newPage, settle, serve, cached } from './lib.mjs';
13
+ import { SCORED, parseArgs, openBrowser, newPage, settle, serve, cached, maskToggle, matchPct, PNG } from './lib.mjs';
10
14
 
11
15
  const { pos, flags } = parseArgs(process.argv.slice(2));
12
16
  const [name] = pos;
13
17
  if (!name) {
14
- console.error('usage: npm run hovers -- <name> [--viewport desktop] [--url <clone url>]');
18
+ console.error('usage: trueclone hovers <name> [--viewport desktop] [--url <clone url>] [--max 60]');
15
19
  process.exit(1);
16
20
  }
17
21
  const root = path.resolve('sites', name);
18
22
  const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
19
- const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
20
- const MAX = Number(flags.max || 40);
23
+ const vp = SCORED.find((v) => v.name === (flags.viewport || 'desktop'));
24
+ const MAX = Number(flags.max || 60);
25
+ const template = meta.mode !== 'exact';
26
+ // the hovered region must match this well (exact mode renders the same text, so it can be stricter)
27
+ const OUTCOME_MIN = template ? 95 : 97;
28
+ const REACTS_BELOW = 99.5; // the region before vs after the hover: below this, the hover changed something visible
21
29
  const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
22
30
  const cloneUrl = server ? server.url : flags.url;
23
31
 
32
+ // Elements with a hover-type listener, marked data-lab-hover through CDP (one call for the whole document).
33
+ async function markListened(page) {
34
+ const cdp = await page.context().newCDPSession(page);
35
+ try {
36
+ const { result } = await cdp.send('Runtime.evaluate', { expression: 'document', objectGroup: 'lab' });
37
+ const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId, depth: -1, pierce: false });
38
+ const HOVER = new Set(['mouseenter', 'mouseover', 'pointerenter', 'pointerover', 'mousemove', 'pointermove']);
39
+ const nodes = [...new Set(listeners.filter((l) => HOVER.has(l.type) && l.backendNodeId).map((l) => l.backendNodeId))];
40
+ let marked = 0;
41
+ for (const backendNodeId of nodes.slice(0, 400)) {
42
+ try {
43
+ const { object } = await cdp.send('DOM.resolveNode', { backendNodeId, objectGroup: 'lab' });
44
+ await cdp.send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: 'function(){ if (this.setAttribute && this !== document.documentElement && this !== document.body) this.setAttribute("data-lab-hover", ""); }' });
45
+ marked++;
46
+ } catch { /* detached */ }
47
+ }
48
+ return marked;
49
+ } catch { return 0; } finally { await cdp.detach().catch(() => {}); }
50
+ }
51
+
24
52
  function targets(max) {
25
53
  const seen = new Set();
26
54
  const out = [];
27
- for (const el of document.querySelectorAll('a, button, [role="button"], *')) {
55
+ const sel = (el) => `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')}`;
56
+ const consider = (el, why) => {
28
57
  const s = getComputedStyle(el);
29
- if (s.cursor !== 'pointer' && !['A', 'BUTTON'].includes(el.tagName)) continue;
30
- if (s.display === 'none' || s.visibility === 'hidden') continue;
58
+ if (s.display === 'none' || s.visibility === 'hidden') return;
31
59
  const r = el.getBoundingClientRect();
32
- if (r.width < 10 || r.height < 10 || r.width > innerWidth * 0.9) continue;
33
- // one target per interactive root: skip descendants of an already listed element
34
- let p = el.parentElement, nested = false;
35
- while (p) { if (seen.has(p)) { nested = true; break; } p = p.parentElement; }
36
- if (nested) continue;
60
+ if (r.width < 10 || r.height < 10 || r.width > innerWidth * 0.95) return;
61
+ // one target per interactive root: never the same element twice (found by its listener and by its cursor), nor
62
+ // one inside or around an element already listed (the same region, counted twice)
63
+ if (seen.has(el)) return;
64
+ for (let p = el.parentElement; p; p = p.parentElement) if (seen.has(p)) return;
65
+ for (const s of seen) if (el.contains(s)) return;
37
66
  seen.add(el);
38
- out.push({ x: Math.round(r.left + r.width / 2), y: Math.round(r.top + scrollY + r.height / 2), sel: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')}` });
67
+ out.push({ x: Math.round(r.left + r.width / 2), y: Math.round(r.top + scrollY + r.height / 2), w: Math.round(r.width), h: Math.round(r.height), sel: sel(el), why });
68
+ };
69
+ // listened elements first: they are the ones a visual check at rest never sees
70
+ for (const el of document.querySelectorAll('[data-lab-hover]')) { if (out.length >= max) break; consider(el, 'listener'); }
71
+ for (const el of document.querySelectorAll('a, button, [role="button"], *')) {
39
72
  if (out.length >= max) break;
73
+ const s = getComputedStyle(el);
74
+ if (s.cursor !== 'pointer' && !['A', 'BUTTON'].includes(el.tagName)) continue;
75
+ consider(el, 'pointer');
40
76
  }
41
- return out;
77
+ return out.sort((a, b) => a.y - b.y);
42
78
  }
43
79
 
44
- // Signature of the interactive root under a point and its first descendants.
80
+ // Signature of the hover root under a point and its first descendants.
45
81
  function signature([px, vy]) {
46
82
  let el = document.elementFromPoint(px, vy);
47
- // cursor:pointer is inherited, so prefer the nearest real control (a/button/[role=button]); otherwise the
48
- // outermost ancestor that still has a pointer cursor
49
- const control = el?.closest('a, button, [role="button"]');
83
+ const control = el?.closest('[data-lab-hover], a, button, [role="button"]');
50
84
  if (control) el = control;
51
85
  else {
52
86
  let top = null;
@@ -58,7 +92,7 @@ function signature([px, vy]) {
58
92
  const props = {};
59
93
  nodes.forEach((n, i) => {
60
94
  const s = getComputedStyle(n);
61
- props[i] = { color: s.color, background: s.backgroundColor, transform: s.transform, opacity: s.opacity, shadow: s.boxShadow, decoration: s.textDecorationLine, border: s.borderColor, filter: s.filter, scale: s.scale };
95
+ props[i] = { color: s.color, background: s.backgroundColor, transform: s.transform, opacity: s.opacity, shadow: s.boxShadow, decoration: s.textDecorationLine, border: s.borderColor, filter: s.filter, scale: s.scale, visibility: s.visibility };
62
96
  });
63
97
  return { sel: `${el.tagName.toLowerCase()}.${String(el.getAttribute('class') || '').split(/\s+/).slice(0, 2).join('.')}`, props };
64
98
  }
@@ -68,27 +102,67 @@ const changedKinds = (a, b) => {
68
102
  return [...kinds].sort();
69
103
  };
70
104
 
71
- async function hoverAll(browser, url, list) {
105
+ // the region a hover can change: the element and half its size around it (a panel opening next to a card), in view
106
+ const regionOf = (t, vy) => {
107
+ const x = Math.max(0, Math.round(t.x - t.w)), y = Math.max(0, Math.round(vy - t.h));
108
+ return { x, y, width: Math.min(vp.width - x, Math.round(t.w * 2)), height: Math.min(vp.height - y, Math.round(t.h * 2)) };
109
+ };
110
+
111
+ async function hoverAll(browser, url, list, shotDir) {
72
112
  const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
73
113
  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
74
114
  await settle(page);
75
- if (!list) list = await page.evaluate(targets, MAX);
115
+ let listened = 0;
116
+ if (!list) { listened = await markListened(page); list = await page.evaluate(targets, MAX); }
117
+ else await markListened(page);
118
+ await fs.mkdir(shotDir, { recursive: true });
76
119
  const out = [];
77
- for (const t of list) {
120
+ for (const [i, t] of list.entries()) {
78
121
  const top = Math.max(0, t.y - Math.round(vp.height / 2));
79
122
  await page.evaluate((y) => window.scrollTo(0, y), top);
80
123
  await page.mouse.move(2, 2);
81
- await page.waitForTimeout(400);
82
124
  const vy = t.y - (await page.evaluate(() => scrollY));
125
+ const clip = regionOf(t, vy);
126
+ const ok = clip.width > 4 && clip.height > 4;
127
+ // the region shot until two shots in a row agree (a transition has ended), at most 1.5 s: as long as a slow
128
+ // hover needs, no longer than a quick one does. A region that is never still (a loop, a video) gets the fixed
129
+ // 0.9 s wait instead: stillness will not come.
130
+ let still = true;
131
+ const grab = async (file, min = 0, max = 1500) => { // min: a transition-delay must have passed before "still" means done
132
+ if (!ok) { await page.waitForTimeout(Math.max(300, min)); return null; }
133
+ if (template) await maskToggle(page, true);
134
+ let last = null, buf = null;
135
+ const t0 = Date.now();
136
+ try {
137
+ for (;;) {
138
+ await page.waitForTimeout(last ? 120 : 80);
139
+ buf = await page.screenshot({ clip });
140
+ // still = no visible change: a shimmering background or grain never repeats byte for byte
141
+ if (last && (buf.equals(last) || matchPct(PNG.sync.read(last), PNG.sync.read(buf)).pct >= 99.8) && Date.now() - t0 >= min) break;
142
+ if (Date.now() - t0 > max) { still = false; break; }
143
+ last = buf;
144
+ }
145
+ } catch { buf = null; }
146
+ if (template) await maskToggle(page, false);
147
+ if (!buf) return null;
148
+ const p = path.join(shotDir, file);
149
+ await fs.writeFile(p, buf);
150
+ return p;
151
+ };
152
+ const shotBefore = await grab(`${String(i).padStart(2, '0')}-before.png`, 0, 600);
83
153
  const before = await page.evaluate(signature, [t.x, vy]);
84
154
  await page.mouse.move(t.x, vy, { steps: 6 });
85
- await page.waitForTimeout(800);
155
+ const shot = await grab(`${String(i).padStart(2, '0')}.png`, 450, still ? 1500 : 900);
86
156
  const after = await page.evaluate(signature, [t.x, vy]);
87
- out.push({ sel: before?.sel || null, kinds: before && after && before.sel === after.sel ? changedKinds(before, after) : [] });
157
+ // what the hover changed on screen: deep descendants, a panel next to the card, a class-driven state
158
+ const visible = shotBefore && shot ? matchPct(PNG.sync.read(await fs.readFile(shotBefore)), PNG.sync.read(await fs.readFile(shot))).pct : 100;
159
+ const kinds = before && after && before.sel === after.sel ? changedKinds(before, after) : [];
160
+ if (visible < REACTS_BELOW && !kinds.length) kinds.push('pixels');
161
+ out.push({ sel: before?.sel || null, kinds, shot, visible });
88
162
  }
89
163
  const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => null);
90
164
  await context.close();
91
- return { list, out, pageHeight };
165
+ return { list, out, pageHeight, listened };
92
166
  }
93
167
 
94
168
  const browser = await openBrowser(flags.cdp);
@@ -96,30 +170,48 @@ let ref, cl;
96
170
  try {
97
171
  // reference hovers are cached until the next capture (--refresh-ref to re-measure); read twice, so a hover effect
98
172
  // that comes and goes by timing on the reference itself is set aside as unstable rather than demanded of the clone
99
- ref = await cached(root, { tool: 'hovers', vp: vp.name, max: MAX, v: 3 }, async () => {
100
- const first = await hoverAll(browser, meta.url, null);
173
+ ref = await cached(root, { tool: 'hovers', vp: vp.name, max: MAX, v: 8 }, async () => {
174
+ const dir = path.join(root, 'reference', 'cache', `hovers-${vp.name}`);
175
+ const first = await hoverAll(browser, meta.url, null, path.join(dir, 'a'));
101
176
  const captured = meta.viewports?.find((v) => v.viewport === vp.name)?.pageHeight;
102
177
  if (captured && first.pageHeight && Math.abs(first.pageHeight - captured) > Math.max(40, captured * 0.01)) console.warn(`WARNING reference drift (${vp.name}): the live page is ${first.pageHeight}px tall, the capture was ${captured}px (${meta.capturedAt}); re-capture before trusting this score`);
103
- const again = await hoverAll(browser, meta.url, first.list);
104
- return { ...first, unstable: first.out.map((a, i) => a.sel !== again.out[i]?.sel || a.kinds.join() !== (again.out[i]?.kinds || []).join()) };
178
+ const again = await hoverAll(browser, meta.url, first.list, path.join(dir, 'b'));
179
+ const unstable = await Promise.all(first.out.map(async (a, i) => {
180
+ const b = again.out[i];
181
+ if (a.sel !== b?.sel || a.kinds.join() !== (b?.kinds || []).join()) return true;
182
+ if (a.shot && b?.shot) return matchPct(PNG.sync.read(await fs.readFile(a.shot)), PNG.sync.read(await fs.readFile(b.shot))).pct < 97;
183
+ return false;
184
+ }));
185
+ return { ...first, unstable };
105
186
  }, Boolean(flags['refresh-ref']));
106
- cl = await hoverAll(browser, cloneUrl, ref.list);
187
+ cl = await hoverAll(browser, cloneUrl, ref.list, path.join(root, 'compare', `hovers-${vp.name}`));
107
188
  } finally {
108
189
  if (!flags.cdp) await browser.close();
109
190
  server?.close();
110
191
  }
111
- const rows = ref.list.map((t, i) => {
192
+ const rows = [];
193
+ for (const [i, t] of ref.list.entries()) {
112
194
  const a = ref.out[i], b = cl.out[i];
113
- const status = ref.unstable?.[i] ? 'unstable' : !a.kinds.length ? (b.kinds.length ? 'extra' : 'static') : !b.sel ? 'no element' : !b.kinds.length ? 'MISSING' : a.kinds.some((k) => b.kinds.includes(k)) ? 'ok' : 'different';
114
- return { y: t.y, ref: a.sel || t.sel, refChanges: a.kinds.join(',') || '-', clone: b.sel || '-', cloneChanges: b.kinds.join(',') || '-', status };
115
- });
116
- const withHover = rows.filter((r) => r.refChanges !== '-' && r.status !== 'unstable');
195
+ const outcome = a.shot && b.shot ? matchPct(PNG.sync.read(await fs.readFile(a.shot)), PNG.sync.read(await fs.readFile(b.shot))).pct : null;
196
+ const reacts = a.kinds.length > 0;
197
+ let status;
198
+ if (ref.unstable?.[i]) status = 'unstable';
199
+ else if (!reacts && !b.kinds.length) status = 'static';
200
+ else if (!reacts) status = outcome !== null && outcome < OUTCOME_MIN ? 'EXTRA' : 'static'; // the clone reacts where the reference doesn't
201
+ else if (!b.sel) status = 'no element';
202
+ else if (!b.kinds.length) status = 'MISSING';
203
+ else if (!a.kinds.some((k) => b.kinds.includes(k))) status = 'different';
204
+ else if (outcome !== null && outcome < OUTCOME_MIN) status = 'WRONG OUTCOME';
205
+ else status = 'ok';
206
+ rows.push({ y: t.y, found: t.why || 'pointer', ref: a.sel || t.sel, refChanges: a.kinds.join(',') || '-', clone: b.sel || '-', cloneChanges: b.kinds.join(',') || '-', outcome, status });
207
+ }
208
+ const scored = rows.filter((r) => r.status !== 'static' && r.status !== 'unstable');
117
209
  const unstable = rows.filter((r) => r.status === 'unstable').length;
118
- const ok = withHover.filter((r) => r.status === 'ok').length;
119
- const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1, withHover.length)).toFixed(1), unstable, rows };
210
+ const ok = scored.filter((r) => r.status === 'ok').length;
211
+ const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1, scored.length)).toFixed(1), unstable, listened: ref.listened ?? null, rows };
120
212
  const out = path.join(root, 'compare', `hovers.${vp.name}.json`);
121
213
  await fs.mkdir(path.dirname(out), { recursive: true });
122
214
  await fs.writeFile(out, JSON.stringify(report, null, 2));
123
- console.log(`hover match (${vp.name}): ${report.score}% of ${withHover.length} reference hover effects reproduced (${rows.length} interactive elements probed${unstable ? `, ${unstable} unstable on the reference itself, not scored` : ''})`);
124
- console.table(rows.filter((r) => r.status !== 'static' && r.status !== 'ok' && r.status !== 'unstable').slice(0, 25));
215
+ console.log(`hover match (${vp.name}): ${report.score}% of ${scored.length} hover effects reproduced with the same outcome (${rows.length} elements probed, ${rows.filter((r) => r.found === 'listener').length} found by their mouse listeners${unstable ? `, ${unstable} unstable on the reference itself, not scored` : ''})`);
216
+ console.table(rows.filter((r) => !['static', 'ok', 'unstable'].includes(r.status)).slice(0, 25));
125
217
  console.log(`-> ${path.relative(process.cwd(), out)}`);
@@ -11,6 +11,10 @@
11
11
  // "compare": "structure" }] // or "visual"
12
12
  // Step kinds: move [x,y] (fractions of the viewport), hover [x,y], path [[x,y],...], drag {from,to}, down/up [x,y],
13
13
  // click {ref,clone} selector or [x,y], key "Escape", wait ms, shot "label".
14
+ // Points are [x, y] fractions of the viewport (0-1); move and hover also take an element: "selector@n" or { ref, clone }.
15
+ // "selector@n" in at/click/expectChange picks the n-th match, counting from 0 (mind hidden duplicates: a mobile menu
16
+ // button is often the first match). A scenario passes when the change happens on both sides and the shots after it
17
+ // match the reference's (≥ 95).
14
18
  // usage: node scripts/interact.mjs <name> [--only "<text>"] | node scripts/interact.mjs <name> --draft
15
19
  import fs from 'node:fs/promises';
16
20
  import path from 'node:path';
@@ -19,7 +23,7 @@ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, w
19
23
  const { pos, flags } = parseArgs(process.argv.slice(2));
20
24
  const [name] = pos;
21
25
  if (!name) {
22
- console.error('usage: npm run interact -- <name> [--only text] | --draft');
26
+ console.error('usage: trueclone interact <name> [--only "<scenario text>"] | trueclone interact <name> --draft (scenario format: the comment at the top of scripts/interact.mjs, and SKILL.md)');
23
27
  process.exit(1);
24
28
  }
25
29
  const root = path.resolve('sites', name);
@@ -94,6 +98,14 @@ async function runSide(url, side, list) {
94
98
  await wheelTo(page, sc.at?.offset || 0, vp);
95
99
  }
96
100
  await waitStill(page, { timeout: 3000 });
101
+ // the first frame only once the screen itself has stopped changing (a slow intro fade outlasts the page-level
102
+ // wait, and a "before" caught halfway through it is compared against forever): two shots in a row the same
103
+ for (let t0 = Date.now(), last = null; Date.now() - t0 < 6000;) {
104
+ const buf = await page.screenshot();
105
+ if (last && (buf.equals(last) || matchPct(PNG.sync.read(last), PNG.sync.read(buf)).pct >= 99.8)) break;
106
+ last = buf;
107
+ await page.waitForTimeout(250);
108
+ }
97
109
  const px = ([x, y]) => [Math.round(x * vp.width), Math.round(y * vp.height)];
98
110
  const expectSel = sc.expectChange?.[side];
99
111
  const before = expectSel ? await stateOf(page, expectSel) : null;
@@ -109,7 +121,18 @@ async function runSide(url, side, list) {
109
121
  };
110
122
  await shot('before');
111
123
  for (const st of sc.steps) {
112
- if (st.move || st.hover) await page.mouse.move(...px(st.move || st.hover), { steps: 15 });
124
+ if (st.move || st.hover) {
125
+ // a point [x, y], or an element to put the pointer on: "selector@n" or { ref, clone }
126
+ const t = st.move || st.hover;
127
+ const sel = Array.isArray(t) ? null : typeof t === 'string' ? t : t[side];
128
+ if (!sel) await page.mouse.move(...px(t), { steps: 15 });
129
+ else {
130
+ const [s, idx] = sel.split('@');
131
+ const box = await page.locator(s).nth(Number(idx || 0)).boundingBox().catch(() => null);
132
+ if (box) await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 15 });
133
+ else results.push({ name: sc.name, error: `hover ${sel}: no such element on the ${side === 'ref' ? 'reference' : 'clone'}` });
134
+ }
135
+ }
113
136
  else if (st.path) for (const p of st.path) await page.mouse.move(...px(p), { steps: 20 });
114
137
  else if (st.drag) {
115
138
  await page.mouse.move(...px(st.drag.from), { steps: 5 });
@@ -146,7 +169,7 @@ const server = typeof flags.url === 'string' ? null : await serve(path.join(root
146
169
  const cloneUrl = server ? server.url : flags.url;
147
170
  // Reference results are cached per scenario (reference side + viewport + capture time) as long as the shot
148
171
  // files still exist; only new or edited scenarios touch the live site. --refresh-ref re-measures all.
149
- const refKey = (sc) => ({ tool: 'interact', name: sc.name, viewport: sc.viewport, at: sc.at, steps: sc.steps, expect: sc.expectChange?.ref, compare: sc.compare, click: JSON.stringify(sc.steps) });
172
+ const refKey = (sc) => ({ tool: 'interact', v: 2, name: sc.name, viewport: sc.viewport, at: sc.at, steps: sc.steps, expect: sc.expectChange?.ref, compare: sc.compare, click: JSON.stringify(sc.steps) });
150
173
  const refRes = [];
151
174
  const missing = [];
152
175
  for (const sc of scenarios) {
@@ -164,6 +187,7 @@ const cloneRes = await runSide(cloneUrl, 'clone', scenarios);
164
187
  server?.close();
165
188
 
166
189
  const report = { name, at: new Date().toISOString(), scenarios: [] };
190
+ const END_STATE_MIN = 95; // the shots after the action must match this well
167
191
  for (const sc of scenarios) {
168
192
  const a = refRes.find((r) => r.name === sc.name && r.shots), b = cloneRes.find((r) => r.name === sc.name && r.shots);
169
193
  const errors = [...refRes, ...cloneRes].filter((r) => r.name === sc.name && r.error).map((r) => r.error);
@@ -174,7 +198,12 @@ for (const sc of scenarios) {
174
198
  }
175
199
  const labels = Object.keys(shots).filter((l) => l !== 'before');
176
200
  const score = labels.length ? +(labels.reduce((n, l) => n + shots[l], 0) / labels.length).toFixed(2) : null;
177
- const expect = sc.expectChange ? { ref: a?.changed, clone: b?.changed, pass: a?.changed === true && b?.changed === true } : null;
201
+ // a scenario passes when the change happens on both sides AND the clone ends up where the reference does: a wrong
202
+ // panel opening is a change too
203
+ const endOk = score !== null && score >= END_STATE_MIN;
204
+ const expect = sc.expectChange
205
+ ? { ref: a?.changed, clone: b?.changed, endState: score, pass: a?.changed === true && b?.changed === true && endOk }
206
+ : { ref: null, clone: null, endState: score, pass: endOk };
178
207
  report.scenarios.push({ name: sc.name, score, shots, expect, errors });
179
208
  }
180
209
  await fs.writeFile(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
@@ -182,7 +211,8 @@ console.table(report.scenarios.map((s) => ({
182
211
  scenario: s.name,
183
212
  'match %': s.score,
184
213
  shots: Object.entries(s.shots).map(([k, v]) => `${k} ${v}`).join(' '),
185
- 'changes (ref/clone)': s.expect ? `${s.expect.ref ? 'yes' : 'NO'} / ${s.expect.clone ? 'yes' : 'NO'}${s.expect.pass ? '' : ' FAIL'}` : '-',
214
+ 'changes (ref/clone)': s.expect.ref === null ? '-' : `${s.expect.ref ? 'yes' : 'NO'} / ${s.expect.clone ? 'yes' : 'NO'}`,
215
+ 'end state': `${s.score ?? '-'}${s.expect.pass ? '' : ' FAIL'}`,
186
216
  errors: s.errors.join('; ') || '',
187
217
  })));
188
218
  console.log(`-> ${path.relative(process.cwd(), path.join(outDir, 'report.json'))}`);
package/scripts/lib.mjs CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import crypto from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
5
  import http from 'node:http';
6
+ import zlib from 'node:zlib';
6
7
  import path from 'node:path';
7
8
  import { chromium } from 'playwright';
8
9
  import { PNG } from 'pngjs';
@@ -10,11 +11,19 @@ import pixelmatch from 'pixelmatch';
10
11
 
11
12
  export { PNG };
12
13
 
14
+ // The seed sizes: the clone is built from what the reference shows at these.
13
15
  export const VIEWPORTS = [
14
16
  { name: 'mobile', width: 390, height: 844 },
15
17
  { name: 'tablet', width: 768, height: 1024 },
16
18
  { name: 'desktop', width: 1440, height: 900 },
17
19
  ];
20
+ // Held-out sizes: captured and scored, never read by anything that builds the clone. A clone that is right at the seed
21
+ // sizes and wrong here is fitted to the sizes it was built from, not a copy of the site.
22
+ export const HELD_OUT = [
23
+ { name: 'laptop', width: 1280, height: 720, heldOut: true },
24
+ { name: 'wide', width: 1920, height: 1080, heldOut: true },
25
+ ];
26
+ export const SCORED = [...VIEWPORTS, ...HELD_OUT];
18
27
 
19
28
  // Jump every animation/transition to its end state so screenshots are deterministic.
20
29
  const FREEZE_CSS = `*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;
@@ -133,19 +142,38 @@ canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
133
142
  // would undo an inline override, so the mask is a stylesheet rule on a marker attribute, which inline styles lose
134
143
  // to), gradient text (background-clip: text paints the glyphs with its background, so a transparent colour still
135
144
  // shows the letters) and photo backgrounds on ::before/::after. `off` removes the markers.
145
+ // A script that rebuilds or restyles media after the mask went on (a resize during a full-page shot rebuilds a grid)
146
+ // would leave the new elements unmasked on one side only; an observer marks them as they appear, on both sides.
136
147
  function maskExtrasInPage(enable) {
137
148
  const marks = ['data-lab-bgi', 'data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
138
149
  if (!enable) {
150
+ window.__labMaskObserver?.disconnect();
151
+ window.__labMaskObserver = null;
139
152
  for (const m of marks) for (const el of document.querySelectorAll(`[${m}]`)) el.removeAttribute(m);
140
153
  return;
141
154
  }
142
- for (const el of document.querySelectorAll('*')) {
155
+ const markOne = (el) => {
143
156
  const s = getComputedStyle(el);
144
157
  if (s.backgroundImage.includes('url(')) el.setAttribute('data-lab-bgi', '');
145
158
  if (s.backgroundClip === 'text' || s.webkitBackgroundClip === 'text') el.setAttribute('data-lab-cliptext', '');
146
159
  if (getComputedStyle(el, '::before').backgroundImage.includes('url(')) el.setAttribute('data-lab-bgb', '');
147
160
  if (getComputedStyle(el, '::after').backgroundImage.includes('url(')) el.setAttribute('data-lab-bga', '');
148
- }
161
+ };
162
+ for (const el of document.querySelectorAll('*')) markOne(el);
163
+ window.__labMaskObserver?.disconnect();
164
+ window.__labMaskObserver = new MutationObserver((records) => {
165
+ for (const r of records) {
166
+ if (r.type === 'attributes') {
167
+ if (!(r.target instanceof Element) || marks.includes(r.attributeName)) continue;
168
+ markOne(r.target);
169
+ // a custom property or url() set on an ancestor (--hero-img) reaches its descendants' backgrounds
170
+ if (/url\(|--/.test(r.target.getAttribute('style') || '')) for (const el of r.target.querySelectorAll('*')) markOne(el);
171
+ continue;
172
+ }
173
+ for (const n of r.addedNodes) if (n instanceof Element) { markOne(n); for (const el of n.querySelectorAll('*')) markOne(el); }
174
+ }
175
+ });
176
+ window.__labMaskObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['style', 'class'] });
149
177
  }
150
178
 
151
179
  export async function mask(page) {
@@ -226,17 +254,29 @@ const MIME = {
226
254
  };
227
255
 
228
256
  // Port 0 = a free port picked by the OS, so several scoring tools can run side by side; use server.url.
257
+ // Serves a clone the way a host would: text compressed (brotli or gzip, as the browser asks), so the load check
258
+ // compares the clone with a CDN-served original on equal terms.
259
+ const COMPRESSIBLE = /\.(html?|css|m?js|json|svg|txt|xml|map)$/i;
229
260
  export function serve(dir, port = 0) {
261
+ dir = path.resolve(dir); // forward or back slashes, relative or not: one form, so the containment check holds
230
262
  return new Promise((resolve) => {
231
263
  const server = http.createServer(async (req, res) => {
232
264
  let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
233
265
  if (p.endsWith('/')) p += 'index.html';
234
266
  const file = path.join(dir, p);
235
- if (!file.startsWith(dir)) { res.writeHead(403).end(); return; }
267
+ if (file !== dir && !file.startsWith(dir + path.sep)) { res.writeHead(403).end(); return; }
236
268
  try {
237
269
  await fs.access(file);
238
- res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
239
- createReadStream(file).pipe(res);
270
+ const headers = { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' };
271
+ const accept = String(req.headers['accept-encoding'] || '');
272
+ const enc = COMPRESSIBLE.test(file) ? (/\bbr\b/.test(accept) ? 'br' : /\bgzip\b/.test(accept) ? 'gzip' : null) : null;
273
+ if (enc) {
274
+ res.writeHead(200, { ...headers, 'content-encoding': enc, vary: 'accept-encoding' });
275
+ createReadStream(file).pipe(enc === 'br' ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }) : zlib.createGzip({ level: 6 })).pipe(res);
276
+ } else {
277
+ res.writeHead(200, headers);
278
+ createReadStream(file).pipe(res);
279
+ }
240
280
  } catch { res.writeHead(404).end(); }
241
281
  });
242
282
  server.listen(port, '127.0.0.1', () => { server.url = `http://127.0.0.1:${server.address().port}/`; resolve(server); });
@@ -261,6 +301,30 @@ export function crop(png, y, h) {
261
301
 
262
302
  export const match = (a, b, diff) => pixelmatch(a.data, b.data, diff ? diff.data : null, a.width, a.height, { threshold: 0.1, includeAA: false });
263
303
 
304
+ // How alike two canvas regions look, 0-100: what a scene draws rarely matches pixel for pixel (time, physics, random
305
+ // seeds), so it is compared by what is drawn rather than where: the colour distribution (64 bins) and how much of the
306
+ // region is covered by drawing at all (pixels away from the region's dominant colour). An empty or wrong canvas scores
307
+ // low; the same scene a frame apart scores high.
308
+ export function canvasSimilarity(a, b) {
309
+ const stats = (img) => {
310
+ const hist = new Float64Array(64);
311
+ const n = img.width * img.height;
312
+ for (let i = 0; i < n; i++) hist[((img.data[i * 4] >> 6) << 4) | ((img.data[i * 4 + 1] >> 6) << 2) | (img.data[i * 4 + 2] >> 6)]++;
313
+ let mode = 0;
314
+ for (let k = 1; k < 64; k++) if (hist[k] > hist[mode]) mode = k;
315
+ const [mr, mg, mb] = [(mode >> 4) * 64 + 32, ((mode >> 2) & 3) * 64 + 32, (mode & 3) * 64 + 32];
316
+ let ink = 0;
317
+ for (let i = 0; i < n; i++) if (Math.abs(img.data[i * 4] - mr) + Math.abs(img.data[i * 4 + 1] - mg) + Math.abs(img.data[i * 4 + 2] - mb) > 96) ink++;
318
+ for (let k = 0; k < 64; k++) hist[k] /= n || 1;
319
+ return { hist, ink: ink / (n || 1) };
320
+ };
321
+ const A = stats(a), B = stats(b);
322
+ let inter = 0;
323
+ for (let k = 0; k < 64; k++) inter += Math.min(A.hist[k], B.hist[k]);
324
+ const cover = 1 - Math.abs(A.ink - B.ink) / Math.max(A.ink, B.ink, 0.05);
325
+ return +(100 * (0.5 * inter + 0.5 * cover)).toFixed(2);
326
+ }
327
+
264
328
  export function matchPct(pngA, pngB, withDiff = false) {
265
329
  const w = Math.max(pngA.width, pngB.width), h = Math.max(pngA.height, pngB.height);
266
330
  const a = pad(pngA, w, h), b = pad(pngB, w, h);
package/scripts/load.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // -> sites/<name>/compare/load.json, frames in compare/load/ and reference/load/
10
10
  import fs from 'node:fs/promises';
11
11
  import path from 'node:path';
12
- import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, matchPct, cached, PNG, withTimeout } from './lib.mjs';
12
+ import { SCORED as VIEWPORTS, parseArgs, openBrowser, newPage, serve, matchPct, cached, PNG, withTimeout } from './lib.mjs';
13
13
 
14
14
  const { pos, flags } = parseArgs(process.argv.slice(2));
15
15
  const [name] = pos;
@@ -81,14 +81,18 @@ try {
81
81
  const clone = await twice(cloneUrl || `${server.url}index.html`, path.join(root, 'compare', 'load'), vp);
82
82
  const gap = Object.fromEntries(TIMES.map((t) => [t, +Math.abs(ref.curve[t] - clone.curve[t]).toFixed(2)]));
83
83
  const noise = +Math.max(ref.noise, clone.noise).toFixed(2);
84
- // a side that does not agree with itself (an intro that starts a second later in one run) cannot be scored
85
- // against anything: the width is reported as unstable, like a probe, instead of as a number
86
- const unstable = noise > NOISE_LIMIT;
87
- const score = unstable ? null : +(100 - TIMES.reduce((s, t) => s + gap[t], 0) / TIMES.length).toFixed(2);
88
- viewports.push({ viewport: vp.name, score, unstable, noise, gap, ref, clone });
84
+ const match = (curve) => +(100 - TIMES.reduce((s, t) => s + Math.abs(ref.curve[t] - curve[t]), 0) / TIMES.length).toFixed(2);
85
+ // Only the reference not agreeing with itself (an intro that starts a second later in one run) leaves nothing to
86
+ // score against: that width is reported as unstable, like a probe. A clone that loads differently from one run to
87
+ // the next is a defect, not noise: it is scored by its worse run (a visitor may get that one).
88
+ const unstable = ref.noise > NOISE_LIMIT;
89
+ const cloneUnstable = !unstable && clone.noise > NOISE_LIMIT;
90
+ const score = unstable ? null : cloneUnstable ? Math.min(...clone.runs.map((r) => match(r.curve))) : match(clone.curve);
91
+ viewports.push({ viewport: vp.name, score, unstable, cloneUnstable, noise, gap, ref, clone });
92
+ const curves = `curves ref ${TIMES.map((t) => ref.curve[t]).join('/')} clone ${clone.runs.map((r) => TIMES.map((t) => r.curve[t]).join('/')).join(' | ')}`;
89
93
  console.log(unstable
90
- ? `${vp.name}: load unstable (noise ±${noise} between two runs of the ${ref.noise > clone.noise ? 'reference' : 'clone'} itself; not scored; curves ref ${TIMES.map((t) => ref.curve[t]).join('/')} clone ${TIMES.map((t) => clone.curve[t]).join('/')})`
91
- : `${vp.name}: load match ${score}% (noise ±${noise}; settled: reference ${ref.settledAt ?? '>4000'}ms, clone ${clone.settledAt ?? '>4000'}ms; curves ref ${TIMES.map((t) => ref.curve[t]).join('/')} clone ${TIMES.map((t) => clone.curve[t]).join('/')})`);
94
+ ? `${vp.name}: load unstable (the reference differs by ±${ref.noise} between two runs of itself; not scored; ${curves})`
95
+ : `${vp.name}: load match ${score}%${cloneUnstable ? ` (the clone loads differently each time, ±${clone.noise}: scored by its worse run)` : ` (noise ±${noise})`}; settled: reference ${ref.settledAt ?? '>4000'}ms, clone ${clone.settledAt ?? '>4000'}ms; ${curves}`);
92
96
  }
93
97
  } finally {
94
98
  if (!flags.cdp) await browser.close();