@trueclone/cli 0.1.0 → 0.2.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 +20 -9
- package/bin/trueclone.mjs +8 -1
- package/package.json +1 -1
- package/scripts/capture.mjs +68 -9
- package/scripts/diff.mjs +16 -0
- package/scripts/interact.mjs +7 -3
- package/scripts/lib.mjs +35 -1
- package/scripts/next.mjs +227 -11
- package/scripts/reveals.mjs +191 -0
- package/scripts/seed.mjs +466 -21
- package/scripts/states.mjs +2 -1
- package/scripts/widgets.mjs +169 -0
- package/skill/SKILL.md +23 -3
package/README.md
CHANGED
|
@@ -23,30 +23,39 @@ npx @trueclone/cli init # once per project: installs trueclone, the s
|
|
|
23
23
|
|
|
24
24
|
## Proof
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Three benchmarks with very different stacks, scored by the tool itself:
|
|
27
27
|
|
|
28
28
|
**[rea.tools](https://rea.tools/)**: static docs with a canvas game and interactive demos, exact mode (copied with the owner's permission).
|
|
29
29
|
|
|
30
30
|
| Check | Desktop | Tablet | Mobile |
|
|
31
31
|
|---|---|---|---|
|
|
32
32
|
| Layout, real pixels | 100% | 100% | 100% |
|
|
33
|
-
| Scroll states | 100% | 100% |
|
|
33
|
+
| Scroll states | 100% | 100% | 100% |
|
|
34
34
|
| Computed styles (≈280 points per width) | 100% | 100% | 100% |
|
|
35
35
|
| Hover effects | 100% | | |
|
|
36
36
|
| Interactions (copy, tabs, calculator, dinosaur game, language menu) | 5/5 | | |
|
|
37
37
|
|
|
38
|
-
**A
|
|
38
|
+
**A Next.js + React + Framer Motion developer landing page** (12,000px, 11 scroll reveals with spring easing, a scroll-state header, marquees, view-mounted components), template mode, **seeded automatically**: no hand-written layout code, no original text or media in the clone.
|
|
39
|
+
|
|
40
|
+
| Check | Desktop | Tablet | Mobile |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| Layout, structure | 99.99% | 99.93% | 99.94% |
|
|
43
|
+
| Scroll states | 99.96% | 99.92% | 99.94% |
|
|
44
|
+
| Computed styles | 99.5% | 98.7% | 99.2% |
|
|
45
|
+
| Hover effects | 100% | | |
|
|
46
|
+
|
|
47
|
+
**A Webflow motorsport portfolio** built with GSAP, ScrollTrigger, SplitText, Lenis, Swiper and three.js (97 scroll triggers), template mode, built by hand before seeding existed:
|
|
39
48
|
|
|
40
49
|
| Check | Desktop | Tablet | Mobile |
|
|
41
50
|
|---|---|---|---|
|
|
42
51
|
| Layout, structure | 99.6% | 99.7% | 96.8% |
|
|
43
|
-
| Scroll states |
|
|
44
|
-
| Motion curves | 96.
|
|
45
|
-
| Computed styles | 94% | 88.9% |
|
|
52
|
+
| Scroll states | 98.0% | 96.6% | 96.5% |
|
|
53
|
+
| Motion curves | 96.9% | 98.2% | 99.6% |
|
|
54
|
+
| Computed styles | 94% | 88.9% | 85.4% |
|
|
46
55
|
| Hover effects | 100% | | |
|
|
47
56
|
| Interactions (drag sliders, drag gallery, cursor follow, menu, skip) | 5/5 | | |
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
The hand-built portfolio isn't perfect yet, and its report says so: computed styles and mobile layout are below target (placeholder text in highlighted phrases, a scroll-linked colour fade, mobile spacing). The generated Next.js app scores within half a point of the HTML clone on every check. A full scoring run takes 1.5 to 9 minutes.
|
|
50
59
|
|
|
51
60
|
## How it works
|
|
52
61
|
|
|
@@ -61,7 +70,7 @@ URL ─► capture ─► understand ─► design system ─► build ─► sc
|
|
|
61
70
|
1. **Capture** (`trueclone capture`): full-page screenshots at 390, 768 and 1440px, sections, rendered HTML, assets, every network request.
|
|
62
71
|
2. **Understand** (`trueclone motion`, REA): reads the live animation runtime (GSAP timelines, ScrollTriggers, Lenis and Swiper settings, CSS animations, cursor-driven tweens) and, through REA, bundles, source maps, event listeners and API endpoints.
|
|
63
72
|
3. **Design system** (`trueclone design`): one canonical `design-tokens.json`, rendered to DESIGN.md, globals.css and the clone's tokens.
|
|
64
|
-
4. **Build**:
|
|
73
|
+
4. **Build**: `trueclone reveals` records the original's scroll reveals frame by frame and its scroll-driven class changes; `trueclone widgets` clicks every tab and menu and records each state; `trueclone seed` turns the script-free mirror into the clone (template mode: layout-measured lorem text and same-size media placeholders) and replays what was recorded. Layout is at 99%+ before anyone writes CSS; your agent writes the remaining behaviour, guided by the skill. `trueclone rules` shows the original's CSS for any element (like DevTools).
|
|
65
74
|
5. **Score** (`trueclone score`): layout pixel diff per section, scroll states replayed, motion curves sampled, interaction scenarios with change assertions, every hover, computed styles at matching points. Reference measurements are cached, so only the clone is re-measured.
|
|
66
75
|
6. **Report**: `REPORT.md` with the verdict, what to fix first, and what was not copied.
|
|
67
76
|
|
|
@@ -99,7 +108,9 @@ npx trueclone <url> [name] # capture + design-system draft
|
|
|
99
108
|
npx trueclone capture <url> [name] [--mode exact --permission "<owner>"]
|
|
100
109
|
npx trueclone motion <name> # motion inventory
|
|
101
110
|
npx trueclone design <name> # DESIGN.md + globals.css
|
|
102
|
-
npx trueclone
|
|
111
|
+
npx trueclone reveals <name> # record scroll reveals and scroll class changes
|
|
112
|
+
npx trueclone widgets <name> # record every tab and menu state
|
|
113
|
+
npx trueclone seed <name> # clone/ from the mirror (template: placeholder text and media)
|
|
103
114
|
npx trueclone rules <name> "<selector>" # the original's CSS rules for an element
|
|
104
115
|
npx trueclone textshape <name> --write # template mode: placeholder text with the original's line breaks
|
|
105
116
|
npx trueclone score <name> [--quick] # every check, then REPORT.md
|
package/bin/trueclone.mjs
CHANGED
|
@@ -23,6 +23,8 @@ const STEPS = {
|
|
|
23
23
|
seed: ['seed.mjs', 'exact mode: start clone/ from the script-free mirror (markup, text, styles, media)'],
|
|
24
24
|
design: ['design.mjs', 'design-tokens.json -> DESIGN.md (linted), shadcn globals.css, clone/tokens.css'],
|
|
25
25
|
motion: ['motion.mjs', 'motion inventory from the live runtime (GSAP/ScrollTrigger, Lenis, Swiper, CSS animations)'],
|
|
26
|
+
reveals: ['reveals.mjs', 'record one-shot scroll reveals frame by frame, so seed can replay them (any library)'],
|
|
27
|
+
widgets: ['widgets.mjs', 'record every tab and menu state by clicking it, so seed can replay them (template mode)'],
|
|
26
28
|
states: ['states.mjs', 'live scroll states; --clone scores the rebuild step by step'],
|
|
27
29
|
curves: ['curves.mjs', 'motion score: sampled property curves, reference vs clone (motion-map.json)'],
|
|
28
30
|
interact: ['interact.mjs', 'interaction tests: click/hover/drag/key scenarios on both sides (interactions.json)'],
|
|
@@ -38,6 +40,11 @@ const STEPS = {
|
|
|
38
40
|
report: ['report.mjs', 'REPORT.md from the latest scores: every number, every gap, what cannot be copied'],
|
|
39
41
|
};
|
|
40
42
|
|
|
43
|
+
// npm/npx are .cmd files on Windows and need a shell; Node warns (DEP0190) when a shell gets separate arguments, so
|
|
44
|
+
// build one quoted command line there instead
|
|
45
|
+
const spawnCmd = (cmd, args, opts = {}) => (process.platform === 'win32'
|
|
46
|
+
? spawn([cmd, ...args].map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(' '), { ...opts, shell: true })
|
|
47
|
+
: spawn(cmd, args, opts));
|
|
41
48
|
const run = (file, args) => new Promise((resolve) => {
|
|
42
49
|
const child = spawn(process.execPath, [file, ...args], { stdio: 'inherit' });
|
|
43
50
|
child.on('close', (code) => resolve(code ?? 1));
|
|
@@ -88,7 +95,7 @@ async function init() {
|
|
|
88
95
|
if (!process.argv.includes('--no-install')) {
|
|
89
96
|
if (!(await exists(path.join(cwd, 'package.json')))) await fs.writeFile(path.join(cwd, 'package.json'), `${JSON.stringify({ name: path.basename(cwd).toLowerCase().replace(/[^a-z0-9-]+/g, '-'), private: true }, null, 2)}\n`);
|
|
90
97
|
const code = await new Promise((resolve) => {
|
|
91
|
-
const child =
|
|
98
|
+
const child = spawnCmd('npm', ['install', '--save-dev', '--save-exact', `${name}@${version}`], { stdio: 'inherit' });
|
|
92
99
|
child.on('close', (c) => resolve(c ?? 1));
|
|
93
100
|
});
|
|
94
101
|
installed = code === 0 ? `${name}@${version} (dev dependency: npx trueclone <step> now runs it)` : `failed: run npm install -D ${name}@${version} yourself`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trueclone/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A working twin of any website from its URL: HTML clone, Next.js + Tailwind + shadcn app, DESIGN.md and globals.css, proven by layout, motion, interaction and style scores. Bundles REA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/capture.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Capture a live page as the clone's reference: full-page screenshots, sections, design tokens,
|
|
2
2
|
// rendered HTML, CSS/image/font files and a HAR.
|
|
3
|
-
// usage: node scripts/capture.mjs <url> [name] [--hide ".cookie-banner,#popup"] [--cdp [endpoint]]
|
|
3
|
+
// usage: node scripts/capture.mjs <url> [name] [--hide ".cookie-banner,#popup"] [--cdp [endpoint]] [--mode exact --permission "<owner>"]
|
|
4
|
+
// node scripts/capture.mjs <url> <name> --mirror-only (rebuild reference/mirror from the saved capture)
|
|
4
5
|
import fs from 'node:fs/promises';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import crypto from 'node:crypto';
|
|
@@ -32,6 +33,14 @@ const SAVED_TYPES = new Set(['stylesheet', 'image', 'font', 'media']);
|
|
|
32
33
|
const EXTRA_MEDIA = /\.(glb|gltf|bin|hdr|exr|ktx2|basis|mp4|webm|mov|m3u8|ts|m4s|lottie|riv|json)(\?|$)/i;
|
|
33
34
|
const keepResponse = (type, url) => SAVED_TYPES.has(type) || (mode === 'exact' && ['fetch', 'xhr', 'other'].includes(type) && EXTRA_MEDIA.test(url));
|
|
34
35
|
|
|
36
|
+
// --mirror-only: rebuild reference/mirror from an existing capture (no new screenshots, caches stay valid)
|
|
37
|
+
if (flags['mirror-only']) {
|
|
38
|
+
const meta = JSON.parse(await fs.readFile(path.join(ref, 'meta.json'), 'utf8'));
|
|
39
|
+
const saved = JSON.parse(await fs.readFile(path.join(ref, 'assets', 'manifest.json'), 'utf8'));
|
|
40
|
+
await buildMirror(saved, meta.url, meta.mode === 'exact' ? `Exact mode, authorised by: ${meta.permission}.` : "Template mode: the site's own content, kept as a reference only.");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
// Only clear what capture owns; states/, motion.*, *.spec.json come from other scripts and stay.
|
|
36
45
|
await fs.rm(path.join(ref, 'assets'), { recursive: true, force: true });
|
|
37
46
|
await fs.rm(path.join(ref, 'mirror'), { recursive: true, force: true });
|
|
@@ -106,16 +115,64 @@ try {
|
|
|
106
115
|
});
|
|
107
116
|
}
|
|
108
117
|
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
|
|
118
|
+
// Components that mount only while in view (a lazy wordmark, an embed) are often unmounted again by the time
|
|
119
|
+
// the DOM is saved. Watch from load: an element that was empty, got content while scrolled into view and is empty
|
|
120
|
+
// again at snapshot time is saved with the fullest content it had. Things that start full and empty out (a
|
|
121
|
+
// loader) are left alone.
|
|
122
|
+
if (desktop) await page.evaluate(() => {
|
|
123
|
+
const count = (el) => el.getElementsByTagName('*').length;
|
|
124
|
+
const initial = new WeakMap();
|
|
125
|
+
for (const el of document.body.getElementsByTagName('*')) initial.set(el, count(el));
|
|
126
|
+
window.__tcRich = new Map();
|
|
127
|
+
new MutationObserver((records) => {
|
|
128
|
+
for (const r of records) {
|
|
129
|
+
for (let el = r.target, up = 0; el instanceof Element && el !== document.body && up < 8; el = el.parentElement, up++) {
|
|
130
|
+
const start = initial.has(el) ? initial.get(el) : 0;
|
|
131
|
+
const n = count(el);
|
|
132
|
+
if (start > 0 || n === 0) continue;
|
|
133
|
+
const best = window.__tcRich.get(el);
|
|
134
|
+
if (!best || n > best.n) window.__tcRich.set(el, { n, html: el.innerHTML });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}).observe(document.body, { subtree: true, childList: true });
|
|
138
|
+
});
|
|
109
139
|
await settle(page, hide);
|
|
110
|
-
|
|
140
|
+
// A full-page screenshot brings the whole page into view at once, which mounts view-mounted components and starts
|
|
141
|
+
// their reveals: shoot until two shots agree, so the reference shows a settled page, not a fade caught at 0%.
|
|
142
|
+
let shot = await page.screenshot({ fullPage: true });
|
|
143
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
144
|
+
await page.waitForTimeout(2000);
|
|
145
|
+
const again = await page.screenshot({ fullPage: true });
|
|
146
|
+
const same = again.equals(shot);
|
|
147
|
+
shot = again;
|
|
148
|
+
if (same) break;
|
|
149
|
+
}
|
|
150
|
+
await fs.writeFile(path.join(ref, `${vp.name}.png`), shot);
|
|
111
151
|
const sections = await sectionsOf(page);
|
|
112
152
|
await fs.writeFile(path.join(ref, `${vp.name}.sections.json`), JSON.stringify(sections, null, 2));
|
|
113
153
|
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
114
154
|
rootFontSize[vp.name] = await page.evaluate(() => getComputedStyle(document.documentElement).fontSize);
|
|
115
155
|
if (desktop) {
|
|
116
156
|
const html = await page.evaluate(() => {
|
|
157
|
+
// restore view-mounted content (see above): only elements that are empty again now
|
|
158
|
+
const restore = [];
|
|
159
|
+
let i = 0;
|
|
160
|
+
for (const [el, best] of window.__tcRich || []) {
|
|
161
|
+
if (!el.isConnected || el.getElementsByTagName('*').length > 0) continue;
|
|
162
|
+
el.setAttribute('data-tc-rich', String(i));
|
|
163
|
+
restore[i++] = best.html;
|
|
164
|
+
}
|
|
117
165
|
const doc = document.documentElement.cloneNode(true);
|
|
166
|
+
for (const el of document.querySelectorAll('[data-tc-rich]')) el.removeAttribute('data-tc-rich');
|
|
167
|
+
for (const el of doc.querySelectorAll('[data-tc-rich]')) {
|
|
168
|
+
el.innerHTML = restore[Number(el.getAttribute('data-tc-rich'))];
|
|
169
|
+
el.removeAttribute('data-tc-rich');
|
|
170
|
+
// marked for the layout diff: a full-page still can't show view-mounted content on either side, so its box
|
|
171
|
+
// is excluded there (the scroll-state check, with a real viewport, scores it)
|
|
172
|
+
el.setAttribute('data-tc-viewmount', '');
|
|
173
|
+
}
|
|
118
174
|
doc.querySelector('style[data-lab-freeze]')?.remove();
|
|
175
|
+
console.log(`tc-rich: restored ${restore.length} view-mounted element(s)`);
|
|
119
176
|
return '<!DOCTYPE html>\n' + doc.outerHTML;
|
|
120
177
|
});
|
|
121
178
|
await fs.writeFile(path.join(ref, 'rendered.html'), html);
|
|
@@ -132,15 +189,16 @@ try {
|
|
|
132
189
|
|
|
133
190
|
await fs.writeFile(path.join(ref, 'assets', 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
134
191
|
await fs.writeFile(path.join(ref, 'meta.json'), JSON.stringify({ url, name, mode, permission, hide, capturedAt: new Date().toISOString(), rootFontSize, viewports: summary }, null, 2));
|
|
135
|
-
|
|
192
|
+
await buildMirror(manifest, url, mode === 'exact' ? `Exact mode, authorised by: ${permission}.` : "Template mode: the site's own content, kept as a reference only.");
|
|
136
193
|
console.table(summary);
|
|
137
|
-
console.log(`${manifest.length} assets saved. Reference: ${path.relative(process.cwd(), ref)}${mode === 'exact' ? ` (exact mode
|
|
194
|
+
console.log(`${manifest.length} assets saved. Reference: ${path.relative(process.cwd(), ref)}, mirror in reference/mirror${mode === 'exact' ? ` (exact mode, authorised by: ${permission})` : ''}`);
|
|
138
195
|
|
|
139
|
-
// ----------
|
|
196
|
+
// ---------- Static local mirror of the rendered page ----------
|
|
140
197
|
// Same DOM as rendered.html, asset URLs rewritten to the saved copies, every script and inline handler
|
|
141
198
|
// removed (we never reuse the site's code), forms disabled, noindex. Static by design: motion is rebuilt
|
|
142
|
-
// from the states/motion inventory, not replayed.
|
|
143
|
-
|
|
199
|
+
// from the states/motion inventory, not replayed. Exact mode seeds the clone from it as is; template mode
|
|
200
|
+
// seeds it with every text and medium swapped for placeholders (seed.mjs).
|
|
201
|
+
async function buildMirror(manifest, url, note) {
|
|
144
202
|
const mirror = path.join(ref, 'mirror');
|
|
145
203
|
await fs.rm(mirror, { recursive: true, force: true });
|
|
146
204
|
await fs.mkdir(path.join(mirror, 'css'), { recursive: true });
|
|
@@ -155,7 +213,8 @@ async function buildMirror() {
|
|
|
155
213
|
return path.relative(fromDir, path.join(ref, m.file)).replaceAll('\\', '/');
|
|
156
214
|
};
|
|
157
215
|
const rewriteCssUrls = (css, base, fromDir) => css
|
|
158
|
-
|
|
216
|
+
// url(#id) points inside the document (SVG gradients, filters, masks): never resolve it against the stylesheet
|
|
217
|
+
.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (all, q, u) => (u.startsWith('data:') || u.trim().startsWith('#') ? all : `url("${local(abs(u, base), fromDir) || abs(u, base) || u}")`))
|
|
159
218
|
.replace(/@import\s+(['"])([^'"]+)\1/g, (all, q, u) => `@import "${local(abs(u, base), fromDir) || u}"`);
|
|
160
219
|
|
|
161
220
|
// stylesheets: rewritten copies under mirror/css
|
|
@@ -182,7 +241,7 @@ async function buildMirror() {
|
|
|
182
241
|
}).join(', ')}${q}`)
|
|
183
242
|
.replace(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi, (all, attrs, css) => `<style${attrs}>${rewriteCssUrls(css, url, mirror)}</style>`)
|
|
184
243
|
.replace(/\sstyle=("|')([^"']*url\([^"']*)\1/gi, (all, q, css) => ` style=${q}${rewriteCssUrls(css, url, mirror)}${q}`)
|
|
185
|
-
.replace(/<head([^>]*)>/i, `<head$1>\n<!--
|
|
244
|
+
.replace(/<head([^>]*)>/i, `<head$1>\n<!-- trueclone local mirror of ${url}. ${note.replace(/--/g, '-')} Local reference only: do not deploy. -->\n<meta name="robots" content="noindex,nofollow">`);
|
|
186
245
|
await fs.writeFile(path.join(mirror, 'index.html'), html);
|
|
187
246
|
const missing = [...html.matchAll(/\s(?:src|href)=["'](https?:[^"']+)["']/gi)].map((m) => m[1]).filter((u) => !/^https?:\/\/[^/]+\/?$/.test(u));
|
|
188
247
|
console.log(`mirror: reference/mirror/index.html (${manifest.length} local assets, ${missing.length} remote URLs left, e.g. links)`);
|
package/scripts/diff.mjs
CHANGED
|
@@ -32,12 +32,27 @@ try {
|
|
|
32
32
|
if (structure) await mask(page);
|
|
33
33
|
const clonePath = path.join(out, `${vp.name}${suffix}.clone.png`);
|
|
34
34
|
await page.screenshot({ path: clonePath, fullPage: true });
|
|
35
|
+
// View-mounted content (marked by capture): a full-page still can't show it on either side, so its boxes are
|
|
36
|
+
// painted the same flat colour in both images; the scroll-state check scores it with a real viewport.
|
|
37
|
+
const excluded = await page.evaluate(() => [...document.querySelectorAll('[data-tc-viewmount]')]
|
|
38
|
+
.map((e) => { const r = e.getBoundingClientRect(); return [r.left, r.top + scrollY, r.width, r.height].map(Math.round); })
|
|
39
|
+
.filter((r) => r[2] > 0 && r[3] > 0));
|
|
35
40
|
await context.close();
|
|
36
41
|
|
|
37
42
|
const a0 = PNG.sync.read(await fs.readFile(path.join(ref, `${vp.name}${suffix}.png`)));
|
|
38
43
|
const b0 = PNG.sync.read(await fs.readFile(clonePath));
|
|
39
44
|
const w = Math.max(a0.width, b0.width), h = Math.max(a0.height, b0.height);
|
|
40
45
|
const a = pad(a0, w, h), b = pad(b0, w, h);
|
|
46
|
+
for (const [x, y, rw, rh] of excluded) {
|
|
47
|
+
for (const img of [a, b]) {
|
|
48
|
+
for (let yy = Math.max(0, y); yy < Math.min(h, y + rh); yy++) {
|
|
49
|
+
for (let xx = Math.max(0, x); xx < Math.min(w, x + rw); xx++) {
|
|
50
|
+
const i = (yy * w + xx) * 4;
|
|
51
|
+
img.data[i] = 64; img.data[i + 1] = 64; img.data[i + 2] = 64; img.data[i + 3] = 255;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
41
56
|
const diff = new PNG({ width: w, height: h });
|
|
42
57
|
const bad = match(a, b, diff);
|
|
43
58
|
await fs.writeFile(path.join(out, `${vp.name}${suffix}.diff.png`), PNG.sync.write(diff));
|
|
@@ -57,6 +72,7 @@ try {
|
|
|
57
72
|
match: +(100 - (bad / (w * h)) * 100).toFixed(2),
|
|
58
73
|
heightRef: a0.height,
|
|
59
74
|
heightClone: b0.height,
|
|
75
|
+
excludedViewMounted: excluded.length,
|
|
60
76
|
worstSections: [...perSection].sort((x, y) => x.match - y.match).slice(0, 5),
|
|
61
77
|
sections: perSection,
|
|
62
78
|
});
|
package/scripts/interact.mjs
CHANGED
|
@@ -64,7 +64,7 @@ const stateOf = (page, selector) => page.evaluate((sel) => {
|
|
|
64
64
|
const r = el.getBoundingClientRect();
|
|
65
65
|
// Besides geometry and visibility: text, state attributes and canvas pixels, so a button that changes its label,
|
|
66
66
|
// a tab that flips aria-pressed or a game that redraws its canvas counts as a change too.
|
|
67
|
-
const attrs = ['class', 'aria-pressed', 'aria-current', 'aria-expanded', 'open', 'hidden', 'disabled']
|
|
67
|
+
const attrs = ['class', 'aria-pressed', 'aria-current', 'aria-expanded', 'aria-selected', 'data-state', 'open', 'hidden', 'disabled']
|
|
68
68
|
.map((a) => (el.hasAttribute(a) ? `${a}=${el.getAttribute(a)}` : '')).join(',') // boolean attributes are "" + (el.value !== undefined ? `,${el.value}` : '');
|
|
69
69
|
const text = el.textContent.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
70
70
|
let pixels = '';
|
|
@@ -88,7 +88,7 @@ async function runSide(url, side, list) {
|
|
|
88
88
|
await warmUp(page);
|
|
89
89
|
const atSel = sc.at?.[side];
|
|
90
90
|
if (atSel && atSel !== 'body') {
|
|
91
|
-
const y = await page.evaluate((
|
|
91
|
+
const y = await page.evaluate((sel) => { const [s, idx] = sel.split('@'); const el = document.querySelectorAll(s)[Number(idx || 0)]; return el ? el.getBoundingClientRect().top + scrollY : 0; }, atSel);
|
|
92
92
|
await wheelTo(page, Math.max(0, Math.round(y + (sc.at.offset || 0))), vp);
|
|
93
93
|
} else {
|
|
94
94
|
await wheelTo(page, sc.at?.offset || 0, vp);
|
|
@@ -121,7 +121,11 @@ async function runSide(url, side, list) {
|
|
|
121
121
|
else if (st.click) {
|
|
122
122
|
const target = Array.isArray(st.click) ? st.click : st.click[side];
|
|
123
123
|
if (Array.isArray(target)) await page.mouse.click(...px(target));
|
|
124
|
-
else
|
|
124
|
+
else {
|
|
125
|
+
// selector@n: the n-th match, as everywhere else in the scenario file
|
|
126
|
+
const [s, idx] = target.split('@');
|
|
127
|
+
await page.locator(s).nth(Number(idx || 0)).click({ timeout: 5000 }).catch((e) => results.push({ name: sc.name, error: `click ${target}: ${e.message.split('\n')[0]}` }));
|
|
128
|
+
}
|
|
125
129
|
} else if (st.key) await page.keyboard.press(st.key);
|
|
126
130
|
else if (st.wait) await page.waitForTimeout(st.wait);
|
|
127
131
|
else if (st.shot) await shot(st.shot);
|
package/scripts/lib.mjs
CHANGED
|
@@ -124,7 +124,26 @@ const MASK_CSS = `*,*::before,*::after{color:transparent!important;text-shadow:n
|
|
|
124
124
|
::placeholder{color:transparent!important}
|
|
125
125
|
img,video{object-position:-99999px -99999px!important;background:#888!important}
|
|
126
126
|
svg{background:#888!important} svg *{visibility:hidden!important} iframe{visibility:hidden!important}
|
|
127
|
-
canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
|
|
127
|
+
canvas{background:#888!important;filter:contrast(0) brightness(1.066)!important}
|
|
128
|
+
[data-lab-cliptext]{background:none!important;-webkit-text-fill-color:transparent!important}
|
|
129
|
+
[data-lab-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
|
+
|
|
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.
|
|
134
|
+
function maskExtrasInPage(enable) {
|
|
135
|
+
const marks = ['data-lab-cliptext', 'data-lab-bgb', 'data-lab-bga'];
|
|
136
|
+
if (!enable) {
|
|
137
|
+
for (const m of marks) for (const el of document.querySelectorAll(`[${m}]`)) el.removeAttribute(m);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const el of document.querySelectorAll('*')) {
|
|
141
|
+
const s = getComputedStyle(el);
|
|
142
|
+
if (s.backgroundClip === 'text' || s.webkitBackgroundClip === 'text') el.setAttribute('data-lab-cliptext', '');
|
|
143
|
+
if (getComputedStyle(el, '::before').backgroundImage.includes('url(')) el.setAttribute('data-lab-bgb', '');
|
|
144
|
+
if (getComputedStyle(el, '::after').backgroundImage.includes('url(')) el.setAttribute('data-lab-bga', '');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
128
147
|
|
|
129
148
|
export async function mask(page) {
|
|
130
149
|
const tag = await page.addStyleTag({ content: MASK_CSS });
|
|
@@ -137,9 +156,23 @@ export async function mask(page) {
|
|
|
137
156
|
}
|
|
138
157
|
}
|
|
139
158
|
});
|
|
159
|
+
await page.evaluate(maskExtrasInPage, true);
|
|
140
160
|
await page.waitForTimeout(200);
|
|
141
161
|
}
|
|
142
162
|
|
|
163
|
+
// Endless animations (marquees, rotating borders, pulsing glows) sit at whatever phase the page's clock happens to
|
|
164
|
+
// be at, so two screenshots of the same page differ. Before a still comparison, every infinite animation is put on
|
|
165
|
+
// its first frame and paused, on both sides. Continuous motion is scored by the motion checks, not by stills.
|
|
166
|
+
export async function freezeLoops(page) {
|
|
167
|
+
await page.evaluate(() => {
|
|
168
|
+
for (const a of document.getAnimations()) {
|
|
169
|
+
if (a.effect?.getTiming().iterations !== Infinity) continue;
|
|
170
|
+
a.currentTime = 0;
|
|
171
|
+
a.pause();
|
|
172
|
+
}
|
|
173
|
+
}).catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
// Reversible version of mask() for live pages: on() masks, off() restores every inline style it touched.
|
|
144
177
|
export async function maskToggle(page, on) {
|
|
145
178
|
await page.evaluate(([css, enable]) => {
|
|
@@ -164,6 +197,7 @@ export async function maskToggle(page, on) {
|
|
|
164
197
|
}
|
|
165
198
|
}
|
|
166
199
|
}, [MASK_CSS, on]);
|
|
200
|
+
await page.evaluate(maskExtrasInPage, on);
|
|
167
201
|
await page.waitForTimeout(on ? 150 : 50);
|
|
168
202
|
}
|
|
169
203
|
|