html-minifier-setup 0.0.0-stage → 0.1.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/LICENSE.txt +21 -0
- package/README.md +111 -2
- package/cli.js +199 -0
- package/package.json +57 -3
- package/src/catalog.js +158 -0
- package/src/color.js +17 -0
- package/src/equivalence.js +68 -0
- package/src/hazards.js +329 -0
- package/src/index.js +8 -0
- package/src/measure.js +60 -0
- package/src/pages.js +230 -0
- package/src/progress.js +165 -0
- package/src/render.js +396 -0
- package/src/report.js +251 -0
- package/src/select.js +508 -0
- package/src/setup.js +227 -0
package/src/progress.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A one-line progress indicator: animated on terminals, and otherwise quiet (or, if verbose, line
|
|
2
|
+
// by line)
|
|
3
|
+
|
|
4
|
+
import { CSI, ESC, paint, supportsColor } from './color.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} ProgressEvent
|
|
8
|
+
* @prop {'read' | 'start' | 'evaluate' | 'render' | 'contribute' | 'presets'} phase
|
|
9
|
+
* @prop {string} [detail] What is being worked on
|
|
10
|
+
* @prop {number} [current] Steps done in the phase
|
|
11
|
+
* @prop {number} [total] Steps in the phase, if known
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} Progress
|
|
16
|
+
* @prop {(event: ProgressEvent) => void} update
|
|
17
|
+
* @prop {(message: string) => void} log Prints a message above the indicator
|
|
18
|
+
* @prop {(settings?: {ok?: boolean}) => boolean} stop Returns whether the indicator was drawn
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RE_ANSI = new RegExp(`${ESC}\\[[\\d;?]*[A-Za-z]`, 'g');
|
|
22
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
23
|
+
const WIDTH_BAR = 24;
|
|
24
|
+
const INTERVAL = 80;
|
|
25
|
+
|
|
26
|
+
/** @type {Record<ProgressEvent['phase'], string>} */
|
|
27
|
+
export const TITLES = {
|
|
28
|
+
read: 'Reading pages',
|
|
29
|
+
start: 'Starting browser',
|
|
30
|
+
evaluate: 'Evaluating options',
|
|
31
|
+
render: 'Rendering',
|
|
32
|
+
contribute: 'Measuring contributions',
|
|
33
|
+
presets: 'Comparing presets'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** @param {string} text */
|
|
37
|
+
export const visibleLength = (text) => [...text.replace(RE_ANSI, '')].length;
|
|
38
|
+
|
|
39
|
+
/** @param {number} ms */
|
|
40
|
+
const formatElapsed = (ms) => {
|
|
41
|
+
const seconds = Math.floor(ms / 1000);
|
|
42
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} text
|
|
47
|
+
* @param {number} width
|
|
48
|
+
*/
|
|
49
|
+
const truncate = (text, width) => {
|
|
50
|
+
const chars = [...text];
|
|
51
|
+
if (width <= 0) return '';
|
|
52
|
+
return chars.length > width ? `${chars.slice(0, Math.max(0, width - 1)).join('')}…` : text;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One frame of the indicator, fit to the terminal width
|
|
57
|
+
* @param {{title: string, detail: string, current?: number, total?: number, elapsed: number, frame: number, columns: number, color: boolean}} state
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function formatLine({ title, detail, current = 0, total, elapsed, frame, columns, color }) {
|
|
61
|
+
const spinner = paint('accent', FRAMES[frame % FRAMES.length] ?? '', color);
|
|
62
|
+
/** @param {string} text */
|
|
63
|
+
const head = (text) => `${spinner} ${paint('bold', text, color)}`;
|
|
64
|
+
const time = paint('dim', formatElapsed(elapsed), color);
|
|
65
|
+
/** @type {string[]} */
|
|
66
|
+
const parts = [head(title)];
|
|
67
|
+
if (total) {
|
|
68
|
+
const ratio = Math.min(1, Math.max(0, current / total));
|
|
69
|
+
const filled = Math.round(ratio * WIDTH_BAR);
|
|
70
|
+
// Without color, the rest of the bar needs a lighter line to show
|
|
71
|
+
const bar = paint('accent', '━'.repeat(filled), color) + paint('dim', (color ? '━' : '─').repeat(WIDTH_BAR - filled), color);
|
|
72
|
+
parts.push(`${bar} ${`${Math.floor(ratio * 100)}%`.padStart(4)}`);
|
|
73
|
+
}
|
|
74
|
+
parts.push(time);
|
|
75
|
+
const budget = columns - 1;
|
|
76
|
+
// Where even without a detail the line is too long, the bar goes first
|
|
77
|
+
if (visibleLength(parts.join(' ')) > budget && parts.length > 2) parts.splice(1, 1);
|
|
78
|
+
// And then the title
|
|
79
|
+
const over = visibleLength(parts.join(' ')) - budget;
|
|
80
|
+
if (over > 0) parts[0] = head(truncate(title, [...title].length - over));
|
|
81
|
+
const room = budget - visibleLength(parts.join(' ')) - 2;
|
|
82
|
+
if (detail && room > 3) parts.push(truncate(detail, room));
|
|
83
|
+
return parts.join(' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {{stream?: {isTTY?: boolean, columns?: number, write: (chunk: string) => unknown}, color?: boolean, verbose?: boolean}} [settings]
|
|
88
|
+
* @returns {Progress}
|
|
89
|
+
*/
|
|
90
|
+
export function createProgress({ stream = process.stderr, color, verbose = false } = {}) {
|
|
91
|
+
const animated = Boolean(stream.isTTY);
|
|
92
|
+
const colored = color ?? supportsColor(stream);
|
|
93
|
+
/** @type {(ProgressEvent & {start: number}) | null} */
|
|
94
|
+
let state = null;
|
|
95
|
+
let frame = 0;
|
|
96
|
+
let lastDraw = 0;
|
|
97
|
+
let lastLine = '';
|
|
98
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
99
|
+
let timer = null;
|
|
100
|
+
|
|
101
|
+
const showCursor = () => stream.write(`${CSI}?25h`);
|
|
102
|
+
const onInterrupt = () => {
|
|
103
|
+
stream.write(`\r${CSI}2K`);
|
|
104
|
+
showCursor();
|
|
105
|
+
process.exit(130);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const draw = () => {
|
|
109
|
+
if (!state) return;
|
|
110
|
+
lastDraw = Date.now();
|
|
111
|
+
frame++;
|
|
112
|
+
stream.write(`\r${CSI}2K${formatLine({
|
|
113
|
+
title: TITLES[state.phase],
|
|
114
|
+
detail: state.detail ?? '',
|
|
115
|
+
current: state.current ?? 0,
|
|
116
|
+
...(state.total ? { total: state.total } : {}),
|
|
117
|
+
elapsed: Date.now() - state.start,
|
|
118
|
+
frame,
|
|
119
|
+
columns: stream.columns || 80,
|
|
120
|
+
color: colored
|
|
121
|
+
})}`);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** @param {boolean} ok */
|
|
125
|
+
const finish = (ok) => {
|
|
126
|
+
if (!state || !animated) return;
|
|
127
|
+
const mark = ok ? paint('success', '✔', colored) : paint('failure', '✖', colored);
|
|
128
|
+
stream.write(`\r${CSI}2K${mark} ${TITLES[state.phase]} ${paint('dim', formatElapsed(Date.now() - state.start), colored)}\n`);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
update(event) {
|
|
133
|
+
if (state && state.phase !== event.phase) finish(true);
|
|
134
|
+
state = { ...event, start: state && state.phase === event.phase ? state.start : Date.now() };
|
|
135
|
+
if (!animated) {
|
|
136
|
+
const line = `${TITLES[event.phase]}${event.detail ? `: ${event.detail}` : ''}`;
|
|
137
|
+
if (verbose && line !== lastLine) stream.write(`${line}\n`);
|
|
138
|
+
lastLine = line;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!timer) {
|
|
142
|
+
stream.write(`${CSI}?25l`);
|
|
143
|
+
process.once('SIGINT', onInterrupt);
|
|
144
|
+
timer = setInterval(draw, INTERVAL);
|
|
145
|
+
timer.unref();
|
|
146
|
+
}
|
|
147
|
+
// Drawn on updates, too, as long stretches of minification leave the timer no turn
|
|
148
|
+
if (Date.now() - lastDraw >= INTERVAL) draw();
|
|
149
|
+
},
|
|
150
|
+
log(message) {
|
|
151
|
+
stream.write(animated ? `\r${CSI}2K${message}\n` : `${message}\n`);
|
|
152
|
+
if (animated) draw();
|
|
153
|
+
},
|
|
154
|
+
stop({ ok = true } = {}) {
|
|
155
|
+
finish(ok);
|
|
156
|
+
state = null;
|
|
157
|
+
if (!timer) return false;
|
|
158
|
+
clearInterval(timer);
|
|
159
|
+
timer = null;
|
|
160
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
161
|
+
showCursor();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Renders pages as written and as minified in a headless browser, and compares screenshots, text,
|
|
2
|
+
// and script errors—what catches CSS and scripts relying on what minification changed
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { isInside } from './pages.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} Verdict
|
|
13
|
+
* @prop {boolean} equal
|
|
14
|
+
* @prop {boolean} inconclusive Whether, at some viewport, nothing but script errors could be compared, and none differed
|
|
15
|
+
* @prop {string[]} reasons Why the minified page isn’t equal
|
|
16
|
+
* @prop {string[]} notes What limited the comparison
|
|
17
|
+
* @prop {string[]} [blocked] Files pages requested but didn’t get, as symbolic links lead them outside the site
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} Renderer
|
|
22
|
+
* @prop {(page: import('./pages.js').Page, html: string) => Promise<Verdict>} compare
|
|
23
|
+
* @prop {() => Promise<void>} close
|
|
24
|
+
* @prop {Viewport[]} [viewports]
|
|
25
|
+
* @prop {string} [browser] Name and major version
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @typedef {{name: string, channel?: string}} Browser */
|
|
29
|
+
|
|
30
|
+
/** @typedef {{width: number, height: number}} Viewport */
|
|
31
|
+
/** @typedef {{shot: Buffer, text: string, errors: string[], blocked: string[]}} Capture */
|
|
32
|
+
|
|
33
|
+
export const CONCURRENCY_DEFAULT = 4;
|
|
34
|
+
|
|
35
|
+
/** @type {Viewport[]} */
|
|
36
|
+
export const VIEWPORTS_DEFAULT = [{ width: 1280, height: 800 }, { width: 390, height: 844 }];
|
|
37
|
+
|
|
38
|
+
/** @type {Record<string, string>} */
|
|
39
|
+
const TYPES = {
|
|
40
|
+
'.css': 'text/css',
|
|
41
|
+
'.gif': 'image/gif',
|
|
42
|
+
'.htm': 'text/html',
|
|
43
|
+
'.html': 'text/html',
|
|
44
|
+
'.ico': 'image/x-icon',
|
|
45
|
+
'.jpeg': 'image/jpeg',
|
|
46
|
+
'.jpg': 'image/jpeg',
|
|
47
|
+
'.js': 'text/javascript',
|
|
48
|
+
'.json': 'application/json',
|
|
49
|
+
'.mjs': 'text/javascript',
|
|
50
|
+
'.png': 'image/png',
|
|
51
|
+
'.svg': 'image/svg+xml',
|
|
52
|
+
'.txt': 'text/plain',
|
|
53
|
+
'.webp': 'image/webp',
|
|
54
|
+
'.woff': 'font/woff',
|
|
55
|
+
'.woff2': 'font/woff2'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** @param {Viewport} viewport */
|
|
59
|
+
const describeViewport = ({ width, height }) => `${width}×${height}`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Settles as the page work does, or rejects where it takes longer than a responsive page would
|
|
63
|
+
* @template T
|
|
64
|
+
* @param {Promise<T>} work
|
|
65
|
+
* @param {number} limit
|
|
66
|
+
* @returns {Promise<T>}
|
|
67
|
+
*/
|
|
68
|
+
async function responsive(work, limit) {
|
|
69
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
70
|
+
let timer;
|
|
71
|
+
// The work rejects as its context closes, which is no longer of interest
|
|
72
|
+
work.catch(() => {});
|
|
73
|
+
try {
|
|
74
|
+
return await Promise.race([work, new Promise((_, reject) => {
|
|
75
|
+
timer = setTimeout(() => reject(new Error(`Page stopped responding (no answer within ${limit} ms)`)), limit);
|
|
76
|
+
})]);
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Serves a directory on a local port, so that pages load their resources as on the web
|
|
84
|
+
* @param {string | null} root
|
|
85
|
+
* @param {{followLinks?: boolean}} [settings] `followLinks`: serve what symbolic links lead to outside the root
|
|
86
|
+
* @returns {Promise<{origin: string, close: () => Promise<void>}>}
|
|
87
|
+
*/
|
|
88
|
+
export async function serve(root, { followLinks = false } = {}) {
|
|
89
|
+
const rootReal = root ? await fs.promises.realpath(root).catch(() => null) : null;
|
|
90
|
+
const server = http.createServer(async (request, response) => {
|
|
91
|
+
let pathname;
|
|
92
|
+
try {
|
|
93
|
+
pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname);
|
|
94
|
+
} catch {
|
|
95
|
+
response.writeHead(400).end();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const file = root ? path.join(root, pathname.endsWith('/') ? `${pathname}index.html` : pathname) : null;
|
|
99
|
+
const inside = Boolean(root && file && isInside(root, file));
|
|
100
|
+
// Forbidden rather than not found, for captures to tell what they didn’t get
|
|
101
|
+
const fileReal = inside && !followLinks ? await fs.promises.realpath(/** @type {string} */ (file)).catch(() => null) : null;
|
|
102
|
+
if (fileReal && !(rootReal && isInside(rootReal, fileReal))) {
|
|
103
|
+
response.writeHead(403).end();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const body = inside && file ? await fs.promises.readFile(file).catch(() => null) : null;
|
|
107
|
+
if (!body || !file) {
|
|
108
|
+
response.writeHead(404).end();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
response.writeHead(200, { 'content-type': TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream' }).end(body);
|
|
112
|
+
});
|
|
113
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(undefined)));
|
|
114
|
+
const { port } = /** @type {import('node:net').AddressInfo} */ (server.address());
|
|
115
|
+
return {
|
|
116
|
+
origin: `http://127.0.0.1:${port}`,
|
|
117
|
+
close: () => new Promise((resolve) => {
|
|
118
|
+
server.closeAllConnections();
|
|
119
|
+
server.close(() => resolve());
|
|
120
|
+
})
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The first line each text has that the other lacks, as a hint at what changed
|
|
126
|
+
* @param {string} a
|
|
127
|
+
* @param {string} b
|
|
128
|
+
*/
|
|
129
|
+
function firstDifference(a, b) {
|
|
130
|
+
const [linesA, linesB] = [a, b].map((text) => text.split('\n').map((line) => line.trim()).filter(Boolean));
|
|
131
|
+
const [setA, setB] = [new Set(linesA), new Set(linesB)];
|
|
132
|
+
const lost = linesA?.find((line) => !setB.has(line));
|
|
133
|
+
const gained = linesB?.find((line) => !setA.has(line));
|
|
134
|
+
/** @param {string | undefined} line */
|
|
135
|
+
const quote = (line) => line === undefined ? 'nothing' : `“${line.length > 40 ? `${line.slice(0, 39)}…` : line}”`;
|
|
136
|
+
return lost === undefined && gained === undefined ? 'line breaks or spacing changed' : `${quote(lost)} became ${quote(gained)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The original’s two captures as one, with what stayed the same between loads and every file
|
|
141
|
+
* either load didn’t get
|
|
142
|
+
* @param {Capture} first
|
|
143
|
+
* @param {Capture} second
|
|
144
|
+
*/
|
|
145
|
+
export const combineOriginals = (first, second) => ({
|
|
146
|
+
capture: { ...first, blocked: [...new Set([...first.blocked, ...second.blocked])] },
|
|
147
|
+
stableShot: first.shot.equals(second.shot),
|
|
148
|
+
stableText: first.text === second.text
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Playwright’s Chromium, or else browsers people tend to have, as original and minified pages are
|
|
152
|
+
// compared in the same browser
|
|
153
|
+
/** @type {Browser[]} */
|
|
154
|
+
export const BROWSERS = [{ name: 'Chromium' }, { name: 'Chrome', channel: 'chrome' }, { name: 'Edge', channel: 'msedge' }];
|
|
155
|
+
|
|
156
|
+
/** The path of Playwright’s command line interface, if installed */
|
|
157
|
+
export function playwrightCli() {
|
|
158
|
+
try {
|
|
159
|
+
return path.join(path.dirname(createRequire(import.meta.url).resolve('playwright/package.json')), 'cli.js');
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Downloads Playwright’s Chromium, showing the installer’s output
|
|
167
|
+
* @returns {Promise<boolean>}
|
|
168
|
+
*/
|
|
169
|
+
export const installChromium = () => new Promise((resolve) => {
|
|
170
|
+
const fileCli = playwrightCli();
|
|
171
|
+
if (!fileCli) return resolve(false);
|
|
172
|
+
const child = spawn(process.execPath, [fileCli, 'install', 'chromium'], { stdio: 'inherit' });
|
|
173
|
+
child.on('error', () => resolve(false));
|
|
174
|
+
child.on('close', (code) => resolve(code === 0));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Runs functions given no more than `limit` at a time, in the order given
|
|
179
|
+
* @param {number} limit
|
|
180
|
+
*/
|
|
181
|
+
export function createLimit(limit) {
|
|
182
|
+
let active = 0;
|
|
183
|
+
/** @type {(() => void)[]} */
|
|
184
|
+
const queue = [];
|
|
185
|
+
/**
|
|
186
|
+
* @template T
|
|
187
|
+
* @param {() => Promise<T>} fn
|
|
188
|
+
* @returns {Promise<T>}
|
|
189
|
+
*/
|
|
190
|
+
return async (fn) => {
|
|
191
|
+
if (active < limit) {
|
|
192
|
+
active++;
|
|
193
|
+
} else {
|
|
194
|
+
await new Promise((resolve) => queue.push(() => resolve(undefined)));
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return await fn();
|
|
198
|
+
} finally {
|
|
199
|
+
// Hands the slot to the next in line, if any
|
|
200
|
+
const next = queue.shift();
|
|
201
|
+
if (next) {
|
|
202
|
+
next();
|
|
203
|
+
} else {
|
|
204
|
+
active--;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A renderer using the first browser that starts, or, where none does, why not
|
|
212
|
+
* @param {{viewports?: Viewport[], timeout?: number, browsers?: Browser[], concurrency?: number, followLinks?: boolean}} [settings] `concurrency`: pages rendered at once; `followLinks`: as for `serve`
|
|
213
|
+
* @returns {Promise<{renderer: Renderer | null, reason: string, missing?: boolean}>}
|
|
214
|
+
*/
|
|
215
|
+
export async function createRenderer({ viewports = VIEWPORTS_DEFAULT, timeout = 30000, browsers = BROWSERS, concurrency = CONCURRENCY_DEFAULT, followLinks = false } = {}) {
|
|
216
|
+
if (!viewports.length || viewports.some(({ width, height }) => !(width >= 1 && height >= 1))) {
|
|
217
|
+
throw new Error(`Invalid viewports: ${JSON.stringify(viewports)} (at least one, each at least 1×1)`);
|
|
218
|
+
}
|
|
219
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error(`Invalid concurrency: ${concurrency} (pages rendered at once, at least 1)`);
|
|
220
|
+
/** @type {typeof import('playwright') | null} */
|
|
221
|
+
const playwright = await import('playwright').catch(() => null);
|
|
222
|
+
if (!playwright) return { renderer: null, reason: 'Playwright isn’t installed' };
|
|
223
|
+
/** @type {import('playwright').Browser | null} */
|
|
224
|
+
let browser = null;
|
|
225
|
+
let nameBrowser = '';
|
|
226
|
+
for (const { name, channel } of browsers) {
|
|
227
|
+
try {
|
|
228
|
+
browser = await playwright.chromium.launch(channel ? { channel } : {});
|
|
229
|
+
nameBrowser = `${name} ${browser.version().split('.')[0]}`;
|
|
230
|
+
break;
|
|
231
|
+
} catch {
|
|
232
|
+
// Tries the next browser
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!browser) {
|
|
236
|
+
return { renderer: null, reason: `No browser found (${browsers.map(({ name }) => name).join(', ')})—\`npx playwright install chromium\` downloads Chromium`, missing: true };
|
|
237
|
+
}
|
|
238
|
+
const browserOpen = browser;
|
|
239
|
+
|
|
240
|
+
/** @type {Map<string | null, ReturnType<typeof serve>>} */
|
|
241
|
+
const servers = new Map();
|
|
242
|
+
/** @type {WeakMap<import('./pages.js').Page, Map<string, Promise<{capture: Capture, stableShot: boolean, stableText: boolean}>>>} */
|
|
243
|
+
const originals = new WeakMap();
|
|
244
|
+
/** @type {WeakMap<import('./pages.js').Page, Map<string, Promise<Verdict>>>} */
|
|
245
|
+
const verdicts = new WeakMap();
|
|
246
|
+
// Bounds all browser work, which is otherwise started at once
|
|
247
|
+
const limit = createLimit(concurrency);
|
|
248
|
+
|
|
249
|
+
/** @param {import('./pages.js').Page} page */
|
|
250
|
+
async function urlOf(page) {
|
|
251
|
+
if (page.url) return page.url;
|
|
252
|
+
let server = servers.get(page.root);
|
|
253
|
+
if (!server) {
|
|
254
|
+
server = serve(page.root, { followLinks });
|
|
255
|
+
servers.set(page.root, server);
|
|
256
|
+
}
|
|
257
|
+
return `${(await server).origin}/${page.id.split('/').map(encodeURIComponent).join('/')}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @param {import('./pages.js').Page} page
|
|
262
|
+
* @param {string} html
|
|
263
|
+
* @param {Viewport} viewport
|
|
264
|
+
* @returns {Promise<Capture>}
|
|
265
|
+
*/
|
|
266
|
+
async function capture(page, html, viewport) {
|
|
267
|
+
const url = await urlOf(page);
|
|
268
|
+
// Requests leave out fragments
|
|
269
|
+
const urlRequested = new URL(url);
|
|
270
|
+
urlRequested.hash = '';
|
|
271
|
+
return limit(async () => {
|
|
272
|
+
const context = await browserOpen.newContext({ viewport, deviceScaleFactor: 1, reducedMotion: 'reduce' });
|
|
273
|
+
try {
|
|
274
|
+
const tab = await context.newPage();
|
|
275
|
+
/** @type {string[]} */
|
|
276
|
+
const errors = [];
|
|
277
|
+
tab.on('pageerror', (err) => errors.push(err.message));
|
|
278
|
+
tab.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
|
|
279
|
+
/** @type {string[]} */
|
|
280
|
+
const blocked = [];
|
|
281
|
+
if (!page.url) {
|
|
282
|
+
tab.on('response', (response) => {
|
|
283
|
+
const target = new URL(response.url());
|
|
284
|
+
if (response.status() === 403 && target.origin === urlRequested.origin) blocked.push(decodeURIComponent(target.pathname.slice(1)));
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
await tab.route((target) => target.href === urlRequested.href, (route) => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: html }));
|
|
288
|
+
await tab.goto(url, { waitUntil: 'load', timeout });
|
|
289
|
+
// For fonts and two frames, but not beyond the timeout, as either may never come
|
|
290
|
+
await responsive(tab.evaluate(async (wait) => {
|
|
291
|
+
const settled = (async () => {
|
|
292
|
+
await document.fonts?.ready;
|
|
293
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
294
|
+
})();
|
|
295
|
+
await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, wait))]);
|
|
296
|
+
}, timeout), 2 * timeout);
|
|
297
|
+
const shot = await tab.screenshot({ fullPage: true, animations: 'disabled', caret: 'hide', timeout });
|
|
298
|
+
const text = await responsive(tab.evaluate(() => document.body?.innerText ?? ''), timeout);
|
|
299
|
+
return { shot, text, errors, blocked };
|
|
300
|
+
} finally {
|
|
301
|
+
await context.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Renders the original twice, as what differs between loads (e.g., animations, ads, or
|
|
308
|
+
* dates) can’t tell minification apart
|
|
309
|
+
* @param {import('./pages.js').Page} page
|
|
310
|
+
* @param {Viewport} viewport
|
|
311
|
+
*/
|
|
312
|
+
function original(page, viewport) {
|
|
313
|
+
const byViewport = originals.get(page) ?? new Map();
|
|
314
|
+
originals.set(page, byViewport);
|
|
315
|
+
const key = describeViewport(viewport);
|
|
316
|
+
let pending = byViewport.get(key);
|
|
317
|
+
if (!pending) {
|
|
318
|
+
pending = (async () => {
|
|
319
|
+
const [first, second] = await Promise.all([capture(page, page.html, viewport), capture(page, page.html, viewport)]);
|
|
320
|
+
return combineOriginals(first, second);
|
|
321
|
+
})();
|
|
322
|
+
byViewport.set(key, pending);
|
|
323
|
+
}
|
|
324
|
+
return pending;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {import('./pages.js').Page} page
|
|
329
|
+
* @param {string} html
|
|
330
|
+
* @returns {Promise<Verdict>}
|
|
331
|
+
*/
|
|
332
|
+
async function compareUncached(page, html) {
|
|
333
|
+
const results = await Promise.all(viewports.map(async (viewport) => {
|
|
334
|
+
/** @type {string[]} */
|
|
335
|
+
const reasons = [];
|
|
336
|
+
/** @type {string[]} */
|
|
337
|
+
const notes = [];
|
|
338
|
+
let blind = false;
|
|
339
|
+
const at = describeViewport(viewport);
|
|
340
|
+
const [{ capture: before, stableShot, stableText }, after] = await Promise.all([original(page, viewport), capture(page, html, viewport)]);
|
|
341
|
+
if (!stableShot && !stableText) {
|
|
342
|
+
blind = true;
|
|
343
|
+
notes.push(`Looks and reads different between loads at ${at}, so only script errors were compared`);
|
|
344
|
+
} else if (!stableShot) {
|
|
345
|
+
notes.push(`Looks different between loads at ${at}, so screenshots weren’t compared`);
|
|
346
|
+
} else if (!stableText) {
|
|
347
|
+
notes.push(`Text differs between loads at ${at}, so text wasn’t compared`);
|
|
348
|
+
}
|
|
349
|
+
if (stableShot && !before.shot.equals(after.shot)) {
|
|
350
|
+
reasons.push(`Looks different at ${at}`);
|
|
351
|
+
}
|
|
352
|
+
if (stableText && before.text !== after.text) {
|
|
353
|
+
reasons.push(`Text differs at ${at}: ${firstDifference(before.text, after.text)}`);
|
|
354
|
+
}
|
|
355
|
+
const known = [...before.errors];
|
|
356
|
+
for (const error of after.errors) {
|
|
357
|
+
const index = known.indexOf(error);
|
|
358
|
+
if (index === -1) {
|
|
359
|
+
reasons.push(`New script error at ${at}: ${error.split('\n')[0]?.slice(0, 120)}`);
|
|
360
|
+
} else {
|
|
361
|
+
known.splice(index, 1);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { reasons, notes, blind, blocked: [...before.blocked, ...after.blocked] };
|
|
365
|
+
}));
|
|
366
|
+
const reasons = [...new Set(results.flatMap((result) => result.reasons))];
|
|
367
|
+
const notes = [...new Set(results.flatMap((result) => result.notes))];
|
|
368
|
+
const inconclusive = results.some((result) => result.blind) && reasons.length === 0;
|
|
369
|
+
const blocked = [...new Set(results.flatMap((result) => result.blocked))].sort();
|
|
370
|
+
return { equal: reasons.length === 0 && !inconclusive, inconclusive, reasons, notes, blocked };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
reason: '',
|
|
375
|
+
renderer: {
|
|
376
|
+
viewports,
|
|
377
|
+
browser: nameBrowser,
|
|
378
|
+
compare(page, html) {
|
|
379
|
+
// The same output, as different options may produce, renders the same
|
|
380
|
+
const byHtml = verdicts.get(page) ?? new Map();
|
|
381
|
+
verdicts.set(page, byHtml);
|
|
382
|
+
let verdict = byHtml.get(html);
|
|
383
|
+
if (!verdict) {
|
|
384
|
+
verdict = compareUncached(page, html);
|
|
385
|
+
byHtml.set(html, verdict);
|
|
386
|
+
}
|
|
387
|
+
return verdict;
|
|
388
|
+
},
|
|
389
|
+
async close() {
|
|
390
|
+
await browserOpen.close();
|
|
391
|
+
await Promise.all([...servers.values()].map(async (server) => (await server).close()));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
}
|