@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.
@@ -11,6 +11,10 @@
11
11
  // "compare": "structure" }] // or "visual"
12
12
  // Step kinds: move [x,y] (fractions of the viewport), hover [x,y], path [[x,y],...], drag {from,to}, down/up [x,y],
13
13
  // click {ref,clone} selector or [x,y], key "Escape", wait ms, shot "label".
14
+ // Points are [x, y] fractions of the viewport (0-1); move and hover also take an element: "selector@n" or { ref, clone }.
15
+ // "selector@n" in at/click/expectChange picks the n-th match, counting from 0 (mind hidden duplicates: a mobile menu
16
+ // button is often the first match). A scenario passes when the change happens on both sides and the shots after it
17
+ // match the reference's (≥ 95).
14
18
  // usage: node scripts/interact.mjs <name> [--only "<text>"] | node scripts/interact.mjs <name> --draft
15
19
  import fs from 'node:fs/promises';
16
20
  import path from 'node:path';
@@ -19,7 +23,7 @@ import { VIEWPORTS, parseArgs, openBrowser, newPage, serve, warmUp, waitStill, w
19
23
  const { pos, flags } = parseArgs(process.argv.slice(2));
20
24
  const [name] = pos;
21
25
  if (!name) {
22
- console.error('usage: npm run interact -- <name> [--only text] | --draft');
26
+ console.error('usage: trueclone interact <name> [--only "<scenario text>"] | trueclone interact <name> --draft (scenario format: the comment at the top of scripts/interact.mjs, and SKILL.md)');
23
27
  process.exit(1);
24
28
  }
25
29
  const root = path.resolve('sites', name);
@@ -94,6 +98,14 @@ async function runSide(url, side, list) {
94
98
  await wheelTo(page, sc.at?.offset || 0, vp);
95
99
  }
96
100
  await waitStill(page, { timeout: 3000 });
101
+ // the first frame only once the screen itself has stopped changing (a slow intro fade outlasts the page-level
102
+ // wait, and a "before" caught halfway through it is compared against forever): two shots in a row the same
103
+ for (let t0 = Date.now(), last = null; Date.now() - t0 < 6000;) {
104
+ const buf = await page.screenshot();
105
+ if (last && (buf.equals(last) || matchPct(PNG.sync.read(last), PNG.sync.read(buf)).pct >= 99.8)) break;
106
+ last = buf;
107
+ await page.waitForTimeout(250);
108
+ }
97
109
  const px = ([x, y]) => [Math.round(x * vp.width), Math.round(y * vp.height)];
98
110
  const expectSel = sc.expectChange?.[side];
99
111
  const before = expectSel ? await stateOf(page, expectSel) : null;
@@ -109,7 +121,18 @@ async function runSide(url, side, list) {
109
121
  };
110
122
  await shot('before');
111
123
  for (const st of sc.steps) {
112
- if (st.move || st.hover) await page.mouse.move(...px(st.move || st.hover), { steps: 15 });
124
+ if (st.move || st.hover) {
125
+ // a point [x, y], or an element to put the pointer on: "selector@n" or { ref, clone }
126
+ const t = st.move || st.hover;
127
+ const sel = Array.isArray(t) ? null : typeof t === 'string' ? t : t[side];
128
+ if (!sel) await page.mouse.move(...px(t), { steps: 15 });
129
+ else {
130
+ const [s, idx] = sel.split('@');
131
+ const box = await page.locator(s).nth(Number(idx || 0)).boundingBox().catch(() => null);
132
+ if (box) await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 15 });
133
+ else results.push({ name: sc.name, error: `hover ${sel}: no such element on the ${side === 'ref' ? 'reference' : 'clone'}` });
134
+ }
135
+ }
113
136
  else if (st.path) for (const p of st.path) await page.mouse.move(...px(p), { steps: 20 });
