@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/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;
|
|
@@ -126,36 +135,50 @@ img,video{object-position:-99999px -99999px!important;background:#888!important}
|
|
|
126
135
|
svg{background:#888!important} svg *{visibility:hidden!important} iframe{visibility:hidden!important}
|
|
127
136
|
canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
|
|
128
137
|
[data-lab-cliptext]{background:none!important;-webkit-text-fill-color:transparent!important}
|
|
138
|
+
[data-lab-bgi]{background-image:none!important;background-color:#888!important}
|
|
129
139
|
[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
|
|
130
140
|
|
|
131
|
-
// What CSS alone can't select:
|
|
132
|
-
//
|
|
133
|
-
// the
|
|
141
|
+
// What CSS alone can't select: photo backgrounds (a script that rewrites an element's inline background every frame
|
|
142
|
+
// would undo an inline override, so the mask is a stylesheet rule on a marker attribute, which inline styles lose
|
|
143
|
+
// to), gradient text (background-clip: text paints the glyphs with its background, so a transparent colour still
|
|
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.
|
|
134
147
|
function maskExtrasInPage(enable) {
|
|
135
|
-
const marks = ['data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
|
|
148
|
+
const marks = ['data-lab-bgi', 'data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
|
|
136
149
|
if (!enable) {
|
|
150
|
+
window.__labMaskObserver?.disconnect();
|
|
151
|
+
window.__labMaskObserver = null;
|
|
137
152
|
for (const m of marks) for (const el of document.querySelectorAll(`[${m}]`)) el.removeAttribute(m);
|
|
138
153
|
return;
|
|
139
154
|
}
|
|
140
|
-
|
|
155
|
+
const markOne = (el) => {
|
|
141
156
|
const s = getComputedStyle(el);
|
|
157
|
+
if (s.backgroundImage.includes('url(')) el.setAttribute('data-lab-bgi', '');
|
|
142
158
|
if (s.backgroundClip === 'text' || s.webkitBackgroundClip === 'text') el.setAttribute('data-lab-cliptext', '');
|
|
143
159
|
if (getComputedStyle(el, '::before').backgroundImage.includes('url(')) el.setAttribute('data-lab-bgb', '');
|
|
144
160
|
if (getComputedStyle(el, '::after').backgroundImage.includes('url(')) el.setAttribute('data-lab-bga', '');
|
|
145
|
-
}
|
|
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'] });
|
|
146
177
|
}
|
|
147
178
|
|
|
148
179
|
export async function mask(page) {
|
|
149
180
|
const tag = await page.addStyleTag({ content: MASK_CSS });
|
|
150
181
|
await tag.evaluate((el) => el.setAttribute('data-lab-freeze', ''));
|
|
151
|
-
await page.evaluate(() => {
|
|
152
|
-
for (const el of document.querySelectorAll('*')) {
|
|
153
|
-
if (getComputedStyle(el).backgroundImage.includes('url(')) {
|
|
154
|
-
el.style.setProperty('background-image', 'none', 'important');
|
|
155
|
-
el.style.setProperty('background-color', '#888', 'important');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
182
|
await page.evaluate(maskExtrasInPage, true);
|
|
160
183
|
await page.waitForTimeout(200);
|
|
161
184
|
}
|
|
@@ -173,7 +196,7 @@ export async function freezeLoops(page) {
|
|
|
173
196
|
}).catch(() => {});
|
|
174
197
|
}
|
|
175
198
|
|
|
176
|
-
// Reversible version of mask() for live pages: on() masks, off()
|
|
199
|
+
// Reversible version of mask() for live pages: on() masks, off() removes the stylesheet and every marker.
|
|
177
200
|
export async function maskToggle(page, on) {
|
|
178
201
|
await page.evaluate(([css, enable]) => {
|
|
179
202
|
if (enable) {
|
|
@@ -182,19 +205,8 @@ export async function maskToggle(page, on) {
|
|
|
182
205
|
tag.setAttribute('data-lab-freeze', '');
|
|
183
206
|
tag.textContent = css;
|
|
184
207
|
document.head.appendChild(tag);
|
|
185
|
-
for (const el of document.querySelectorAll('*')) {
|
|
186
|
-
if (getComputedStyle(el).backgroundImage.includes('url(')) {
|
|
187
|
-
el.setAttribute('data-lab-bg', el.style.cssText);
|
|
188
|
-
el.style.setProperty('background-image', 'none', 'important');
|
|
189
|
-
el.style.setProperty('background-color', '#888', 'important');
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
208
|
} else {
|
|
193
209
|
document.getElementById('lab-mask')?.remove();
|
|
194
|
-
for (const el of document.querySelectorAll('[data-lab-bg]')) {
|
|
195
|
-
el.style.cssText = el.getAttribute('data-lab-bg');
|
|
196
|
-
el.removeAttribute('data-lab-bg');
|
|
197
|
-
}
|
|
198
210
|
}
|
|
199
211
|
}, [MASK_CSS, on]);
|
|
200
212
|
await page.evaluate(maskExtrasInPage, on);
|
|
@@ -242,17 +254,29 @@ const MIME = {
|
|
|
242
254
|
};
|
|
243
255
|
|
|
244
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;
|
|
245
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
|
|
246
262
|
return new Promise((resolve) => {
|
|
247
263
|
const server = http.createServer(async (req, res) => {
|
|
248
264
|
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
249
265
|
if (p.endsWith('/')) p += 'index.html';
|
|
250
266
|
const file = path.join(dir, p);
|
|
251
|
-
if (!file.startsWith(dir)) { res.writeHead(403).end(); return; }
|
|
267
|
+
if (file !== dir && !file.startsWith(dir + path.sep)) { res.writeHead(403).end(); return; }
|
|
252
268
|
try {
|
|
253
269
|
await fs.access(file);
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
}
|
|
256
280
|
} catch { res.writeHead(404).end(); }
|
|
257
281
|
});
|
|
258
282
|
server.listen(port, '127.0.0.1', () => { server.url = `http://127.0.0.1:${server.address().port}/`; resolve(server); });
|
|
@@ -277,6 +301,30 @@ export function crop(png, y, h) {
|
|
|
277
301
|
|
|
278
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 });
|
|
279
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
|
+
|
|
280
328
|
export function matchPct(pngA, pngB, withDiff = false) {
|
|
281
329
|
const w = Math.max(pngA.width, pngB.width), h = Math.max(pngA.height, pngB.height);
|
|
282
330
|
const a = pad(pngA, w, h), b = pad(pngB, w, h);
|
package/scripts/load.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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 { SCORED as 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
|
+
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(' | ')}`;
|
|
93
|
+
console.log(unstable
|
|
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}`);
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
if (!flags.cdp) await browser.close();
|
|
99
|
+
server?.close();
|
|
100
|
+
}
|
|
101
|
+
await fs.mkdir(path.join(root, 'compare'), { recursive: true });
|
|
102
|
+
const out = path.join(root, 'compare', 'load.json');
|
|
103
|
+
const previous = await fs.readFile(out, 'utf8').then(JSON.parse).catch(() => null);
|
|
104
|
+
const merged = vps.length === VIEWPORTS.length || !previous ? viewports : [...(previous.viewports || []).filter((v) => !viewports.some((n) => n.viewport === v.viewport)), ...viewports];
|
|
105
|
+
await fs.writeFile(out, JSON.stringify({ at: new Date().toISOString(), clone: cloneUrl || server?.url, times: TIMES, network: NETWORK, viewports: merged }, null, 2));
|
|
106
|
+
console.log(`-> ${path.relative(process.cwd(), out)}`);
|