@trueclone/cli 0.2.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.
- package/README.md +27 -32
- package/bin/trueclone.mjs +28 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +239 -9
- package/scripts/curves.mjs +44 -7
- package/scripts/detail.mjs +19 -6
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +55 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +144 -43
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +77 -29
- package/scripts/load.mjs +106 -0
- package/scripts/next.mjs +158 -28
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +83 -17
- package/scripts/reveals.mjs +178 -80
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +74 -9
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +420 -56
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/understand.mjs +231 -0
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -116
package/scripts/curves.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// { "name": "quote reveal", "ref": ".is-quote .reveal-line", "clone": ".is-quote .split-line",
|
|
8
8
|
// "props": ["ty"], "time": { "before": 0, "at": 700, "ms": 1400, "every": 100 } }
|
|
9
9
|
//
|
|
10
|
-
// selector@n picks the n-th match (default 0). Props: opacity, tx, ty, sx, sy, rot, top, left, width, height, saturate.
|
|
10
|
+
// selector@n picks the n-th match (default 0); path:<recorded path> picks an element the reveals recorder saw. Props: opacity, tx, ty, sx, sy, rot, top, left, width, height, saturate.
|
|
11
11
|
// Viewport-independent ranges: "scroll": { "of": { "ref": "...", "clone": "..." }, "start": "top bottom", "end": "bottom top", "steps": 10 }
|
|
12
12
|
// resolve each side's own scroll positions, so one map scores desktop, tablet and mobile (--viewport tablet).
|
|
13
13
|
// usage: node scripts/curves.mjs <name> [--viewport desktop] [--only "<name substring>"]
|
|
@@ -29,7 +29,7 @@ const vp = VIEWPORTS.find((v) => v.name === (flags.viewport || 'desktop'));
|
|
|
29
29
|
|
|
30
30
|
// ---------- Draft a map from the motion inventory ----------
|
|
31
31
|
if (flags.draft) {
|
|
32
|
-
const motion = JSON.parse(await fs.readFile(path.join(ref, `motion.${vp.name}.json`), 'utf8'));
|
|
32
|
+
const motion = JSON.parse(await fs.readFile(path.join(ref, `motion.${vp.name}.json`), 'utf8').catch(() => '{"triggers":[],"onScroll":[]}'));
|
|
33
33
|
const propsFor = (vars) => {
|
|
34
34
|
const out = new Set();
|
|
35
35
|
for (const k of Object.keys(vars || {})) {
|
|
@@ -69,9 +69,27 @@ if (flags.draft) {
|
|
|
69
69
|
const y = tw.firedAt[0];
|
|
70
70
|
entries.push({ name: `reveal ${sel}`, ref: sel, clone: 'TODO', props: propsFor({ ...leaf.vars, ...(leaf.vars.startAt || {}) }), time: { before: Math.max(0, y - vp.height), at: y, ms: Math.ceil((tw.duration + 0.4) * 10) * 100, every: 100 } });
|
|
71
71
|
}
|
|
72
|
+
// Scroll-linked curves the recorder sampled (any library, or none the inventory can read): one entry per element,
|
|
73
|
+
// over the scroll range it was seen moving at this size
|
|
74
|
+
const recorded = JSON.parse(await fs.readFile(path.join(ref, `reveals.${vp.name}.json`), 'utf8').catch(() => '{}')).scrubs || [];
|
|
75
|
+
let fromScrubs = 0;
|
|
76
|
+
for (const s of recorded) {
|
|
77
|
+
const samples = (s.samples || []).filter((x) => x && Number.isFinite(x.y));
|
|
78
|
+
if (samples.length < 2 || seen.has(`path:${s.path}`)) continue;
|
|
79
|
+
const moved = (k) => Math.max(...samples.map((x) => x[k] ?? 0)) - Math.min(...samples.map((x) => x[k] ?? 0));
|
|
80
|
+
const props = [];
|
|
81
|
+
if (moved('o') > 0.02) props.push('opacity');
|
|
82
|
+
if (moved('tx') > 1) props.push('tx');
|
|
83
|
+
if (moved('ty') > 1) props.push('ty');
|
|
84
|
+
if (moved('s') > 0.01) props.push('sx', 'sy');
|
|
85
|
+
if (!props.length) continue;
|
|
86
|
+
seen.add(`path:${s.path}`);
|
|
87
|
+
fromScrubs++;
|
|
88
|
+
entries.push({ name: `scrub ${s.cls || s.path}`, ref: `path:${s.path}`, clone: 'TODO', props, viewports: [vp.name], scroll: { from: samples[0].y, to: samples.at(-1).y, steps: Math.min(12, Math.max(4, samples.length - 1)) } });
|
|
89
|
+
}
|
|
72
90
|
const out = path.join(root, 'motion-map.draft.json');
|
|
73
91
|
await fs.writeFile(out, JSON.stringify(entries, null, 2));
|
|
74
|
-
console.log(`${entries.length} entries -> ${path.relative(process.cwd(), out)}. Fill in the clone selectors, then save as motion-map.json.`);
|
|
92
|
+
console.log(`${entries.length} entries (${fromScrubs} from recorded scroll curves) -> ${path.relative(process.cwd(), out)}. Fill in the clone selectors, then save as motion-map.json.`);
|
|
75
93
|
process.exit(0);
|
|
76
94
|
}
|
|
77
95
|
|
|
@@ -86,9 +104,28 @@ let map = JSON.parse(mapText);
|
|
|
86
104
|
if (typeof flags.only === 'string') map = map.filter((e) => e.name.includes(flags.only));
|
|
87
105
|
map = map.filter((e) => e.clone && e.clone !== 'TODO' && (!e.viewports || e.viewports.includes(vp.name)));
|
|
88
106
|
|
|
107
|
+
// `path:section:3>div:2` is a recorded element path (from trueclone reveals: tag:position among the children that
|
|
108
|
+
// are not script/style/meta, 1-based, from <body>); anything else is a CSS selector with an optional @n.
|
|
109
|
+
// Installed on every page as window.__tcFind.
|
|
110
|
+
function installFind() {
|
|
111
|
+
window.__tcFind = (selector) => {
|
|
112
|
+
if (selector.startsWith('path:')) {
|
|
113
|
+
const SKIP = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
114
|
+
let n = document.body;
|
|
115
|
+
for (const part of selector.slice(5).split('>')) {
|
|
116
|
+
const [tag, i] = part.split(':');
|
|
117
|
+
n = n && [...n.children].filter((c) => !SKIP.has(c.tagName))[Number(i) - 1];
|
|
118
|
+
if (!n || n.tagName.toLowerCase() !== tag) return null;
|
|
119
|
+
}
|
|
120
|
+
return n;
|
|
121
|
+
}
|
|
122
|
+
const [sel, idx] = selector.split('@');
|
|
123
|
+
return document.querySelectorAll(sel)[Number(idx || 0)] || null;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
89
127
|
function sampleInPage([selector, props]) {
|
|
90
|
-
const
|
|
91
|
-
const el = document.querySelectorAll(sel)[Number(idx || 0)];
|
|
128
|
+
const el = window.__tcFind(selector);
|
|
92
129
|
if (!el) return null;
|
|
93
130
|
const s = getComputedStyle(el);
|
|
94
131
|
const m = new DOMMatrixReadOnly(s.transform === 'none' ? undefined : s.transform);
|
|
@@ -132,6 +169,7 @@ async function run(url, side, entries, onEntry) {
|
|
|
132
169
|
};
|
|
133
170
|
try {
|
|
134
171
|
const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
|
|
172
|
+
await page.addInitScript(installFind);
|
|
135
173
|
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
|
|
136
174
|
await page.mouse.move(5, vp.height - 5);
|
|
137
175
|
await waitStill(page, { timeout: 15000, interval: 200 });
|
|
@@ -139,8 +177,7 @@ async function run(url, side, entries, onEntry) {
|
|
|
139
177
|
// Scroll entries: resolve every range first (at the top, before anything is pinned), then visit all sample
|
|
140
178
|
// positions of all curves in one downward pass, so the page glides short distances and never rewinds.
|
|
141
179
|
const resolve = (of, spec) => page.evaluate(([sel, s]) => {
|
|
142
|
-
const
|
|
143
|
-
const el = document.querySelectorAll(selector)[Number(idx || 0)];
|
|
180
|
+
const el = window.__tcFind(sel);
|
|
144
181
|
if (!el) return 0;
|
|
145
182
|
const r = el.getBoundingClientRect();
|
|
146
183
|
const top = r.top + scrollY;
|
package/scripts/detail.mjs
CHANGED
|
@@ -155,7 +155,7 @@ async function sample(browser, url, vp, probes) {
|
|
|
155
155
|
if (y >= max) break;
|
|
156
156
|
}
|
|
157
157
|
await context.close();
|
|
158
|
-
return { list, results };
|
|
158
|
+
return { list, results, pageHeight: max + vp.height };
|
|
159
159
|
}
|
|
160
160
|
const list = probes;
|
|
161
161
|
const results = new Array(list.length);
|
|
@@ -177,14 +177,27 @@ const report = { name, at: new Date().toISOString(), viewports: [] };
|
|
|
177
177
|
try {
|
|
178
178
|
for (const vpName of wanted) {
|
|
179
179
|
const vp = VIEWPORTS.find((v) => v.name === vpName);
|
|
180
|
-
// reference probes are cached until the next capture (--refresh-ref to re-measure)
|
|
181
|
-
|
|
180
|
+
// reference probes are cached until the next capture (--refresh-ref to re-measure). The reference is read twice:
|
|
181
|
+
// a probe that does not agree with itself (random motion, a hover that lands by timing, a rotating word) can
|
|
182
|
+
// never be matched by any clone and is set aside as unstable instead of being handed over as a gap.
|
|
183
|
+
const ref = await cached(root, { tool: 'detail', vp: vpName, max: MAX, v: 9 }, async () => {
|
|
184
|
+
const first = await sample(browser, meta.url, vp, null);
|
|
185
|
+
// a live site changes: a reference read against today's page scores a clone of yesterday's capture against the
|
|
186
|
+
// wrong thing. The page height is a cheap tell; the fix is a new capture, not a new clone.
|
|
187
|
+
const captured = meta.viewports?.find((v) => v.viewport === vpName)?.pageHeight;
|
|
188
|
+
if (captured && first.pageHeight && Math.abs(first.pageHeight - captured) > Math.max(40, captured * 0.01)) console.warn(`WARNING reference drift (${vpName}): the live page is ${first.pageHeight}px tall, the capture was ${captured}px (${meta.capturedAt}); re-capture before trusting this score`);
|
|
189
|
+
const again = await sample(browser, meta.url, vp, first.list.map(({ x, xs, y, kind, w, h, scroll }) => ({ x, xs, y, kind, w, h, scroll })));
|
|
190
|
+
const unstable = first.list.map((p, i) => JSON.stringify(first.results[i]?.style) !== JSON.stringify(again.results[i]?.style) || first.results[i]?.sel !== again.results[i]?.sel);
|
|
191
|
+
return { ...first, unstable };
|
|
192
|
+
}, Boolean(flags['refresh-ref']));
|
|
182
193
|
const cl = await sample(browser, cloneUrl, vp, ref.list.map(({ x, xs, y, kind, w, h, scroll }) => ({ x, xs, y, kind, w, h, scroll })));
|
|
183
194
|
const issues = [];
|
|
195
|
+
const unstableList = [];
|
|
184
196
|
let clean = 0, missing = 0;
|
|
185
197
|
ref.list.forEach((p, i) => {
|
|
186
198
|
const a = ref.results[i], b = cl.results[i];
|
|
187
199
|
if (!a?.style) return;
|
|
200
|
+
if (ref.unstable?.[i]) { unstableList.push({ y: p.y, kind: p.kind, ref: a.sel }); return; }
|
|
188
201
|
if (!b?.style) { missing++; issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b?.top || '(nothing)', props: [`no ${p.kind} element at this point on the clone`] }); return; }
|
|
189
202
|
const A = a.style, B = b.style, props = [];
|
|
190
203
|
if (p.kind === 'text') {
|
|
@@ -203,10 +216,10 @@ try {
|
|
|
203
216
|
if (props.length) issues.push({ y: p.y, kind: p.kind, ref: a.sel, clone: b.sel, props });
|
|
204
217
|
else clean++;
|
|
205
218
|
});
|
|
206
|
-
const total = ref.list.filter((p, i) => ref.results[i]?.style).length;
|
|
219
|
+
const total = ref.list.filter((p, i) => ref.results[i]?.style && !ref.unstable?.[i]).length;
|
|
207
220
|
const byProp = {};
|
|
208
221
|
issues.forEach((it) => it.props.forEach((pr) => { const k = pr.split(' ')[0] === 'no' ? 'missing element' : pr.split(' ')[0]; byProp[k] = (byProp[k] || 0) + 1; }));
|
|
209
|
-
report.viewports.push({ viewport: vpName, probes: total, clean, missing, score: +(100 * clean / Math.max(1, total)).toFixed(1), byProp, issues });
|
|
222
|
+
report.viewports.push({ viewport: vpName, probes: total, clean, missing, unstable: unstableList.length, score: +(100 * clean / Math.max(1, total)).toFixed(1), byProp, issues, unstableProbes: unstableList });
|
|
210
223
|
}
|
|
211
224
|
} finally {
|
|
212
225
|
if (!flags.cdp) await browser.close();
|
|
@@ -216,7 +229,7 @@ const out = path.join(root, 'compare', 'detail.json');
|
|
|
216
229
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
217
230
|
await fs.writeFile(out, JSON.stringify(report, null, 2));
|
|
218
231
|
for (const v of report.viewports) {
|
|
219
|
-
console.log(`\n${v.viewport}: detail match ${v.score}% (${v.clean}/${v.probes} probes clean, ${v.missing} with no counterpart) by property: ${JSON.stringify(v.byProp)}`);
|
|
232
|
+
console.log(`\n${v.viewport}: detail match ${v.score}% (${v.clean}/${v.probes} probes clean, ${v.missing} with no counterpart${v.unstable ? `; ${v.unstable} unstable on the reference itself, not scored: ${[...new Set(v.unstableProbes.map((u) => u.ref))].slice(0, 4).join(', ')}` : ''}) by property: ${JSON.stringify(v.byProp)}`);
|
|
220
233
|
// group identical issues so one root cause prints once with a count
|
|
221
234
|
const groups = new Map();
|
|
222
235
|
for (const it of v.issues) { const k = `${it.ref} -> ${it.clone}: ${it.props.join('; ')}`; const g = groups.get(k) || { ...it, n: 0 }; g.n++; groups.set(k, g); }
|
package/scripts/diff.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Without --url it serves sites/<name>/clone/ itself on a free local port.
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { SCORED, parseArgs, openBrowser, newPage, settle, mask, serve, pad, crop, match, PNG } from './lib.mjs';
|
|
7
7
|
|
|
8
8
|
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
9
9
|
const [name] = pos;
|
|
@@ -25,7 +25,9 @@ const target = typeof flags.url === 'string' ? flags.url : server.url;
|
|
|
25
25
|
const browser = await openBrowser(flags.cdp);
|
|
26
26
|
const report = { name, mode: structure ? 'structure' : 'visual', source: meta.url, clone: target, at: new Date().toISOString(), viewports: [] };
|
|
27
27
|
try {
|
|
28
|
-
for (const vp of
|
|
28
|
+
for (const vp of SCORED) {
|
|
29
|
+
// a capture made before held-out sizes existed has no reference for them: skipped, not failed
|
|
30
|
+
if (!(await fs.stat(path.join(ref, `${vp.name}${suffix}.png`)).then(() => true, () => false))) { console.log(`${vp.name}: no reference capture at ${vp.width}x${vp.height} (re-capture to score it)`); continue; }
|
|
29
31
|
const { context, page } = await newPage(browser, vp);
|
|
30
32
|
await page.goto(target, { waitUntil: 'load', timeout: 60000 });
|
|
31
33
|
await settle(page);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Has the live page changed since the capture? Layout and scroll states score against the captured screenshots, but
|
|
2
|
+
// detail, hovers, motion curves, interactions and the load sequence measure the live page: when the site changed in
|
|
3
|
+
// between, those checks compare the clone with something it was never a copy of. One page load per width: the page
|
|
4
|
+
// height and the number of top-level sections against meta.json. Written to compare/drift.json; REPORT.md marks the
|
|
5
|
+
// live-measured checks "stale capture" and the advice is to re-capture, not to touch the clone.
|
|
6
|
+
// usage: node scripts/drift.mjs <name> [--viewport desktop|all]
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { SCORED as VIEWPORTS, parseArgs, openBrowser, newPage, settle, sectionsOf } from './lib.mjs';
|
|
10
|
+
|
|
11
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
12
|
+
const [name] = pos;
|
|
13
|
+
if (!name) {
|
|
14
|
+
console.error('usage: trueclone drift <name> [--viewport desktop|all]');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const root = path.resolve('sites', name);
|
|
18
|
+
const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
|
|
19
|
+
const vps = !flags.viewport || flags.viewport === 'all' ? VIEWPORTS : [VIEWPORTS.find((v) => v.name === flags.viewport)].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
const browser = await openBrowser(flags.cdp);
|
|
22
|
+
const viewports = [];
|
|
23
|
+
try {
|
|
24
|
+
for (const vp of vps) {
|
|
25
|
+
const captured = meta.viewports?.find((v) => v.viewport === vp.name);
|
|
26
|
+
if (!captured) continue;
|
|
27
|
+
const sample = async () => {
|
|
28
|
+
const { context, page } = await newPage(browser, vp);
|
|
29
|
+
try {
|
|
30
|
+
await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
|
|
31
|
+
await settle(page, meta.hide || []);
|
|
32
|
+
return { h: await page.evaluate(() => document.documentElement.scrollHeight), n: (await sectionsOf(page)).length };
|
|
33
|
+
} finally {
|
|
34
|
+
await context.close();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
// 1% (at least 40px) absorbs font hinting and a rotating line of text; a new section or a block that moved is above that
|
|
38
|
+
const differs = (s) => Math.abs(s.h - captured.pageHeight) > Math.max(40, captured.pageHeight * 0.01) || s.n !== captured.sections;
|
|
39
|
+
// a page that renders differently from one load to the next (a random block, a late embed) is not a changed site:
|
|
40
|
+
// a difference counts only when a second load shows it too
|
|
41
|
+
let s = await sample();
|
|
42
|
+
let confirmed = false;
|
|
43
|
+
if (differs(s)) { const again = await sample(); confirmed = differs(again); if (!confirmed) s = again; }
|
|
44
|
+
const stale = confirmed;
|
|
45
|
+
viewports.push({ viewport: vp.name, livePageHeight: s.h, capturedPageHeight: captured.pageHeight, liveSections: s.n, capturedSections: captured.sections, stale });
|
|
46
|
+
console.log(`${vp.name}: live ${s.h}px / ${s.n} sections, captured ${captured.pageHeight}px / ${captured.sections} sections${stale ? ' <- changed since the capture (seen on two loads)' : ' (same)'}`);
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
if (!flags.cdp) await browser.close();
|
|
50
|
+
}
|
|
51
|
+
const stale = viewports.some((v) => v.stale);
|
|
52
|
+
await fs.mkdir(path.join(root, 'compare'), { recursive: true });
|
|
53
|
+
await fs.writeFile(path.join(root, 'compare', 'drift.json'), JSON.stringify({ at: new Date().toISOString(), capturedAt: meta.capturedAt, stale, viewports }, null, 2));
|
|
54
|
+
if (stale) console.warn(`WARNING reference drift: the live page has changed since the capture of ${meta.capturedAt}; detail, hovers, curves, interactions and load now measure a page the clone was never a copy of. Re-capture (trueclone capture, then states, reveals, seed --force) before trusting them.`);
|
|
55
|
+
else console.log(`drift: none since ${meta.capturedAt}`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// One look at one place on the page: what every check saw there, so a gap is read from the tool's own data instead of
|
|
2
|
+
// re-measured by hand. Given a page y (or a section label) at one width, it prints the layout section's score, the
|
|
3
|
+
// scroll states near it, the detail probes that differ, the hover rows that differ, and writes a side-by-side crop
|
|
4
|
+
// (reference | clone | diff, masked) around that y.
|
|
5
|
+
// usage: node scripts/explain.mjs <name> <y | "section label"> [--viewport desktop] [--range 800]
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { PNG, parseArgs, crop } from './lib.mjs';
|
|
9
|
+
|
|
10
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
11
|
+
const [name, where] = pos;
|
|
12
|
+
if (!name || where === undefined) {
|
|
13
|
+
console.error('usage: trueclone explain <name> <y | "section label"> [--viewport desktop] [--range 800]');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const vp = flags.viewport || 'desktop';
|
|
17
|
+
const range = Number(flags.range || 800);
|
|
18
|
+
const root = path.resolve('sites', name);
|
|
19
|
+
const read = async (rel) => fs.readFile(path.join(root, rel), 'utf8').then(JSON.parse).catch(() => null);
|
|
20
|
+
|
|
21
|
+
const layout = await read('compare/report.mask.json');
|
|
22
|
+
const lv = layout?.viewports.find((v) => v.viewport === vp);
|
|
23
|
+
if (!lv) {
|
|
24
|
+
console.error(`no layout score for ${vp} yet: run trueclone score ${name} first`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const section = /^\d+$/.test(where)
|
|
28
|
+
? lv.sections.find((s) => Number(where) >= s.y && Number(where) < s.y + s.height) || null
|
|
29
|
+
: lv.sections.find((s) => String(s.label).toLowerCase().includes(where.toLowerCase())) || null;
|
|
30
|
+
const y = /^\d+$/.test(where) ? Number(where) : section?.y;
|
|
31
|
+
if (y === undefined) {
|
|
32
|
+
console.error(`no section labelled like "${where}" at ${vp}; sections: ${lv.sections.map((s) => `${s.y} ${String(s.label).slice(0, 24)}`).join(' | ')}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const lo = Math.max(0, y - range / 2), hi = y + range / 2;
|
|
36
|
+
const within = (v) => v >= lo && v <= hi;
|
|
37
|
+
|
|
38
|
+
console.log(`${name} @ ${vp} y ${y} (±${range / 2}px)`);
|
|
39
|
+
console.log(`\nLayout: page ${lv.heightRef}px vs clone ${lv.heightClone}px (${lv.match}% overall)`);
|
|
40
|
+
if (section) console.log(` section "${String(section.label).slice(0, 60)}" y ${section.y} h ${section.height}: ${section.match}%`);
|
|
41
|
+
for (const s of lv.sections.filter((s) => s !== section && (within(s.y) || within(s.y + s.height)))) console.log(` nearby "${String(s.label).slice(0, 40)}" y ${s.y}: ${s.match}%`);
|
|
42
|
+
|
|
43
|
+
const states = await read('compare/states/report.mask.json');
|
|
44
|
+
const sv = states?.viewports?.find((v) => v.viewport === vp);
|
|
45
|
+
const steps = (sv?.steps || sv?.states || []).filter((st) => within(st.scrollY ?? st.y ?? st.scroll ?? -1));
|
|
46
|
+
if (steps.length) console.log(`\nScroll states scrolled to here: ${steps.map((st) => `${st.scrollY ?? st.y ?? st.scroll}px ${st.match ?? st.score}%`).join(', ')}`);
|
|
47
|
+
|
|
48
|
+
const detail = await read('compare/detail.json');
|
|
49
|
+
const dv = detail?.viewports?.find((v) => v.viewport === vp);
|
|
50
|
+
if (dv) {
|
|
51
|
+
const here = dv.issues.filter((it) => within(it.y));
|
|
52
|
+
const unstable = (dv.unstableProbes || []).filter((it) => within(it.y));
|
|
53
|
+
console.log(`\nDetail: ${here.length} differing probe(s) here${unstable.length ? `, ${unstable.length} unstable on the reference (not scored)` : ''}`);
|
|
54
|
+
const groups = new Map();
|
|
55
|
+
for (const it of here) { const k = `${it.ref} -> ${it.clone}: ${it.props.join('; ')}`; const g = groups.get(k) || { ...it, n: 0 }; g.n++; groups.set(k, g); }
|
|
56
|
+
[...groups.values()].sort((a, b) => b.n - a.n).slice(0, 12).forEach((g) => console.log(` x${String(g.n).padEnd(3)} y${String(g.y).padEnd(6)} ${g.ref} -> ${g.clone}: ${g.props.join('; ')}`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hovers = await read(`compare/hovers.${vp}.json`);
|
|
60
|
+
if (hovers) {
|
|
61
|
+
const rows = hovers.rows.filter((r) => within(r.y) && !['static', 'ok', 'unstable'].includes(r.status));
|
|
62
|
+
if (rows.length) { console.log(`\nHovers differing here:`); rows.slice(0, 10).forEach((r) => console.log(` y${r.y} ${r.ref}: reference changes ${r.refChanges}, clone ${r.cloneChanges} (${r.status})`)); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// the crop: reference | clone | diff, masked, half size
|
|
66
|
+
try {
|
|
67
|
+
const imgs = await Promise.all([`reference/${vp}.mask.png`, `compare/${vp}.mask.clone.png`, `compare/${vp}.mask.diff.png`].map(async (f) => PNG.sync.read(await fs.readFile(path.join(root, f)))));
|
|
68
|
+
const top = Math.max(0, Math.round(lo)), h = Math.min(Math.round(hi) - top, Math.min(...imgs.map((i) => i.height)) - top);
|
|
69
|
+
const parts = imgs.map((img) => crop(img, top, h));
|
|
70
|
+
const scale = 0.5, w = Math.round(parts[0].width * scale), hh = Math.round(h * scale);
|
|
71
|
+
const out = new PNG({ width: w * 3 + 20, height: hh });
|
|
72
|
+
out.data.fill(255);
|
|
73
|
+
parts.forEach((p, k) => {
|
|
74
|
+
for (let yy = 0; yy < hh; yy++) for (let x = 0; x < w; x++) {
|
|
75
|
+
const sx = Math.min(p.width - 1, Math.round(x / scale)), sy = Math.min(p.height - 1, Math.round(yy / scale));
|
|
76
|
+
const si = (sy * p.width + sx) * 4, di = (yy * out.width + x + k * (w + 10)) * 4;
|
|
77
|
+
out.data[di] = p.data[si]; out.data[di + 1] = p.data[si + 1]; out.data[di + 2] = p.data[si + 2]; out.data[di + 3] = 255;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const file = path.join(root, 'compare', 'explain', `${vp}-${y}.png`);
|
|
81
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
82
|
+
await fs.writeFile(file, PNG.sync.write(out));
|
|
83
|
+
console.log(`\nCrop (reference | clone | diff, y ${top}-${top + h}): ${path.relative(process.cwd(), file)}`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.log(`\n(no crop: ${e.message.split('\n')[0]})`);
|
|
86
|
+
}
|
|
87
|
+
console.log(`\nNext: npx trueclone rules ${name} "<selector>" --viewport ${vp} (the reference's CSS) and --clone (the clone's), or npx trueclone score ${name} --only layout,detail --viewport ${vp} after a fix.`);
|
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,49 +102,116 @@ 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
|
}
|
|
163
|
+
const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => null);
|
|
89
164
|
await context.close();
|
|
90
|
-
return { list, out };
|
|
165
|
+
return { list, out, pageHeight, listened };
|
|
91
166
|
}
|
|
92
167
|
|
|
93
168
|
const browser = await openBrowser(flags.cdp);
|
|
94
169
|
let ref, cl;
|
|
95
170
|
try {
|
|
96
|
-
// reference hovers are cached until the next capture (--refresh-ref to re-measure)
|
|
97
|
-
|
|
98
|
-
|
|
171
|
+
// reference hovers are cached until the next capture (--refresh-ref to re-measure); read twice, so a hover effect
|
|
172
|
+
// that comes and goes by timing on the reference itself is set aside as unstable rather than demanded of the clone
|
|
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'));
|
|
176
|
+
const captured = meta.viewports?.find((v) => v.viewport === vp.name)?.pageHeight;
|
|
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`);
|
|
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 };
|
|
186
|
+
}, Boolean(flags['refresh-ref']));
|
|
187
|
+
cl = await hoverAll(browser, cloneUrl, ref.list, path.join(root, 'compare', `hovers-${vp.name}`));
|
|
99
188
|
} finally {
|
|
100
189
|
if (!flags.cdp) await browser.close();
|
|
101
190
|
server?.close();
|
|
102
191
|
}
|
|
103
|
-
const rows =
|
|
192
|
+
const rows = [];
|
|
193
|
+
for (const [i, t] of ref.list.entries()) {
|
|
104
194
|
const a = ref.out[i], b = cl.out[i];
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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');
|
|
209
|
+
const unstable = rows.filter((r) => r.status === 'unstable').length;
|
|
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 };
|
|
111
212
|
const out = path.join(root, 'compare', `hovers.${vp.name}.json`);
|
|
112
213
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
113
214
|
await fs.writeFile(out, JSON.stringify(report, null, 2));
|
|
114
|
-
console.log(`hover match (${vp.name}): ${report.score}% of ${
|
|
115
|
-
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));
|
|
116
217
|
console.log(`-> ${path.relative(process.cwd(), out)}`);
|