@trueclone/cli 0.1.0 → 0.3.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 +28 -14
- package/bin/trueclone.mjs +14 -2
- package/package.json +1 -1
- package/scripts/capture.mjs +192 -15
- package/scripts/detail.mjs +19 -6
- package/scripts/diff.mjs +16 -0
- package/scripts/drift.mjs +48 -0
- package/scripts/explain.mjs +87 -0
- package/scripts/hovers.mjs +17 -8
- package/scripts/interact.mjs +7 -3
- package/scripts/lib.mjs +38 -20
- package/scripts/load.mjs +102 -0
- package/scripts/next.mjs +296 -15
- package/scripts/owned.mjs +66 -0
- package/scripts/report.mjs +43 -11
- package/scripts/reveals.mjs +285 -0
- package/scripts/score.mjs +71 -9
- package/scripts/seed.mjs +758 -22
- package/scripts/states.mjs +2 -1
- package/scripts/understand.mjs +231 -0
- package/scripts/widgets.mjs +169 -0
- package/skill/SKILL.md +62 -13
|
@@ -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
|
@@ -86,15 +86,23 @@ async function hoverAll(browser, url, list) {
|
|
|
86
86
|
const after = await page.evaluate(signature, [t.x, vy]);
|
|
87
87
|
out.push({ sel: before?.sel || null, kinds: before && after && before.sel === after.sel ? changedKinds(before, after) : [] });
|
|
88
88
|
}
|
|
89
|
+
const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => null);
|
|
89
90
|
await context.close();
|
|
90
|
-
return { list, out };
|
|
91
|
+
return { list, out, pageHeight };
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
const browser = await openBrowser(flags.cdp);
|
|
94
95
|
let ref, cl;
|
|
95
96
|
try {
|
|
96
|
-
// reference hovers are cached until the next capture (--refresh-ref to re-measure)
|
|
97
|
-
|
|
97
|
+
// reference hovers are cached until the next capture (--refresh-ref to re-measure); read twice, so a hover effect
|
|
98
|
+
// 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);
|
|
101
|
+
const captured = meta.viewports?.find((v) => v.viewport === vp.name)?.pageHeight;
|
|
102
|
+
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()) };
|
|
105
|
+
}, Boolean(flags['refresh-ref']));
|
|
98
106
|
cl = await hoverAll(browser, cloneUrl, ref.list);
|
|
99
107
|
} finally {
|
|
100
108
|
if (!flags.cdp) await browser.close();
|
|
@@ -102,15 +110,16 @@ try {
|
|
|
102
110
|
}
|
|
103
111
|
const rows = ref.list.map((t, i) => {
|
|
104
112
|
const a = ref.out[i], b = cl.out[i];
|
|
105
|
-
const status = !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';
|
|
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';
|
|
106
114
|
return { y: t.y, ref: a.sel || t.sel, refChanges: a.kinds.join(',') || '-', clone: b.sel || '-', cloneChanges: b.kinds.join(',') || '-', status };
|
|
107
115
|
});
|
|
108
|
-
const withHover = rows.filter((r) => r.refChanges !== '-');
|
|
116
|
+
const withHover = rows.filter((r) => r.refChanges !== '-' && r.status !== 'unstable');
|
|
117
|
+
const unstable = rows.filter((r) => r.status === 'unstable').length;
|
|
109
118
|
const ok = withHover.filter((r) => r.status === 'ok').length;
|
|
110
|
-
const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1, withHover.length)).toFixed(1), rows };
|
|
119
|
+
const report = { name, viewport: vp.name, at: new Date().toISOString(), score: +(100 * ok / Math.max(1, withHover.length)).toFixed(1), unstable, rows };
|
|
111
120
|
const out = path.join(root, 'compare', `hovers.${vp.name}.json`);
|
|
112
121
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
113
122
|
await fs.writeFile(out, JSON.stringify(report, null, 2));
|
|
114
|
-
console.log(`hover match (${vp.name}): ${report.score}% of ${withHover.length} reference hover effects reproduced (${rows.length} interactive elements probed)`);
|
|
115
|
-
console.table(rows.filter((r) => r.status !== 'static' && r.status !== 'ok').slice(0, 25));
|
|
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));
|
|
116
125
|
console.log(`-> ${path.relative(process.cwd(), out)}`);
|
package/scripts/interact.mjs
CHANGED
|
@@ -64,7 +64,7 @@ const stateOf = (page, selector) => page.evaluate((sel) => {
|
|
|
64
64
|
const r = el.getBoundingClientRect();
|
|
65
65
|
// Besides geometry and visibility: text, state attributes and canvas pixels, so a button that changes its label,
|
|
66
66
|
// a tab that flips aria-pressed or a game that redraws its canvas counts as a change too.
|
|
67
|
-
const attrs = ['class', 'aria-pressed', 'aria-current', 'aria-expanded', 'open', 'hidden', 'disabled']
|
|
67
|
+
const attrs = ['class', 'aria-pressed', 'aria-current', 'aria-expanded', 'aria-selected', 'data-state', 'open', 'hidden', 'disabled']
|
|
68
68
|
.map((a) => (el.hasAttribute(a) ? `${a}=${el.getAttribute(a)}` : '')).join(',') // boolean attributes are "" + (el.value !== undefined ? `,${el.value}` : '');
|
|
69
69
|
const text = el.textContent.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
70
70
|
let pixels = '';
|
|
@@ -88,7 +88,7 @@ async function runSide(url, side, list) {
|
|
|
88
88
|
await warmUp(page);
|
|
89
89
|
const atSel = sc.at?.[side];
|
|
90
90
|
if (atSel && atSel !== 'body') {
|
|
91
|
-
const y = await page.evaluate((
|
|
91
|
+
const y = await page.evaluate((sel) => { const [s, idx] = sel.split('@'); const el = document.querySelectorAll(s)[Number(idx || 0)]; return el ? el.getBoundingClientRect().top + scrollY : 0; }, atSel);
|
|
92
92
|
await wheelTo(page, Math.max(0, Math.round(y + (sc.at.offset || 0))), vp);
|
|
93
93
|
} else {
|
|
94
94
|
await wheelTo(page, sc.at?.offset || 0, vp);
|
|
@@ -121,7 +121,11 @@ async function runSide(url, side, list) {
|
|
|
121
121
|
else if (st.click) {
|
|
122
122
|
const target = Array.isArray(st.click) ? st.click : st.click[side];
|
|
123
123
|
if (Array.isArray(target)) await page.mouse.click(...px(target));
|
|
124
|
-
else
|
|
124
|
+
else {
|
|
125
|
+
// selector@n: the n-th match, as everywhere else in the scenario file
|
|
126
|
+
const [s, idx] = target.split('@');
|
|
127
|
+
await page.locator(s).nth(Number(idx || 0)).click({ timeout: 5000 }).catch((e) => results.push({ name: sc.name, error: `click ${target}: ${e.message.split('\n')[0]}` }));
|
|
128
|
+
}
|
|
125
129
|
} else if (st.key) await page.keyboard.press(st.key);
|
|
126
130
|
else if (st.wait) await page.waitForTimeout(st.wait);
|
|
127
131
|
else if (st.shot) await shot(st.shot);
|
package/scripts/lib.mjs
CHANGED
|
@@ -124,23 +124,51 @@ const MASK_CSS = `*,*::before,*::after{color:transparent!important;text-shadow:n
|
|
|
124
124
|
::placeholder{color:transparent!important}
|
|
125
125
|
img,video{object-position:-99999px -99999px!important;background:#888!important}
|
|
126
126
|
svg{background:#888!important} svg *{visibility:hidden!important} iframe{visibility:hidden!important}
|
|
127
|
-
canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
|
|
127
|
+
canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
|
|
128
|
+
[data-lab-cliptext]{background:none!important;-webkit-text-fill-color:transparent!important}
|
|
129
|
+
[data-lab-bgi]{background-image:none!important;background-color:#888!important}
|
|
130
|
+
[data-lab-bgb]::before,[data-lab-bga]::after{background-image:none!important;background-color:#888!important}`; // canvas = media (WebGL heroes, 3D models): flat grey box like images
|
|
131
|
+
|
|
132
|
+
// What CSS alone can't select: photo backgrounds (a script that rewrites an element's inline background every frame
|
|
133
|
+
// would undo an inline override, so the mask is a stylesheet rule on a marker attribute, which inline styles lose
|
|
134
|
+
// to), gradient text (background-clip: text paints the glyphs with its background, so a transparent colour still
|
|
135
|
+
// shows the letters) and photo backgrounds on ::before/::after. `off` removes the markers.
|
|
136
|
+
function maskExtrasInPage(enable) {
|
|
137
|
+
const marks = ['data-lab-bgi', 'data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
|
|
138
|
+
if (!enable) {
|
|
139
|
+
for (const m of marks) for (const el of document.querySelectorAll(`[${m}]`)) el.removeAttribute(m);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const el of document.querySelectorAll('*')) {
|
|
143
|
+
const s = getComputedStyle(el);
|
|
144
|
+
if (s.backgroundImage.includes('url(')) el.setAttribute('data-lab-bgi', '');
|
|
145
|
+
if (s.backgroundClip === 'text' || s.webkitBackgroundClip === 'text') el.setAttribute('data-lab-cliptext', '');
|
|
146
|
+
if (getComputedStyle(el, '::before').backgroundImage.includes('url(')) el.setAttribute('data-lab-bgb', '');
|
|
147
|
+
if (getComputedStyle(el, '::after').backgroundImage.includes('url(')) el.setAttribute('data-lab-bga', '');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
128
150
|
|
|
129
151
|
export async function mask(page) {
|
|
130
152
|
const tag = await page.addStyleTag({ content: MASK_CSS });
|
|
131
153
|
await tag.evaluate((el) => el.setAttribute('data-lab-freeze', ''));
|
|
154
|
+
await page.evaluate(maskExtrasInPage, true);
|
|
155
|
+
await page.waitForTimeout(200);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Endless animations (marquees, rotating borders, pulsing glows) sit at whatever phase the page's clock happens to
|
|
159
|
+
// be at, so two screenshots of the same page differ. Before a still comparison, every infinite animation is put on
|
|
160
|
+
// its first frame and paused, on both sides. Continuous motion is scored by the motion checks, not by stills.
|
|
161
|
+
export async function freezeLoops(page) {
|
|
132
162
|
await page.evaluate(() => {
|
|
133
|
-
for (const
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
163
|
+
for (const a of document.getAnimations()) {
|
|
164
|
+
if (a.effect?.getTiming().iterations !== Infinity) continue;
|
|
165
|
+
a.currentTime = 0;
|
|
166
|
+
a.pause();
|
|
138
167
|
}
|
|
139
|
-
});
|
|
140
|
-
await page.waitForTimeout(200);
|
|
168
|
+
}).catch(() => {});
|
|
141
169
|
}
|
|
142
170
|
|
|
143
|
-
// Reversible version of mask() for live pages: on() masks, off()
|
|
171
|
+
// Reversible version of mask() for live pages: on() masks, off() removes the stylesheet and every marker.
|
|
144
172
|
export async function maskToggle(page, on) {
|
|
145
173
|
await page.evaluate(([css, enable]) => {
|
|
146
174
|
if (enable) {
|
|
@@ -149,21 +177,11 @@ export async function maskToggle(page, on) {
|
|
|
149
177
|
tag.setAttribute('data-lab-freeze', '');
|
|
150
178
|
tag.textContent = css;
|
|
151
179
|
document.head.appendChild(tag);
|
|
152
|
-
for (const el of document.querySelectorAll('*')) {
|
|
153
|
-
if (getComputedStyle(el).backgroundImage.includes('url(')) {
|
|
154
|
-
el.setAttribute('data-lab-bg', el.style.cssText);
|
|
155
|
-
el.style.setProperty('background-image', 'none', 'important');
|
|
156
|
-
el.style.setProperty('background-color', '#888', 'important');
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
180
|
} else {
|
|
160
181
|
document.getElementById('lab-mask')?.remove();
|
|
161
|
-
for (const el of document.querySelectorAll('[data-lab-bg]')) {
|
|
162
|
-
el.style.cssText = el.getAttribute('data-lab-bg');
|
|
163
|
-
el.removeAttribute('data-lab-bg');
|
|
164
|
-
}
|
|
165
182
|
}
|
|
166
183
|
}, [MASK_CSS, on]);
|
|
184
|
+
await page.evaluate(maskExtrasInPage, on);
|
|
167
185
|
await page.waitForTimeout(on ? 150 : 50);
|
|
168
186
|
}
|
|
169
187
|
|
package/scripts/load.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Load sequence: what a visitor sees between the first byte and the settled page, on both sides, measured the same
|
|
2
|
+
// way. Each side loads in a fresh context (cold cache, cache disabled) on the same emulated network, and the visible
|
|
3
|
+
// viewport is photographed 250, 500, 1000, 2000 and 4000 ms after navigation starts, then once more when the page has
|
|
4
|
+
// settled. "Settled at t" is how much the frame at t already matches the final frame (pixel match), so a clone that
|
|
5
|
+
// paints a wrong skeleton for two seconds, or settles much later or earlier than the original, shows up as a gap
|
|
6
|
+
// between the two curves. The score is 100 minus the mean gap. Reference curves are cached per capture.
|
|
7
|
+
//
|
|
8
|
+
// usage: node scripts/load.mjs <name> [--viewport desktop|tablet|mobile|all] [--url <clone url>] [--refresh-ref]
|
|
9
|
+
// -> sites/<name>/compare/load.json, frames in compare/load/ and reference/load/
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, matchPct, cached, PNG, withTimeout } from './lib.mjs';
|
|
13
|
+
|
|
14
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
15
|
+
const [name] = pos;
|
|
16
|
+
if (!name) {
|
|
17
|
+
console.error('usage: trueclone load <name> [--viewport desktop|all] [--url <clone url>] [--refresh-ref]');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const root = path.resolve('sites', name);
|
|
21
|
+
const meta = JSON.parse(await fs.readFile(path.join(root, 'reference', 'meta.json'), 'utf8'));
|
|
22
|
+
const vps = !flags.viewport || flags.viewport === 'all' ? VIEWPORTS : [VIEWPORTS.find((v) => v.name === flags.viewport)].filter(Boolean);
|
|
23
|
+
const TIMES = [250, 500, 1000, 2000, 4000];
|
|
24
|
+
const SETTLED = 98; // a frame this close to the final one counts as settled
|
|
25
|
+
const NOISE_LIMIT = 15; // two runs of one side further apart than this: that side is unstable, the width is not scored
|
|
26
|
+
// the same network for both sides: the clone is served from this machine, the reference from a CDN, so both are
|
|
27
|
+
// bounded by the emulated link, not by where they come from
|
|
28
|
+
const NETWORK = { offline: false, latency: 40, downloadThroughput: 1.25e6, uploadThroughput: 6e5 };
|
|
29
|
+
|
|
30
|
+
async function sequence(browser, url, vp, dir) {
|
|
31
|
+
const { context, page } = await newPage(browser, vp, { reducedMotion: 'no-preference' });
|
|
32
|
+
try {
|
|
33
|
+
const cdp = await context.newCDPSession(page);
|
|
34
|
+
await cdp.send('Network.enable');
|
|
35
|
+
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
|
|
36
|
+
await cdp.send('Network.emulateNetworkConditions', NETWORK);
|
|
37
|
+
await fs.mkdir(dir, { recursive: true });
|
|
38
|
+
const frames = {};
|
|
39
|
+
const t0 = Date.now();
|
|
40
|
+
const nav = page.goto(url, { waitUntil: 'load', timeout: 60000 }).catch(() => null);
|
|
41
|
+
for (const t of TIMES) {
|
|
42
|
+
const wait = t0 + t - Date.now();
|
|
43
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
44
|
+
// the frame may be blank before the first byte (a valid part of the sequence)
|
|
45
|
+
const shot = await withTimeout(page.screenshot({ fullPage: false, animations: 'allow' }), 5000, `load shot ${t}`).catch(() => null);
|
|
46
|
+
frames[t] = shot ? PNG.sync.read(shot) : null;
|
|
47
|
+
if (shot) await fs.writeFile(path.join(dir, `${vp.name}-${t}.png`), shot);
|
|
48
|
+
}
|
|
49
|
+
await nav;
|
|
50
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
|
51
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
52
|
+
await page.waitForTimeout(1500);
|
|
53
|
+
const finalShot = await page.screenshot({ fullPage: false, animations: 'allow' });
|
|
54
|
+
await fs.writeFile(path.join(dir, `${vp.name}-final.png`), finalShot);
|
|
55
|
+
const final = PNG.sync.read(finalShot);
|
|
56
|
+
const curve = {};
|
|
57
|
+
for (const t of TIMES) curve[t] = frames[t] ? matchPct(frames[t], final).pct : 0;
|
|
58
|
+
const settledAt = TIMES.find((t) => curve[t] >= SETTLED) ?? null;
|
|
59
|
+
return { curve, settledAt };
|
|
60
|
+
} finally {
|
|
61
|
+
await context.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cloneUrl = typeof flags.url === 'string' ? flags.url : null;
|
|
66
|
+
const server = cloneUrl ? null : await serve(path.join(root, 'clone'));
|
|
67
|
+
const browser = await openBrowser(flags.cdp);
|
|
68
|
+
const viewports = [];
|
|
69
|
+
try {
|
|
70
|
+
// each side twice: a load is never the same twice (an image that arrives a frame later moves an intro), so the
|
|
71
|
+
// curve is the mean of two runs and the spread between them is reported as the measurement's own noise
|
|
72
|
+
const twice = async (url, dir, vp) => {
|
|
73
|
+
const a = await sequence(browser, url, vp, dir);
|
|
74
|
+
const b = await sequence(browser, url, vp, dir);
|
|
75
|
+
const curve = Object.fromEntries(TIMES.map((t) => [t, +((a.curve[t] + b.curve[t]) / 2).toFixed(2)]));
|
|
76
|
+
const noise = +Math.max(...TIMES.map((t) => Math.abs(a.curve[t] - b.curve[t]))).toFixed(2);
|
|
77
|
+
return { curve, noise, settledAt: TIMES.find((t) => curve[t] >= SETTLED) ?? null, runs: [a, b] };
|
|
78
|
+
};
|
|
79
|
+
for (const vp of vps) {
|
|
80
|
+
const ref = await cached(root, { tool: 'load', vp: vp.name, times: TIMES, network: NETWORK, v: 2 }, () => twice(meta.url, path.join(root, 'reference', 'load'), vp), Boolean(flags['refresh-ref']));
|
|
81
|
+
const clone = await twice(cloneUrl || `${server.url}index.html`, path.join(root, 'compare', 'load'), vp);
|
|
82
|
+
const gap = Object.fromEntries(TIMES.map((t) => [t, +Math.abs(ref.curve[t] - clone.curve[t]).toFixed(2)]));
|
|
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 });
|
|
89
|
+
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('/')})`);
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
if (!flags.cdp) await browser.close();
|
|
95
|
+
server?.close();
|
|
96
|
+
}
|
|
97
|
+
await fs.mkdir(path.join(root, 'compare'), { recursive: true });
|
|
98
|
+
const out = path.join(root, 'compare', 'load.json');
|
|
99
|
+
const previous = await fs.readFile(out, 'utf8').then(JSON.parse).catch(() => null);
|
|
100
|
+
const merged = vps.length === VIEWPORTS.length || !previous ? viewports : [...(previous.viewports || []).filter((v) => !viewports.some((n) => n.viewport === v.viewport)), ...viewports];
|
|
101
|
+
await fs.writeFile(out, JSON.stringify({ at: new Date().toISOString(), clone: cloneUrl || server?.url, times: TIMES, network: NETWORK, viewports: merged }, null, 2));
|
|
102
|
+
console.log(`-> ${path.relative(process.cwd(), out)}`);
|