motion-audit 0.0.0-stage → 1.0.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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/package.json +58 -4
- package/skills/motion-audit/SKILL.md +225 -0
- package/skills/motion-audit/references/fixes.md +25 -0
- package/skills/motion-audit/scripts/lib/args.mjs +88 -0
- package/skills/motion-audit/scripts/lib/audit.mjs +342 -0
- package/skills/motion-audit/scripts/lib/browser.mjs +240 -0
- package/skills/motion-audit/scripts/lib/cli.mjs +71 -0
- package/skills/motion-audit/scripts/lib/compare.mjs +70 -0
- package/skills/motion-audit/scripts/lib/config.mjs +101 -0
- package/skills/motion-audit/scripts/lib/constants.mjs +161 -0
- package/skills/motion-audit/scripts/lib/continuity.mjs +81 -0
- package/skills/motion-audit/scripts/lib/index.mjs +9 -0
- package/skills/motion-audit/scripts/lib/judge.mjs +128 -0
- package/skills/motion-audit/scripts/lib/kinds.mjs +33 -0
- package/skills/motion-audit/scripts/lib/page-scripts.mjs +259 -0
- package/skills/motion-audit/scripts/lib/page-tracer.mjs +188 -0
- package/skills/motion-audit/scripts/lib/playwright.mjs +36 -0
- package/skills/motion-audit/scripts/lib/probes.mjs +504 -0
- package/skills/motion-audit/scripts/lib/reduced.mjs +39 -0
- package/skills/motion-audit/scripts/lib/series.mjs +278 -0
- package/skills/motion-audit/scripts/lib/sightings.mjs +76 -0
- package/skills/motion-audit/scripts/lib/undecided.mjs +86 -0
- package/skills/motion-audit/scripts/motion-audit.mjs +7 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Functions evaluated inside the page (page.evaluate / handle.evaluate). Each one is serialised,
|
|
2
|
+
// so it must not reference anything outside its own body: configuration comes in as an argument.
|
|
3
|
+
|
|
4
|
+
// Inputs hidden behind a styled label are audited through the label.
|
|
5
|
+
export const proxyOf = (n) => (n.matches('input') && getComputedStyle(n).opacity === '0' ? n.closest('label') ?? n : n);
|
|
6
|
+
|
|
7
|
+
// Can this element be reached? state: 'ok' | 'hover-only' (opacity 0 but laid out: maybe revealed
|
|
8
|
+
// by a hover on a container) | 'skip' with a reason.
|
|
9
|
+
export const reachability = (n) => {
|
|
10
|
+
if (n.disabled || n.getAttribute('aria-disabled') === 'true') return { state: 'skip', reason: 'disabled' };
|
|
11
|
+
const target = n.matches('input') && getComputedStyle(n).opacity === '0' ? n.closest('label') ?? n : n;
|
|
12
|
+
let transparent = false;
|
|
13
|
+
for (let e = target; e; e = e.parentElement) {
|
|
14
|
+
const c = getComputedStyle(e);
|
|
15
|
+
if (c.visibility === 'hidden') return { state: 'skip', reason: 'hidden' };
|
|
16
|
+
if (c.display === 'none') return { state: 'skip', reason: 'hidden' };
|
|
17
|
+
if (c.opacity === '0') transparent = true;
|
|
18
|
+
}
|
|
19
|
+
const r = target.getBoundingClientRect();
|
|
20
|
+
if (r.width <= 1 || r.height <= 1) return { state: 'skip', reason: 'zero-size' };
|
|
21
|
+
if (r.right <= 0) return { state: 'skip', reason: 'off-screen' };
|
|
22
|
+
// Inside a closed drawer or collapsed panel: an ancestor that clips has no area left for the
|
|
23
|
+
// element. A clipping ancestor that scrolls does not: the element is reached by scrolling it.
|
|
24
|
+
const box = { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
|
|
25
|
+
for (let e = target.parentElement; e; e = e.parentElement) {
|
|
26
|
+
if (e.inert) return { state: 'skip', reason: 'inert' };
|
|
27
|
+
const c = getComputedStyle(e);
|
|
28
|
+
if (c.overflowX === 'visible' && c.overflowY === 'visible') continue;
|
|
29
|
+
const a = e.getBoundingClientRect();
|
|
30
|
+
if (a.width <= 1 || a.height <= 1) return { state: 'skip', reason: 'clipped' };
|
|
31
|
+
const scrollsX = /(auto|scroll)/.test(c.overflowX) && e.scrollWidth > e.clientWidth + 1;
|
|
32
|
+
const scrollsY = /(auto|scroll)/.test(c.overflowY) && e.scrollHeight > e.clientHeight + 1;
|
|
33
|
+
if (c.overflowX !== 'visible' && !scrollsX && (box.right <= a.left || box.left >= a.right)) return { state: 'skip', reason: 'clipped' };
|
|
34
|
+
if (c.overflowY !== 'visible' && !scrollsY && e !== document.body && e !== document.documentElement && (box.bottom <= a.top || box.top >= a.bottom)) return { state: 'skip', reason: 'clipped' };
|
|
35
|
+
}
|
|
36
|
+
return { state: transparent ? 'hover-only' : 'ok' };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// A viewport point where `n` (not something on top of it) receives the pointer, or null.
|
|
40
|
+
export const hitPoint = (n, points) => {
|
|
41
|
+
const r = n.getBoundingClientRect();
|
|
42
|
+
for (const [fx, fy] of points) {
|
|
43
|
+
const x = r.left + r.width * fx;
|
|
44
|
+
const y = r.top + r.height * fy;
|
|
45
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
|
|
46
|
+
const hit = document.elementFromPoint(x, y);
|
|
47
|
+
if (hit && (hit === n || n.contains(hit) || hit.closest('label') === n || (n.matches('label') && n.contains(hit)))) return { x, y };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// A label without DOM indices: tag, id, text and the landmark it lives in.
|
|
53
|
+
export const describeElement = (n) => {
|
|
54
|
+
const text = (n.getAttribute('aria-label') || n.textContent || n.getAttribute('placeholder') || n.getAttribute('name') || n.getAttribute('title') || '').trim().replace(/\s+/g, ' ').slice(0, 28);
|
|
55
|
+
let where = '';
|
|
56
|
+
for (let e = n.parentElement; e && e !== document.body; e = e.parentElement) {
|
|
57
|
+
if (e.id) { where = `#${e.id}`; break; }
|
|
58
|
+
const role = e.getAttribute('role');
|
|
59
|
+
if (/^(NAV|HEADER|FOOTER|MAIN|ASIDE|DIALOG|FORM)$/.test(e.tagName) || /^(dialog|navigation|menu|toolbar|tablist|banner|alertdialog)$/.test(role ?? '')) { where = role ?? e.tagName.toLowerCase(); break; }
|
|
60
|
+
}
|
|
61
|
+
return `${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ''}${text ? ` "${text}"` : ''}${where ? ` in ${where}` : ''}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const elementInfo = (n) => {
|
|
65
|
+
const cs = getComputedStyle(n);
|
|
66
|
+
const role = n.getAttribute('role');
|
|
67
|
+
let kind = role ?? n.tagName.toLowerCase();
|
|
68
|
+
if (/^(INPUT|SELECT|TEXTAREA)$/.test(n.tagName) && cs.opacity !== '0') kind = 'input';
|
|
69
|
+
else if ((!role && !/^(A|BUTTON|SUMMARY|LABEL)$/.test(n.tagName)) || /^(region|group|table|figure|img|document|tabpanel|list|article)$/.test(role ?? '')) kind = 'focus-only';
|
|
70
|
+
return {
|
|
71
|
+
kind,
|
|
72
|
+
selected: n.getAttribute('aria-selected') === 'true' || n.getAttribute('aria-checked') === 'true' || n.getAttribute('aria-current') === 'page',
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const readTransition = (n) => {
|
|
77
|
+
const cs = getComputedStyle(n);
|
|
78
|
+
return { properties: cs.transitionProperty.split(',').map((s) => s.trim()), durations: cs.transitionDuration, easings: cs.transitionTimingFunction.split(/,(?![^(]*\))/).map((s) => s.trim()) };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const cssPath = (n) => {
|
|
82
|
+
const parts = [];
|
|
83
|
+
for (let e = n; e && e.nodeType === 1 && e !== document.documentElement; e = e.parentElement) {
|
|
84
|
+
if (e.id) { parts.unshift(`#${CSS.escape(e.id)}`); break; }
|
|
85
|
+
const same = [...(e.parentElement?.children ?? [])].filter((c) => c.tagName === e.tagName);
|
|
86
|
+
parts.unshift(`${e.tagName.toLowerCase()}${same.length > 1 ? `:nth-of-type(${same.indexOf(e) + 1})` : ''}`);
|
|
87
|
+
}
|
|
88
|
+
return parts.join(' > ');
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Resolves when `n` (with its subtree and the element its aria-controls names) is still: no running
|
|
92
|
+
// finite CSS or WAAPI animation and an unchanged signature of position, size, opacity, transform and
|
|
93
|
+
// colours for `quietMs`, but not before `minMs` and not later than `capMs`. Infinite animations
|
|
94
|
+
// (spinners, tickers) are ambient: they neither count as running nor enter the signature.
|
|
95
|
+
export const waitSettled = (n, { minMs, quietMs, capMs }) => new Promise((resolve) => {
|
|
96
|
+
const controlled = document.getElementById(n.getAttribute?.('aria-controls') || '');
|
|
97
|
+
const roots = [n, controlled].filter(Boolean);
|
|
98
|
+
const isInfinite = (a) => a.effect?.getComputedTiming().iterations === Infinity;
|
|
99
|
+
const finiteRunning = () => roots.some((r) => r.getAnimations({ subtree: true }).some((a) => a.playState === 'running' && !isInfinite(a) && Number.isFinite(a.effect?.getComputedTiming().endTime)));
|
|
100
|
+
const signature = () => {
|
|
101
|
+
const ambient = roots.flatMap((r) => r.getAnimations({ subtree: true })).filter(isInfinite).map((a) => a.effect.target);
|
|
102
|
+
const nodes = roots.flatMap((r) => [r, ...r.querySelectorAll('*')].slice(0, 40)).filter((e) => !ambient.some((a) => a === e || a?.contains(e)));
|
|
103
|
+
const parts = nodes.map((e) => {
|
|
104
|
+
const r = e.getBoundingClientRect();
|
|
105
|
+
const c = getComputedStyle(e);
|
|
106
|
+
return [r.left, r.top, r.width, r.height].map((v) => v.toFixed(1)).join(',') + `,${c.opacity},${c.transform},${c.backgroundColor},${c.color},${c.boxShadow}`;
|
|
107
|
+
});
|
|
108
|
+
return `${document.documentElement.scrollHeight},${document.documentElement.scrollWidth}|${parts.join('|')}`;
|
|
109
|
+
};
|
|
110
|
+
const start = performance.now();
|
|
111
|
+
let last = signature();
|
|
112
|
+
let changed = start;
|
|
113
|
+
const tick = () => {
|
|
114
|
+
const now = performance.now();
|
|
115
|
+
const sig = signature();
|
|
116
|
+
if (sig !== last) { last = sig; changed = now; }
|
|
117
|
+
if (finiteRunning()) changed = now;
|
|
118
|
+
if (now - start >= capMs || (now - start >= minMs && now - changed >= quietMs)) resolve();
|
|
119
|
+
else requestAnimationFrame(tick);
|
|
120
|
+
};
|
|
121
|
+
requestAnimationFrame(tick);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Why --interact must not press or toggle this element, or null.
|
|
125
|
+
export const denyReason = (n, { selector, text, flags, named }) => {
|
|
126
|
+
if (n.matches(selector)) return 'submit, reset, drag or slider control';
|
|
127
|
+
// A link that does not point at an element of this page (`#` or `#id` of an element that exists) navigates, or is
|
|
128
|
+
// a hash route (`#/orders/12/delete`) a router follows, before anything can notice; so does a control inside one.
|
|
129
|
+
const link = n.closest('a[href], area[href]');
|
|
130
|
+
if (link) {
|
|
131
|
+
const target = link.getAttribute('href') ?? '';
|
|
132
|
+
let id = null;
|
|
133
|
+
try { id = decodeURIComponent(target.slice(1)); } catch { id = null; }
|
|
134
|
+
const inPage = target === '#' || (target.startsWith('#') && id && document.getElementById(id));
|
|
135
|
+
if (!inPage) return `link that leaves the page or routes (${target.slice(0, 40)})`;
|
|
136
|
+
}
|
|
137
|
+
// A submit button of a form, wherever it sits (inside it or tied to it with form=): a default <button> submits.
|
|
138
|
+
if (n.form && ['submit', 'image', 'reset'].includes(n.type)) return 'submits or resets a form';
|
|
139
|
+
const byIds = (n.getAttribute('aria-labelledby') ?? '').split(/\s+/).filter(Boolean).map((id) => document.getElementById(id)?.textContent ?? '');
|
|
140
|
+
const inner = [...n.querySelectorAll('img[alt], [aria-label], svg title')].map((x) => x.getAttribute('alt') ?? x.getAttribute('aria-label') ?? x.textContent);
|
|
141
|
+
const names = [...byIds, n.getAttribute('aria-label'), n.textContent, n.getAttribute('title'), n.getAttribute('value'), n.getAttribute('alt'), ...inner].map((x) => (x ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
142
|
+
const wording = names.find((x) => new RegExp(text, flags).test(x));
|
|
143
|
+
if (wording) return `destructive or irreversible wording ("${wording.slice(0, 30)}")`;
|
|
144
|
+
if (!names.length && n.matches(named)) return 'no accessible name: it may be a destructive icon button';
|
|
145
|
+
return null;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// :hover rules that move things outside @media (hover: hover): on touch screens they stick after a tap.
|
|
149
|
+
export const ungatedHoverMotion = () => {
|
|
150
|
+
const found = [];
|
|
151
|
+
let unreadable = 0;
|
|
152
|
+
const walk = (rules, gated) => {
|
|
153
|
+
for (const r of rules) {
|
|
154
|
+
if (r.media && r.cssRules) walk(r.cssRules, gated || /hover:\s*hover/.test(r.media.mediaText));
|
|
155
|
+
else if (r.cssRules && !r.selectorText) walk(r.cssRules, gated);
|
|
156
|
+
if (r.selectorText && /:hover/.test(r.selectorText) && !gated) {
|
|
157
|
+
const s = r.style;
|
|
158
|
+
if ((s.transform && s.transform !== 'none') || s.translate || s.scale || s.rotate) found.push(r.selectorText);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
for (const sheet of document.styleSheets) {
|
|
163
|
+
let rules;
|
|
164
|
+
try { rules = sheet.cssRules; } catch (e) { if (e.name !== 'SecurityError') throw e; unreadable++; continue; }
|
|
165
|
+
walk(rules, false);
|
|
166
|
+
}
|
|
167
|
+
return { found: [...new Set(found)].slice(0, 10), unreadable };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const hasReducedMotionRule = () => {
|
|
171
|
+
const walk = (rules) => [...rules].some((r) => (r.media && /prefers-reduced-motion/.test(r.media.mediaText)) || (r.cssRules && walk(r.cssRules)));
|
|
172
|
+
let unreadable = 0;
|
|
173
|
+
for (const sheet of document.styleSheets) {
|
|
174
|
+
let rules;
|
|
175
|
+
try { rules = sheet.cssRules; } catch (e) { if (e.name !== 'SecurityError') throw e; unreadable++; continue; }
|
|
176
|
+
if (walk(rules)) return { found: true, unreadable };
|
|
177
|
+
}
|
|
178
|
+
return { found: false, unreadable };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Sections below the fold (relative to their own scroller): did anything animate in when first
|
|
182
|
+
// scrolled to? Scrolls the window and every inner scroll container end to end, and returns the long
|
|
183
|
+
// animation frames of the scrolling with their scripts.
|
|
184
|
+
export const scrollPass = async ({ step, stepMs, tail, maxScrollers }) => {
|
|
185
|
+
const isScroller = (e) => {
|
|
186
|
+
const c = getComputedStyle(e);
|
|
187
|
+
return /(auto|scroll)/.test(c.overflowY) && e.scrollHeight > e.clientHeight + 4 && e.clientHeight > 40;
|
|
188
|
+
};
|
|
189
|
+
const inner = [...document.querySelectorAll('*')].filter((e) => e !== document.body && e !== document.documentElement && isScroller(e)).slice(0, maxScrollers);
|
|
190
|
+
const scrollerOf = (n) => { for (let e = n.parentElement; e; e = e.parentElement) if (inner.includes(e)) return e; return null; };
|
|
191
|
+
const belowFold = (c) => {
|
|
192
|
+
const s = scrollerOf(c);
|
|
193
|
+
if (!s) return c.getBoundingClientRect().top + scrollY > innerHeight;
|
|
194
|
+
return c.getBoundingClientRect().top - s.getBoundingClientRect().top + s.scrollTop > s.clientHeight;
|
|
195
|
+
};
|
|
196
|
+
const sections = [...new Set([...document.querySelectorAll('h2, h3')].map((h) => h.closest('section, article, li, div') ?? h).filter(belowFold))]
|
|
197
|
+
.filter((c, _, all) => !all.some((o) => o !== c && o.contains(c)));
|
|
198
|
+
const moved = new Set();
|
|
199
|
+
const owner = (t) => sections.find((s) => s === t || s.contains(t));
|
|
200
|
+
const mark = (e) => { const s = owner(e.target); if (s) moved.add(s); };
|
|
201
|
+
document.addEventListener('animationstart', mark, true);
|
|
202
|
+
document.addEventListener('transitionstart', mark, true);
|
|
203
|
+
const mo = new MutationObserver((l) => {
|
|
204
|
+
for (const m of l) {
|
|
205
|
+
const t = m.target;
|
|
206
|
+
if (m.attributeName === 'style' && (t.style.opacity !== '' || t.style.transform !== '' || t.style.translate !== '')) { const s = owner(t); if (s) moved.add(s); }
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
mo.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] });
|
|
210
|
+
const from = performance.now();
|
|
211
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
212
|
+
const settleAnimations = () => { for (const a of document.getAnimations()) { const s = a.effect?.target && owner(a.effect.target); if (s) moved.add(s); } };
|
|
213
|
+
for (let y = 0; y < document.documentElement.scrollHeight; y += step) { window.scrollTo(0, y); await wait(stepMs); settleAnimations(); }
|
|
214
|
+
for (const s of inner) {
|
|
215
|
+
for (let y = 0; y < s.scrollHeight; y += step) { s.scrollTop = y; await wait(stepMs); settleAnimations(); }
|
|
216
|
+
s.scrollTop = 0;
|
|
217
|
+
}
|
|
218
|
+
await wait(tail);
|
|
219
|
+
mo.disconnect();
|
|
220
|
+
document.removeEventListener('animationstart', mark, true);
|
|
221
|
+
document.removeEventListener('transitionstart', mark, true);
|
|
222
|
+
const keyframeLayout = [...new Set(document.getAnimations().flatMap((a) => (a.effect?.getKeyframes?.() ?? []).flatMap((k) => Object.keys(k)))
|
|
223
|
+
.filter((p) => /^(width|height|top|left|right|bottom|margin|padding|maxHeight|fontSize)/.test(p)))];
|
|
224
|
+
window.scrollTo(0, 0);
|
|
225
|
+
const loaf = (window.__mt?.loaf ?? []).filter(([s]) => s >= from);
|
|
226
|
+
return { sections: sections.slice(0, 30).map((s) => ({ section: (s.querySelector('h2, h3')?.textContent ?? s.tagName).trim().slice(0, 40), animated: moved.has(s) })), loaf, keyframeLayout, scrollers: inner.length };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Text and images laid out but at opacity 0 (after the page was scrolled end to end).
|
|
230
|
+
export const invisibleContent = () => [...document.querySelectorAll('h1, h2, h3, p, li, img')].filter((n) => {
|
|
231
|
+
const r = n.getBoundingClientRect();
|
|
232
|
+
if (r.width < 2 || r.height < 2 || n.closest('[aria-hidden="true"]')) return false;
|
|
233
|
+
let shown = true;
|
|
234
|
+
for (let e = n; e; e = e.parentElement) { const c = getComputedStyle(e); if (c.display === 'none' || c.visibility === 'hidden') return false; if (c.opacity === '0') shown = false; }
|
|
235
|
+
return !shown;
|
|
236
|
+
}).map((n) => `${n.tagName.toLowerCase()} "${(n.textContent || n.getAttribute('alt') || '').trim().slice(0, 30)}"`);
|
|
237
|
+
|
|
238
|
+
// Infinite moving animations that keep running, except small rotate-only ones (spinners).
|
|
239
|
+
export const infiniteAnimations = ({ spinnerPx }) => document.getAnimations().filter((a) => {
|
|
240
|
+
const t = a.effect?.target;
|
|
241
|
+
if (a.effect?.getTiming?.().iterations !== Infinity || !t || a.playState !== 'running') return false;
|
|
242
|
+
for (let e = t; e; e = e.parentElement) { const c = getComputedStyle(e); if (c.opacity === '0' || c.display === 'none' || c.visibility === 'hidden') return false; }
|
|
243
|
+
const frames = a.effect.getKeyframes?.() ?? [];
|
|
244
|
+
if (!frames.some((f) => f.transform || f.translate || f.scale || f.rotate || f.left || f.top)) return false;
|
|
245
|
+
const rotateOnly = frames.every((f) => !f.translate && !f.scale && !f.left && !f.top && (!f.transform || f.transform === 'none' || /^(rotate|rotateZ)\([^)]*\)(\s+(rotate|rotateZ)\([^)]*\))*$/.test(f.transform)));
|
|
246
|
+
const r = t.getBoundingClientRect();
|
|
247
|
+
return !(rotateOnly && r.width <= spinnerPx && r.height <= spinnerPx);
|
|
248
|
+
}).map((a) => { const t = a.effect.target; return `${t.tagName.toLowerCase()}${t.id ? `#${t.id}` : ''}${t.classList[0] ? `.${t.classList[0]}` : ''}`; });
|
|
249
|
+
|
|
250
|
+
// Signs of a development server (HMR, dev-mode React): long frames there are not the product's.
|
|
251
|
+
export const devSignals = () => {
|
|
252
|
+
const signals = [];
|
|
253
|
+
if (document.querySelector('script[src*="/@vite/client"]') || window.__vite_plugin_react_preamble_installed__) signals.push('Vite dev server');
|
|
254
|
+
if (window.__NEXT_DATA__?.buildId === 'development' || document.querySelector('script[src*="_next/static/development"], nextjs-portal')) signals.push('Next.js dev server');
|
|
255
|
+
if ([...document.scripts].some((s) => /webpack-dev-server|hot-update|sockjs-node|__webpack_hmr|webpack-hmr/.test(s.src))) signals.push('webpack HMR');
|
|
256
|
+
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
257
|
+
if (hook?.renderers && [...hook.renderers.values()].some((r) => r.bundleType === 1)) signals.push('React development build');
|
|
258
|
+
return signals;
|
|
259
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// In-page frame tracer. Installed with addInitScript, so it must be self-contained (it is
|
|
2
|
+
// serialised and run in the page; nothing from this module's scope exists there).
|
|
3
|
+
//
|
|
4
|
+
// It samples rect, opacity and transform of the tracked nodes on every animation frame, follows
|
|
5
|
+
// nodes mounted while a recording runs (portals, conditionally rendered overlays), records Animation
|
|
6
|
+
// objects that start or are cancelled (restart evidence) and collects Long Animation Frames with
|
|
7
|
+
// the scripts that caused them.
|
|
8
|
+
export const tracer = (cfg) => {
|
|
9
|
+
if (window.__mt || window !== window.top) return;
|
|
10
|
+
const { cols: COLS, skip, subtreeCap, pageCap, mountCap, minSize } = cfg;
|
|
11
|
+
const SKIP = new Set(skip);
|
|
12
|
+
const PRIORITY = 'dialog, [role=dialog], [role=alertdialog], [role=menu], [role=listbox], [role=tooltip], [popover], [aria-modal=true]';
|
|
13
|
+
const ids = new WeakMap();
|
|
14
|
+
let next = 1;
|
|
15
|
+
const idOf = (n) => { let i = ids.get(n); if (!i) { i = next++; ids.set(n, i); } return i; };
|
|
16
|
+
const labelOf = (n) => `${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ''}${[...n.classList].slice(0, 2).map((c) => `.${c}`).join('')}`;
|
|
17
|
+
const matrixOf = (value) => {
|
|
18
|
+
const m = /^matrix(3d)?\(([^)]*)\)$/.exec(value);
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
const v = m[2].split(',').map(Number);
|
|
21
|
+
return m[1] ? { a: v[0], b: v[1], c: v[4], d: v[5], tx: v[12], ty: v[13] } : { a: v[0], b: v[1], c: v[2], d: v[3], tx: v[4], ty: v[5] };
|
|
22
|
+
};
|
|
23
|
+
const pose = (n) => {
|
|
24
|
+
const r = n.getBoundingClientRect();
|
|
25
|
+
const cs = getComputedStyle(n);
|
|
26
|
+
let tx = 0; let ty = 0; let sx = 1; let sy = 1; let rot = 0;
|
|
27
|
+
const m = cs.transform !== 'none' ? matrixOf(cs.transform) : null;
|
|
28
|
+
if (m) { tx = m.tx; ty = m.ty; sx = Math.hypot(m.a, m.b); sy = Math.hypot(m.c, m.d); rot = (Math.atan2(m.b, m.a) * 180) / Math.PI; }
|
|
29
|
+
if (cs.translate && cs.translate !== 'none') { const p = cs.translate.split(' ').map(parseFloat); tx += p[0] || 0; ty += p[1] || 0; }
|
|
30
|
+
if (cs.scale && cs.scale !== 'none') { const p = cs.scale.split(' ').map(parseFloat); sx *= p[0]; sy *= p[1] ?? p[0]; }
|
|
31
|
+
if (cs.rotate && cs.rotate !== 'none') rot += parseFloat(cs.rotate) || 0;
|
|
32
|
+
return [r.left + scrollX, r.top + scrollY, r.width, r.height, parseFloat(cs.opacity), tx, ty, sx, sy, rot, n.offsetWidth ?? r.width, n.offsetHeight ?? r.height];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const loaf = [];
|
|
36
|
+
const loafSupported = typeof PerformanceObserver !== 'undefined' && (PerformanceObserver.supportedEntryTypes ?? []).includes('long-animation-frame');
|
|
37
|
+
let loafObserver = null;
|
|
38
|
+
if (loafSupported) {
|
|
39
|
+
loafObserver = new PerformanceObserver((list) => {
|
|
40
|
+
for (const e of list.getEntries()) loaf.push([e.startTime, e.duration, (e.scripts ?? []).map((s) => ({ url: s.sourceURL, fn: s.sourceFunctionName, invoker: s.invoker, duration: Math.round(s.duration), forced: Math.round(s.forcedStyleAndLayoutDuration ?? 0) }))]);
|
|
41
|
+
});
|
|
42
|
+
loafObserver.observe({ type: 'long-animation-frame', buffered: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const animKind = (a) => {
|
|
46
|
+
if (typeof CSSTransition !== 'undefined' && a instanceof CSSTransition) return 'transition';
|
|
47
|
+
if (typeof CSSAnimation !== 'undefined' && a instanceof CSSAnimation) return 'animation';
|
|
48
|
+
return 'waapi';
|
|
49
|
+
};
|
|
50
|
+
const NOT_PROPS = new Set(['offset', 'easing', 'composite', 'computedOffset']);
|
|
51
|
+
const describeAnim = (a) => {
|
|
52
|
+
const props = new Set();
|
|
53
|
+
let explicitFrom = false;
|
|
54
|
+
for (const k of a.effect?.getKeyframes?.() ?? []) {
|
|
55
|
+
for (const key of Object.keys(k)) if (!NOT_PROPS.has(key)) { props.add(key); if (k.offset === 0) explicitFrom = true; }
|
|
56
|
+
}
|
|
57
|
+
return { props: [...props], explicitFrom };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const recs = new Map();
|
|
61
|
+
const watched = new WeakSet();
|
|
62
|
+
let running = false;
|
|
63
|
+
const active = () => [...recs.values()].filter((r) => r.active);
|
|
64
|
+
|
|
65
|
+
const watchCancel = (a) => {
|
|
66
|
+
if (watched.has(a)) return;
|
|
67
|
+
watched.add(a);
|
|
68
|
+
const kind = animKind(a);
|
|
69
|
+
a.addEventListener('cancel', () => {
|
|
70
|
+
const target = a.effect?.target;
|
|
71
|
+
if (!target || kind !== 'animation') return;
|
|
72
|
+
for (const r of active()) r.anims.push({ t: performance.now(), nodeId: idOf(target), type: 'cancel', props: describeAnim(a).props, explicitFrom: false });
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const scanAnimations = (rec, ts) => {
|
|
76
|
+
const list = rec.root ? rec.root.getAnimations({ subtree: true }) : document.getAnimations();
|
|
77
|
+
for (const a of list) {
|
|
78
|
+
watchCancel(a);
|
|
79
|
+
if (rec.seen.has(a)) continue;
|
|
80
|
+
rec.seen.add(a);
|
|
81
|
+
const target = a.effect?.target;
|
|
82
|
+
if (!rec.primed || !target) continue;
|
|
83
|
+
rec.anims.push({ t: ts, nodeId: idOf(target), type: animKind(a), ...describeAnim(a) });
|
|
84
|
+
}
|
|
85
|
+
rec.primed = true;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const track = (rec, n) => {
|
|
89
|
+
if (rec.set.has(n) || SKIP.has(n.tagName)) return false;
|
|
90
|
+
rec.set.add(n);
|
|
91
|
+
rec.list.push(n);
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// The next sample is requested once this frame has been drawn, so in the next frame it runs after the
|
|
96
|
+
// callbacks the page requested during this one: it reads what that frame shows, not the frame before.
|
|
97
|
+
const after = new MessageChannel();
|
|
98
|
+
after.port1.onmessage = () => requestAnimationFrame(frame);
|
|
99
|
+
const frame = (ts) => {
|
|
100
|
+
for (const rec of active()) {
|
|
101
|
+
scanAnimations(rec, ts);
|
|
102
|
+
for (const n of rec.list) {
|
|
103
|
+
if (!n.isConnected) continue;
|
|
104
|
+
const id = idOf(n);
|
|
105
|
+
let e = rec.elems.get(id);
|
|
106
|
+
if (!e) {
|
|
107
|
+
e = { id, parent: n.parentElement ? idOf(n.parentElement) : 0, label: labelOf(n), tag: n.tagName, role: n.getAttribute('role'), root: n === rec.root, t: [], r: [] };
|
|
108
|
+
for (const c of COLS) e[c] = [];
|
|
109
|
+
rec.elems.set(id, e);
|
|
110
|
+
}
|
|
111
|
+
const p = pose(n);
|
|
112
|
+
e.t.push(ts);
|
|
113
|
+
e.r.push(performance.now());
|
|
114
|
+
COLS.forEach((c, i) => e[c].push(p[i]));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (active().length) after.port2.postMessage(0); else running = false;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const onClick = (e) => { for (const r of active()) r.marks.push(e.timeStamp); };
|
|
121
|
+
document.addEventListener('click', onClick, true);
|
|
122
|
+
|
|
123
|
+
// Nodes added while a recording runs join it: a dialog mounted on open is the motion under test.
|
|
124
|
+
const mounts = new MutationObserver((records) => {
|
|
125
|
+
const recsActive = active();
|
|
126
|
+
if (!recsActive.length) return;
|
|
127
|
+
for (const m of records) {
|
|
128
|
+
for (const added of m.addedNodes) {
|
|
129
|
+
if (added.nodeType !== 1) continue;
|
|
130
|
+
for (const rec of recsActive) {
|
|
131
|
+
if (rec.root && !rec.root.contains(added)) continue;
|
|
132
|
+
if (rec.mounted >= mountCap) continue;
|
|
133
|
+
for (const n of [added, ...added.querySelectorAll('*')]) if (rec.mounted < mountCap && track(rec, n)) rec.mounted++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
mounts.observe(document, { childList: true, subtree: true });
|
|
139
|
+
|
|
140
|
+
const initialNodes = (root, all, hint) => {
|
|
141
|
+
if (root) return [root, ...root.querySelectorAll('*')].filter((n) => !SKIP.has(n.tagName)).slice(0, subtreeCap);
|
|
142
|
+
const rank = (n) => {
|
|
143
|
+
if ((hint && hint.contains(n)) || n.closest(PRIORITY)) return 0;
|
|
144
|
+
const r = n.getBoundingClientRect();
|
|
145
|
+
return r.bottom > -innerHeight && r.top < 2 * innerHeight ? 1 : 2;
|
|
146
|
+
};
|
|
147
|
+
const nodes = [...document.body.querySelectorAll('*')].filter((n) => {
|
|
148
|
+
if (SKIP.has(n.tagName)) return false;
|
|
149
|
+
if (all) return true;
|
|
150
|
+
const r = n.getBoundingClientRect();
|
|
151
|
+
return r.width >= minSize && r.height >= minSize;
|
|
152
|
+
});
|
|
153
|
+
return nodes.map((n) => [rank(n), n]).sort((x, y) => x[0] - y[0]).slice(0, pageCap).map((x) => x[1]);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
window.__mt = {
|
|
157
|
+
loaf,
|
|
158
|
+
loafSupported,
|
|
159
|
+
now: () => performance.now(),
|
|
160
|
+
// root: follow that node and its subtree; null: the page. all: include zero-size nodes.
|
|
161
|
+
// hint: node whose subtree (an aria-controls target) is traced first.
|
|
162
|
+
begin(name, root, all, hint) {
|
|
163
|
+
const rec = { active: true, root, list: [], set: new Set(), elems: new Map(), marks: [], anims: [], seen: new WeakSet(), primed: false, mounted: 0 };
|
|
164
|
+
for (const n of initialNodes(root, all, hint)) track(rec, n);
|
|
165
|
+
scanAnimations(rec, performance.now());
|
|
166
|
+
recs.set(name, rec);
|
|
167
|
+
if (!running) { running = true; requestAnimationFrame(frame); }
|
|
168
|
+
},
|
|
169
|
+
mark(name) { recs.get(name)?.marks.push(performance.now()); },
|
|
170
|
+
// Stops every observer and listener and removes the tracer from this document.
|
|
171
|
+
dispose() {
|
|
172
|
+
for (const r of recs.values()) r.active = false;
|
|
173
|
+
recs.clear();
|
|
174
|
+
mounts.disconnect();
|
|
175
|
+
after.port1.close();
|
|
176
|
+
loafObserver?.disconnect();
|
|
177
|
+
document.removeEventListener('click', onClick, true);
|
|
178
|
+
delete window.__mt;
|
|
179
|
+
},
|
|
180
|
+
end(name) {
|
|
181
|
+
const rec = recs.get(name);
|
|
182
|
+
if (!rec) return null;
|
|
183
|
+
rec.active = false;
|
|
184
|
+
recs.delete(name);
|
|
185
|
+
return { marks: rec.marks, elems: [...rec.elems.values()], anims: rec.anims, mounted: rec.mounted };
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Playwright is a peer dependency. Resolution order: PLAYWRIGHT_ROOT, the working directory, this package.
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { MIN_PLAYWRIGHT } from './constants.mjs';
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PACKAGES = ['playwright', '@playwright/test', 'playwright-core'];
|
|
9
|
+
|
|
10
|
+
export const versionAtLeast = (version, min = MIN_PLAYWRIGHT) => {
|
|
11
|
+
const [maj, mnr] = String(version).split('.').map((x) => parseInt(x, 10));
|
|
12
|
+
return maj > min[0] || (maj === min[0] && mnr >= min[1]);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const isMissing = (e, name) => e?.code === 'MODULE_NOT_FOUND' && String(e.message).includes(`'${name}'`);
|
|
16
|
+
|
|
17
|
+
// Returns { chromium, version, from } or throws with the real reason.
|
|
18
|
+
export const loadPlaywright = (env = process.env, cwd = process.cwd()) => {
|
|
19
|
+
const roots = [env.PLAYWRIGHT_ROOT, cwd, join(here, '..', '..', '..', '..')].filter(Boolean);
|
|
20
|
+
for (const root of roots) {
|
|
21
|
+
const req = createRequire(join(root, 'package.json'));
|
|
22
|
+
for (const name of PACKAGES) {
|
|
23
|
+
let mod;
|
|
24
|
+
try { mod = req(name); } catch (e) {
|
|
25
|
+
if (isMissing(e, name)) continue;
|
|
26
|
+
throw new Error(`found ${name} under ${root} but it failed to load: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
if (!mod?.chromium) continue;
|
|
29
|
+
let version = null;
|
|
30
|
+
try { version = req(`${name}/package.json`).version; } catch (e) { if (e?.code !== 'MODULE_NOT_FOUND' && e?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw e; }
|
|
31
|
+
if (version && !versionAtLeast(version)) throw new Error(`${name} ${version} under ${root} is too old: motion-audit needs Playwright >= ${MIN_PLAYWRIGHT.join('.')} (page.addInitScript returns the Disposable that removes the tracer from a page passed to audit())`);
|
|
32
|
+
return { chromium: mod.chromium, version, from: root };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Playwright not found (looked in ${roots.join(', ')}): \`npm i -D playwright && npx playwright install chromium\`, or set PLAYWRIGHT_ROOT to a project that has it`);
|
|
36
|
+
};
|