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,342 @@
|
|
|
1
|
+
// audit(pageOrUrl, options): the whole run. Returns the report (schemaVersion 1).
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
CONFIRM_MAJORITY, CONFIRM_RUNS, DEFAULT_MAX_ELEMENTS, FRAME_NOISE_FACTOR, INTERACTIVE, LONG_FRAME_MS, MIN_PLAYWRIGHT, MAX_TOGGLES, NOISY_UNATTRIBUTED_MS, ENV_NOISE_MIN_ELEMENTS, RECOUNT_AFTER_MS, SCHEMA_VERSION,
|
|
7
|
+
SCROLL_STEP_MS, SCROLL_STEP_PX, SCROLL_TAIL_MS, TOGGLE, VIEWPORT,
|
|
8
|
+
} from './constants.mjs';
|
|
9
|
+
import { UsageError } from './args.mjs';
|
|
10
|
+
import { AuditError, TRACER_CONFIG, createPixelComparer, calibrateFrames, idleFrameNoise, createPseudoStates, describeLong, load, longFramesBetween, sameLocation, setUnattributedLimit, unattributedLimit } from './browser.mjs';
|
|
11
|
+
import { baselineIds, readReport } from './compare.mjs';
|
|
12
|
+
import { finalize, loadConfig, validateConfig } from './config.mjs';
|
|
13
|
+
import { inconsistencies, judgeElement } from './judge.mjs';
|
|
14
|
+
import { devSignals, cssPath, describeElement, hasReducedMotionRule, invisibleContent, reachability, scrollPass, ungatedHoverMotion } from './page-scripts.mjs';
|
|
15
|
+
import { tracer } from './page-tracer.mjs';
|
|
16
|
+
import { clickRerun, clickRows, confirmToggle, toggleRow, undecidedOf } from './undecided.mjs';
|
|
17
|
+
import { loadPlaywright } from './playwright.mjs';
|
|
18
|
+
import { auditElement, clickMeasurer, contactSheet, eligibleToggles, probeToggle, reducedPass } from './probes.mjs';
|
|
19
|
+
|
|
20
|
+
const SCROLL_OPTIONS = { step: SCROLL_STEP_PX, stepMs: SCROLL_STEP_MS, tail: SCROLL_TAIL_MS, maxScrollers: 5 };
|
|
21
|
+
const MAX_SKIP_EXAMPLES = 3;
|
|
22
|
+
|
|
23
|
+
// Flaky aspects of a measurement are kept only when most of CONFIRM_RUNS runs agree.
|
|
24
|
+
const confirm = async (first, aspects, rerun) => {
|
|
25
|
+
const flagged = Object.entries(aspects).filter(([, has]) => has(first));
|
|
26
|
+
if (!flagged.length) return [];
|
|
27
|
+
const later = [];
|
|
28
|
+
for (let i = 1; i < CONFIRM_RUNS; i++) later.push(await rerun());
|
|
29
|
+
for (const [name, has] of flagged) {
|
|
30
|
+
const hits = 1 + later.filter((r) => r && has(r)).length;
|
|
31
|
+
if (hits < CONFIRM_MAJORITY) first.unconfirmed = [...(first.unconfirmed ?? []), name];
|
|
32
|
+
}
|
|
33
|
+
return later;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const unresolvedMoves = (el) => Object.entries(el.moments).flatMap(([moment, d]) => d.motion.flatMap((e) => e.moves.filter((m) => m.unresolved > 0 && !m.jumps.length).map((m) => ({ moment, label: e.label, prop: m.prop, cadence: m.cadence }))));
|
|
37
|
+
const jumpKeys = (el) => Object.entries(el.moments).flatMap(([m, d]) => d.motion.flatMap((e) => e.moves.flatMap((mv) => mv.jumps.map(() => `${m}|${e.label}|${mv.prop}`))));
|
|
38
|
+
// Long frames are confirmed per gesture: a frame that shows up in the press of one run and in the
|
|
39
|
+
// focus of the next is two borderline frames, not one confirmed long frame.
|
|
40
|
+
const elementAspects = (first) => ({
|
|
41
|
+
...Object.fromEntries([...new Set(first.long.map((l) => l.moment))].map((m) => [`long:${m}`, (e) => e.long.some((l) => l.moment === m)])),
|
|
42
|
+
interrupt: (e) => (e.interrupt?.length ?? 0) > 0,
|
|
43
|
+
jump: (e) => jumpKeys(e).length > 0,
|
|
44
|
+
// A step the frames could not judge: the re-measures decide whether it is ruled out or stays undecided.
|
|
45
|
+
unresolved: (e) => unresolvedMoves(e).length > 0,
|
|
46
|
+
});
|
|
47
|
+
const dropUnconfirmed = (el) => {
|
|
48
|
+
for (const name of el.unconfirmed ?? []) {
|
|
49
|
+
if (name.startsWith('long:')) el.long = el.long.filter((l) => l.moment !== name.slice(5));
|
|
50
|
+
if (name === 'interrupt') el.interrupt = [];
|
|
51
|
+
if (name === 'jump') for (const d of Object.values(el.moments)) for (const e of d.motion) for (const m of e.moves) m.jumps = [];
|
|
52
|
+
}
|
|
53
|
+
delete el.unconfirmed;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const resolveConfig = (c) => (c && typeof c === 'object' ? validateConfig(c, 'config option') : loadConfig(c));
|
|
57
|
+
|
|
58
|
+
const describeLongFrames = (long) => {
|
|
59
|
+
const { frames, culprit } = describeLong(long);
|
|
60
|
+
return `${frames.length} frame${frames.length > 1 ? 's' : ''} over ${LONG_FRAME_MS} ms (${frames.slice(0, 5).join(', ')} ms), ${culprit}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const audit = async (pageOrUrl, o = {}) => {
|
|
64
|
+
const isPage = typeof pageOrUrl === 'object' && pageOrUrl !== null && typeof pageOrUrl.goto === 'function';
|
|
65
|
+
const config = resolveConfig(o.config);
|
|
66
|
+
const baseline = o.baseline ? (typeof o.baseline === 'string' ? baselineIds(readReport(o.baseline)) : new Set(o.baseline)) : new Set();
|
|
67
|
+
const out = resolve(o.out ?? mkdtempSync(join(tmpdir(), 'motion-audit-')));
|
|
68
|
+
mkdirSync(out, { recursive: true });
|
|
69
|
+
const max = o.max ?? DEFAULT_MAX_ELEMENTS;
|
|
70
|
+
|
|
71
|
+
let browser = null;
|
|
72
|
+
let context;
|
|
73
|
+
let page;
|
|
74
|
+
let url;
|
|
75
|
+
let initScript = null;
|
|
76
|
+
let throttle = null;
|
|
77
|
+
let pixels = null;
|
|
78
|
+
let pseudo = null;
|
|
79
|
+
if (isPage) {
|
|
80
|
+
page = pageOrUrl;
|
|
81
|
+
context = page.context();
|
|
82
|
+
url = o.url ?? page.url();
|
|
83
|
+
if (!url || url === 'about:blank') throw new AuditError('the page has not been navigated: pass options.url or call page.goto() first');
|
|
84
|
+
// Before Playwright 1.59 addInitScript returns nothing and a script cannot be removed: find out with an empty
|
|
85
|
+
// one (harmless if left behind) before the tracer is added.
|
|
86
|
+
const probe = await page.addInitScript(() => {});
|
|
87
|
+
if (typeof probe?.dispose !== 'function') throw new AuditError(`audit(page) needs Playwright ${MIN_PLAYWRIGHT.join('.')} or later: the page's Playwright cannot remove the tracer it installs (addInitScript returned no Disposable). Upgrade it, or pass a URL`);
|
|
88
|
+
await probe.dispose();
|
|
89
|
+
initScript = await page.addInitScript(tracer, TRACER_CONFIG);
|
|
90
|
+
} else {
|
|
91
|
+
url = pageOrUrl;
|
|
92
|
+
const { chromium } = loadPlaywright();
|
|
93
|
+
browser = await chromium.launch();
|
|
94
|
+
context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1, ...(o.storageState ? { storageState: o.storageState } : {}) });
|
|
95
|
+
await context.addInitScript(tracer, TRACER_CONFIG);
|
|
96
|
+
page = await context.newPage();
|
|
97
|
+
}
|
|
98
|
+
const notes = [];
|
|
99
|
+
try {
|
|
100
|
+
if (o.cpu > 1) {
|
|
101
|
+
throttle = await context.newCDPSession(page);
|
|
102
|
+
await throttle.send('Emulation.setCPUThrottlingRate', { rate: o.cpu });
|
|
103
|
+
}
|
|
104
|
+
const loadOptions = { waitFor: o.waitFor, allowRedirect: Boolean(o.allowRedirect) };
|
|
105
|
+
const first = await load(page, url, loadOptions);
|
|
106
|
+
const landed = first.landed;
|
|
107
|
+
if (first.redirected) notes.push(`${url} redirected to ${landed}`);
|
|
108
|
+
pseudo = await createPseudoStates(context, page);
|
|
109
|
+
pixels = await createPixelComparer(context);
|
|
110
|
+
const noise = Math.max(await calibrateFrames(context), await idleFrameNoise(page));
|
|
111
|
+
const unattributedMs = Math.max(noise >= LONG_FRAME_MS ? NOISY_UNATTRIBUTED_MS : LONG_FRAME_MS, Math.ceil(noise * FRAME_NOISE_FACTOR));
|
|
112
|
+
setUnattributedLimit(page, unattributedMs);
|
|
113
|
+
if (unattributedMs > LONG_FRAME_MS) notes.push(`this machine or page is noisy (frames of ${Math.round(noise)} ms with nothing happening): frames with no application script count only above ${unattributedMs} ms`);
|
|
114
|
+
const ctx = { page, context, pseudo, pixels, url, interact: Boolean(o.interact), loadOptions, notes };
|
|
115
|
+
const devServer = await page.evaluate(devSignals);
|
|
116
|
+
if (devServer.length) notes.push(`development build detected (${devServer.join(', ')}): long frames are reported as warnings; audit a production build (next start, vite preview) for them to count`);
|
|
117
|
+
const loafSupported = await page.evaluate(() => window.__mt.loafSupported);
|
|
118
|
+
const raw = [];
|
|
119
|
+
const add = (kind, element, detail, key) => raw.push({ kind, element, detail, key });
|
|
120
|
+
const coverage = { candidates: 0, audited: 0, errored: 0, skipped: {}, max, toggles: { candidates: 0, probed: 0, skipped: {} } };
|
|
121
|
+
const skip = (reason, example) => {
|
|
122
|
+
const s = (coverage.skipped[reason] ??= { count: 0, examples: [] });
|
|
123
|
+
s.count++;
|
|
124
|
+
if (example && s.examples.length < MAX_SKIP_EXAMPLES) s.examples.push(example);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Scroll the page end to end: entrances, long frames, content that stays invisible.
|
|
128
|
+
const scrollOnce = async () => { const r = await page.evaluate(scrollPass, SCROLL_OPTIONS); return { ...r, long: longFramesBetween(r.loaf, 0, Infinity, unattributedLimit(page)) }; };
|
|
129
|
+
let scroll = await scrollOnce();
|
|
130
|
+
if (scroll.long.length) {
|
|
131
|
+
const later = [];
|
|
132
|
+
for (let i = 1; i < CONFIRM_RUNS; i++) later.push(await scrollOnce());
|
|
133
|
+
if (1 + later.filter((r) => r.long.length).length < CONFIRM_MAJORITY) scroll = { ...scroll, long: [] };
|
|
134
|
+
}
|
|
135
|
+
const hiddenNormally = new Set(await page.evaluate(invisibleContent));
|
|
136
|
+
|
|
137
|
+
// Controls.
|
|
138
|
+
const controlsLoad = await load(page, url, loadOptions);
|
|
139
|
+
const findHandles = async () => (o.only?.length ? (await Promise.all(o.only.map(async (s) => {
|
|
140
|
+
try { return await page.$$(s); } catch (e) { throw new UsageError(`--only "${s}" is not a valid selector: ${e.message.split('\n')[0]}`); }
|
|
141
|
+
}))).flat() : page.$$(INTERACTIVE));
|
|
142
|
+
const handles = await findHandles();
|
|
143
|
+
coverage.candidates = handles.length;
|
|
144
|
+
const elements = [];
|
|
145
|
+
const targets = [];
|
|
146
|
+
const labels = new Map();
|
|
147
|
+
const seenPaths = new Set();
|
|
148
|
+
let navigated = false;
|
|
149
|
+
for (const h of handles) {
|
|
150
|
+
if (navigated) { skip('page navigated during the audit'); continue; }
|
|
151
|
+
if (elements.length >= max) { skip(`beyond --max ${max}`); continue; }
|
|
152
|
+
let label;
|
|
153
|
+
try {
|
|
154
|
+
const reach = await h.evaluate(reachability);
|
|
155
|
+
label = await h.evaluate(describeElement);
|
|
156
|
+
if (reach.state === 'skip') { skip(reach.reason, label); continue; }
|
|
157
|
+
const path = await h.evaluate(cssPath);
|
|
158
|
+
if (seenPaths.has(path)) { coverage.candidates--; continue; }
|
|
159
|
+
seenPaths.add(path);
|
|
160
|
+
const n = (labels.get(label) ?? 0) + 1;
|
|
161
|
+
labels.set(label, n);
|
|
162
|
+
const unique = n > 1 ? `${label} (${n})` : label;
|
|
163
|
+
const result = await auditElement(ctx, h, unique, { hoverOnly: reach.state === 'hover-only' });
|
|
164
|
+
if (result.skipped) { skip(result.skipped, label); continue; }
|
|
165
|
+
const { el } = result;
|
|
166
|
+
const later = await confirm(el, elementAspects(el), async () => {
|
|
167
|
+
const again = await auditElement(ctx, h, unique, { hoverOnly: reach.state === 'hover-only' });
|
|
168
|
+
return again.el ?? null;
|
|
169
|
+
});
|
|
170
|
+
el.undecided = undecidedOf(el, later);
|
|
171
|
+
dropUnconfirmed(el);
|
|
172
|
+
el.path = path;
|
|
173
|
+
el.real = el.pressMode === 'real';
|
|
174
|
+
elements.push(el);
|
|
175
|
+
targets.push({ path, real: el.real, label });
|
|
176
|
+
if (o.interact && !sameLocation(page.url(), landed)) {
|
|
177
|
+
navigated = true;
|
|
178
|
+
notes.push(`interacting with ${label} navigated to ${page.url()}: audit stopped there; the controls after it were not audited`);
|
|
179
|
+
await load(page, url, loadOptions);
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
if (o.interact && /context was destroyed|navigat/i.test(e.message)) await page.waitForTimeout(400);
|
|
183
|
+
if (o.interact && !sameLocation(page.url(), landed)) {
|
|
184
|
+
// A gesture navigated (a link that routes on mousedown): not an error of the control.
|
|
185
|
+
navigated = true;
|
|
186
|
+
notes.push(`interacting with ${label ?? 'a control'} navigated to ${page.url()}: audit stopped there; the controls after it were not audited`);
|
|
187
|
+
skip('page navigated during the audit');
|
|
188
|
+
await load(page, url, loadOptions);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
coverage.errored++;
|
|
192
|
+
skip('error', `${label ?? 'element'}: ${e.message.split('\n')[0].slice(0, 90)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
coverage.audited = elements.length;
|
|
196
|
+
|
|
197
|
+
// Controls that arrive later than the first count are not covered: say so instead of reporting "N of N".
|
|
198
|
+
{
|
|
199
|
+
const age = Date.now() - controlsLoad.loadedAt;
|
|
200
|
+
if (!navigated && age < RECOUNT_AFTER_MS) await page.waitForTimeout(RECOUNT_AFTER_MS - age);
|
|
201
|
+
const now = navigated ? handles.length : (await findHandles()).length;
|
|
202
|
+
coverage.late = Math.max(0, now - handles.length);
|
|
203
|
+
if (coverage.late) notes.push(`${coverage.late} controls appeared after the audit counted ${handles.length}: the page renders late, so the audit is incomplete. Re-run with --wait-for <selector of a late control>`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const css = await page.evaluate(ungatedHoverMotion);
|
|
207
|
+
const reducedRule = await page.evaluate(hasReducedMotionRule);
|
|
208
|
+
|
|
209
|
+
// Toggles are clicked for real, so only with --interact.
|
|
210
|
+
const toggles = [];
|
|
211
|
+
if (o.interact && !navigated) {
|
|
212
|
+
await load(page, url, loadOptions);
|
|
213
|
+
const { eligible, skipped } = await eligibleToggles(page);
|
|
214
|
+
coverage.toggles.candidates = eligible.length + skipped.length;
|
|
215
|
+
for (const s of skipped) coverage.toggles.skipped[s.reason] = (coverage.toggles.skipped[s.reason] ?? 0) + 1;
|
|
216
|
+
for (const index of eligible.slice(0, MAX_TOGGLES)) {
|
|
217
|
+
let r = await probeToggle(ctx, index);
|
|
218
|
+
if (!r.label) continue;
|
|
219
|
+
if (r.jumps.length) {
|
|
220
|
+
const again = [await probeToggle(ctx, index), await probeToggle(ctx, index)];
|
|
221
|
+
r = confirmToggle(r, again);
|
|
222
|
+
}
|
|
223
|
+
coverage.toggles.probed++;
|
|
224
|
+
toggles.push(r);
|
|
225
|
+
if (r.jumps.length) {
|
|
226
|
+
const top = [...new Map(r.jumps.map((j) => [j.label, j])).values()].slice(0, 3).map((j) => `${j.label} ${j.prop} ${j.from} -> ${j.to} in one frame (${j.direction}, ${j.delta})`).join('; ');
|
|
227
|
+
add('not-interruptible', r.label, `reversed part-way through its ${r.dur} ms motion the animation restarts instead of continuing: ${top}`, 'toggle');
|
|
228
|
+
}
|
|
229
|
+
const undecided = toggleRow(r);
|
|
230
|
+
if (undecided) raw.push(undecided);
|
|
231
|
+
if (!sameLocation(page.url(), landed)) { notes.push(`toggling ${r.label} navigated to ${page.url()}: toggle probing stopped`); break; }
|
|
232
|
+
}
|
|
233
|
+
if (eligible.length > MAX_TOGGLES) coverage.toggles.skipped[`beyond ${MAX_TOGGLES} toggles`] = eligible.length - MAX_TOGGLES;
|
|
234
|
+
} else coverage.toggles.skipped['observe-only (use --interact)'] = (coverage.toggles.candidates = (await page.$$(TOGGLE)).length);
|
|
235
|
+
|
|
236
|
+
// --click <selector>
|
|
237
|
+
let click = null;
|
|
238
|
+
if (o.click) {
|
|
239
|
+
const measure = clickMeasurer(ctx, o.click, o.interrupt);
|
|
240
|
+
let r = await measure();
|
|
241
|
+
if (r.missing) { add('click-target', o.click, `${o.click} ${r.missing}`, 'target'); await measure.restore(); }
|
|
242
|
+
else {
|
|
243
|
+
const label = `click ${o.click}`;
|
|
244
|
+
const holder = { long: r.long, jumps: r.jumps, interrupt: r.interrupt };
|
|
245
|
+
const reruns = [];
|
|
246
|
+
const later = await confirm(holder, { long: (x) => x.long.length > 0, jump: (x) => x.jumps.length > 0, interrupt: (x) => x.interrupt.length > 0 }, clickRerun(measure, reruns));
|
|
247
|
+
const undecidedRows = clickRows(label, r, holder.unconfirmed ?? [], reruns);
|
|
248
|
+
for (const name of holder.unconfirmed ?? []) r = { ...r, [name === 'jump' ? 'jumps' : name]: [] };
|
|
249
|
+
// Back to the state the target started in when the presses ended odd (a retry is one press).
|
|
250
|
+
const restored = await measure.restore();
|
|
251
|
+
click = { selector: o.click, moved: r.moved.length, mounted: r.mounted, cadence: r.cadence, long: r.long, interrupt: r.interrupt, presses: measure.presses(), remeasures: later.length, restored };
|
|
252
|
+
raw.push(...undecidedRows);
|
|
253
|
+
if (r.jumps.length) add('jump', label, `${r.jumps.slice(0, 4).map((j) => `${j.label} ${j.prop} jumps ${j.size} at ${j.atMs} ms`).join('; ')}${r.jumps.length > 4 ? ` (+${r.jumps.length - 4} more)` : ''}`, 'click');
|
|
254
|
+
for (const l of r.layout) add('layout-animated', label, `${l.label} animates its ${l.prop === 'layoutWidth' ? 'width' : 'height'} over ${l.durationMs} ms`, `${l.label} ${l.prop}`);
|
|
255
|
+
if (r.long.length) add('long-frame', label, `during the transition: ${describeLongFrames(r.long)}`, 'click');
|
|
256
|
+
if (r.interrupt.length) add('interrupt-jump', label, `second click: ${r.interrupt.slice(0, 4).map((j) => `${j.label} ${j.prop}`).join(', ')} jump`, 'click');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const sheets = [];
|
|
261
|
+
for (const s of o.frames ?? []) sheets.push(await contactSheet(ctx, s, out));
|
|
262
|
+
const reduced = await reducedPass(ctx, targets);
|
|
263
|
+
for (const el of elements) el.reduced = reduced.per.get(el.path);
|
|
264
|
+
|
|
265
|
+
// Frames no application script explains, in many unrelated controls, are the machine's: a page that janks does
|
|
266
|
+
// so in its own scripts and in the controls that run them. The calibration at the start cannot see a noisy
|
|
267
|
+
// phase that comes later.
|
|
268
|
+
const unattributed = (l) => !l.scripts?.length;
|
|
269
|
+
const noisy = elements.filter((e) => e.long.some(unattributed)).length;
|
|
270
|
+
if (noisy >= ENV_NOISE_MIN_ELEMENTS) {
|
|
271
|
+
const environmental = (l) => unattributed(l) && l.duration < NOISY_UNATTRIBUTED_MS;
|
|
272
|
+
for (const el of elements) el.long = el.long.filter((l) => !environmental(l));
|
|
273
|
+
scroll = { ...scroll, long: scroll.long.filter((l) => !environmental(l)) };
|
|
274
|
+
notes.push(`frames over ${LONG_FRAME_MS} ms with no script behind them showed up in ${noisy} unrelated controls: the machine drops frames, so those are not reported as long frames`);
|
|
275
|
+
}
|
|
276
|
+
for (const el of elements) raw.push(...judgeElement(el, config));
|
|
277
|
+
raw.push(...inconsistencies(elements, config));
|
|
278
|
+
for (const el of elements) {
|
|
279
|
+
for (const moment of [...new Set(el.long.map((l) => l.moment))]) add('long-frame', el.label, `during ${moment}: ${describeLongFrames(el.long.filter((l) => l.moment === moment))}`, moment);
|
|
280
|
+
}
|
|
281
|
+
if (scroll.long.length) add('long-frame', 'page scroll', `while scrolling${o.cpu > 1 ? ` (CPU ${o.cpu}x)` : ''}: ${describeLongFrames(scroll.long)}`, 'scroll');
|
|
282
|
+
if (scroll.keyframeLayout.length) add('layout-animated', 'keyframes', `CSS/WAAPI animations animate ${scroll.keyframeLayout.join(', ')}`, 'keyframes');
|
|
283
|
+
if (o.entrances) for (const s of scroll.sections.filter((x) => !x.animated)) add('no-entrance', `section "${s.section}"`, 'appears without an entrance the first time it is scrolled to', 'entrance');
|
|
284
|
+
for (const sel of css.found) add('ungated-hover', sel, 'moves on :hover outside @media (hover: hover): sticks after a tap on touch screens', 'ungated');
|
|
285
|
+
if (reduced.infinite.length) add('reduced-motion-loop', reduced.infinite.slice(0, 4).join(', '), 'infinite moving animation keeps running with prefers-reduced-motion', 'loop');
|
|
286
|
+
const stranded = reduced.hidden.filter((h) => !hiddenNormally.has(h)).slice(0, 6);
|
|
287
|
+
if (stranded.length) add('reduced-motion-hidden', stranded.join(', '), 'content stays at opacity 0 with prefers-reduced-motion: the entrance it waits for never runs', 'hidden');
|
|
288
|
+
const anyMotion = elements.some((e) => Object.values(e.moments).some((d) => d?.motion.some((m) => m.moves.some((x) => x.prop !== 'opacity'))));
|
|
289
|
+
const unreadable = Math.max(css.unreadable, reducedRule.unreadable);
|
|
290
|
+
if (anyMotion && !reducedRule.found) {
|
|
291
|
+
raw.push({ kind: 'reduced-motion-missing', element: 'stylesheets', key: 'rules', level: unreadable ? 'info' : undefined, detail: unreadable ? `unknown: ${unreadable} stylesheet(s) could not be read (cross-origin or file://), so a reduced-motion rule may exist there` : 'elements move but no stylesheet has a prefers-reduced-motion rule (JS-driven handling is not seen here)' });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const done = finalize(raw, config, { devServer: devServer.length > 0 });
|
|
295
|
+
coverage.inconclusive = done.findings.filter((f) => f.kind === 'inconclusive').length;
|
|
296
|
+
const baselined = done.findings.filter((f) => baseline.has(f.id));
|
|
297
|
+
const findings = done.findings.filter((f) => !baseline.has(f.id));
|
|
298
|
+
const report = {
|
|
299
|
+
schemaVersion: SCHEMA_VERSION,
|
|
300
|
+
url,
|
|
301
|
+
finalUrl: landed,
|
|
302
|
+
cpu: o.cpu ?? 1,
|
|
303
|
+
mode: o.interact ? 'interact' : 'observe-only',
|
|
304
|
+
loafSupported,
|
|
305
|
+
devServer,
|
|
306
|
+
cssUnreadable: unreadable,
|
|
307
|
+
coverage,
|
|
308
|
+
notes,
|
|
309
|
+
elements: elements.map(({ label, kind, transition, moments, interrupt, reduced: red, pressMode, hoverOnly }) => ({ label, kind, pressMode, hoverOnly, transition, moments: Object.fromEntries(Object.entries(moments).map(([k, d]) => [k, { changed: d.changed, pixels: d.pixels, transition: d.transition, cadence: d.cadence, motion: d.motion }])), interrupt, reduced: red })),
|
|
310
|
+
sections: scroll.sections,
|
|
311
|
+
toggles,
|
|
312
|
+
click,
|
|
313
|
+
sheets: sheets.filter(Boolean),
|
|
314
|
+
findings,
|
|
315
|
+
baselined,
|
|
316
|
+
suppressed: done.suppressed,
|
|
317
|
+
};
|
|
318
|
+
writeFileSync(join(out, 'report.json'), JSON.stringify(report, null, 1));
|
|
319
|
+
return { ...report, out };
|
|
320
|
+
} finally {
|
|
321
|
+
await pixels?.close();
|
|
322
|
+
if (browser) await browser.close();
|
|
323
|
+
else {
|
|
324
|
+
await pseudo?.detach();
|
|
325
|
+
// The page belongs to the caller: leave it as it was found.
|
|
326
|
+
if (throttle) {
|
|
327
|
+
await throttle.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => {});
|
|
328
|
+
await throttle.detach().catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
await initScript?.dispose().catch(() => {});
|
|
331
|
+
await page.evaluate(() => window.__mt?.dispose?.()).catch(() => {});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// 0 clean, 1 findings that fail, 2 the audit did not cover enough to mean anything.
|
|
337
|
+
export const exitCode = (report, { strict = false, minCoverage } = {}) => {
|
|
338
|
+
const { candidates, audited } = report.coverage;
|
|
339
|
+
if (audited === 0 || report.coverage.late > 0) return 2;
|
|
340
|
+
if (minCoverage !== undefined && audited / candidates < minCoverage) return 2;
|
|
341
|
+
return report.findings.some((f) => f.kind !== 'inconclusive' && (f.level === 'error' || (strict && f.level === 'warning'))) ? 1 : 0;
|
|
342
|
+
};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Helpers that drive the page: tracing a gesture, snapshots, forced pseudo-states over CDP,
|
|
2
|
+
// navigation with redirect checks.
|
|
3
|
+
import { CALIBRATION_CYCLES, CLIP_MARGIN_PX, IDLE_NOISE_MS, VIEWPORT, HYDRATION_WAIT_MS, LOGIN_PATH, LONG_FRAME_MS, NAV_TIMEOUT_MS, PAGE_SCRIPT_MIN_MS, PIXEL_BLOCK_PX, PIXEL_CHANNEL_TOLERANCE, PIXEL_MIN_BLOCKS, RELOAD_WAIT_MS, SETTLE_MAX_MS, TRACE_MIN_SIZE_PX, TRACE_MOUNT_CAP, TRACE_PAGE_CAP, TRACE_SUBTREE_CAP, WAIT_FOR_TIMEOUT_MS } from './constants.mjs';
|
|
4
|
+
import { COLS, analyzeRecording } from './series.mjs';
|
|
5
|
+
|
|
6
|
+
export class AuditError extends Error {}
|
|
7
|
+
|
|
8
|
+
export const TRACER_CONFIG = { cols: COLS, skip: ['SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'BR', 'TEMPLATE'], subtreeCap: TRACE_SUBTREE_CAP, pageCap: TRACE_PAGE_CAP, mountCap: TRACE_MOUNT_CAP, minSize: TRACE_MIN_SIZE_PX };
|
|
9
|
+
|
|
10
|
+
export const snapshot = (h, props) => h.evaluate((n, p) => { const cs = getComputedStyle(n); return Object.fromEntries(p.map((x) => [x, cs.getPropertyValue(x)])); }, props);
|
|
11
|
+
export const diff = (a, b) => Object.keys(a).filter((k) => a[k] !== b[k]);
|
|
12
|
+
export const shot = async (page, box) => (await page.screenshot({ clip: { x: Math.max(0, box.x - CLIP_MARGIN_PX), y: Math.max(0, box.y - CLIP_MARGIN_PX), width: box.width + 2 * CLIP_MARGIN_PX, height: box.height + 2 * CLIP_MARGIN_PX } })).toString('base64');
|
|
13
|
+
export const toMs = (list) => String(list).split(',').map((s) => s.trim()).map((s) => (s.endsWith('ms') ? parseFloat(s) : parseFloat(s) * 1000));
|
|
14
|
+
|
|
15
|
+
// Records one gesture: the tracer follows `root` and its subtree while `action` runs.
|
|
16
|
+
export const traced = async (page, root, action) => {
|
|
17
|
+
await root.evaluate((n) => window.__mt.begin('m', n));
|
|
18
|
+
const from = await page.evaluate(() => window.__mt.now());
|
|
19
|
+
await action();
|
|
20
|
+
const rec = await page.evaluate(() => window.__mt.end('m'));
|
|
21
|
+
const to = await page.evaluate(() => window.__mt.now());
|
|
22
|
+
const loaf = await page.evaluate(() => window.__mt.loaf);
|
|
23
|
+
return { rec, motion: analyzeRecording(rec, { longFrames: longWindows(loaf, from, unattributedLimit(page)) }), long: longFramesBetween(loaf, from, to, unattributedLimit(page)) };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// How long a frame with no application script must be to count: LONG_FRAME_MS, or more on a machine
|
|
27
|
+
// whose frames are slow with nothing to do (see calibrateFrames).
|
|
28
|
+
const limits = new WeakMap();
|
|
29
|
+
export const setUnattributedLimit = (page, ms) => limits.set(page, ms);
|
|
30
|
+
export const unattributedLimit = (page) => limits.get(page) ?? LONG_FRAME_MS;
|
|
31
|
+
|
|
32
|
+
// An auxiliary page next to the audited one. A page from `browser.newPage()` owns its context, which refuses
|
|
33
|
+
// further pages: the auxiliary page then lives in a context of its own (with the page's session when `session`)
|
|
34
|
+
// that closes with it.
|
|
35
|
+
export const auxPage = async (context, { session = false } = {}) => {
|
|
36
|
+
try {
|
|
37
|
+
return await context.newPage();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
const browser = context.browser();
|
|
40
|
+
if (!browser || !/browser.newContext()/.test(String(e?.message))) throw e;
|
|
41
|
+
const own = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1, ...(session ? { storageState: await context.storageState() } : {}) });
|
|
42
|
+
const page = await own.newPage();
|
|
43
|
+
const close = page.close.bind(page);
|
|
44
|
+
page.close = async (options) => { await close(options).catch(() => {}); await own.close().catch(() => {}); };
|
|
45
|
+
return page;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// The longest frame of the audited page while nothing touches it (ambient animations, a busy
|
|
50
|
+
// machine): a frame at least that long during a gesture says nothing about the gesture.
|
|
51
|
+
export const idleFrameNoise = async (page, ms = IDLE_NOISE_MS) => {
|
|
52
|
+
const from = await page.evaluate(() => window.__mt.now());
|
|
53
|
+
await page.waitForTimeout(ms);
|
|
54
|
+
const loaf = await page.evaluate(() => window.__mt.loaf);
|
|
55
|
+
return Math.max(0, ...loaf.filter(([s, d]) => s + d >= from).map(([, d]) => d));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Measures the frames of a blank page doing what the audit does (hover, leave, screenshot): the
|
|
59
|
+
// longest one is the noise of this machine. Returns 0 where Long Animation Frames are unsupported.
|
|
60
|
+
export const calibrateFrames = async (context) => {
|
|
61
|
+
const page = await auxPage(context);
|
|
62
|
+
try {
|
|
63
|
+
await page.setViewportSize(VIEWPORT);
|
|
64
|
+
await page.setContent('<body style="margin:0"><style>button{margin:40px;padding:8px 14px;transition:background-color 140ms}button:hover{background:#ddd}</style><button id="b">Probe</button>' + '<p>text</p>'.repeat(200) + '</body>');
|
|
65
|
+
await page.evaluate(() => {
|
|
66
|
+
window.__noise = [];
|
|
67
|
+
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) new PerformanceObserver((l) => { for (const e of l.getEntries()) window.__noise.push(e.duration); }).observe({ type: 'long-animation-frame', buffered: true });
|
|
68
|
+
});
|
|
69
|
+
const box = await (await page.$('#b')).boundingBox();
|
|
70
|
+
const clip = { x: box.x - 6, y: box.y - 6, width: box.width + 12, height: box.height + 12 };
|
|
71
|
+
for (let i = 0; i < CALIBRATION_CYCLES; i++) {
|
|
72
|
+
await page.mouse.move(box.x + 5, box.y + 5);
|
|
73
|
+
await page.waitForTimeout(150);
|
|
74
|
+
await page.screenshot({ clip });
|
|
75
|
+
await page.mouse.move(1, 1);
|
|
76
|
+
await page.waitForTimeout(150);
|
|
77
|
+
}
|
|
78
|
+
return Math.max(0, ...(await page.evaluate(() => window.__noise)));
|
|
79
|
+
} catch {
|
|
80
|
+
return 0;
|
|
81
|
+
} finally {
|
|
82
|
+
await page.close().catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The part of a frame's time that is the audit's own work: scripts with no source URL (the tracer's
|
|
87
|
+
// frame callback, page.evaluate) minus the style and layout they forced, which the page's own
|
|
88
|
+
// changes made necessary.
|
|
89
|
+
const auditWork = (scripts) => scripts.filter((s) => !s.url).reduce((sum, s) => sum + Math.max(0, s.duration - (s.forced ?? 0)), 0);
|
|
90
|
+
|
|
91
|
+
// Long frames (over LONG_FRAME_MS) in [from, to] that are the page's: what is left of a frame once
|
|
92
|
+
// the audit's own script time is taken out. Frames with no script at all (style, layout, paint) count
|
|
93
|
+
// when they are over `unattributedMs`, and so do frames whose page scripts are short (PAGE_SCRIPT_MIN_MS).
|
|
94
|
+
const pageScripts = (scripts) => scripts.filter((x) => x.url).reduce((sum, x) => sum + x.duration, 0);
|
|
95
|
+
const pageOwned = (d, scripts, unattributedMs) => d - auditWork(scripts) > (pageScripts(scripts) >= PAGE_SCRIPT_MIN_MS ? LONG_FRAME_MS : unattributedMs);
|
|
96
|
+
export const longFramesBetween = (loaf, from, to = Infinity, unattributedMs = LONG_FRAME_MS) => loaf
|
|
97
|
+
.filter(([s, d, scripts]) => s + d >= from && s <= to && pageOwned(d, scripts ?? [], unattributedMs))
|
|
98
|
+
.map(([, d, scripts]) => ({ duration: Math.round(d), scripts: (scripts ?? []).filter((x) => x.url) }));
|
|
99
|
+
|
|
100
|
+
// [[start, duration]] of the page's long frames (the ones longFramesBetween reports) that end after `from`:
|
|
101
|
+
// a step in one of them is that frame. A frame no longer than the machine's own noise explains nothing.
|
|
102
|
+
export const longWindows = (loaf, from, unattributedMs = LONG_FRAME_MS) => loaf
|
|
103
|
+
.filter(([s, d, scripts]) => s + d >= from && pageOwned(d, scripts ?? [], unattributedMs))
|
|
104
|
+
.map(([s, d]) => [s, d]);
|
|
105
|
+
|
|
106
|
+
// Which scripts a long frame is attributed to, from the LoAF `scripts[]`.
|
|
107
|
+
export const describeLong = (long) => {
|
|
108
|
+
const frames = long.map((l) => l.duration);
|
|
109
|
+
const byUrl = new Map();
|
|
110
|
+
for (const l of long) for (const s of l.scripts ?? []) {
|
|
111
|
+
const name = s.url ? s.url.replace(/^.*\//, '').replace(/\?.*$/, '') || s.url : '(inline or injected script)';
|
|
112
|
+
byUrl.set(name, (byUrl.get(name) ?? 0) + s.duration);
|
|
113
|
+
}
|
|
114
|
+
const top = [...byUrl.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([n, d]) => `${n} ${d} ms`);
|
|
115
|
+
return { frames, culprit: top.length ? `scripts: ${top.join(', ')}` : 'no script attributed (style, layout or paint)' };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Forces CSS pseudo-classes on an element through the DevTools protocol: no pointer or focus
|
|
119
|
+
// event is dispatched, so nothing in the app reacts.
|
|
120
|
+
export const createPseudoStates = async (context, page) => {
|
|
121
|
+
const session = await context.newCDPSession(page);
|
|
122
|
+
await session.send('DOM.enable');
|
|
123
|
+
await session.send('CSS.enable');
|
|
124
|
+
const nodeId = async (handle, up = 0) => {
|
|
125
|
+
await handle.evaluate((n, levels) => { let e = n; for (let i = 0; i < levels && e.parentElement; i++) e = e.parentElement; window.__maTarget = e; }, up);
|
|
126
|
+
const { result } = await session.send('Runtime.evaluate', { expression: 'window.__maTarget' });
|
|
127
|
+
await handle.evaluate(() => { delete window.__maTarget; });
|
|
128
|
+
return (await session.send('DOM.requestNode', { objectId: result.objectId })).nodeId;
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
async force(handle, states, ancestors = {}) {
|
|
132
|
+
// Node ids are only valid until the next getDocument, so it is called once, before resolving any.
|
|
133
|
+
await session.send('DOM.getDocument', { depth: 0 });
|
|
134
|
+
const ids = [{ id: await nodeId(handle), states }];
|
|
135
|
+
for (const [up, st] of Object.entries(ancestors)) ids.push({ id: await nodeId(handle, Number(up)), states: st });
|
|
136
|
+
for (const { id, states: s } of ids) await session.send('CSS.forcePseudoState', { nodeId: id, forcedPseudoClasses: s });
|
|
137
|
+
return ids;
|
|
138
|
+
},
|
|
139
|
+
async clear(ids) { for (const { id } of ids) await session.send('CSS.forcePseudoState', { nodeId: id, forcedPseudoClasses: [] }); },
|
|
140
|
+
detach: () => session.detach().catch(() => {}),
|
|
141
|
+
session,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const sameDocument = (a, b) => {
|
|
146
|
+
const x = new URL(a);
|
|
147
|
+
const y = new URL(b);
|
|
148
|
+
return x.origin === y.origin && x.pathname.replace(/\/$/, '') === y.pathname.replace(/\/$/, '') && x.search === y.search;
|
|
149
|
+
};
|
|
150
|
+
export const sameLocation = sameDocument;
|
|
151
|
+
|
|
152
|
+
const loadedUrls = new WeakMap();
|
|
153
|
+
|
|
154
|
+
// Loads `url` fresh (never reload: a probe may have navigated) and checks where it landed: after
|
|
155
|
+
// the load, after --wait-for and after the hydration wait, because a session check or a router
|
|
156
|
+
// redirects from script, after the response.
|
|
157
|
+
export const load = async (page, url, { waitFor, allowRedirect = false } = {}) => {
|
|
158
|
+
const response = await page.goto(url, { waitUntil: 'load', timeout: NAV_TIMEOUT_MS });
|
|
159
|
+
if (response && response.status() >= 400) throw new AuditError(`${url} answered HTTP ${response.status()}`);
|
|
160
|
+
const verify = () => {
|
|
161
|
+
const landed = page.url();
|
|
162
|
+
if (sameDocument(url, landed)) return landed;
|
|
163
|
+
const requested = new URL(url);
|
|
164
|
+
const final = new URL(landed);
|
|
165
|
+
if (LOGIN_PATH.test(final.pathname + final.search) && !LOGIN_PATH.test(requested.pathname)) {
|
|
166
|
+
throw new AuditError(`${url} redirected to ${landed}: that is a login page, not the page to audit. Pass --storage-state <file> with a logged-in session`);
|
|
167
|
+
}
|
|
168
|
+
if (!allowRedirect) throw new AuditError(`${url} redirected to ${landed}: the page audited is not the one requested. Audit that URL, or pass --allow-redirect to audit where it lands`);
|
|
169
|
+
return landed;
|
|
170
|
+
};
|
|
171
|
+
verify();
|
|
172
|
+
if (waitFor) {
|
|
173
|
+
try { await page.waitForSelector(waitFor, { state: 'visible', timeout: WAIT_FOR_TIMEOUT_MS }); } catch (e) {
|
|
174
|
+
throw new AuditError(`--wait-for "${waitFor}" did not appear within ${WAIT_FOR_TIMEOUT_MS / 1000} s on ${page.url()}: ${e.message.split('\n')[0]}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// The first load of a URL gets the full hydration wait; the probes' reloads of it a short one,
|
|
178
|
+
// then until its own entrance animations have finished.
|
|
179
|
+
const reload = loadedUrls.get(page)?.has(url);
|
|
180
|
+
await page.waitForTimeout(reload ? RELOAD_WAIT_MS : HYDRATION_WAIT_MS);
|
|
181
|
+
await page.evaluate((cap) => new Promise((resolve) => {
|
|
182
|
+
const end = performance.now() + cap;
|
|
183
|
+
const check = () => (document.getAnimations().some((a) => a.playState === 'running' && Number.isFinite(a.effect?.getComputedTiming().endTime)) && performance.now() < end ? setTimeout(check, 50) : resolve());
|
|
184
|
+
check();
|
|
185
|
+
}), SETTLE_MAX_MS).catch(() => {});
|
|
186
|
+
if (!loadedUrls.has(page)) loadedUrls.set(page, new Set());
|
|
187
|
+
loadedUrls.get(page).add(url);
|
|
188
|
+
const landed = verify();
|
|
189
|
+
return { landed, redirected: !sameDocument(url, landed), loadedAt: Date.now() };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Compares two screenshots (base64 PNG) the way an eye does: the share of pixels that differ by more
|
|
193
|
+
// than PIXEL_CHANNEL_TOLERANCE in some channel. Anti-aliasing and compositing noise stay below it.
|
|
194
|
+
// Decoding happens in a blank page of the same context, away from the page under audit.
|
|
195
|
+
export const createPixelComparer = async (context) => {
|
|
196
|
+
const page = await auxPage(context);
|
|
197
|
+
const stats = (a, b, tolerance) => page.evaluate(async ([x, y, tol, block]) => {
|
|
198
|
+
const decode = async (b64) => {
|
|
199
|
+
const bin = atob(b64);
|
|
200
|
+
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
201
|
+
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
202
|
+
const ctx = new OffscreenCanvas(bmp.width, bmp.height).getContext('2d');
|
|
203
|
+
ctx.drawImage(bmp, 0, 0);
|
|
204
|
+
return ctx.getImageData(0, 0, bmp.width, bmp.height);
|
|
205
|
+
};
|
|
206
|
+
const [p, q] = await Promise.all([decode(x), decode(y)]);
|
|
207
|
+
const bw = Math.ceil(p.width / block);
|
|
208
|
+
const bh = Math.ceil(p.height / block);
|
|
209
|
+
if (p.width !== q.width || p.height !== q.height) return { changed: bw * bh, total: bw * bh, max: 255 };
|
|
210
|
+
let changed = 0;
|
|
211
|
+
let max = 0;
|
|
212
|
+
for (let by = 0; by < bh; by++) {
|
|
213
|
+
for (let bx = 0; bx < bw; bx++) {
|
|
214
|
+
const sum = [0, 0, 0, 0, 0, 0, 0, 0];
|
|
215
|
+
let n = 0;
|
|
216
|
+
for (let y = by * block; y < Math.min(p.height, (by + 1) * block); y++) {
|
|
217
|
+
for (let x = bx * block; x < Math.min(p.width, (bx + 1) * block); x++) {
|
|
218
|
+
const i = (y * p.width + x) * 4;
|
|
219
|
+
for (let c = 0; c < 4; c++) { sum[c] += p.data[i + c]; sum[c + 4] += q.data[i + c]; }
|
|
220
|
+
n++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const d = Math.max(...[0, 1, 2, 3].map((c) => Math.abs(sum[c] - sum[c + 4]) / n));
|
|
224
|
+
if (d > tol) changed++;
|
|
225
|
+
if (d > max) max = d;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { changed, total: bw * bh, max: Math.round(max) };
|
|
229
|
+
}, [a, b, tolerance, PIXEL_BLOCK_PX]);
|
|
230
|
+
return {
|
|
231
|
+
stats: (a, b, tolerance = PIXEL_CHANNEL_TOLERANCE) => (a === b ? Promise.resolve({ changed: 0, total: 1, max: 0 }) : stats(a, b, tolerance)),
|
|
232
|
+
// Whether the two screenshots show a visible difference.
|
|
233
|
+
async differ(a, b) {
|
|
234
|
+
if (a === b) return false;
|
|
235
|
+
const { changed } = await stats(a, b, PIXEL_CHANNEL_TOLERANCE);
|
|
236
|
+
return changed >= PIXEL_MIN_BLOCKS;
|
|
237
|
+
},
|
|
238
|
+
close: () => page.close().catch(() => {}),
|
|
239
|
+
};
|
|
240
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Command-line entry: argument handling, output, exit codes.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { USAGE, UsageError, parseArgs } from './args.mjs';
|
|
6
|
+
import { audit, exitCode } from './audit.mjs';
|
|
7
|
+
import { compareReports, readReport } from './compare.mjs';
|
|
8
|
+
import { ConfigError } from './config.mjs';
|
|
9
|
+
import { AuditError } from './browser.mjs';
|
|
10
|
+
import { DEFAULT_MAX_ELEMENTS } from './constants.mjs';
|
|
11
|
+
|
|
12
|
+
const toUrl = (target) => (/^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : existsSync(target) ? pathToFileURL(resolve(target)).href : target);
|
|
13
|
+
|
|
14
|
+
const summary = (r) => {
|
|
15
|
+
const n = (m) => r.elements.filter((e) => e.moments[m]?.changed.length || e.moments[m]?.pixels).length;
|
|
16
|
+
const c = r.coverage;
|
|
17
|
+
const skipped = Object.entries(c.skipped).map(([reason, s]) => `${s.count} ${reason}`);
|
|
18
|
+
const lines = [`audited ${c.audited} of ${c.candidates} controls${skipped.length ? ` (not audited: ${skipped.join(', ')})` : ''} · mode ${r.mode}`];
|
|
19
|
+
lines.push(`respond on hover ${n('hover')} · press ${n('press')} · focus ${n('focus')} · sections with entrance ${r.sections.filter((s) => s.animated).length}/${r.sections.length}${r.loafSupported ? '' : ' · long frames: not supported by this browser'}${r.cssUnreadable ? ` · ${r.cssUnreadable} stylesheets not read` : ''}`);
|
|
20
|
+
if (c.inconclusive) lines.push(`inconclusive: ${c.inconclusive} check(s) could not be decided because frames were too sparse; rerun on a quieter machine, without --cpu`);
|
|
21
|
+
const t = c.toggles;
|
|
22
|
+
const togglesSkipped = Object.entries(t.skipped).filter(([, v]) => v > 0);
|
|
23
|
+
lines.push(`toggles: ${t.probed} probed of ${t.candidates}${togglesSkipped.length ? ` (not probed: ${togglesSkipped.map(([k, v]) => `${v} ${k}`).join(', ')})` : ''}`);
|
|
24
|
+
for (const [reason, s] of Object.entries(c.skipped)) if (s.examples.length) lines.push(` NOT RUN ${reason}: ${s.examples.join('; ')}`);
|
|
25
|
+
for (const note of r.notes) lines.push(`note: ${note}`);
|
|
26
|
+
return lines;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const printText = (r, code, opts) => {
|
|
30
|
+
for (const line of summary(r)) console.log(line);
|
|
31
|
+
for (const f of r.findings) console.log(`${f.level.padEnd(7)} ${f.kind}${f.check ? ` (${f.check})` : ''}: ${f.element}: ${f.detail} [${f.id}]`);
|
|
32
|
+
if (r.baselined.length) console.log(`${r.baselined.length} finding(s) already in the baseline`);
|
|
33
|
+
for (const s of r.suppressed) console.log(`suppressed ${s.kind}: ${s.element} [${s.id}]: ${s.reason}`);
|
|
34
|
+
if (r.sheets.length) console.log(`contact sheets: ${r.sheets.join(', ')}`);
|
|
35
|
+
console.log(`report: ${join(r.out, 'report.json')}`);
|
|
36
|
+
const failing = r.findings.filter((f) => f.kind !== 'inconclusive' && (f.level === 'error' || (opts.strict && f.level === 'warning')));
|
|
37
|
+
if (r.coverage.audited === 0) console.log('NOT AUDITED: no control was audited, so there is no result (exit 2)');
|
|
38
|
+
else if (r.coverage.late > 0) console.log(`INCOMPLETE: ${r.coverage.late} controls appeared after the audit started, so they were not audited; re-run with --wait-for <selector> (exit 2)`);
|
|
39
|
+
else if (code === 2) console.log(`coverage ${r.coverage.audited}/${r.coverage.candidates} is below --min-coverage ${opts.minCoverage} (exit 2)`);
|
|
40
|
+
else console.log(failing.length ? `${failing.length} to fix, or suppress in the config with a reason (${r.findings.length - failing.length} others do not fail)` : `clean: ${r.findings.length} non-failing finding(s)`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const runCompare = (argv) => {
|
|
44
|
+
if (argv.length !== 2) throw new UsageError('compare needs <ref.json> <mine.json>');
|
|
45
|
+
const c = compareReports(readReport(argv[0]), readReport(argv[1]));
|
|
46
|
+
for (const l of c.lines) console.log(l);
|
|
47
|
+
for (const f of c.added) console.log(`new in mine: ${f.kind}: ${f.element} [${f.id}]`);
|
|
48
|
+
for (const f of c.gone) console.log(`gone from mine: ${f.kind}: ${f.element} [${f.id}]`);
|
|
49
|
+
console.log(`${c.differ} of ${c.total} differ`);
|
|
50
|
+
return 0;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Returns the exit code.
|
|
54
|
+
export const main = async (argv) => {
|
|
55
|
+
try {
|
|
56
|
+
if (argv[0] === 'compare') return runCompare(argv.slice(1));
|
|
57
|
+
const { opts, positional } = parseArgs(argv);
|
|
58
|
+
if (opts.help) { console.log(USAGE); return 0; }
|
|
59
|
+
if (positional.length !== 1) throw new UsageError(positional.length ? `expected one url or file, got ${positional.length}` : 'missing <url|file> (see --help)');
|
|
60
|
+
const report = await audit(toUrl(positional[0]), { ...opts, max: opts.max ?? DEFAULT_MAX_ELEMENTS });
|
|
61
|
+
const code = exitCode(report, { strict: opts.strict, minCoverage: opts.minCoverage });
|
|
62
|
+
if (opts.json) console.log(JSON.stringify(report, null, 1));
|
|
63
|
+
else printText(report, code, opts);
|
|
64
|
+
return code;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e instanceof UsageError) { console.error(`${e.message}\n\n${USAGE}`); return 2; }
|
|
67
|
+
if (e instanceof ConfigError || e instanceof AuditError) { console.error(`could not run: ${e.message}`); return 2; }
|
|
68
|
+
console.error(`could not run: ${e.stack ?? e.message}`);
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
};
|