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,504 @@
|
|
|
1
|
+
// The gestures: what one control, one toggle, one click target and the reduced-motion pass do.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
CLICK_INTERRUPT_MS, CLICK_LEAD_MS, CLICK_RECORD_MS, CLICK_RESOLVE_RETRIES, CONTACT_SHEET_STEP_MS, PRESS_GUARD_MS, DENY_SELECTOR, DENY_TEXT, NAMED_CONTROL, FRAME_MS,
|
|
5
|
+
HIT_TEST_POINTS, INTERRUPT_LEAD_MS, INTERRUPT_MIN_MOTION_MS, INTERRUPT_RETRIES, SETTLE_MAX_MS, SETTLE_MIN_MS, SETTLE_QUIET_MS,
|
|
6
|
+
REDUCED_LOOP_SPINNER_PX, SPARSE_DENSITY, SPARSE_MIN_MOTION_MS, SPARSE_RETRIES, TOGGLE, TOGGLE_MIN_MOTION_MS, TOGGLE_SHARES, VIEWPORT, VISIBLE_PROPS,
|
|
7
|
+
TOGGLE_QUIET_MS, TOGGLE_SETTLE_MIN_MS,
|
|
8
|
+
} from './constants.mjs';
|
|
9
|
+
import { auxPage, createPseudoStates, diff, load, longFramesBetween, longWindows, shot, snapshot, toMs, traced, unattributedLimit } from './browser.mjs';
|
|
10
|
+
import { interruptionJumps, markCadence, restartUnfollowed } from './continuity.mjs';
|
|
11
|
+
import { denyReason, describeElement, elementInfo, hitPoint, infiniteAnimations, invisibleContent, proxyOf, readTransition, scrollPass, waitSettled } from './page-scripts.mjs';
|
|
12
|
+
import { reducedMoves, reducedResidue } from './reduced.mjs';
|
|
13
|
+
import { analyzeRecording, animatedMove, median } from './series.mjs';
|
|
14
|
+
import { NO_COVERAGE, coverageOf, isDense, mergeCoverage } from './sightings.mjs';
|
|
15
|
+
|
|
16
|
+
const DENY = { selector: DENY_SELECTOR, text: DENY_TEXT.source, flags: DENY_TEXT.flags, named: NAMED_CONTROL };
|
|
17
|
+
const asElement = (jsHandle) => jsHandle.asElement();
|
|
18
|
+
const wait = (page, ms) => page.waitForTimeout(ms);
|
|
19
|
+
|
|
20
|
+
// Waits until `root` is still (see waitSettled): a slow transition is measured to its end, a fast
|
|
21
|
+
// one costs a few frames.
|
|
22
|
+
const settle = (root, { min = SETTLE_MIN_MS, quiet = SETTLE_QUIET_MS } = {}) => root.evaluate(waitSettled, { minMs: min, quietMs: quiet, capMs: SETTLE_MAX_MS });
|
|
23
|
+
const settlePage = async (page, options) => settle(await page.$('html'), options);
|
|
24
|
+
|
|
25
|
+
export const denied = (handle) => handle.evaluate(denyReason, DENY);
|
|
26
|
+
|
|
27
|
+
// Samples per 60 Hz frame of the longest motion in a recording (Infinity when nothing moves for as
|
|
28
|
+
// long as SPARSE_MIN_MOTION_MS): below SPARSE_DENSITY the curve of the motion is not reliable.
|
|
29
|
+
export const sparsity = (motion) => {
|
|
30
|
+
const long = motion.flatMap((e) => e.moves).filter((m) => m.durationMs >= SPARSE_MIN_MOTION_MS && m.samples !== undefined);
|
|
31
|
+
return long.length ? Math.max(...long.map((m) => m.samples / (m.durationMs / FRAME_MS))) : Infinity;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Whether a recording is a better basis than `cur` to judge the curve: fewer ties that sparse samples cannot
|
|
35
|
+
// break, then denser.
|
|
36
|
+
const undecided = (motion) => motion.flatMap((e) => e.moves).filter((m) => m.easeInTie && m.samples / (m.durationMs / FRAME_MS) < SPARSE_DENSITY).length;
|
|
37
|
+
const needsRetry = (motion) => sparsity(motion) < SPARSE_DENSITY || undecided(motion) > 0;
|
|
38
|
+
const better = (next, cur) => (undecided(next.motion) !== undecided(cur.motion) ? undecided(next.motion) < undecided(cur.motion) : sparsity(next.motion) > sparsity(cur.motion));
|
|
39
|
+
|
|
40
|
+
// A real press is released where it was pressed, never after a movement: a pointer that moves while
|
|
41
|
+
// down starts a drag (native or a pointer-event sortable) and drops it somewhere. Before the release the
|
|
42
|
+
// press is cancelled with a pointercancel on the control, as the browser does when it takes a gesture over:
|
|
43
|
+
// libraries that activate on pointerup (React Aria's usePress) abort instead of firing the action. The release
|
|
44
|
+
// still makes the browser dispatch a click on the control; it is cancelled in the capture phase of the window,
|
|
45
|
+
// before any page handler sees it, and the guard stays on for PRESS_GUARD_MS so that a click a library
|
|
46
|
+
// dispatches itself a moment after the pointerup (usePress waits 80 ms for the native one) is eaten too.
|
|
47
|
+
// Only then does the pointer move away. What remains: a plain mouseup or pointerup handler on the control still runs.
|
|
48
|
+
const releasePress = async (page, target) => {
|
|
49
|
+
await target.evaluate((n) => n.dispatchEvent(new PointerEvent('pointercancel', { pointerId: 1, pointerType: 'mouse', isPrimary: true, bubbles: true })));
|
|
50
|
+
await page.mouse.up();
|
|
51
|
+
await wait(page, PRESS_GUARD_MS);
|
|
52
|
+
await guardPress(page, false);
|
|
53
|
+
await page.mouse.move(1, 1);
|
|
54
|
+
};
|
|
55
|
+
const guardPress = (page, on) => page.evaluate((enable) => {
|
|
56
|
+
const events = ['click', 'auxclick', 'dblclick', 'dragstart'];
|
|
57
|
+
if (enable) {
|
|
58
|
+
window.__maGuard = (e) => { e.stopImmediatePropagation(); e.preventDefault(); };
|
|
59
|
+
for (const t of events) window.addEventListener(t, window.__maGuard, true);
|
|
60
|
+
} else if (window.__maGuard) {
|
|
61
|
+
for (const t of events) window.removeEventListener(t, window.__maGuard, true);
|
|
62
|
+
delete window.__maGuard;
|
|
63
|
+
}
|
|
64
|
+
}, on);
|
|
65
|
+
|
|
66
|
+
// A point where the control itself receives the pointer, scrolling it to the centre if the first
|
|
67
|
+
// try is covered (sticky header, cookie banner); null when something stays on top.
|
|
68
|
+
export const reachablePoint = async (proxy) => {
|
|
69
|
+
await proxy.scrollIntoViewIfNeeded();
|
|
70
|
+
let point = await proxy.evaluate(hitPoint, HIT_TEST_POINTS);
|
|
71
|
+
if (!point) {
|
|
72
|
+
await proxy.evaluate((n) => n.scrollIntoView({ block: 'center', inline: 'center' }));
|
|
73
|
+
point = await proxy.evaluate(hitPoint, HIT_TEST_POINTS);
|
|
74
|
+
}
|
|
75
|
+
return point;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const stillTransparent = (h) => h.evaluate((n) => { for (let e = n; e; e = e.parentElement) if (getComputedStyle(e).opacity === '0') return true; return false; });
|
|
79
|
+
|
|
80
|
+
// Hover, press, hover-leave and keyboard focus of one control. Returns { el } or { skipped: reason }.
|
|
81
|
+
// Observe-only by default: the pointer only hovers; :active and :focus-visible are forced over CDP.
|
|
82
|
+
// With ctx.interact the press is real (mouse down, released in place with the click cancelled, then moved away).
|
|
83
|
+
export const auditElement = async (ctx, original, label, { hoverOnly = false } = {}) => {
|
|
84
|
+
const { page, pseudo } = ctx;
|
|
85
|
+
const proxy = asElement(await original.evaluateHandle(proxyOf)) ?? original;
|
|
86
|
+
const point = await reachablePoint(proxy);
|
|
87
|
+
if (!point) return { skipped: 'covered' };
|
|
88
|
+
const box = await proxy.boundingBox();
|
|
89
|
+
if (!box || box.width < 2 || box.height < 2) return { skipped: 'zero-size' };
|
|
90
|
+
const info = await proxy.evaluate(elementInfo);
|
|
91
|
+
const refused = await denied(original);
|
|
92
|
+
const real = ctx.interact && !refused;
|
|
93
|
+
const transition = (raw) => ({ properties: raw.properties, durations: toMs(raw.durations), easings: raw.easings });
|
|
94
|
+
const rest = transition(await proxy.evaluate(readTransition));
|
|
95
|
+
const el = { label, kind: info.kind, selected: info.selected, transition: rest, moments: {}, long: [], pressMode: real ? 'real' : 'forced', hoverOnly };
|
|
96
|
+
const moment = async (name, before, beforePx, after, afterPx, run) => {
|
|
97
|
+
el.moments[name] = { changed: diff(before, after), pixels: await ctx.pixels.differ(beforePx, afterPx), rootLabel: run.rec?.elems?.find((x) => x.root)?.label, cadence: frameCadence(run.rec), motion: run.motion, transition: transition(await (name === 'focus' ? ring : proxy).evaluate(readTransition)) };
|
|
98
|
+
el.long.push(...run.long.map((l) => ({ moment: name, ...l })));
|
|
99
|
+
};
|
|
100
|
+
// The element that shows the focus ring: a visually hidden input shows it on its sibling.
|
|
101
|
+
const ring = asElement(await original.evaluateHandle((n) => (n.matches('input') && getComputedStyle(n).opacity === '0' ? n.nextElementSibling ?? n : n))) ?? original;
|
|
102
|
+
|
|
103
|
+
if (info.kind !== 'focus-only') {
|
|
104
|
+
const base = await snapshot(proxy, VISIBLE_PROPS);
|
|
105
|
+
const basePx = await shot(page, box);
|
|
106
|
+
const hoverOnce = () => traced(page, proxy, async () => { await page.mouse.move(point.x, point.y); await settle(proxy); });
|
|
107
|
+
let hover = await hoverOnce();
|
|
108
|
+
// A machine that renders few frames can sample a motion too coarsely to classify its curve: hover
|
|
109
|
+
// again and keep the densest recording.
|
|
110
|
+
for (let again = 0; again < SPARSE_RETRIES && needsRetry(hover.motion); again++) {
|
|
111
|
+
await page.mouse.move(1, 1);
|
|
112
|
+
await settle(proxy);
|
|
113
|
+
const next = await hoverOnce();
|
|
114
|
+
if (better(next, hover)) hover = next;
|
|
115
|
+
}
|
|
116
|
+
if (hoverOnly && (await stillTransparent(proxy))) return { skipped: 'hidden' };
|
|
117
|
+
const hovered = await snapshot(proxy, VISIBLE_PROPS);
|
|
118
|
+
const hoverPx = await shot(page, box);
|
|
119
|
+
await moment('hover', base, basePx, hovered, hoverPx, hover);
|
|
120
|
+
if (info.kind !== 'input') {
|
|
121
|
+
let press;
|
|
122
|
+
if (real) {
|
|
123
|
+
await guardPress(page, true);
|
|
124
|
+
press = await traced(page, proxy, async () => { await page.mouse.down(); await settle(proxy); });
|
|
125
|
+
const pressed = await snapshot(proxy, VISIBLE_PROPS);
|
|
126
|
+
const pressedPx = await shot(page, box);
|
|
127
|
+
await releasePress(page, proxy);
|
|
128
|
+
await moment('press', hovered, hoverPx, pressed, pressedPx, press);
|
|
129
|
+
} else {
|
|
130
|
+
let ids;
|
|
131
|
+
const pressOnce = () => traced(page, proxy, async () => { ids = await pseudo.force(proxy, ['hover', 'active']); await settle(proxy); });
|
|
132
|
+
press = await pressOnce();
|
|
133
|
+
for (let again = 0; again < SPARSE_RETRIES && needsRetry(press.motion); again++) {
|
|
134
|
+
await pseudo.clear(ids);
|
|
135
|
+
await settle(proxy);
|
|
136
|
+
const next = await pressOnce();
|
|
137
|
+
if (better(next, press)) press = next;
|
|
138
|
+
}
|
|
139
|
+
const pressed = await snapshot(proxy, VISIBLE_PROPS);
|
|
140
|
+
const pressedPx = await shot(page, box);
|
|
141
|
+
await moment('press', hovered, hoverPx, pressed, pressedPx, press);
|
|
142
|
+
await pseudo.clear(ids);
|
|
143
|
+
await page.mouse.move(1, 1);
|
|
144
|
+
}
|
|
145
|
+
} else await page.mouse.move(1, 1);
|
|
146
|
+
// Back to rest before the next gesture, or a slow transition leaks into the focus snapshot.
|
|
147
|
+
await settle(proxy);
|
|
148
|
+
// Interrupt: enter, leave half-way through the hover motion; a reversing transition stays continuous.
|
|
149
|
+
const hoverMs = Math.max(...hover.motion.flatMap((e) => e.moves.map((m) => m.durationMs)), 0);
|
|
150
|
+
if (hoverMs >= INTERRUPT_MIN_MOTION_MS) {
|
|
151
|
+
// The hover is left mid-way and recorded again while an animation restart was seen that the sparse frames
|
|
152
|
+
// could not follow (at most INTERRUPT_RETRIES times).
|
|
153
|
+
for (let again = 0; again <= INTERRUPT_RETRIES; again++) {
|
|
154
|
+
await proxy.evaluate((n) => window.__mt.begin('i', n));
|
|
155
|
+
const interruptFrom = await page.evaluate(() => window.__mt.now());
|
|
156
|
+
await wait(page, INTERRUPT_LEAD_MS);
|
|
157
|
+
await page.mouse.move(point.x, point.y);
|
|
158
|
+
await wait(page, Math.max(70, hoverMs / 2));
|
|
159
|
+
await page.evaluate(() => window.__mt.mark('i'));
|
|
160
|
+
await page.mouse.move(1, 1);
|
|
161
|
+
await settle(proxy);
|
|
162
|
+
const rec = await page.evaluate(() => window.__mt.end('i'));
|
|
163
|
+
const frames = longWindows(await page.evaluate(() => window.__mt.loaf), interruptFrom, unattributedLimit(page));
|
|
164
|
+
el.interrupt = rec?.marks.length ? interruptionJumps(rec, rec.marks[0], { durationMs: hoverMs, small: true, longFrames: frames }) : [];
|
|
165
|
+
el.interruptUnsure = rec?.marks.length && !el.interrupt.length ? restartUnfollowed(rec, rec.marks[0]) : null;
|
|
166
|
+
const around = rec?.marks.length ? markCadence(rec, rec.marks[0]) : null;
|
|
167
|
+
el.interruptCadence = around;
|
|
168
|
+
// What this recording could see of the second gesture, and how clear each sighting was.
|
|
169
|
+
el.interruptCoverage = rec?.marks.length ? coverageOf(analyzeRecording(rec), { mark: around }) : NO_COVERAGE;
|
|
170
|
+
el.interruptSightings = el.interrupt.map((j) => ({ label: j.label, prop: j.prop, dense: isDense(around), cadence: around }));
|
|
171
|
+
if (!el.interruptUnsure) break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Keyboard focus as :focus-visible shows it, forced over CDP: nothing receives a focus event.
|
|
177
|
+
// Ancestors get :focus-within so a ring drawn on a wrapper counts.
|
|
178
|
+
const wrapperHandles = [];
|
|
179
|
+
for (const up of [1, 2]) wrapperHandles.push(asElement(await ring.evaluateHandle((n, levels) => { let e = n; for (let i = 0; i < levels && e.parentElement; i++) e = e.parentElement; return e; }, up)));
|
|
180
|
+
const before = await snapshot(ring, VISIBLE_PROPS);
|
|
181
|
+
const wrapperBefore = await Promise.all(wrapperHandles.map((h) => snapshot(h, VISIBLE_PROPS)));
|
|
182
|
+
const beforePx = await shot(page, box);
|
|
183
|
+
let ids;
|
|
184
|
+
const focus = await traced(page, ring, async () => { ids = await pseudo.force(original, ['focus', 'focus-visible'], { 1: ['focus-within'], 2: ['focus-within'] }); await settle(ring); });
|
|
185
|
+
const after = await snapshot(ring, VISIBLE_PROPS);
|
|
186
|
+
const wrapperAfter = await Promise.all(wrapperHandles.map((h) => snapshot(h, VISIBLE_PROPS)));
|
|
187
|
+
const afterPx = await shot(page, box);
|
|
188
|
+
const wrapperChanged = wrapperBefore.flatMap((b, i) => diff(b, wrapperAfter[i]).map((p) => `wrapper:${p}`));
|
|
189
|
+
const hiddenUntilHover = hoverOnly && (await stillTransparent(ring));
|
|
190
|
+
el.moments.focus = { changed: [...diff(before, after), ...wrapperChanged], pixels: await ctx.pixels.differ(beforePx, afterPx), rootLabel: focus.rec?.elems?.find((x) => x.root)?.label, cadence: frameCadence(focus.rec), motion: focus.motion, transition: transition(await ring.evaluate(readTransition)), hiddenUntilHover };
|
|
191
|
+
el.long.push(...focus.long.map((l) => ({ moment: 'focus', ...l })));
|
|
192
|
+
await pseudo.clear(ids);
|
|
193
|
+
return { el };
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ---------- toggles (only with --interact) ----------
|
|
197
|
+
|
|
198
|
+
// Clicks the very node that was chosen (never "the nth toggle": a list that reorders would hand the
|
|
199
|
+
// second click to another item), after checking again that it is still attached and still safe.
|
|
200
|
+
const clickToggle = async (handle) => {
|
|
201
|
+
if (!(await handle.evaluate((n) => n.isConnected))) throw new Error('the control left the page');
|
|
202
|
+
const refused = await denied(handle);
|
|
203
|
+
if (refused) throw new Error(`no longer safe to click (${refused})`);
|
|
204
|
+
await handle.evaluate((n) => n.click());
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Indices of the toggles that may be clicked: reachable, enabled and not on the denylist.
|
|
208
|
+
export const eligibleToggles = async (page) => {
|
|
209
|
+
const handles = await page.$$(TOGGLE);
|
|
210
|
+
const eligible = [];
|
|
211
|
+
const skipped = [];
|
|
212
|
+
for (const [i, h] of handles.entries()) {
|
|
213
|
+
const refused = await denied(h);
|
|
214
|
+
if (refused) { skipped.push({ index: i, reason: 'denied' }); continue; }
|
|
215
|
+
const reach = await h.evaluate((n) => { const r = n.getBoundingClientRect(); const c = getComputedStyle(n); return !n.disabled && c.display !== 'none' && c.visibility !== 'hidden' && r.width > 1 && r.height > 1; });
|
|
216
|
+
if (reach) eligible.push(i); else skipped.push({ index: i, reason: 'hidden' });
|
|
217
|
+
}
|
|
218
|
+
return { eligible, skipped };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Clicks a toggle, learns how long its motion lasts, then clicks the reverse at the middle of that
|
|
222
|
+
// motion in both directions and looks for a restart. The number of clicks is always even, so the
|
|
223
|
+
// control ends in the state it started in.
|
|
224
|
+
export const probeToggle = async (ctx, index) => {
|
|
225
|
+
const { page, url } = ctx;
|
|
226
|
+
await load(page, url, ctx.loadOptions);
|
|
227
|
+
const handle = (await page.$$(TOGGLE))[index];
|
|
228
|
+
if (!handle) return { label: null };
|
|
229
|
+
const label = await handle.evaluate((n) => n.tagName.toLowerCase() + (n.id ? `#${n.id}` : '') + ` "${(n.getAttribute('aria-label') || n.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 28)}"`);
|
|
230
|
+
if (await denied(handle)) return { label: null };
|
|
231
|
+
let clicks = 0;
|
|
232
|
+
const click = async () => { await clickToggle(handle); clicks++; };
|
|
233
|
+
const run = async (steps) => {
|
|
234
|
+
const hint = await handle.evaluateHandle((n) => document.getElementById(n.getAttribute('aria-controls') || ''));
|
|
235
|
+
await page.evaluate(([h]) => window.__mt.begin('p', null, true, h), [hint]);
|
|
236
|
+
// 'settle' waits until the toggle and the element it controls are still; a number waits that many ms.
|
|
237
|
+
for (const s of steps) {
|
|
238
|
+
if (s === 'click') await click();
|
|
239
|
+
else if (s === 'settle') await settle(handle, { min: TOGGLE_SETTLE_MIN_MS, quiet: TOGGLE_QUIET_MS });
|
|
240
|
+
else await wait(page, s);
|
|
241
|
+
}
|
|
242
|
+
return page.evaluate(() => window.__mt.end('p'));
|
|
243
|
+
};
|
|
244
|
+
const moved = (rec) => analyzeRecording(rec).flatMap((e) => e.moves.filter((m) => m.firstT >= rec.marks[0] - 2 * FRAME_MS));
|
|
245
|
+
const jumps = [];
|
|
246
|
+
// What the reversals could not decide, and whether a jump seen was a clear step in frame-by-frame sampling.
|
|
247
|
+
let sighted = false;
|
|
248
|
+
let dense = true;
|
|
249
|
+
let unsure = null;
|
|
250
|
+
let cadence = null;
|
|
251
|
+
let coverage = { ran: true, mark: null, moves: [] };
|
|
252
|
+
let dur = 0;
|
|
253
|
+
try {
|
|
254
|
+
const first = await run(['click', 'settle']);
|
|
255
|
+
dur = Math.max(0, ...moved(first).map((m) => m.durationMs));
|
|
256
|
+
if (dur >= TOGGLE_MIN_MOTION_MS) {
|
|
257
|
+
const check = (rec, direction) => {
|
|
258
|
+
if (rec.marks.length < 2) return;
|
|
259
|
+
const mark = rec.marks.at(-1);
|
|
260
|
+
const found = interruptionJumps(rec, mark, { durationMs: dur });
|
|
261
|
+
const around = markCadence(rec, mark);
|
|
262
|
+
for (const j of found) jumps.push({ ...j, direction, cadence: around });
|
|
263
|
+
if (found.length) { sighted = true; if (!isDense(around)) dense = false; } else unsure ??= restartUnfollowed(rec, mark);
|
|
264
|
+
cadence ??= around;
|
|
265
|
+
coverage = mergeCoverage(coverage, coverageOf(analyzeRecording(rec), { mark: around }));
|
|
266
|
+
};
|
|
267
|
+
await run(['click', 'settle']); // back to the starting state
|
|
268
|
+
for (const share of TOGGLE_SHARES) {
|
|
269
|
+
const half = Math.max(50, Math.round(dur * share));
|
|
270
|
+
check(await run(['click', half, 'click', 'settle']), 'opening'); // opening, reversed part-way
|
|
271
|
+
check(await run(['click', 'settle', 'click', half, 'click', 'settle']), 'closing'); // closing, reversed part-way
|
|
272
|
+
await run(['click', 'settle']); // closed again
|
|
273
|
+
}
|
|
274
|
+
} else await run(['click', 'settle']); // no motion: still return to the starting state
|
|
275
|
+
} catch (e) {
|
|
276
|
+
coverage = { ...coverage, ran: false };
|
|
277
|
+
ctx.notes.push(`toggle ${label} stopped: ${e.message.split('\n')[0]}`);
|
|
278
|
+
} finally {
|
|
279
|
+
if (clicks % 2) await click().catch((e) => ctx.notes.push(`could not restore toggle ${label}: ${e.message.split('\n')[0]}`));
|
|
280
|
+
}
|
|
281
|
+
jumps.sort((x, y) => y.delta - x.delta);
|
|
282
|
+
const sightings = [...new Map(jumps.map((j) => [`${j.label}|${j.prop}`, { label: j.label, prop: j.prop, dense: sighted && dense, cadence: j.cadence }])).values()];
|
|
283
|
+
return { label, dur, jumps, sightings, coverage, unsure, cadence, dense: sighted && dense };
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// ---------- --click <selector> ----------
|
|
287
|
+
|
|
288
|
+
// Everything that moves for 1.5 s after a real click, with the tracer off for the long-frame pass
|
|
289
|
+
// (tracing hundreds of nodes would itself overrun the frames it times) and on for the trajectories.
|
|
290
|
+
// The jumps seen in a recording, with how clear each was (whatever drives the motion).
|
|
291
|
+
export const jumpSightingsOf = (moved) => moved.flatMap((e) => e.moves.filter((m) => m.jumps.length).map((m) => ({ label: e.label, prop: m.prop, dense: isDense(m.cadence), cadence: m.cadence })));
|
|
292
|
+
|
|
293
|
+
// The cadence of the step a click's jump check could not decide, or null: only script-driven motion (the browser
|
|
294
|
+
// says a CSS transition or animation is one, and no teleport) whose step fell in intervals too long to judge.
|
|
295
|
+
export const clickUnsure = (moved) => moved.flatMap((e) => e.moves)
|
|
296
|
+
.filter((m) => m.unresolved).sort((a, b) => b.cadence.max - a.cadence.max)[0]?.cadence ?? null;
|
|
297
|
+
|
|
298
|
+
// Median and longest interval between the frames recorded after the click: how well the recording could follow a value.
|
|
299
|
+
// Median and longest interval (ms) between the frames of a recording from `from` on: how well it could follow a value.
|
|
300
|
+
export const frameCadence = (rec, from = -Infinity) => {
|
|
301
|
+
const frames = [...new Set((rec?.elems ?? []).flatMap((e) => e.t))].filter((x) => x >= from).sort((a, b) => a - b);
|
|
302
|
+
const dts = frames.slice(1).map((x, i) => x - frames[i]);
|
|
303
|
+
return dts.length ? { median: Math.round(median(dts)), max: Math.round(Math.max(...dts)) } : null;
|
|
304
|
+
};
|
|
305
|
+
export const recordingCadence = (rec) => frameCadence(rec, (rec.marks[0] ?? 0) - 2 * FRAME_MS);
|
|
306
|
+
|
|
307
|
+
// One press of the target and the recording of what follows it: `trace` records the trajectories, otherwise only the long frames.
|
|
308
|
+
// Each call presses the target once (twice with `interrupt`): the press budget of --click is counted in calls. `.press()` is a bare
|
|
309
|
+
// press, used to restore the state a target keeps (server side, storage) when the count of presses ended odd.
|
|
310
|
+
const REQUEST_TYPES = new Set(['fetch', 'xhr', 'ping', 'other']);
|
|
311
|
+
const pageAttempt = (ctx, selector, interrupt) => {
|
|
312
|
+
const { page, url } = ctx;
|
|
313
|
+
// Each attempt reloads the page: a request the last press started and that has not finished is lost with it, and so is a timer that
|
|
314
|
+
// would send one later, so the state the target keeps may differ from the press count. Pending requests are said; timers cannot be seen.
|
|
315
|
+
const pending = new Set();
|
|
316
|
+
const started = (r) => { if (REQUEST_TYPES.has(r.resourceType())) pending.add(r); };
|
|
317
|
+
const ended = (r) => pending.delete(r);
|
|
318
|
+
page.on('request', started);
|
|
319
|
+
page.on('requestfinished', ended);
|
|
320
|
+
page.on('requestfailed', ended);
|
|
321
|
+
let warned = false;
|
|
322
|
+
const beforeReload = () => {
|
|
323
|
+
if (pending.size && !warned) {
|
|
324
|
+
warned = true;
|
|
325
|
+
ctx.notes.push(`click ${selector}: ${pending.size} request(s) were still pending when the page was reloaded for the next press: their effect may not have landed, so the state the target keeps may not be restored`);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const attempt = async (trace) => {
|
|
329
|
+
beforeReload();
|
|
330
|
+
await load(page, url, ctx.loadOptions);
|
|
331
|
+
const target = await page.$(selector);
|
|
332
|
+
if (!target) return { missing: 'matches nothing' };
|
|
333
|
+
const point = await reachablePoint(target);
|
|
334
|
+
if (!point) return { missing: 'is covered by another element or not visible' };
|
|
335
|
+
const hint = await target.evaluateHandle((n) => document.getElementById(n.getAttribute('aria-controls') || ''));
|
|
336
|
+
if (trace) await page.evaluate(([h]) => window.__mt.begin('c', null, true, h), [hint]);
|
|
337
|
+
const from = await page.evaluate(() => window.__mt.now());
|
|
338
|
+
await wait(page, CLICK_LEAD_MS);
|
|
339
|
+
await page.mouse.click(point.x, point.y);
|
|
340
|
+
// The second click is dispatched on the element itself: an overlay opened by the first one
|
|
341
|
+
// (a drawer over its own trigger) would otherwise receive it.
|
|
342
|
+
if (interrupt) { await wait(page, CLICK_INTERRUPT_MS); await target.evaluate((n) => n.click()); }
|
|
343
|
+
await wait(page, CLICK_RECORD_MS);
|
|
344
|
+
const loaf = await page.evaluate(() => window.__mt.loaf);
|
|
345
|
+
return { from, loaf, rec: trace ? await page.evaluate(() => window.__mt.end('c')) : null };
|
|
346
|
+
};
|
|
347
|
+
attempt.release = () => { page.off('request', started); page.off('requestfinished', ended); page.off('requestfailed', ended); };
|
|
348
|
+
attempt.press = async () => {
|
|
349
|
+
beforeReload();
|
|
350
|
+
await load(page, url, ctx.loadOptions);
|
|
351
|
+
const target = await page.$(selector);
|
|
352
|
+
const point = target && (await reachablePoint(target));
|
|
353
|
+
if (point) await page.mouse.click(point.x, point.y);
|
|
354
|
+
return Boolean(point);
|
|
355
|
+
};
|
|
356
|
+
return attempt;
|
|
357
|
+
};
|
|
358
|
+
export const clickMeasurer = (ctx, selector, interrupt, makeAttempt = pageAttempt) => {
|
|
359
|
+
const { page } = ctx;
|
|
360
|
+
const pressing = makeAttempt(ctx, selector, interrupt);
|
|
361
|
+
let presses = 0;
|
|
362
|
+
const attempt = async (trace) => {
|
|
363
|
+
const done = await pressing(trace);
|
|
364
|
+
if (!done.missing) presses += interrupt ? 2 : 1;
|
|
365
|
+
return done;
|
|
366
|
+
};
|
|
367
|
+
// `retries`: how many more recordings a step too coarse to judge may cost (each presses the target again). The measurement itself
|
|
368
|
+
// re-records for nothing else: a recording with no unresolved motion costs exactly two presses (four with --interrupt). The caller
|
|
369
|
+
// (audit.mjs) re-measures with `retries` 0 to confirm a flaky aspect, and `restore` ends the run in the state it began in.
|
|
370
|
+
const measure = async (retries = CLICK_RESOLVE_RETRIES) => {
|
|
371
|
+
const quiet = await attempt(false);
|
|
372
|
+
if (quiet.missing) return quiet;
|
|
373
|
+
// What was already moving before the click (loops, carousels) is ambient, not the click's motion: a motion
|
|
374
|
+
// that starts after the click, or that has come to rest. A loop sampled with a long first interval looks
|
|
375
|
+
// like it starts after the click, but it never rests.
|
|
376
|
+
const analyze = (t) => {
|
|
377
|
+
const clickT = t.rec.marks[0] ?? Infinity;
|
|
378
|
+
return analyzeRecording(t.rec, { longFrames: longWindows(t.loaf, t.from, unattributedLimit(page)) }).map((e) => ({ ...e, moves: e.moves.filter((m) => m.firstT >= clickT - 2 * FRAME_MS && (m.settled || m.startT >= clickT - 2 * FRAME_MS)) })).filter((e) => e.moves.length);
|
|
379
|
+
};
|
|
380
|
+
let traced2 = await attempt(true);
|
|
381
|
+
if (traced2.missing) return traced2;
|
|
382
|
+
let moved = analyze(traced2);
|
|
383
|
+
// A step that only a frame too long to judge keeps from being a jump is recorded again: a machine that
|
|
384
|
+
// drops frames rarely drops the same one every time.
|
|
385
|
+
for (let again = 0; again < retries && !moved.some((e) => e.moves.some((m) => m.jumps.length)) && moved.some((e) => e.moves.some((m) => m.unresolved)); again++) {
|
|
386
|
+
const next = await attempt(true);
|
|
387
|
+
if (next.missing) break;
|
|
388
|
+
traced2 = next;
|
|
389
|
+
moved = analyze(traced2);
|
|
390
|
+
}
|
|
391
|
+
const rec = traced2.rec;
|
|
392
|
+
const interruptJumps = interrupt && rec.marks.length > 1 ? interruptionJumps(rec, rec.marks[1], { durationMs: CLICK_INTERRUPT_MS * 2, small: true, longFrames: longWindows(traced2.loaf, traced2.from, unattributedLimit(page)) }) : [];
|
|
393
|
+
return {
|
|
394
|
+
moved,
|
|
395
|
+
long: longFramesBetween(quiet.loaf, quiet.from, Infinity, unattributedLimit(page)),
|
|
396
|
+
// A step the frames were too sparse to judge, with no jump found in any recording: the check is undecided.
|
|
397
|
+
unsure: clickUnsure(moved, rec),
|
|
398
|
+
cadence: recordingCadence(rec),
|
|
399
|
+
// What this recording could see of each motion, and the jumps of script-driven motions (a CSS transition that cannot
|
|
400
|
+
// teleport is never left undecided) with how clear each was.
|
|
401
|
+
coverage: coverageOf(moved, { mark: rec.marks.length > 1 ? markCadence(rec, rec.marks[1]) : null }),
|
|
402
|
+
jumpSightings: jumpSightingsOf(moved),
|
|
403
|
+
jumps: moved.flatMap((e) => e.moves.flatMap((m) => m.jumps.map((j) => ({ label: e.label, prop: m.prop, ...j })))),
|
|
404
|
+
layout: moved.flatMap((e) => e.moves.filter((m) => /^layout/.test(m.prop) && animatedMove(m)).map((m) => ({ label: e.label, prop: m.prop, durationMs: m.durationMs }))),
|
|
405
|
+
interrupt: interruptJumps,
|
|
406
|
+
// The second click could not be judged (a restart the frames cannot follow), and how clear a sighting was.
|
|
407
|
+
interruptUnsure: interrupt && rec.marks.length > 1 && !interruptJumps.length ? restartUnfollowed(rec, rec.marks[1]) : null,
|
|
408
|
+
interruptSightings: interruptJumps.map((j) => { const around = rec.marks.length > 1 ? markCadence(rec, rec.marks[1]) : null; return { label: j.label, prop: j.prop, dense: isDense(around), cadence: around }; }),
|
|
409
|
+
mounted: rec.mounted,
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
measure.presses = () => presses;
|
|
413
|
+
// Without --interrupt a retry is one press: an odd count leaves a target that keeps state (a drawer opened server side) toggled,
|
|
414
|
+
// so it is pressed once more. With --interrupt every recording presses twice and the count is even.
|
|
415
|
+
measure.restore = async () => {
|
|
416
|
+
const pressed = !interrupt && presses % 2 === 1 && pressing.press ? await pressing.press() : false;
|
|
417
|
+
if (pressed) presses++;
|
|
418
|
+
pressing.release?.();
|
|
419
|
+
return pressed;
|
|
420
|
+
};
|
|
421
|
+
return measure;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// ---------- reduced motion ----------
|
|
425
|
+
|
|
426
|
+
export const reducedPass = async (ctx, targets) => {
|
|
427
|
+
const { context, url } = ctx;
|
|
428
|
+
const page = await auxPage(context, { session: true });
|
|
429
|
+
let pseudo = null;
|
|
430
|
+
try {
|
|
431
|
+
await page.setViewportSize(ctx.viewport ?? VIEWPORT);
|
|
432
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
433
|
+
await load(page, url, ctx.loadOptions);
|
|
434
|
+
pseudo = await createPseudoStates(page.context(), page);
|
|
435
|
+
const per = new Map();
|
|
436
|
+
for (const { path, real, label } of targets) {
|
|
437
|
+
const h = await page.$(path);
|
|
438
|
+
if (!h || !(await h.isVisible())) continue;
|
|
439
|
+
// The path (nth-of-type) may name another element after a reload: it is pressed only when it is the
|
|
440
|
+
// control that was audited, still safe, and the one under the pointer.
|
|
441
|
+
const found = await h.evaluate(describeElement);
|
|
442
|
+
if (found !== label) { ctx.notes.push(`reduced-motion pass skipped ${label}: the path now leads to ${found}`); continue; }
|
|
443
|
+
const refused = real ? await denied(h) : null;
|
|
444
|
+
if (refused) { ctx.notes.push(`reduced-motion pass skipped ${label}: no longer safe to press (${refused})`); continue; }
|
|
445
|
+
const point = await reachablePoint(h);
|
|
446
|
+
if (!point) { ctx.notes.push(`reduced-motion pass skipped ${label}: covered by another element`); continue; }
|
|
447
|
+
const T = ['transform', 'translate', 'scale', 'rotate'];
|
|
448
|
+
const rest = await snapshot(h, T);
|
|
449
|
+
await page.mouse.move(point.x, point.y);
|
|
450
|
+
await settle(h);
|
|
451
|
+
let ids = null;
|
|
452
|
+
if (real) { await guardPress(page, true); await page.mouse.down(); } else ids = await pseudo.force(h, ['hover', 'active']);
|
|
453
|
+
await settle(h);
|
|
454
|
+
const pressed = await snapshot(h, T);
|
|
455
|
+
if (real) await releasePress(page, h); else { await pseudo.clear(ids); await page.mouse.move(1, 1); }
|
|
456
|
+
await settle(h);
|
|
457
|
+
const released = await snapshot(h, T);
|
|
458
|
+
per.set(path, { moves: reducedMoves(rest, pressed), residue: reducedResidue(rest, released) });
|
|
459
|
+
}
|
|
460
|
+
await page.evaluate(scrollPass, { step: 400, stepMs: 60, tail: 600, maxScrollers: 5 });
|
|
461
|
+
const hidden = await page.evaluate(invisibleContent);
|
|
462
|
+
const infinite = await page.evaluate(infiniteAnimations, { spinnerPx: REDUCED_LOOP_SPINNER_PX });
|
|
463
|
+
return { per, hidden, infinite };
|
|
464
|
+
} finally {
|
|
465
|
+
await pseudo?.detach();
|
|
466
|
+
await page.close();
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// ---------- contact sheet ----------
|
|
471
|
+
|
|
472
|
+
// One frame after another through hover, press and release, each with the time it really was taken.
|
|
473
|
+
export const contactSheet = async (ctx, selector, out) => {
|
|
474
|
+
const { page, context, pseudo, url } = ctx;
|
|
475
|
+
await load(page, url, ctx.loadOptions);
|
|
476
|
+
const h = await page.$(selector);
|
|
477
|
+
if (!h) return null;
|
|
478
|
+
await h.scrollIntoViewIfNeeded();
|
|
479
|
+
const box = await h.boundingBox();
|
|
480
|
+
if (!box) return null;
|
|
481
|
+
const clip = { x: Math.max(0, box.x - 16), y: Math.max(0, box.y - 16), width: box.width + 32, height: box.height + 32 };
|
|
482
|
+
const shots = [];
|
|
483
|
+
const t0 = Date.now();
|
|
484
|
+
const take = async (tag) => { const data = (await page.screenshot({ clip })).toString('base64'); shots.push({ data, at: Date.now() - t0, tag }); };
|
|
485
|
+
const burst = async (n, tag) => { for (let i = 0; i < n; i++) { await wait(page, CONTACT_SHEET_STEP_MS); await take(i === 0 ? tag : ''); } };
|
|
486
|
+
await take('rest');
|
|
487
|
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
488
|
+
await burst(8, 'hover');
|
|
489
|
+
const ids = await pseudo.force(h, ['hover', 'active']);
|
|
490
|
+
await burst(5, 'press');
|
|
491
|
+
await pseudo.clear(ids);
|
|
492
|
+
await page.mouse.move(1, 1);
|
|
493
|
+
await burst(6, 'out');
|
|
494
|
+
const sheet = await auxPage(context);
|
|
495
|
+
try {
|
|
496
|
+
await sheet.setViewportSize({ width: 1200, height: 200 });
|
|
497
|
+
await sheet.setContent(`<body style="margin:0;background:#888;display:flex;flex-wrap:wrap;gap:4px;padding:4px">${shots.map((s) => `<figure style="margin:0;font:10px sans-serif;color:#fff"><img src="data:image/png;base64,${s.data}"><figcaption>${s.at} ms${s.tag ? ` ${s.tag}` : ''}</figcaption></figure>`).join('')}</body>`);
|
|
498
|
+
const file = join(out, `${selector.replace(/[^\w-]+/g, '_').slice(0, 60)}.png`);
|
|
499
|
+
await sheet.screenshot({ path: file, fullPage: true });
|
|
500
|
+
return file;
|
|
501
|
+
} finally {
|
|
502
|
+
await sheet.close();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Reduced-motion judgement from computed transform snapshots. Reduced motion means gentler, not
|
|
2
|
+
// zero: a small press scale is fine, a translate, a rotation or a large scale is not.
|
|
3
|
+
import { REDUCED_SCALE_DELTA, REDUCED_TRANSLATE_PX } from './constants.mjs';
|
|
4
|
+
import { round } from './series.mjs';
|
|
5
|
+
|
|
6
|
+
const numbers = (s) => String(s ?? '').match(/-?\d*\.?\d+(?:e-?\d+)?/gi)?.map(Number) ?? [];
|
|
7
|
+
|
|
8
|
+
// { tx, ty, sx, sy, rot } from the computed `transform`, `translate`, `scale` and `rotate` values.
|
|
9
|
+
export const transformState = (snap) => {
|
|
10
|
+
const state = { tx: 0, ty: 0, sx: 1, sy: 1, rot: 0 };
|
|
11
|
+
const t = String(snap.transform ?? 'none');
|
|
12
|
+
const m = /^matrix(3d)?\(([^)]*)\)$/.exec(t);
|
|
13
|
+
if (m) {
|
|
14
|
+
const v = m[2].split(',').map(Number);
|
|
15
|
+
const [a, b, c, d, tx, ty] = m[1] ? [v[0], v[1], v[4], v[5], v[12], v[13]] : v;
|
|
16
|
+
Object.assign(state, { tx, ty, sx: Math.hypot(a, b), sy: Math.hypot(c, d), rot: (Math.atan2(b, a) * 180) / Math.PI });
|
|
17
|
+
}
|
|
18
|
+
if (snap.translate && snap.translate !== 'none') { const [x = 0, y = 0] = numbers(snap.translate); state.tx += x; state.ty += y; }
|
|
19
|
+
if (snap.scale && snap.scale !== 'none') { const [x = 1, y = x] = numbers(snap.scale); state.sx *= x; state.sy *= y; }
|
|
20
|
+
if (snap.rotate && snap.rotate !== 'none') state.rot += numbers(snap.rotate)[0] ?? 0;
|
|
21
|
+
return state;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const describeDelta = (a, b, { translate, scale, rotate }) => {
|
|
25
|
+
const parts = [];
|
|
26
|
+
const move = Math.hypot(b.tx - a.tx, b.ty - a.ty);
|
|
27
|
+
if (move > translate) parts.push(`translate ${round(move)} px`);
|
|
28
|
+
const grow = Math.max(Math.abs(b.sx - a.sx), Math.abs(b.sy - a.sy));
|
|
29
|
+
if (grow > scale) parts.push(`scale by ${round(grow, 2)}`);
|
|
30
|
+
const turn = Math.abs(b.rot - a.rot);
|
|
31
|
+
if (turn > rotate) parts.push(`rotate ${round(turn)} deg`);
|
|
32
|
+
return parts.join(', ');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// What a press still does under reduced motion beyond a small scale change.
|
|
36
|
+
export const reducedMoves = (rest, pressed) => describeDelta(transformState(rest), transformState(pressed), { translate: REDUCED_TRANSLATE_PX, scale: REDUCED_SCALE_DELTA, rotate: 1 });
|
|
37
|
+
|
|
38
|
+
// Any transform left after release.
|
|
39
|
+
export const reducedResidue = (rest, released) => describeDelta(transformState(rest), transformState(released), { translate: 0.5, scale: 0.01, rotate: 0.5 });
|