@trueclone/cli 0.3.0 → 0.4.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 +24 -32
- package/bin/trueclone.mjs +23 -19
- package/package.json +2 -1
- package/scripts/capture.mjs +136 -24
- package/scripts/curves.mjs +44 -7
- package/scripts/diff.mjs +4 -2
- package/scripts/drift.mjs +21 -14
- package/scripts/hovers.mjs +137 -45
- package/scripts/interact.mjs +35 -5
- package/scripts/lib.mjs +69 -5
- package/scripts/load.mjs +12 -8
- package/scripts/next.mjs +74 -9
- package/scripts/report.mjs +46 -12
- package/scripts/reveals.mjs +4 -0
- package/scripts/review.mjs +149 -0
- package/scripts/score.mjs +4 -1
- package/scripts/scripts-evidence.mjs +116 -0
- package/scripts/seed.mjs +188 -115
- package/scripts/states.mjs +38 -5
- package/scripts/tiles.mjs +33 -17
- package/scripts/viewport-fit.mjs +198 -0
- package/skill/SKILL.md +119 -145
package/scripts/next.mjs
CHANGED
|
@@ -304,21 +304,26 @@ await fs.cp(path.join(cloneDir, 'assets'), path.join(out, 'public', 'assets'), {
|
|
|
304
304
|
for (const f of ['DESIGN.md', 'design-tokens.json']) await fs.copyFile(path.join(root, f), path.join(out, f)).catch(() => {});
|
|
305
305
|
await fs.writeFile(path.join(out, 'package.json'), JSON.stringify(PKG, null, 2) + '\n');
|
|
306
306
|
// custom elements (<code-island>, <swiper-container>) have no JSX type: declared here, or `next build` fails
|
|
307
|
+
// Types for what the page uses that React's typings don't know: custom elements (<code-island>), and HTML attributes
|
|
308
|
+
// newer than the typings or outside them (command, commandfor, popovertarget, a framework's own): an HTML attribute
|
|
309
|
+
// is valid markup whether React lists it or not, so every element accepts any attribute here.
|
|
307
310
|
const custom = new Set();
|
|
308
311
|
(function walk(n) { if (n.tagName && n.tagName.includes('-')) custom.add(n.tagName); for (const c of n.childNodes || []) walk(c); })(body);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
|
312
|
+
await fs.rm(path.join(out, 'custom-elements.d.ts'), { force: true });
|
|
313
|
+
await fs.writeFile(path.join(out, 'html-types.d.ts'), `// Generated by scripts/next.mjs: the page's markup, typed for JSX as the browser accepts it.
|
|
314
|
+
import type { DetailedHTMLProps, HTMLAttributes as ReactHTMLAttributes } from 'react';
|
|
312
315
|
|
|
313
316
|
declare module 'react' {
|
|
314
|
-
|
|
317
|
+
interface HTMLAttributes<T> {
|
|
318
|
+
[attribute: string]: unknown;
|
|
319
|
+
}
|
|
320
|
+
${custom.size ? ` namespace JSX {
|
|
315
321
|
interface IntrinsicElements {
|
|
316
|
-
${[...custom].map((t) => ` ${JSON.stringify(t)}: DetailedHTMLProps<
|
|
322
|
+
${[...custom].map((t) => ` ${JSON.stringify(t)}: DetailedHTMLProps<ReactHTMLAttributes<HTMLElement>, HTMLElement>;`).join('\n')}
|
|
317
323
|
}
|
|
318
324
|
}
|
|
319
|
-
}
|
|
325
|
+
` : ''}}
|
|
320
326
|
`);
|
|
321
|
-
} else await fs.rm(path.join(out, 'custom-elements.d.ts'), { force: true });
|
|
322
327
|
await fs.writeFile(path.join(out, 'postcss.config.mjs'), "export default { plugins: { '@tailwindcss/postcss': {} } };\n");
|
|
323
328
|
await fs.writeFile(path.join(out, 'next.config.mjs'), `import path from 'node:path';
|
|
324
329
|
import { fileURLToPath } from 'node:url';
|
|
@@ -366,12 +371,31 @@ async function seededApp() {
|
|
|
366
371
|
// follow the head's in document order
|
|
367
372
|
const bodyStyles = [];
|
|
368
373
|
(function walk(n) { for (const c of n.childNodes || []) { if (c.tagName === 'style' || c.tagName === 'link') bodyStyles.push(c); else walk(c); } })(body);
|
|
374
|
+
const layerOrder = []; // the site's own cascade layers, in the order its stylesheets first declare them
|
|
375
|
+
// a stylesheet's relative @import (patches/x.css importing ../tokens.css): the imported file comes along, renamed
|
|
376
|
+
// into styles/site/, and the @import points at it there
|
|
377
|
+
const carryImports = async (css, fromDir) => {
|
|
378
|
+
const out2 = [];
|
|
379
|
+
for (const m of css.matchAll(/@import\s+(?:url\()?\s*(['"])([^'"]+)\1\s*\)?/g)) {
|
|
380
|
+
const spec = m[2];
|
|
381
|
+
if (/^(https?:)?\/\//.test(spec) || spec.startsWith('data:') || !/\.css(\?|$)/i.test(spec)) continue;
|
|
382
|
+
const src = path.resolve(fromDir, spec.split('?')[0]);
|
|
383
|
+
const text = await fs.readFile(src, 'utf8').catch(() => null);
|
|
384
|
+
if (text === null) { console.warn(`WARNING @import ${spec} not found next to its stylesheet`); continue; }
|
|
385
|
+
const name = `imported-${path.basename(src)}`;
|
|
386
|
+
await fs.writeFile(path.join(out, 'styles', 'site', name), text.replace(/url\((['"]?)(?:\.\.\/)*assets\//g, (q0, q) => `url(${q}/assets/`));
|
|
387
|
+
out2.push([m[0], `@import "./${name}"`]);
|
|
388
|
+
}
|
|
389
|
+
for (const [a, b] of out2) css = css.split(a).join(b);
|
|
390
|
+
return css;
|
|
391
|
+
};
|
|
369
392
|
for (const e of [...els(head), ...bodyStyles]) {
|
|
370
393
|
const a = attrsOf(e);
|
|
371
|
-
let css = null, file = null;
|
|
394
|
+
let css = null, file = null, fromDir = cloneDir;
|
|
372
395
|
if (e.tagName === 'link' && a.rel === 'stylesheet' && a.href && !/^(https?:)?\/\//.test(a.href)) {
|
|
373
396
|
css = await fs.readFile(path.join(cloneDir, a.href), 'utf8').catch(() => null);
|
|
374
397
|
file = path.basename(a.href);
|
|
398
|
+
fromDir = path.dirname(path.join(cloneDir, a.href));
|
|
375
399
|
} else if (e.tagName === 'style') {
|
|
376
400
|
css = (e.childNodes || []).map((c) => c.value || '').join('');
|
|
377
401
|
file = `inline-${++inline}.css`;
|
|
@@ -379,6 +403,8 @@ async function seededApp() {
|
|
|
379
403
|
if (css === null) continue;
|
|
380
404
|
// stylesheets lived in clone/css/ (assets at ../assets/), inline blocks in the page (assets at assets/)
|
|
381
405
|
css = css.replace(/url\((['"]?)(?:\.\.\/)?assets\//g, (m, q) => `url(${q}/assets/`);
|
|
406
|
+
css = await carryImports(css, fromDir);
|
|
407
|
+
for (const m of css.matchAll(/@layer\s+([\w-]+(?:\s*,\s*[\w-]+)*)\s*[;{]/g)) for (const l of m[1].split(',').map((x) => x.trim())) if (!layerOrder.includes(l)) layerOrder.push(l);
|
|
382
408
|
usesTailwind ||= /--tw-|tailwindcss v\d/.test(css);
|
|
383
409
|
await fs.writeFile(path.join(out, 'styles', 'site', file), css);
|
|
384
410
|
imports.push(`import '../styles/site/${file}';`);
|
|
@@ -391,7 +417,11 @@ async function seededApp() {
|
|
|
391
417
|
const tailwind = usesTailwind ? '@import "tailwindcss" source(none);'
|
|
392
418
|
: '/* no preflight: the original was not built with Tailwind and relies on browser defaults */\n@layer theme, base, components, utilities;\n@import "tailwindcss/theme.css" layer(theme);\n@import "tailwindcss/utilities.css" layer(utilities) source(none);';
|
|
393
419
|
const scoped = theme.replace('@import "tailwindcss";', `${tailwind}\n@source "../components/ui";`);
|
|
394
|
-
|
|
420
|
+
// Cascade layers are ordered by their first declaration anywhere in the page. globals.css loads first, so the site's
|
|
421
|
+
// own order goes at its very top: otherwise Tailwind's "base" is declared before the site's "reset", and the site's
|
|
422
|
+
// reset ends up overriding its own base.
|
|
423
|
+
const layerHead = layerOrder.length ? `/* the original's cascade layer order, declared first (see scripts/next.mjs) */\n@layer ${layerOrder.join(', ')};\n` : '';
|
|
424
|
+
await fs.writeFile(path.join(out, 'app', 'globals.css'), layerHead + scoped);
|
|
395
425
|
const siteCssImports = imports;
|
|
396
426
|
|
|
397
427
|
// markup
|
|
@@ -482,6 +512,30 @@ async function seededApp() {
|
|
|
482
512
|
}
|
|
483
513
|
};
|
|
484
514
|
for (const a of scriptTags) if (a.src) await copyScript(a.src);
|
|
515
|
+
// the page's other head links (icons, font preloads, preconnects, a manifest), as in the HTML clone; local files are
|
|
516
|
+
// served from public/ at the same path
|
|
517
|
+
const headLinks = [];
|
|
518
|
+
for (const e of els(head)) {
|
|
519
|
+
// the seed's own small head scripts (tc-vw keeps --tc-vw at 1% of the layout width): they must run before paint
|
|
520
|
+
if (e.tagName === 'script' && /^tc-/.test(attrsOf(e).id || '') && !attrsOf(e).src) {
|
|
521
|
+
headLinks.push(` <script id=${JSON.stringify(attrsOf(e).id)} dangerouslySetInnerHTML={{ __html: ${JSON.stringify((e.childNodes || []).map((c) => c.value || '').join(''))} }} />`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (e.tagName !== 'link') continue;
|
|
525
|
+
const a = attrsOf(e);
|
|
526
|
+
const rel = String(a.rel || '').toLowerCase();
|
|
527
|
+
if (!a.href || rel === 'stylesheet' || !/\b(icon|apple-touch-icon|mask-icon|preload|prefetch|preconnect|dns-prefetch|manifest)\b/.test(rel)) continue;
|
|
528
|
+
if (/\bpreload\b/.test(rel) && a.as === 'script') continue; // the scripts' own preloads are written below
|
|
529
|
+
let href = a.href;
|
|
530
|
+
if (!/^(https?:)?\/\//.test(href) && !href.startsWith('data:')) {
|
|
531
|
+
const clean = href.replace(/^\.\//, '').split(/[?#]/)[0];
|
|
532
|
+
const to = path.join(out, 'public', clean);
|
|
533
|
+
if (!clean.startsWith('assets/')) { await fs.mkdir(path.dirname(to), { recursive: true }); await fs.copyFile(path.join(cloneDir, clean), to).catch(() => {}); }
|
|
534
|
+
href = `/${clean}`;
|
|
535
|
+
}
|
|
536
|
+
const attrs = Object.entries({ ...a, href }).filter(([k]) => !/^on/.test(k)).map(([k, v]) => `${k === 'crossorigin' ? 'crossOrigin' : k === 'hreflang' ? 'hrefLang' : k === 'referrerpolicy' ? 'referrerPolicy' : k === 'fetchpriority' ? 'fetchPriority' : k}=${JSON.stringify(k === 'crossorigin' && v === '' ? 'anonymous' : v)}`);
|
|
537
|
+
headLinks.push(` <link ${attrs.join(' ')} />`);
|
|
538
|
+
}
|
|
485
539
|
const scriptList = scriptTags.map((a) => ({ ...(a.src ? { src: '/' + a.src.split(/[?#]/)[0].replace(/^\.\//, '') } : { code: a.code }), ...(a.type === 'module' ? { type: 'module' } : {}) }));
|
|
486
540
|
await fs.writeFile(path.join(out, 'components', 'site', 'SiteScripts.tsx'), `'use client';
|
|
487
541
|
// Generated by scripts/next.mjs: runs the clone's scripts (trueclone's players, inline scripts, script.js) in order,
|
|
@@ -533,6 +587,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
533
587
|
return (
|
|
534
588
|
<html${htmlAttrs} suppressHydrationWarning>
|
|
535
589
|
<head>
|
|
590
|
+
${headLinks.join('\n')}
|
|
536
591
|
{/* the clone's scripts start downloading with the page; SiteScripts runs them the moment hydration is done */}
|
|
537
592
|
${scriptList.filter((s) => s.src).map((s) => ` <link rel="${s.type === 'module' ? 'modulepreload' : 'preload'}" href=${JSON.stringify(s.src)}${s.type === 'module' ? '' : ' as="script"'} />`).join('\n')}
|
|
538
593
|
</head>
|
|
@@ -587,11 +642,21 @@ if (flags.verify) {
|
|
|
587
642
|
for (let i = 0; i < 60; i++) { try { if ((await fetch(url)).ok) break; } catch { /* starting */ } await new Promise((r) => setTimeout(r, 1000)); }
|
|
588
643
|
const lab = path.resolve('.');
|
|
589
644
|
const score = (args) => run('node', args, { cwd: lab });
|
|
645
|
+
// The app is scored with the same checks, into compare-next/ and REPORT.next.md: the HTML clone's compare/ and
|
|
646
|
+
// REPORT.md are moved aside first and put back after, so its scores are never overwritten by the app's.
|
|
647
|
+
const exists = (p) => fs.stat(p).then(() => true, () => false);
|
|
648
|
+
const keep = [[path.join(root, 'compare'), path.join(root, '.compare-html')], [path.join(root, 'REPORT.md'), path.join(root, '.REPORT-html.md')]];
|
|
649
|
+
for (const [from, to] of keep) { await fs.rm(to, { recursive: true, force: true }); if (await exists(from)) await fs.rename(from, to); }
|
|
590
650
|
try {
|
|
591
651
|
// same scoring entry point as the HTML clone: time limit per step, summary at the end
|
|
592
652
|
await score([path.join(path.dirname(toPath(import.meta.url)), 'score.mjs'), name, '--url', url, ...(flags.quick ? ['--quick'] : [])]);
|
|
593
653
|
} finally {
|
|
594
654
|
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(server.pid), '/T', '/F']);
|
|
595
655
|
else server.kill();
|
|
656
|
+
await fs.rm(path.join(root, 'compare-next'), { recursive: true, force: true });
|
|
657
|
+
if (await exists(path.join(root, 'compare'))) await fs.rename(path.join(root, 'compare'), path.join(root, 'compare-next'));
|
|
658
|
+
if (await exists(path.join(root, 'REPORT.md'))) await fs.rename(path.join(root, 'REPORT.md'), path.join(root, 'REPORT.next.md'));
|
|
659
|
+
for (const [from, to] of keep) if (await exists(to)) await fs.rename(to, from);
|
|
660
|
+
console.log(`Next.js app scores: ${path.relative(process.cwd(), path.join(root, 'REPORT.next.md'))} (details in compare-next/); the HTML clone's REPORT.md is unchanged`);
|
|
596
661
|
}
|
|
597
662
|
}
|
package/scripts/report.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// usage: node scripts/report.mjs <name>
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
|
+
import fsSync from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { parseArgs } from './lib.mjs';
|
|
9
10
|
|
|
@@ -22,10 +23,14 @@ if (!meta) {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
// Targets a clone has to meet (the same ones the skill gives the agent).
|
|
25
|
-
const T = { layout: 98,
|
|
26
|
+
const T = { layout: 98, states: 95, canvas: 90, motion: 95, detail: 95, hovers: 90, load: 95 };
|
|
27
|
+
// an accepted difference never covers a value below this, and needs evidence (a file it names that exists)
|
|
28
|
+
const ACCEPT_FLOOR = 95;
|
|
26
29
|
const mark = (ok, acceptedNote) => (ok === null ? '–' : ok ? 'pass' : acceptedNote ? `accepted (${acceptedNote})` : '**below target**');
|
|
27
30
|
const pct = (v) => (v === undefined || v === null ? '–' : `${v}%`);
|
|
28
|
-
|
|
31
|
+
// the seed sizes, then the held-out sizes (captured and scored, never used to build the clone)
|
|
32
|
+
const VPS = ['desktop', 'tablet', 'mobile', 'laptop', 'wide'];
|
|
33
|
+
const HELD = new Set(['laptop', 'wide']);
|
|
29
34
|
|
|
30
35
|
const layout = await read('compare/report.mask.json');
|
|
31
36
|
const visual = await read('compare/report.json');
|
|
@@ -46,8 +51,23 @@ const notesSection = (title) => {
|
|
|
46
51
|
return m ? m[1].replace(/<!--[\s\S]*?-->/g, '').split('\n').map((l) => l.trim()).filter((l) => /^[-*]\s+\S/.test(l)).map((l) => l.replace(/^[-*]\s+/, '')) : [];
|
|
47
52
|
};
|
|
48
53
|
const accepted = notesSection('Accepted differences');
|
|
54
|
+
// review: the frames trueclone review wrote, and the verdict the agent gave each in notes.md
|
|
55
|
+
const reviewVerdicts = notesSection('Review');
|
|
56
|
+
const reviewFrames = [];
|
|
57
|
+
for (const size of ['wide', 'laptop', 'desktop', 'tablet', 'mobile']) {
|
|
58
|
+
const fr = await read(`compare/review/${size}/frames.json`);
|
|
59
|
+
for (const x of fr?.frames || []) {
|
|
60
|
+
const v = reviewVerdicts.find((l) => l.startsWith(`${x.id} ${size}:`) || l.startsWith(`${x.id} ${size} :`));
|
|
61
|
+
reviewFrames.push({ size, id: x.id, pct: x.pct, verdict: v ? v.slice(v.indexOf(':') + 1).trim() : null });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const reviewOpen = reviewFrames.filter((x) => !x.verdict || !/^same\b/i.test(x.verdict));
|
|
49
65
|
const findings = notesSection('Findings');
|
|
50
|
-
|
|
66
|
+
// an entry needs `evidence: <file>` naming a file in the site folder (a crop, a measurement): a reason without proof
|
|
67
|
+
// is a gap, not a decision
|
|
68
|
+
const hasEvidence = (a) => { const m = /evidence:\s*([^\s;,)]+)/i.exec(a); return Boolean(m && fsSync.existsSync(path.resolve(root, m[1]))); };
|
|
69
|
+
const acceptedFor = (check, vp) => accepted.filter((a) => hasEvidence(a) && new RegExp(`\\b${check}\\b`, 'i').test(a) && (!vp || new RegExp(`\\b${vp}\\b`, 'i').test(a) || !/\b(desktop|tablet|mobile|laptop|wide)\b/i.test(a)));
|
|
70
|
+
const unproven = accepted.filter((a) => !hasEvidence(a));
|
|
51
71
|
|
|
52
72
|
const byVp = (rep, key = 'match') => Object.fromEntries((rep?.viewports || []).map((v) => [v.viewport, v[key] ?? v.score]));
|
|
53
73
|
const L = byVp(layout), V = byVp(visual), S = byVp(states), D = byVp(detail, 'score');
|
|
@@ -60,7 +80,8 @@ const row = (check, values, test, note = '') => {
|
|
|
60
80
|
// below target, but every failing width has an accepted entry in notes.md: the decision is recorded, not hidden
|
|
61
81
|
const key = check.split(' ')[0].toLowerCase();
|
|
62
82
|
const failing = typeof test === 'function' ? present(values).filter((v) => !test(values[v], v)) : ok === false ? present(values) : [];
|
|
63
|
-
|
|
83
|
+
// never below the floor: an accepted difference explains the last few points, not a broken page
|
|
84
|
+
const covered = failing.length && failing.every((v) => (typeof values[v] !== 'number' || values[v] >= ACCEPT_FLOOR) && acceptedFor(key, v).length);
|
|
64
85
|
const stale = drift?.stale && ['detail', 'hovers', 'motion', 'load', 'interactions'].includes(key) ? ' **stale capture**' : '';
|
|
65
86
|
rows.push(`| ${check} | ${VPS.map((v) => pct(values[v])).join(' | ')} | ${mark(ok, covered ? acceptedFor(key)[0].slice(0, 60) : null)}${stale} | ${note} |`);
|
|
66
87
|
};
|
|
@@ -68,17 +89,23 @@ const present = (vals) => VPS.filter((v) => vals[v] !== undefined && vals[v] !==
|
|
|
68
89
|
const all = (vals, test) => (present(vals).length ? present(vals).every((v) => test(vals[v], v)) : null);
|
|
69
90
|
row(`Layout (structure)`, L, (x) => x >= T.layout, `target ≥ ${T.layout}%; text and media masked`);
|
|
70
91
|
if (meta.mode === 'exact') row('Layout (real pixels)', V, (x) => x >= T.layout, 'exact mode: same text and media');
|
|
71
|
-
row('Scroll states', S, (x
|
|
92
|
+
row('Scroll states', S, (x) => x >= T.states, `target ≥ ${T.states}%`);
|
|
93
|
+
const CV = byVp(states, 'canvas');
|
|
94
|
+
if (present(CV).length) row('Canvases', CV, (x) => x >= T.canvas, `target ≥ ${T.canvas}%; what each scene draws, compared in the scroll-state frames (colours and coverage)`);
|
|
72
95
|
row('Motion curves', C, (x) => x >= T.motion, present(C).length ? `target ≥ ${T.motion}%` : 'no motion map (no scroll or time animation to score)');
|
|
73
96
|
row('Detail (styles)', D, (x) => x >= T.detail, `target ≥ ${T.detail}%; font, size, weight, line height, colour, boxes`);
|
|
74
97
|
row('Hovers', { desktop: hovers?.score }, hovers ? hovers.score >= T.hovers : null, `target ≥ ${T.hovers}% of the reference hover effects`);
|
|
75
98
|
const LD = byVp(load, 'score');
|
|
76
99
|
const tts = (side) => VPS.map((v) => load?.viewports.find((x) => x.viewport === v)).filter(Boolean).map((x) => (x[side]?.settledAt === null ? '>4000' : x[side]?.settledAt)).filter((x) => x !== undefined);
|
|
77
100
|
row('Load sequence', LD, (x) => x >= T.load, present(LD).length ? `target ≥ ${T.load}%; how settled each side is at 0.25 to 4 s, cold cache, same network (settled at: reference ${tts('ref').map((x) => `${x}ms`).join(' / ') || '–'}, clone ${tts('clone').map((x) => `${x}ms`).join(' / ') || '–'})` : 'not measured (trueclone load <name>)');
|
|
101
|
+
if (reviewFrames.length) {
|
|
102
|
+
const sizes = [...new Set(reviewFrames.map((x) => x.size))];
|
|
103
|
+
rows.push(`| Review (by eye) | ${VPS.map((v) => (sizes.includes(v) ? `${reviewFrames.filter((x) => x.size === v && x.verdict && /^same\b/i.test(x.verdict)).length}/${reviewFrames.filter((x) => x.size === v).length}` : '–')).join(' | ')} | ${reviewOpen.length ? '**below target**' : 'pass'} | frames signed off "same" in notes.md (trueclone review) |`);
|
|
104
|
+
} else rows.push(`| Review (by eye) | – | – | – | – | – | **below target** | not done: run trueclone review <name> and give every frame a verdict in notes.md |`);
|
|
78
105
|
const scenarios = interact?.scenarios || [];
|
|
79
106
|
const failed = scenarios.filter((s) => s.expect && !s.expect.pass);
|
|
80
107
|
row('Interactions', { desktop: scenarios.length ? +(scenarios.reduce((n, s) => n + (s.score || 0), 0) / scenarios.length).toFixed(2) : undefined }, scenarios.length ? failed.length === 0 : null,
|
|
81
|
-
scenarios.length ? `${scenarios.length - failed.length}/${scenarios.length} scenarios change
|
|
108
|
+
scenarios.length ? `${scenarios.length - failed.length}/${scenarios.length} scenarios change on both sides and end in the reference's state (≥ 95)` : 'no interactions.json');
|
|
82
109
|
|
|
83
110
|
const failing = rows.filter((r) => r.includes('below target')).length;
|
|
84
111
|
const acceptedCount = rows.filter((r) => r.includes('| accepted (')).length;
|
|
@@ -88,7 +115,9 @@ const verdict = (failing ? `${failing} check(s) below target` : 'every scored ch
|
|
|
88
115
|
const weak = [];
|
|
89
116
|
for (const v of VPS) for (const s of (layout?.viewports.find((x) => x.viewport === v)?.worstSections || []).slice(0, 2)) if (s.match < 99) weak.push(`- Layout ${v}: section "${String(s.label).slice(0, 50)}" at y ${s.y} is ${s.match}%`);
|
|
90
117
|
for (const v of VPS) for (const e of (curves[v]?.entries || []).filter((x) => x.score < 90).slice(0, 3)) weak.push(`- Motion ${v}: "${e.name}" ${e.score}%${e.note ? ` (${e.note})` : ''}`);
|
|
91
|
-
for (const s of failed) weak.push(
|
|
118
|
+
for (const s of failed) weak.push(s.expect.ref !== null && !(s.expect.ref && s.expect.clone)
|
|
119
|
+
? `- Interaction "${s.name}": the expected change happened on ${s.expect.ref ? 'the reference' : 'neither side'}${s.expect.clone ? '' : ', not on the clone'}`
|
|
120
|
+
: `- Interaction "${s.name}": the clone ends in a different state (${s.score}% like the reference's)`);
|
|
92
121
|
for (const v of (load?.viewports || []).filter((x) => x.score < T.load)) {
|
|
93
122
|
const worst = Object.entries(v.gap || {}).sort((a, b) => b[1] - a[1])[0];
|
|
94
123
|
if (worst) weak.push(`- Load ${v.viewport}: at ${worst[0]}ms the clone is ${v.clone.curve[worst[0]]}% settled, the reference ${v.ref.curve[worst[0]]}% (settled at ${v.clone.settledAt ?? '>4000'}ms vs ${v.ref.settledAt ?? '>4000'}ms)`);
|
|
@@ -123,15 +152,18 @@ const md = `# trueclone report: ${name}
|
|
|
123
152
|
| Scored | ${layout?.at || '–'}${layout?.clone && !/127\.0\.0\.1:\d+\/$/.test(layout.clone) ? ` against ${layout.clone}` : ''} |
|
|
124
153
|
| Verdict | **${verdict}** |
|
|
125
154
|
${times ? `| Scoring time | ${times.totalMin} min (${times.kind}${times.only ? `, only ${times.only.join(',')}` : ''}): ${Object.entries(times.steps).filter(([, s]) => s.seconds).map(([k, s]) => `${k} ${s.seconds}s`).join(', ')}${Object.values(times.steps).some((s) => /kept/.test(s.status)) ? '; unchanged checks kept from their last run' : ''} |` : ''}
|
|
126
|
-
${(detail?.viewports || []).some((v) => v.unstable) || hovers?.unstable || (load?.viewports || []).some((v) => v.unstable) ? `| Not scored | ${(detail?.viewports || []).filter((v) => v.unstable).map((v) => `${v.unstable} detail probe${v.unstable > 1 ? 's' : ''} (${v.viewport})`).concat(hovers?.unstable ? [`${hovers.unstable} hover${hovers.unstable > 1 ? 's' : ''}`] : [], (load?.viewports || []).filter((v) => v.unstable).map((v) => `load ${v.viewport} (±${v.noise} between two runs)`)).join(', ')}: unstable on the reference
|
|
155
|
+
${(detail?.viewports || []).some((v) => v.unstable) || hovers?.unstable || (load?.viewports || []).some((v) => v.unstable) ? `| Not scored | ${(detail?.viewports || []).filter((v) => v.unstable).map((v) => `${v.unstable} detail probe${v.unstable > 1 ? 's' : ''} (${v.viewport})`).concat(hovers?.unstable ? [`${hovers.unstable} hover${hovers.unstable > 1 ? 's' : ''}`] : [], (load?.viewports || []).filter((v) => v.unstable).map((v) => `load ${v.viewport} (±${v.ref?.noise ?? v.noise} between two runs)`)).join(', ')}: unstable on the reference itself (random motion, timing), excluded from the scores above |` : ''}
|
|
156
|
+
${(load?.viewports || []).some((v) => v.cloneUnstable) ? `| Clone loads inconsistently | ${load.viewports.filter((v) => v.cloneUnstable).map((v) => `${v.viewport} (±${v.clone.noise} between two runs)`).join(', ')}: the clone's own load differs from one run to the next; scored by its worse run, since a visitor can get either |` : ''}
|
|
127
157
|
|
|
128
158
|
## Scores
|
|
129
159
|
|
|
130
|
-
| Check | Desktop | Tablet | Mobile | Status | Notes |
|
|
131
|
-
|
|
160
|
+
| Check | Desktop 1440 | Tablet 768 | Mobile 390 | Laptop 1280 ° | Wide 1920 ° | Status | Notes |
|
|
161
|
+
|---|---|---|---|---|---|---|---|
|
|
132
162
|
${rows.join('\n')}
|
|
133
163
|
|
|
134
|
-
|
|
164
|
+
° held out: captured and scored, never used to build the clone. A clone right at the seed sizes and wrong here is fitted to those sizes, not a copy of the site.
|
|
165
|
+
${unproven.length ? `\n${unproven.length} accepted difference(s) in notes.md name no evidence file and are not applied: ${unproven.map((a) => `"${a.slice(0, 50)}…"`).join('; ')}\n` : ''}
|
|
166
|
+
${drift?.stale ? `## Stale capture\n\nThe live page differs from the capture of ${meta.capturedAt}: ${drift.viewports.filter((v) => v.stale).map((v) => `${v.viewport} ${v.livePageHeight}px tall vs ${v.capturedPageHeight}px captured, ${v.liveSections} vs ${v.capturedSections} sections`).join('; ')}. Layout and states score against the captured screenshots; detail, hovers, motion, interactions and load measure the live page and are marked stale above. Re-capture (\`trueclone capture\`, then \`states\`, \`reveals\`, \`seed --force\`) before reading them.\n\n` : ''}${accepted.length ? `## Accepted differences\n\nRecorded in notes.md with a reason; a check below target that is covered here is marked "accepted" in the table.\n\n${accepted.map((a) => `- ${a}`).join('\n')}\n\n` : ''}${reviewOpen.length ? `## Review: open frames\n\n${reviewOpen.slice(0, 40).map((x) => `- ${x.id} ${x.size}: ${x.verdict || 'no verdict yet'} (compare/review/${x.size}/${x.id}.png)`).join('\n')}\n\n` : ''}${weak.length ? `## Fix first\n\n${weak.join('\n')}\n` : '## Fix first\n\nNothing below 99% on layout, 90% on motion or failing.\n'}
|
|
135
167
|
${findings.length ? `## Findings (tool defects met on this site)\n\n${findings.map((f) => `- ${f}`).join('\n')}\n\n` : ''}## Motion found on the reference
|
|
136
168
|
|
|
137
169
|
${motionInv ? `Libraries: ${libs.length ? libs.join(', ') : 'none (no GSAP, Lenis, Swiper or similar)'}. ScrollTriggers: ${motionInv.triggers?.length ?? 0}. Tweens created while scrolling: ${motionInv.onScroll?.length ?? 0}. CSS animations: ${(motionInv.css || []).length}.` : 'Not inventoried yet (trueclone motion <name>).'}
|
|
@@ -142,5 +174,7 @@ ${motionInv ? `Libraries: ${libs.length ? libs.join(', ') : 'none (no GSAP, Leni
|
|
|
142
174
|
- **Tracking, analytics and bot protection** are left out on purpose${tracking.length ? `: ${[...new Set(tracking.map((c) => { try { const u = new URL(c.url); return `${u.host}${u.pathname.split('/').slice(0, 3).join('/')}`; } catch { return host(c.url); } }))].join(', ')}` : ''}.
|
|
143
175
|
${data.length ? `- **Data and API calls** the reference makes (${uniq(data).length}), which the clone does not call; their captured responses are in reference/desktop.har:\n${uniq(data).slice(0, 15).map((c) => ` - \`${c.method} ${c.url.split('?')[0].slice(0, 110)}\` → ${c.status} ${c.type.split(';')[0]}`).join('\n')}` : '- **Data and API calls**: none seen during the capture (static content).'}
|
|
144
176
|
`;
|
|
145
|
-
|
|
177
|
+
// rows left out of the summary table (an empty optional row) must not leave a blank line that splits it
|
|
178
|
+
const [head, ...rest] = md.split('\n## Scores');
|
|
179
|
+
await fs.writeFile(path.join(root, 'REPORT.md'), [head.replace(/(\|[^\n]*)\n(?:\n)+(?=\|)/g, '$1\n'), ...rest].join('\n## Scores'));
|
|
146
180
|
console.log(`${verdict} -> ${path.relative(process.cwd(), path.join(root, 'REPORT.md'))}`);
|
package/scripts/reveals.mjs
CHANGED
|
@@ -205,6 +205,10 @@ async function record(vp) {
|
|
|
205
205
|
await page.goto(meta.url, { waitUntil: 'load', timeout: 60000 });
|
|
206
206
|
await page.mouse.move(5, vp.height - 5);
|
|
207
207
|
await waitStill(page, { timeout: 15000, interval: 200 });
|
|
208
|
+
// Only what scrolling does: with pointer events off, whatever scrolls under the resting pointer is never hovered,
|
|
209
|
+
// so a card that opens on hover is not recorded as opened by the scroll. (Wheel input still reaches the page.)
|
|
210
|
+
await page.addStyleTag({ content: '*, *::before, *::after { pointer-events: none !important; }' });
|
|
211
|
+
await page.mouse.move(vp.width - 2, Math.round(vp.height / 2));
|
|
208
212
|
const count = await page.evaluate(watchInPage);
|
|
209
213
|
// Class changes caused by scrolling (a header that gets its backdrop, an "is-visible" toggle): watched with a
|
|
210
214
|
// MutationObserver. Anything that changes while the page sits idle (timers, carousels) is ignored.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Review: the reference and the clone side by side, the way a person looks at a website, at a size the clone was never
|
|
2
|
+
// built from (default: the held-out 1920x1080). Frames: the load at 0.3, 1.5 and 4.5 s, then every section (and every
|
|
3
|
+
// screen of a tall one) after scrolling to it with the wheel, each also with the pointer resting on the control nearest
|
|
4
|
+
// the middle of the screen. Each frame is one image, reference left, clone right, in compare/review/<size>/, listed in
|
|
5
|
+
// compare/review/<size>/index.md with its pixel match.
|
|
6
|
+
//
|
|
7
|
+
// The agent looks at every frame and writes a verdict per frame in notes.md under "## Review":
|
|
8
|
+
// - 03-section-2 wide: same
|
|
9
|
+
// - 07-section-4-hover wide: differs: the panel opens on the wrong card
|
|
10
|
+
// REPORT.md counts the frames signed off; "differs" and frames without a verdict are listed as open.
|
|
11
|
+
//
|
|
12
|
+
// usage: node scripts/review.mjs <name> [--size wide|laptop|desktop|tablet|mobile] [--url <clone url>]
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { SCORED, parseArgs, openBrowser, newPage, serve, matchPct, PNG } from './lib.mjs';
|
|
16
|
+
|
|
17
|
+
const { pos, flags } = parseArgs(process.argv.slice(2));
|
|
18
|
+
const [name] = pos;
|
|
19
|
+
if (!name) {
|
|
20
|
+
console.error('usage: trueclone review <name> [--size wide] [--url <clone url>]');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const root = path.resolve('sites', name);
|
|
24
|
+
const ref = path.join(root, 'reference');
|
|
25
|
+
const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
|
|
26
|
+
const vp = SCORED.find((v) => v.name === (flags.size || 'wide'));
|
|
27
|
+
if (!vp) { console.error(`unknown size ${flags.size}: ${SCORED.map((v) => v.name).join(', ')}`); process.exit(1); }
|
|
28
|
+
const server = typeof flags.url === 'string' ? null : await serve(path.join(root, 'clone'));
|
|
29
|
+
const cloneUrl = server ? `${server.url}index.html` : flags.url;
|
|
30
|
+
const outDir = path.join(root, 'compare', 'review', vp.name);
|
|
31
|
+
await fs.rm(outDir, { recursive: true, force: true });
|
|
32
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
33
|
+
|
|
34
|
+
// the frame plan comes from the reference at this size: section tops, and a frame per screen in tall sections
|
|
35
|
+
const sections = JSON.parse(await fs.readFile(path.join(ref, `${vp.name}.sections.json`), 'utf8').catch(() => 'null'));
|
|
36
|
+
if (!sections) { console.error(`no capture at ${vp.name} (${vp.width}x${vp.height}): re-capture with trueclone 0.4+`); process.exit(1); }
|
|
37
|
+
const plan = [];
|
|
38
|
+
sections.forEach((s, i) => {
|
|
39
|
+
const top = Math.max(0, s.y);
|
|
40
|
+
const screens = Math.max(1, Math.min(4, Math.round((s.height || s.h || vp.height) / vp.height)));
|
|
41
|
+
for (let k = 0; k < screens; k++) plan.push({ label: `section-${i + 1}${screens > 1 ? `-${k + 1}` : ''}`, y: top + k * vp.height });
|
|
42
|
+
});
|
|
43
|
+
const frames = plan.slice(0, 30);
|
|
44
|
+
|
|
45
|
+
// the control nearest the middle of the screen: links, buttons, pointer-cursor elements, cards with a hover listener
|
|
46
|
+
function hoverPoint() {
|
|
47
|
+
let best = null;
|
|
48
|
+
for (const el of document.querySelectorAll('a, button, [role="button"], [role="tab"], summary, *')) {
|
|
49
|
+
const s = getComputedStyle(el);
|
|
50
|
+
if (s.cursor !== 'pointer' && !['A', 'BUTTON', 'SUMMARY'].includes(el.tagName)) continue;
|
|
51
|
+
const r = el.getBoundingClientRect();
|
|
52
|
+
if (r.width * r.height < 1500 || r.bottom < 60 || r.top > innerHeight - 20 || r.width > innerWidth * 0.9) continue;
|
|
53
|
+
const cx = r.left + r.width / 2, cy = Math.min(Math.max(r.top + r.height / 2, 70), innerHeight - 20);
|
|
54
|
+
const d = Math.hypot(cx - innerWidth / 2, cy - innerHeight / 2);
|
|
55
|
+
if (!best || d < best.d) best = { x: Math.round(cx), y: Math.round(cy), d };
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function shoot(url, side) {
|
|
61
|
+
const { context, page } = await newPage(await browserP, vp, { reducedMotion: 'no-preference' });
|
|
62
|
+
const shots = {};
|
|
63
|
+
try {
|
|
64
|
+
const t0 = Date.now();
|
|
65
|
+
const nav = page.goto(url, { waitUntil: 'load', timeout: 60000 }).catch(() => null);
|
|
66
|
+
for (const t of [300, 1500, 4500]) {
|
|
67
|
+
const wait = t0 + t - Date.now();
|
|
68
|
+
if (wait > 0) await page.waitForTimeout(wait);
|
|
69
|
+
shots[`load-${t}`] = await page.screenshot().catch(() => null);
|
|
70
|
+
}
|
|
71
|
+
await nav;
|
|
72
|
+
await page.waitForTimeout(1500);
|
|
73
|
+
const scrollTo = async (y) => {
|
|
74
|
+
// with the wheel, the way a visitor scrolls: scroll-linked scenes and smooth scrolling follow wheel input
|
|
75
|
+
await page.mouse.move(Math.round(vp.width * 0.98), Math.round(vp.height * 0.5));
|
|
76
|
+
const now = await page.evaluate(() => scrollY);
|
|
77
|
+
const n = Math.max(1, Math.ceil(Math.abs(y - now) / 400));
|
|
78
|
+
for (let i = 0; i < n; i++) { await page.mouse.wheel(0, (y - now) / n); await page.waitForTimeout(60); }
|
|
79
|
+
await page.waitForTimeout(1600);
|
|
80
|
+
};
|
|
81
|
+
for (const f of frames) {
|
|
82
|
+
await scrollTo(f.y);
|
|
83
|
+
shots[f.label] = await page.screenshot();
|
|
84
|
+
const at = side === 'ref' ? await page.evaluate(hoverPoint) : hoverPoints[f.label];
|
|
85
|
+
if (side === 'ref') hoverPoints[f.label] = at;
|
|
86
|
+
if (at) {
|
|
87
|
+
await page.mouse.move(at.x - 30, at.y, { steps: 4 });
|
|
88
|
+
await page.mouse.move(at.x, at.y, { steps: 6 });
|
|
89
|
+
await page.waitForTimeout(1100);
|
|
90
|
+
shots[`${f.label}-hover`] = await page.screenshot();
|
|
91
|
+
await page.mouse.move(Math.round(vp.width * 0.98), Math.round(vp.height * 0.5));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
await context.close();
|
|
96
|
+
}
|
|
97
|
+
return shots;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hoverPoints = {};
|
|
101
|
+
const browserP = openBrowser(flags.cdp);
|
|
102
|
+
const browser = await browserP;
|
|
103
|
+
let refShots, cloneShots;
|
|
104
|
+
try {
|
|
105
|
+
refShots = await shoot(meta.url, 'ref');
|
|
106
|
+
cloneShots = await shoot(cloneUrl, 'clone');
|
|
107
|
+
} finally {
|
|
108
|
+
if (!flags.cdp) await browser.close();
|
|
109
|
+
server?.close();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// one image per frame: reference | clone, half size, with the frame's pixel match
|
|
113
|
+
const lines = [];
|
|
114
|
+
let n = 0;
|
|
115
|
+
for (const label of Object.keys(refShots)) {
|
|
116
|
+
const a = refShots[label] && PNG.sync.read(refShots[label]);
|
|
117
|
+
const b = cloneShots[label] && PNG.sync.read(cloneShots[label]);
|
|
118
|
+
if (!a || !b) continue;
|
|
119
|
+
const scale = 0.5, w = Math.round(vp.width * scale), h = Math.round(vp.height * scale);
|
|
120
|
+
const img = new PNG({ width: w * 2 + 12, height: h });
|
|
121
|
+
img.data.fill(255);
|
|
122
|
+
[a, b].forEach((src, k) => {
|
|
123
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
124
|
+
const sx = Math.min(src.width - 1, Math.round(x / scale)), sy = Math.min(src.height - 1, Math.round(y / scale));
|
|
125
|
+
const si = (sy * src.width + sx) * 4, di = (y * img.width + x + k * (w + 12)) * 4;
|
|
126
|
+
img.data[di] = src.data[si]; img.data[di + 1] = src.data[si + 1]; img.data[di + 2] = src.data[si + 2]; img.data[di + 3] = 255;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const id = `${String(n++).padStart(2, '0')}-${label}`;
|
|
130
|
+
await fs.writeFile(path.join(outDir, `${id}.png`), PNG.sync.write(img));
|
|
131
|
+
const pct = matchPct(a, b).pct;
|
|
132
|
+
lines.push({ id, pct });
|
|
133
|
+
}
|
|
134
|
+
await fs.writeFile(path.join(outDir, 'index.md'), `# Review at ${vp.name} (${vp.width}x${vp.height}): reference left, clone right
|
|
135
|
+
|
|
136
|
+
Look at every frame. In notes.md under "## Review", one line per frame: \`- <frame> ${vp.name}: same\` or
|
|
137
|
+
\`- <frame> ${vp.name}: differs: <what you see>\`. Pixel match is a hint, not the verdict: a 99% frame can hide a wrong
|
|
138
|
+
panel, and a 90% frame can be a random animation phase.
|
|
139
|
+
|
|
140
|
+
| frame | pixel match |
|
|
141
|
+
|---|---|
|
|
142
|
+
${lines.map((l) => `| [${l.id}](${l.id}.png) | ${l.pct}% |`).join('\n')}
|
|
143
|
+
`);
|
|
144
|
+
// make sure notes.md has the section the verdicts go in
|
|
145
|
+
const notesFile = path.join(root, 'notes.md');
|
|
146
|
+
const notes = await fs.readFile(notesFile, 'utf8').catch(() => '');
|
|
147
|
+
if (!/^## Review/m.test(notes)) await fs.appendFile(notesFile, `\n## Review\n\n<!-- one line per frame of compare/review/<size>/index.md: "- <frame> <size>: same" or "- <frame> <size>: differs: <what>". REPORT.md counts them. -->\n`);
|
|
148
|
+
await fs.writeFile(path.join(outDir, 'frames.json'), JSON.stringify({ size: vp.name, at: new Date().toISOString(), frames: lines }, null, 2));
|
|
149
|
+
console.log(`review (${vp.name} ${vp.width}x${vp.height}): ${lines.length} frames -> ${path.relative(process.cwd(), path.join(outDir, 'index.md'))}; lowest pixel match: ${[...lines].sort((x, y) => x.pct - y.pct).slice(0, 4).map((l) => `${l.id} ${l.pct}%`).join(', ')}`);
|
package/scripts/score.mjs
CHANGED
|
@@ -84,7 +84,10 @@ async function cloneHash() {
|
|
|
84
84
|
const stampsFile = path.join(root, 'compare', '.stamps.json');
|
|
85
85
|
const stamps = url ? {} : await fs.readFile(stampsFile, 'utf8').then(JSON.parse).catch(() => ({}));
|
|
86
86
|
const inputs = url ? null : { hash: await cloneHash(), capturedAt, args: null };
|
|
87
|
-
|
|
87
|
+
// the step's own code is an input too: a changed check must re-measure, never report its old number
|
|
88
|
+
const codeHash = async (file) => crypto.createHash('sha1').update(await fs.readFile(path.join(HERE, path.basename(file)), 'utf8').catch(() => '')).update(await fs.readFile(path.join(HERE, 'lib.mjs'), 'utf8').catch(() => '')).digest('hex').slice(0, 12);
|
|
89
|
+
const codeHashes = Object.fromEntries(await Promise.all(steps.map(async (st) => [st.label, await codeHash(st.args[0])])));
|
|
90
|
+
const stampKey = (step) => JSON.stringify({ hash: inputs?.hash, capturedAt, args: step.args, code: codeHashes[step.label] });
|
|
88
91
|
const reusable = (step) => !step.live && !url && !flags.fresh && !flags['refresh-ref'] && stamps[step.label]?.key === stampKey(step) && stamps[step.label].values !== undefined;
|
|
89
92
|
|
|
90
93
|
const runStep = (step) => new Promise((resolve) => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The reference's own code, as evidence for the agent (never copied into the clone): every script the page ran, saved
|
|
2
|
+
// as shipped and as a readable copy, ranked by how much of it actually ran while the page loaded and was scrolled
|
|
3
|
+
// through (V8 precise coverage), with every element's event listeners pointing at file:line:col. This is what lets an
|
|
4
|
+
// agent port a scene's algorithm (a grid computed from the viewport, a spring, a scroll scene) instead of copying the
|
|
5
|
+
// numbers it produced at one screen size. Read through Chrome's DevTools protocol; no extra tool needed.
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const TRACKING = /google-analytics|googletagmanager|gtag|doubleclick|facebook\.(com|net)|fbevents|hotjar|segment\.(io|com)|plausible|cloudflareinsights|clarity\.ms|sentry|datadog|mixpanel|amplitude|posthog|intercom|hubspot|hs-scripts|linkedin|licdn|tiktok|twitter|ads-twitter|redditstatic|marketo|munchkin|challenges\.cloudflare|recaptcha|hcaptcha|turnstile|cdn-cgi\/rum|debugbear/i;
|
|
12
|
+
|
|
13
|
+
// Before navigation: start collecting. Returns finish(), to call once the page has loaded and been scrolled through.
|
|
14
|
+
export async function startScriptEvidence(page, pageUrl) {
|
|
15
|
+
const cdp = await page.context().newCDPSession(page);
|
|
16
|
+
const scripts = new Map(); // scriptId -> { url, inline }
|
|
17
|
+
cdp.on('Debugger.scriptParsed', (e) => {
|
|
18
|
+
if (!e.executionContextAuxData?.isDefault) return; // the page's own world, not tooling
|
|
19
|
+
if (!e.url) return; // eval'd code
|
|
20
|
+
scripts.set(e.scriptId, { url: e.url, inline: e.url.split('#')[0] === pageUrl.split('#')[0], start: e.startLine });
|
|
21
|
+
});
|
|
22
|
+
await cdp.send('Debugger.enable');
|
|
23
|
+
await cdp.send('Profiler.enable');
|
|
24
|
+
await cdp.send('Profiler.startPreciseCoverage', { callCount: true, detailed: false });
|
|
25
|
+
|
|
26
|
+
return async function finish(outDir) {
|
|
27
|
+
const { result: coverage } = await cdp.send('Profiler.takePreciseCoverage');
|
|
28
|
+
await cdp.send('Profiler.stopPreciseCoverage').catch(() => {});
|
|
29
|
+
// listeners anywhere in the document, with the element they sit on
|
|
30
|
+
let listeners = [];
|
|
31
|
+
try {
|
|
32
|
+
const { result } = await cdp.send('Runtime.evaluate', { expression: 'document', objectGroup: 'tc' });
|
|
33
|
+
({ listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId, depth: -1, pierce: true }));
|
|
34
|
+
} catch { /* no listeners readable */ }
|
|
35
|
+
const labels = new Map();
|
|
36
|
+
const labelOf = async (backendNodeId) => {
|
|
37
|
+
if (!backendNodeId) return 'window';
|
|
38
|
+
if (labels.has(backendNodeId)) return labels.get(backendNodeId);
|
|
39
|
+
let label = '?';
|
|
40
|
+
try {
|
|
41
|
+
const { node } = await cdp.send('DOM.describeNode', { backendNodeId });
|
|
42
|
+
const attrs = {};
|
|
43
|
+
for (let i = 0; i < (node.attributes || []).length; i += 2) attrs[node.attributes[i]] = node.attributes[i + 1];
|
|
44
|
+
label = node.nodeName === '#document' ? 'document' : `${node.localName}${attrs.id ? `#${attrs.id}` : ''}${attrs.class ? `.${attrs.class.trim().split(/\s+/).slice(0, 2).join('.')}` : ''}`;
|
|
45
|
+
} catch { /* detached */ }
|
|
46
|
+
labels.set(backendNodeId, label);
|
|
47
|
+
return label;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
await fs.rm(outDir, { recursive: true, force: true });
|
|
51
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
52
|
+
const beautify = require('js-beautify').js;
|
|
53
|
+
const files = new Map(); // scriptId -> record
|
|
54
|
+
let n = 0;
|
|
55
|
+
for (const [scriptId, s] of scripts) {
|
|
56
|
+
let source = '';
|
|
57
|
+
try { ({ scriptSource: source } = await cdp.send('Debugger.getScriptSource', { scriptId })); } catch { continue; }
|
|
58
|
+
if (!source || source.length < 40) continue;
|
|
59
|
+
const base = s.inline ? `inline-${++n}` : `${String(++n).padStart(2, '0')}-${(new URL(s.url).pathname.split('/').pop() || 'script').replace(/[^a-z0-9._-]/gi, '_').replace(/\.m?js$/i, '').slice(-60)}`;
|
|
60
|
+
const file = `${base}.js`;
|
|
61
|
+
await fs.writeFile(path.join(outDir, file), source);
|
|
62
|
+
// a readable copy when the code is minified (long lines); line numbers in INDEX.md refer to the original
|
|
63
|
+
const lines = source.split('\n');
|
|
64
|
+
const minified = source.length / Math.max(1, lines.length) > 200;
|
|
65
|
+
let pretty = null;
|
|
66
|
+
if (minified && source.length < 3_000_000) {
|
|
67
|
+
try { pretty = `${base}.pretty.js`; await fs.writeFile(path.join(outDir, pretty), beautify(source, { indent_size: 2, max_preserve_newlines: 1 })); } catch { pretty = null; }
|
|
68
|
+
}
|
|
69
|
+
const cov = coverage.find((c) => c.scriptId === scriptId);
|
|
70
|
+
const fns = cov?.functions || [];
|
|
71
|
+
const ran = fns.filter((f) => f.ranges?.[0]?.count > 0);
|
|
72
|
+
files.set(scriptId, { file, pretty, url: s.inline ? `${s.url} (inline)` : s.url, bytes: source.length, functions: fns.length, functionsRan: ran.length, ranNames: [...new Set(ran.map((f) => f.functionName).filter(Boolean))].slice(0, 14), tracking: TRACKING.test(s.url) && !s.inline, source, start: s.start });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// listeners grouped by handler location
|
|
76
|
+
const groups = new Map();
|
|
77
|
+
for (const l of listeners) {
|
|
78
|
+
const f = files.get(l.scriptId);
|
|
79
|
+
const key = `${l.type}|${l.scriptId}|${l.lineNumber}|${l.columnNumber}`;
|
|
80
|
+
if (!groups.has(key)) groups.set(key, { type: l.type, file: f?.file || null, url: f?.url || null, line: l.lineNumber + 1, column: l.columnNumber, elements: [] });
|
|
81
|
+
const g = groups.get(key);
|
|
82
|
+
if (g.elements.length < 6) g.elements.push(await labelOf(l.backendNodeId));
|
|
83
|
+
else g.more = (g.more || 0) + 1;
|
|
84
|
+
}
|
|
85
|
+
const excerpt = (f, line, col) => {
|
|
86
|
+
const text = f?.source.split('\n')[line - 1 - (f.inline ? 0 : 0)] || '';
|
|
87
|
+
return text.length > 160 ? text.slice(Math.max(0, col - 40), col + 140) : text.trim();
|
|
88
|
+
};
|
|
89
|
+
const ranked = [...files.values()].sort((a, b) => b.functionsRan - a.functionsRan);
|
|
90
|
+
const listenerRows = [...groups.values()].filter((g) => g.file && !files.get([...files.entries()].find(([, v]) => v.file === g.file)?.[0])?.tracking)
|
|
91
|
+
.sort((a, b) => (a.file || '').localeCompare(b.file || '') || a.line - b.line);
|
|
92
|
+
const md = [
|
|
93
|
+
`# The reference's scripts (evidence, never copied)`,
|
|
94
|
+
'',
|
|
95
|
+
`Saved as the page shipped them (\`NN-name.js\`) and, when minified, as a readable copy (\`.pretty.js\`). Ranked by how much of each ran while the page loaded and was scrolled through. Read the algorithm, then write your own code: a value a script computes from the screen size must be computed again, not copied.`,
|
|
96
|
+
'',
|
|
97
|
+
'| file | ran | functions | size | from | names that ran |',
|
|
98
|
+
'|---|---|---|---|---|---|',
|
|
99
|
+
...ranked.map((f) => `| [${f.file}](${f.file})${f.pretty ? ` ([readable](${f.pretty}))` : ''} | ${f.functionsRan} | ${f.functions} | ${Math.round(f.bytes / 1024)} KB | ${f.tracking ? 'tracking (never copy)' : f.url.slice(0, 90)} | ${f.ranNames.join(', ')} |`),
|
|
100
|
+
'',
|
|
101
|
+
'## Event listeners, by handler',
|
|
102
|
+
'',
|
|
103
|
+
'Line and column refer to the original file. Elements that share one handler are listed together; a handler on `document` or `window` usually serves every matching element below it (delegation).',
|
|
104
|
+
'',
|
|
105
|
+
'| event | handler | on | code at the handler |',
|
|
106
|
+
'|---|---|---|---|',
|
|
107
|
+
...listenerRows.map((g) => { const f = [...files.values()].find((x) => x.file === g.file); return `| ${g.type} | ${g.file}:${g.line}:${g.column} | ${g.elements.join(', ')}${g.more ? ` +${g.more}` : ''} | \`${excerpt(f, g.line, g.column).replace(/\|/g, '\\|').replace(/`/g, "'")}\` |`; }),
|
|
108
|
+
'',
|
|
109
|
+
].join('\n');
|
|
110
|
+
await fs.writeFile(path.join(outDir, 'INDEX.md'), md);
|
|
111
|
+
await fs.writeFile(path.join(outDir, 'scripts.json'), JSON.stringify({ scripts: ranked.map(({ source, ...r }) => r), listeners: listenerRows }, null, 2));
|
|
112
|
+
await cdp.send('Debugger.disable').catch(() => {});
|
|
113
|
+
await cdp.detach().catch(() => {});
|
|
114
|
+
return { scripts: ranked.length, ran: ranked.filter((f) => f.functionsRan).length, listeners: listenerRows.length };
|
|
115
|
+
};
|
|
116
|
+
}
|