114
137
  else if (st.drag) {
115
138
  await page.mouse.move(...px(st.drag.from), { steps: 5 });
@@ -146,7 +169,7 @@ const server = typeof flags.url === 'string' ? null : await serve(path.join(root
146
169
  const cloneUrl = server ? server.url : flags.url;
147
170
  // Reference results are cached per scenario (reference side + viewport + capture time) as long as the shot
148
171
  // files still exist; only new or edited scenarios touch the live site. --refresh-ref re-measures all.
149
- const refKey = (sc) => ({ tool: 'interact', name: sc.name, viewport: sc.viewport, at: sc.at, steps: sc.steps, expect: sc.expectChange?.ref, compare: sc.compare, click: JSON.stringify(sc.steps) });
172
+ const refKey = (sc) => ({ tool: 'interact', v: 2, name: sc.name, viewport: sc.viewport, at: sc.at, steps: sc.steps, expect: sc.expectChange?.ref, compare: sc.compare, click: JSON.stringify(sc.steps) });
150
173
  const refRes = [];
151
174
  const missing = [];
152
175
  for (const sc of scenarios) {
@@ -164,6 +187,7 @@ const cloneRes = await runSide(cloneUrl, 'clone', scenarios);
164
187
  server?.close();
165
188
 
166
189
  const report = { name, at: new Date().toISOString(), scenarios: [] };
190
+ const END_STATE_MIN = 95; // the shots after the action must match this well
167
191
  for (const sc of scenarios) {
168
192
  const a = refRes.find((r) => r.name === sc.name && r.shots), b = cloneRes.find((r) => r.name === sc.name && r.shots);
169
193
  const errors = [...refRes, ...cloneRes].filter((r) => r.name === sc.name && r.error).map((r) => r.error);
@@ -174,7 +198,12 @@ for (const sc of scenarios) {
174
198
  }
175
199
  const labels = Object.keys(shots).filter((l) => l !== 'before');
176
200
  const score = labels.length ? +(labels.reduce((n, l) => n + shots[l], 0) / labels.length).toFixed(2) : null;
177
- const expect = sc.expectChange ? { ref: a?.changed, clone: b?.changed, pass: a?.changed === true && b?.changed === true } : null;
201
+ // a scenario passes when the change happens on both sides AND the clone ends up where the reference does: a wrong
202
+ // panel opening is a change too
203
+ const endOk = score !== null && score >= END_STATE_MIN;
204
+ const expect = sc.expectChange
205
+ ? { ref: a?.changed, clone: b?.changed, endState: score, pass: a?.changed === true && b?.changed === true && endOk }
206
+ : { ref: null, clone: null, endState: score, pass: endOk };
178
207
  report.scenarios.push({ name: sc.name, score, shots, expect, errors });
179
208
  }
180
209
  await fs.writeFile(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
@@ -182,7 +211,8 @@ console.table(report.scenarios.map((s) => ({
182
211
  scenario: s.name,
183
212
  'match %': s.score,
184
213
  shots: Object.entries(s.shots).map(([k, v]) => `${k} ${v}`).join(' '),
185
- 'changes (ref/clone)': s.expect ? `${s.expect.ref ? 'yes' : 'NO'} / ${s.expect.clone ? 'yes' : 'NO'}${s.expect.pass ? '' : ' FAIL'}` : '-',
214
+ 'changes (ref/clone)': s.expect.ref === null ? '-' : `${s.expect.ref ? 'yes' : 'NO'} / ${s.expect.clone ? 'yes' : 'NO'}`,
215
+ 'end state': `${s.score ?? '-'}${s.expect.pass ? '' : ' FAIL'}`,
186
216
  errors: s.errors.join('; ') || '',
187
217
  })));
188
218
  console.log(`-> ${path.relative(process.cwd(), path.join(outDir, 'report.json'))}`);
package/scripts/lib.mjs CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import crypto from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
5
  import http from 'node:http';
6
+ import zlib from 'node:zlib';
6
7
  import path from 'node:path';
7
8
  import { chromium } from 'playwright';
8
9
  import { PNG } from 'pngjs';
@@ -10,11 +11,19 @@ import pixelmatch from 'pixelmatch';
10
11
 
11
12
  export { PNG };
12
13
 
14
+ // The seed sizes: the clone is built from what the reference shows at these.
13
15
  export const VIEWPORTS = [
14
16
  { name: 'mobile', width: 390, height: 844 },
15
17
  { name: 'tablet', width: 768, height: 1024 },
16
18
  { name: 'desktop', width: 1440, height: 900 },
17
19
  ];
20
+ // Held-out sizes: captured and scored, never read by anything that builds the clone. A clone that is right at the seed
21
+ // sizes and wrong here is fitted to the sizes it was built from, not a copy of the site.
22
+ export const HELD_OUT = [
23
+ { name: 'laptop', width: 1280, height: 720, heldOut: true },
24
+ { name: 'wide', width: 1920, height: 1080, heldOut: true },
25
+ ];
26
+ export const SCORED = [...VIEWPORTS, ...HELD_OUT];
18
27
 
19
28
  // Jump every animation/transition to its end state so screenshots are deterministic.
20
29
  const FREEZE_CSS = `*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;
@@ -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: gradient text (background-clip: text paints the glyphs with its background, so a
132
- // transparent colour still shows the letters) and photo backgrounds on ::before/::after. Marked with attributes
133
- // the mask CSS targets; `off` removes them.
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
- for (const el of document.querySelectorAll('*')) {
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() restores every inline style it touched.
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
- res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
255
- createReadStream(file).pipe(res);
270
+ const headers = { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' };
271
+ const accept = String(req.headers['accept-encoding'] || '');
272
+ const enc = COMPRESSIBLE.test(file) ? (/\bbr\b/.test(accept) ? 'br' : /\bgzip\b/.test(accept) ? 'gzip' : null) : null;
273
+ if (enc) {
274
+ res.writeHead(200, { ...headers, 'content-encoding': enc, vary: 'accept-encoding' });
275
+ createReadStream(file).pipe(enc === 'br' ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }) : zlib.createGzip({ level: 6 })).pipe(res);
276
+ } else {
277
+ res.writeHead(200, headers);
278
+ createReadStream(file).pipe(res);
279
+ }
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);
@@ -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)}`);