@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.
@@ -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.`);
@@ -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
- ref = await cached(root, { tool: 'hovers', vp: vp.name, max: MAX, v: 2 }, () => hoverAll(browser, meta.url, null), Boolean(flags['refresh-ref']));
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)}`);
@@ -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((s) => { const el = document.querySelector(s); return el ? el.getBoundingClientRect().top + scrollY : 0; }, atSel);
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 await page.click(target, { timeout: 5000 }).catch((e) => results.push({ name: sc.name, error: `click ${target}: ${e.message.split('\n')[0]}` }));
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}`; // canvas = media (WebGL heroes, 3D models): flat grey box like images
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 el of document.querySelectorAll('*')) {
134
- if (getComputedStyle(el).backgroundImage.includes('url(')) {
135
- el.style.setProperty('background-image', 'none', 'important');
136
- el.style.setProperty('background-color', '#888', 'important');
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() restores every inline style it touched.
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
 
@@ -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)}`);