@trueclone/cli 0.3.0 → 0.4.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/README.md +24 -32
- package/bin/trueclone.mjs +23 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +136 -24
- package/scripts/curves.mjs +44 -7
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +21 -14
- package/scripts/hovers.mjs +137 -45
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +69 -5
- package/scripts/load.mjs +12 -8
- package/scripts/next.mjs +74 -9
- package/scripts/report.mjs +46 -12
- package/scripts/reveals.mjs +4 -0
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +4 -1
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +188 -115
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -145
package/scripts/hovers.mjs
CHANGED
|
@@ -1,52 +1,86 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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 {
|
|
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:
|
|
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 =
|
|
20
|
-
const MAX = Number(flags.max ||
|
|
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
|
-
|
|
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.
|
|
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.
|
|
33
|
-
// one target per interactive root:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
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),
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
100
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 =
|
|
119
|
-
const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1,
|
|
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 ${
|
|
124
|
-
console.table(rows.filter((r) =>
|
|
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)}`);
|
package/scripts/interact.mjs
CHANGED
|
@@ -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:
|
|
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)
|
|
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
|
-
|
|
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'}
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 (
|
|
91
|
-
: `${vp.name}: load match ${score}
|
|
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();
|