rpc-agent 0.4.0 → 0.6.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 +100 -0
- package/README.md +449 -5
- package/dist/args.js +197 -31
- package/dist/browser.js +57 -3
- package/dist/capture.js +45 -42
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +351 -0
- package/dist/check.js +685 -0
- package/dist/cli.js +88 -1
- package/dist/errors.js +8 -0
- package/dist/gif.js +212 -0
- package/dist/help.js +56 -2
- package/dist/mcp.js +218 -16
- package/dist/motion.js +487 -0
- package/dist/options.js +33 -0
- package/dist/png.js +47 -0
- package/dist/record.js +333 -0
- package/dist/ui.js +199 -0
- package/package.json +2 -2
package/dist/args.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { parseCookie, parseHeader, resolveAuth, toOrigin } from './auth.js';
|
|
3
3
|
import { resolveDevices } from './devices.js';
|
|
4
|
-
import { COLOR_SCHEMES, DEFAULT_TIMEOUT_MS, defaultShotOptions, normalizeUrl, } from './options.js';
|
|
5
|
-
export const HELP_TOPICS = ['shot', 'login', 'sessions', 'devices', 'doctor', 'install', 'mcp'];
|
|
4
|
+
import { COLOR_SCHEMES, DEFAULT_TIMEOUT_MS, defaultCheckOptions, defaultRecordOptions, defaultShotOptions, FAIL_ON, MAX_RECORD_SECONDS, normalizeUrl, } from './options.js';
|
|
5
|
+
export const HELP_TOPICS = ['shot', 'check', 'record', 'login', 'sessions', 'devices', 'doctor', 'install', 'mcp'];
|
|
6
6
|
function isTopic(s) {
|
|
7
7
|
return HELP_TOPICS.includes(s ?? '');
|
|
8
8
|
}
|
|
@@ -16,6 +16,9 @@ export const USAGE = `rpc - screenshot a URL at device viewports, optionally in
|
|
|
16
16
|
|
|
17
17
|
Usage:
|
|
18
18
|
rpc shot <url> [options] capture screenshots
|
|
19
|
+
rpc check <url> [options] pre-launch audit: meta, layout shift, labels, fonts, mobile,
|
|
20
|
+
motion
|
|
21
|
+
rpc record <url> [options] record a short video of the page or one element
|
|
19
22
|
rpc login <url> [--form] sign in once and save the session for that origin
|
|
20
23
|
rpc sessions [list|clear [origin]]
|
|
21
24
|
list or delete saved sessions
|
|
@@ -34,6 +37,10 @@ Shot options:
|
|
|
34
37
|
--slice-width <px> max slice width (default: 768)
|
|
35
38
|
--max-height <px> full-page cap in CSS px (default: 60000)
|
|
36
39
|
--reduced-motion emulate prefers-reduced-motion: reduce
|
|
40
|
+
--finish-animations jump GSAP tweens on window (and Web
|
|
41
|
+
Animations) to their end before capturing
|
|
42
|
+
--no-unpin with --full-page: keep pinned sections as they
|
|
43
|
+
are (default: collapse each to its section)
|
|
37
44
|
-o, --out <dir> output directory (default: ./.rpc-shots)
|
|
38
45
|
--wait-for <selector> wait for a CSS selector before capturing
|
|
39
46
|
--wait <ms> extra settle delay after load (default: 0)
|
|
@@ -43,7 +50,20 @@ Shot options:
|
|
|
43
50
|
--json print the result as JSON
|
|
44
51
|
--open open the output folder when done
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
Check options (plus -d, --wait-for, --wait, --timeout, --color-scheme, -o, --json):
|
|
54
|
+
--fail-on <level> error (default) | warn: exit 1 on warnings too
|
|
55
|
+
--shots screenshot the elements behind layout-shift and
|
|
56
|
+
overflow findings (into --out)
|
|
57
|
+
|
|
58
|
+
Record options (plus --wait-for, --wait, --timeout, --color-scheme,
|
|
59
|
+
--reduced-motion, --no-emulate, -o, --json, --open):
|
|
60
|
+
-d, --device <id> one device id or alias (default: desktop-1440)
|
|
61
|
+
--selector <css> crop to this element (scrolled into view first)
|
|
62
|
+
--duration <s> seconds of video, up to 30 (default: 5)
|
|
63
|
+
--gif also write a GIF
|
|
64
|
+
--mp4 also write an mp4 (needs ffmpeg with H.264 on PATH)
|
|
65
|
+
|
|
66
|
+
Auth options (values are never printed; shot, check and record):
|
|
47
67
|
--cookie <c> "name=value[; Domain=…; Path=…]", repeatable;
|
|
48
68
|
sent to the target origin only
|
|
49
69
|
--header <h> "Name: value", repeatable; target origin only
|
|
@@ -58,6 +78,8 @@ Examples:
|
|
|
58
78
|
rpc shot localhost:3000 -d iphone-15,ipad,desktop-1440 --bezels
|
|
59
79
|
rpc shot https://example.com --full-page --color-scheme dark
|
|
60
80
|
rpc login localhost:3000/sign-in && rpc shot localhost:3000/dashboard
|
|
81
|
+
rpc check localhost:3000 --fail-on warn
|
|
82
|
+
rpc record localhost:3000 --selector '.carousel' --duration 6 --gif
|
|
61
83
|
`;
|
|
62
84
|
export function parseCli(argv, env = process.env) {
|
|
63
85
|
const [cmd, ...rest] = argv;
|
|
@@ -104,6 +126,10 @@ export function parseCli(argv, env = process.env) {
|
|
|
104
126
|
}
|
|
105
127
|
case 'shot':
|
|
106
128
|
return parseShot(rest);
|
|
129
|
+
case 'check':
|
|
130
|
+
return parseCheck(rest);
|
|
131
|
+
case 'record':
|
|
132
|
+
return parseRecord(rest);
|
|
107
133
|
default:
|
|
108
134
|
throw new ArgError(`Unknown command "${cmd}". Run \`rpc --help\`.`);
|
|
109
135
|
}
|
|
@@ -125,13 +151,11 @@ function parseShot(args) {
|
|
|
125
151
|
'slice-width': { type: 'string' },
|
|
126
152
|
'max-height': { type: 'string' },
|
|
127
153
|
'reduced-motion': { type: 'boolean' },
|
|
154
|
+
'finish-animations': { type: 'boolean' },
|
|
155
|
+
'no-unpin': { type: 'boolean' },
|
|
128
156
|
json: { type: 'boolean' },
|
|
129
157
|
open: { type: 'boolean' },
|
|
130
|
-
|
|
131
|
-
header: { type: 'string', multiple: true },
|
|
132
|
-
'storage-state': { type: 'string' },
|
|
133
|
-
'no-session': { type: 'boolean' },
|
|
134
|
-
'allow-origin': { type: 'string', multiple: true },
|
|
158
|
+
...AUTH_FLAGS,
|
|
135
159
|
}, true);
|
|
136
160
|
if (positionals.length === 0)
|
|
137
161
|
throw new ArgError('Missing <url>. Usage: rpc shot <url> [options]');
|
|
@@ -144,17 +168,8 @@ function parseShot(args) {
|
|
|
144
168
|
catch (err) {
|
|
145
169
|
throw new ArgError(err.message);
|
|
146
170
|
}
|
|
147
|
-
if (typeof values.devices === 'string')
|
|
148
|
-
|
|
149
|
-
if (list.length === 0)
|
|
150
|
-
throw new ArgError('--devices is empty');
|
|
151
|
-
try {
|
|
152
|
-
options.devices = resolveDevices(list).map(d => d.id);
|
|
153
|
-
}
|
|
154
|
-
catch (err) {
|
|
155
|
-
throw new ArgError(err.message);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
171
|
+
if (typeof values.devices === 'string')
|
|
172
|
+
options.devices = parseDevices(values.devices);
|
|
158
173
|
if (values.bezels)
|
|
159
174
|
options.bezels = true;
|
|
160
175
|
if (values['full-page'])
|
|
@@ -167,25 +182,38 @@ function parseShot(args) {
|
|
|
167
182
|
options.waitMs = parseMs('--wait', values.wait);
|
|
168
183
|
if (typeof values.timeout === 'string')
|
|
169
184
|
options.timeoutMs = parseMs('--timeout', values.timeout);
|
|
170
|
-
if (typeof values['color-scheme'] === 'string')
|
|
171
|
-
|
|
172
|
-
if (!COLOR_SCHEMES.includes(s)) {
|
|
173
|
-
throw new ArgError(`--color-scheme must be one of ${COLOR_SCHEMES.join(', ')}`);
|
|
174
|
-
}
|
|
175
|
-
options.colorScheme = s;
|
|
176
|
-
}
|
|
185
|
+
if (typeof values['color-scheme'] === 'string')
|
|
186
|
+
options.colorScheme = parseColorScheme(values['color-scheme']);
|
|
177
187
|
if (values['no-emulate'])
|
|
178
188
|
options.emulate = false;
|
|
179
189
|
if (values.slices)
|
|
180
190
|
options.saveSlices = true;
|
|
181
191
|
if (values['reduced-motion'])
|
|
182
192
|
options.reducedMotion = true;
|
|
193
|
+
if (values['finish-animations'])
|
|
194
|
+
options.finishAnimations = true;
|
|
195
|
+
if (values['no-unpin'])
|
|
196
|
+
options.unpin = false;
|
|
183
197
|
if (typeof values['slice-width'] === 'string') {
|
|
184
198
|
options.sliceWidth = parseInt_('--slice-width', values['slice-width'], 256, 4096);
|
|
185
199
|
}
|
|
186
200
|
if (typeof values['max-height'] === 'string') {
|
|
187
201
|
options.maxHeight = parseInt_('--max-height', values['max-height'], 500, 200_000);
|
|
188
202
|
}
|
|
203
|
+
const auth = parseAuth(values, options.url);
|
|
204
|
+
if (auth)
|
|
205
|
+
options.auth = auth;
|
|
206
|
+
return { command: 'shot', options, json: Boolean(values.json), open: Boolean(values.open) };
|
|
207
|
+
}
|
|
208
|
+
const AUTH_FLAGS = {
|
|
209
|
+
cookie: { type: 'string', multiple: true },
|
|
210
|
+
header: { type: 'string', multiple: true },
|
|
211
|
+
'storage-state': { type: 'string' },
|
|
212
|
+
'no-session': { type: 'boolean' },
|
|
213
|
+
'allow-origin': { type: 'string', multiple: true },
|
|
214
|
+
};
|
|
215
|
+
/** Cookie/header/session flags, validated against the URL now: bad input is a usage error. */
|
|
216
|
+
function parseAuth(values, url) {
|
|
189
217
|
const auth = {};
|
|
190
218
|
try {
|
|
191
219
|
if (values.cookie?.length)
|
|
@@ -198,15 +226,153 @@ function parseShot(args) {
|
|
|
198
226
|
auth.useSession = false;
|
|
199
227
|
if (values['allow-origin']?.length)
|
|
200
228
|
auth.allowOrigins = values['allow-origin'].flatMap(splitList).map(toOrigin);
|
|
201
|
-
if (Object.keys(auth).length)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
229
|
+
if (!Object.keys(auth).length)
|
|
230
|
+
return undefined;
|
|
231
|
+
resolveAuth(url, auth);
|
|
232
|
+
return auth;
|
|
205
233
|
}
|
|
206
234
|
catch (err) {
|
|
207
235
|
throw new ArgError(err.message);
|
|
208
236
|
}
|
|
209
|
-
|
|
237
|
+
}
|
|
238
|
+
function parseDevices(raw) {
|
|
239
|
+
const list = splitList(raw);
|
|
240
|
+
if (list.length === 0)
|
|
241
|
+
throw new ArgError('--devices is empty');
|
|
242
|
+
try {
|
|
243
|
+
return resolveDevices(list).map(d => d.id);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
throw new ArgError(err.message);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function parseCheck(args) {
|
|
250
|
+
if (wantsHelp(args))
|
|
251
|
+
return { command: 'help', topic: 'check' };
|
|
252
|
+
const { values, positionals } = parse(args, {
|
|
253
|
+
devices: { type: 'string', short: 'd' },
|
|
254
|
+
out: { type: 'string', short: 'o' },
|
|
255
|
+
'wait-for': { type: 'string' },
|
|
256
|
+
wait: { type: 'string' },
|
|
257
|
+
'color-scheme': { type: 'string' },
|
|
258
|
+
timeout: { type: 'string' },
|
|
259
|
+
'fail-on': { type: 'string' },
|
|
260
|
+
shots: { type: 'boolean' },
|
|
261
|
+
json: { type: 'boolean' },
|
|
262
|
+
...AUTH_FLAGS,
|
|
263
|
+
}, true);
|
|
264
|
+
if (positionals.length === 0)
|
|
265
|
+
throw new ArgError('Missing <url>. Usage: rpc check <url> [options]');
|
|
266
|
+
if (positionals.length > 1)
|
|
267
|
+
throw new ArgError(`Expected one URL, got: ${positionals.join(' ')}`);
|
|
268
|
+
let options;
|
|
269
|
+
try {
|
|
270
|
+
options = defaultCheckOptions(positionals[0]);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
throw new ArgError(err.message);
|
|
274
|
+
}
|
|
275
|
+
if (typeof values.devices === 'string')
|
|
276
|
+
options.devices = parseDevices(values.devices);
|
|
277
|
+
if (typeof values.out === 'string')
|
|
278
|
+
options.outDir = values.out;
|
|
279
|
+
if (typeof values['wait-for'] === 'string')
|
|
280
|
+
options.waitFor = values['wait-for'];
|
|
281
|
+
if (typeof values.wait === 'string')
|
|
282
|
+
options.waitMs = parseMs('--wait', values.wait);
|
|
283
|
+
if (typeof values.timeout === 'string')
|
|
284
|
+
options.timeoutMs = parseMs('--timeout', values.timeout);
|
|
285
|
+
if (typeof values['color-scheme'] === 'string')
|
|
286
|
+
options.colorScheme = parseColorScheme(values['color-scheme']);
|
|
287
|
+
if (typeof values['fail-on'] === 'string') {
|
|
288
|
+
if (!FAIL_ON.includes(values['fail-on']))
|
|
289
|
+
throw new ArgError(`--fail-on must be one of ${FAIL_ON.join(', ')}`);
|
|
290
|
+
options.failOn = values['fail-on'];
|
|
291
|
+
}
|
|
292
|
+
if (values.shots)
|
|
293
|
+
options.shots = true;
|
|
294
|
+
const auth = parseAuth(values, options.url);
|
|
295
|
+
if (auth)
|
|
296
|
+
options.auth = auth;
|
|
297
|
+
return { command: 'check', options, json: Boolean(values.json) };
|
|
298
|
+
}
|
|
299
|
+
function parseRecord(args) {
|
|
300
|
+
if (wantsHelp(args))
|
|
301
|
+
return { command: 'help', topic: 'record' };
|
|
302
|
+
const { values, positionals } = parse(args, {
|
|
303
|
+
device: { type: 'string', short: 'd' },
|
|
304
|
+
devices: { type: 'string' },
|
|
305
|
+
selector: { type: 'string' },
|
|
306
|
+
duration: { type: 'string' },
|
|
307
|
+
gif: { type: 'boolean' },
|
|
308
|
+
mp4: { type: 'boolean' },
|
|
309
|
+
out: { type: 'string', short: 'o' },
|
|
310
|
+
'wait-for': { type: 'string' },
|
|
311
|
+
wait: { type: 'string' },
|
|
312
|
+
'color-scheme': { type: 'string' },
|
|
313
|
+
timeout: { type: 'string' },
|
|
314
|
+
'no-emulate': { type: 'boolean' },
|
|
315
|
+
'reduced-motion': { type: 'boolean' },
|
|
316
|
+
json: { type: 'boolean' },
|
|
317
|
+
open: { type: 'boolean' },
|
|
318
|
+
...AUTH_FLAGS,
|
|
319
|
+
}, true);
|
|
320
|
+
if (positionals.length === 0)
|
|
321
|
+
throw new ArgError('Missing <url>. Usage: rpc record <url> [options]');
|
|
322
|
+
if (positionals.length > 1)
|
|
323
|
+
throw new ArgError(`Expected one URL, got: ${positionals.join(' ')}`);
|
|
324
|
+
let options;
|
|
325
|
+
try {
|
|
326
|
+
options = defaultRecordOptions(positionals[0]);
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
throw new ArgError(err.message);
|
|
330
|
+
}
|
|
331
|
+
const device = values.device ?? values.devices;
|
|
332
|
+
if (typeof device === 'string') {
|
|
333
|
+
const list = parseDevices(device);
|
|
334
|
+
if (list.length !== 1)
|
|
335
|
+
throw new ArgError('rpc record takes one device (-d desktop-1440)');
|
|
336
|
+
options.device = list[0];
|
|
337
|
+
}
|
|
338
|
+
if (typeof values.selector === 'string') {
|
|
339
|
+
if (!values.selector.trim())
|
|
340
|
+
throw new ArgError('--selector is empty');
|
|
341
|
+
options.selector = values.selector;
|
|
342
|
+
}
|
|
343
|
+
if (typeof values.duration === 'string') {
|
|
344
|
+
const n = Number(values.duration);
|
|
345
|
+
if (!Number.isFinite(n) || n < 0.5 || n > MAX_RECORD_SECONDS)
|
|
346
|
+
throw new ArgError(`--duration must be a number of seconds from 0.5 to ${MAX_RECORD_SECONDS}`);
|
|
347
|
+
options.duration = n;
|
|
348
|
+
}
|
|
349
|
+
if (values.gif)
|
|
350
|
+
options.gif = true;
|
|
351
|
+
if (values.mp4)
|
|
352
|
+
options.mp4 = true;
|
|
353
|
+
if (typeof values.out === 'string')
|
|
354
|
+
options.outDir = values.out;
|
|
355
|
+
if (typeof values['wait-for'] === 'string')
|
|
356
|
+
options.waitFor = values['wait-for'];
|
|
357
|
+
if (typeof values.wait === 'string')
|
|
358
|
+
options.waitMs = parseMs('--wait', values.wait);
|
|
359
|
+
if (typeof values.timeout === 'string')
|
|
360
|
+
options.timeoutMs = parseMs('--timeout', values.timeout);
|
|
361
|
+
if (typeof values['color-scheme'] === 'string')
|
|
362
|
+
options.colorScheme = parseColorScheme(values['color-scheme']);
|
|
363
|
+
if (values['no-emulate'])
|
|
364
|
+
options.emulate = false;
|
|
365
|
+
if (values['reduced-motion'])
|
|
366
|
+
options.reducedMotion = true;
|
|
367
|
+
const auth = parseAuth(values, options.url);
|
|
368
|
+
if (auth)
|
|
369
|
+
options.auth = auth;
|
|
370
|
+
return { command: 'record', options, json: Boolean(values.json), open: Boolean(values.open) };
|
|
371
|
+
}
|
|
372
|
+
function parseColorScheme(s) {
|
|
373
|
+
if (!COLOR_SCHEMES.includes(s))
|
|
374
|
+
throw new ArgError(`--color-scheme must be one of ${COLOR_SCHEMES.join(', ')}`);
|
|
375
|
+
return s;
|
|
210
376
|
}
|
|
211
377
|
function parseLogin(args, env) {
|
|
212
378
|
if (wantsHelp(args))
|
package/dist/browser.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
-
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { chromium } from 'playwright';
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
/** Playwright's own CLI, from this package's dependency (so versions match). */
|
|
@@ -41,12 +41,66 @@ export async function launchBrowser() {
|
|
|
41
41
|
throw err;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Playwright's ffmpeg build, which `playwright install chromium` downloads
|
|
46
|
+
* next to the browser. It reads MJPEG, writes VP9 webm and PNG, and has the
|
|
47
|
+
* crop, scale and pad filters: enough for `rpc record` and its GIF frames.
|
|
48
|
+
* Found through Playwright's registry, so PLAYWRIGHT_BROWSERS_PATH applies.
|
|
49
|
+
*/
|
|
50
|
+
export function playwrightFfmpeg() {
|
|
51
|
+
try {
|
|
52
|
+
const fromPlaywright = createRequire(require.resolve('playwright/package.json'));
|
|
53
|
+
const core = fromPlaywright('playwright-core/lib/coreBundle');
|
|
54
|
+
const path = core.registry?.registry?.findExecutable('ffmpeg')?.executablePath();
|
|
55
|
+
return path && existsSync(path) ? path : undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined; // registry layout changed: fall back to PATH
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** `ffmpeg` on PATH, if any. Relative entries (`.`) are skipped: they would run whatever `ffmpeg` sits in the cwd. */
|
|
62
|
+
export function systemFfmpeg(env = process.env) {
|
|
63
|
+
const names = process.platform === 'win32' ? ['ffmpeg.exe'] : ['ffmpeg'];
|
|
64
|
+
for (const dir of (env.PATH ?? '').split(delimiter)) {
|
|
65
|
+
for (const n of names)
|
|
66
|
+
if (isAbsolute(dir) && existsSync(join(dir, n)))
|
|
67
|
+
return join(dir, n);
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
/** The ffmpeg `rpc record` uses: Playwright's build first, then PATH. */
|
|
72
|
+
export function findFfmpeg() {
|
|
73
|
+
const pw = playwrightFfmpeg();
|
|
74
|
+
if (pw)
|
|
75
|
+
return { path: pw, source: 'playwright' };
|
|
76
|
+
const sys = systemFfmpeg();
|
|
77
|
+
return sys ? { path: sys, source: 'system' } : undefined;
|
|
78
|
+
}
|
|
79
|
+
/** An H.264 encoder in this ffmpeg build (Playwright's has none), for --mp4. */
|
|
80
|
+
export function h264Encoder(ffmpeg) {
|
|
81
|
+
const r = spawnSync(ffmpeg, ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 10_000 });
|
|
82
|
+
const list = `${r.stdout ?? ''}`;
|
|
83
|
+
return ['libx264', 'h264_videotoolbox', 'libopenh264'].find(e => new RegExp(`\\s${e}\\s`).test(list));
|
|
84
|
+
}
|
|
85
|
+
/** ffmpeg for `rpc record`, and H.264 for --mp4. */
|
|
86
|
+
export function videoReport() {
|
|
87
|
+
const ff = findFfmpeg();
|
|
88
|
+
const sys = systemFfmpeg();
|
|
89
|
+
const encoder = sys ? h264Encoder(sys) : undefined;
|
|
90
|
+
return {
|
|
91
|
+
ffmpeg: ff
|
|
92
|
+
? { source: ff.source, path: ff.path }
|
|
93
|
+
: { source: 'missing', fix: 'rpc record needs ffmpeg: run `npx rpc-agent install` (Playwright downloads its ffmpeg with Chromium), or put ffmpeg on PATH.' },
|
|
94
|
+
mp4: encoder ? { ok: true, encoder } : { ok: false },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
44
97
|
/** Check that Chromium is installed and actually launches. */
|
|
45
98
|
export async function doctor() {
|
|
46
99
|
const base = {
|
|
47
100
|
node: process.version,
|
|
48
101
|
playwright: playwrightVersion(),
|
|
49
102
|
browsersPath: process.env.PLAYWRIGHT_BROWSERS_PATH || '(Playwright default cache)',
|
|
103
|
+
...videoReport(),
|
|
50
104
|
};
|
|
51
105
|
try {
|
|
52
106
|
const browser = await launchBrowser();
|
package/dist/capture.js
CHANGED
|
@@ -3,8 +3,9 @@ import { basename, extname, resolve } from 'node:path';
|
|
|
3
3
|
import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
|
|
4
4
|
import { launchBrowser } from './browser.js';
|
|
5
5
|
import { renderFramed, renderSlices } from './composite.js';
|
|
6
|
-
import { explainError } from './errors.js';
|
|
6
|
+
import { explainError, plainText } from './errors.js';
|
|
7
7
|
import { emulationFor, resolveDevices } from './devices.js';
|
|
8
|
+
import { detectMotion, finishAnimations, settleMotion, shotWarnings, unpin, warmUpScrollJs, } from './motion.js';
|
|
8
9
|
import { pngSize, stitchPngs } from './png.js';
|
|
9
10
|
import { fitsOneCapture, planSegments, planSlices, sliceScale } from './slices.js';
|
|
10
11
|
export { MAX_CAPTURE_PX } from './slices.js';
|
|
@@ -26,21 +27,7 @@ export async function capture(opts, hooks = {}) {
|
|
|
26
27
|
shot = await captureDevice(browser, device, opts, outDir, hooks, auth);
|
|
27
28
|
}
|
|
28
29
|
catch (err) {
|
|
29
|
-
shot = baseShot(device);
|
|
30
|
-
if (err instanceof AuthRequiredError) {
|
|
31
|
-
shot.status = err.status;
|
|
32
|
-
shot.finalUrl = err.finalUrl;
|
|
33
|
-
shot.error = err.message;
|
|
34
|
-
shot.code = err.code;
|
|
35
|
-
shot.hint = err.hint;
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
const e = explainError(err, { url: opts.url, timeoutMs: opts.timeoutMs, waitFor: opts.waitFor });
|
|
39
|
-
shot.error = redact(e.error, auth.secrets);
|
|
40
|
-
shot.code = e.code;
|
|
41
|
-
if (e.hint)
|
|
42
|
-
shot.hint = redact(e.hint, auth.secrets);
|
|
43
|
-
}
|
|
30
|
+
shot = { ...baseShot(device), ...describeFailure(err, opts, auth) };
|
|
44
31
|
}
|
|
45
32
|
if (shot.consoleErrors?.length && auth.secrets.length) {
|
|
46
33
|
shot.consoleErrors = shot.consoleErrors.map(l => redact(l, auth.secrets));
|
|
@@ -57,6 +44,18 @@ export async function capture(opts, hooks = {}) {
|
|
|
57
44
|
await browser.close();
|
|
58
45
|
}
|
|
59
46
|
}
|
|
47
|
+
/** A device's navigation/capture failure as `{ error, code, hint }`, secrets scrubbed. */
|
|
48
|
+
export function describeFailure(err, opts, auth) {
|
|
49
|
+
if (err instanceof AuthRequiredError) {
|
|
50
|
+
return { status: err.status, finalUrl: err.finalUrl, error: err.message, code: err.code, hint: err.hint };
|
|
51
|
+
}
|
|
52
|
+
const e = explainError(err, { url: opts.url, timeoutMs: opts.timeoutMs, waitFor: opts.waitFor });
|
|
53
|
+
return {
|
|
54
|
+
error: redact(e.error, auth.secrets),
|
|
55
|
+
code: e.code,
|
|
56
|
+
...(e.hint ? { hint: redact(e.hint, auth.secrets) } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
60
59
|
function baseShot(device) {
|
|
61
60
|
return {
|
|
62
61
|
device: device.id,
|
|
@@ -65,7 +64,8 @@ function baseShot(device) {
|
|
|
65
64
|
dpr: device.dpr,
|
|
66
65
|
};
|
|
67
66
|
}
|
|
68
|
-
|
|
67
|
+
/** A fresh context for one device: viewport, DPR, mobile emulation, media features and auth state. */
|
|
68
|
+
export async function newDeviceContext(browser, device, opts, auth) {
|
|
69
69
|
const emu = opts.emulate ? emulationFor(device) : undefined;
|
|
70
70
|
const context = await browser.newContext({
|
|
71
71
|
viewport: { width: device.width, height: device.height },
|
|
@@ -80,15 +80,19 @@ async function captureDevice(browser, device, opts, outDir, hooks, auth) {
|
|
|
80
80
|
});
|
|
81
81
|
if (auth.cookies.length)
|
|
82
82
|
await context.addCookies(auth.cookies);
|
|
83
|
+
return context;
|
|
84
|
+
}
|
|
85
|
+
async function captureDevice(browser, device, opts, outDir, hooks, auth) {
|
|
86
|
+
const context = await newDeviceContext(browser, device, opts, auth);
|
|
83
87
|
const result = baseShot(device);
|
|
84
88
|
const errors = new Set();
|
|
85
89
|
try {
|
|
86
90
|
const page = await context.newPage();
|
|
87
91
|
await applyHeaders(context, page, auth);
|
|
88
|
-
page.on('pageerror', e => errors.add(`pageerror: ${e.message}`));
|
|
92
|
+
page.on('pageerror', e => errors.add(plainText(`pageerror: ${e.message}`)));
|
|
89
93
|
page.on('console', m => {
|
|
90
94
|
if (m.type() === 'error')
|
|
91
|
-
errors.add(`console: ${m.text()}`);
|
|
95
|
+
errors.add(plainText(`console: ${m.text()}`));
|
|
92
96
|
});
|
|
93
97
|
const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
94
98
|
result.status = response?.status() ?? null;
|
|
@@ -108,16 +112,35 @@ async function captureDevice(browser, device, opts, outDir, hooks, auth) {
|
|
|
108
112
|
throw err;
|
|
109
113
|
}
|
|
110
114
|
}
|
|
115
|
+
let warmUp;
|
|
111
116
|
if (opts.fullPage) {
|
|
112
|
-
await warmUpScroll(page, opts.maxHeight);
|
|
117
|
+
warmUp = await warmUpScroll(page, opts.maxHeight);
|
|
113
118
|
await settle(page, opts.timeoutMs);
|
|
114
119
|
}
|
|
120
|
+
const finished = opts.finishAnimations ? await finishAnimations(page) : undefined;
|
|
121
|
+
// Collapse pins before measuring, so the stitched height is the unpinned one.
|
|
122
|
+
const unpinned = opts.fullPage && opts.unpin ? await unpin(page) : [];
|
|
115
123
|
if (opts.waitMs > 0)
|
|
116
124
|
await page.waitForTimeout(opts.waitMs);
|
|
125
|
+
// The screenshot ends CSS and Web Animations itself (animations: 'disabled'), so only wait for JS-driven changes.
|
|
126
|
+
const motion = { settle: await settleMotion(page, undefined, undefined, { waapi: false }) };
|
|
127
|
+
if (warmUp)
|
|
128
|
+
motion.warmUp = warmUp;
|
|
129
|
+
if (finished)
|
|
130
|
+
motion.finished = finished;
|
|
131
|
+
if (unpinned.length)
|
|
132
|
+
motion.unpinned = unpinned;
|
|
133
|
+
result.motion = motion;
|
|
117
134
|
checkLogin();
|
|
118
135
|
result.finalUrl = page.url();
|
|
119
136
|
result.title = await page.title();
|
|
120
137
|
result.scrollHeight = await page.evaluate(SCROLL_HEIGHT_JS);
|
|
138
|
+
const warnings = shotWarnings(await detectMotion(page, opts.fullPage ? 'page' : 'viewport'), motion.settle, {
|
|
139
|
+
fullPage: opts.fullPage,
|
|
140
|
+
unpinned,
|
|
141
|
+
});
|
|
142
|
+
if (warnings.length)
|
|
143
|
+
result.warnings = warnings;
|
|
121
144
|
const base = fileStem(opts.url, device.id);
|
|
122
145
|
const shotOpts = { animations: 'disabled', caret: 'hide', type: 'png' };
|
|
123
146
|
// Viewport shot: the raw output when !fullPage, and the bezel input either way.
|
|
@@ -218,40 +241,20 @@ const WAIT_FOR_ASSETS_JS = `(async () => {
|
|
|
218
241
|
new Promise(r => setTimeout(r, 5000)),
|
|
219
242
|
]);
|
|
220
243
|
})()`;
|
|
221
|
-
/**
|
|
222
|
-
* Walk the page one viewport at a time so loading="lazy" images and
|
|
223
|
-
* IntersectionObserver-driven content render, then return to the top.
|
|
224
|
-
* Replaces the 120 ms per-segment pause in RPC's scroll-and-stitch.
|
|
225
|
-
* Bounded to 100 steps and the full-page height cap.
|
|
226
|
-
*/
|
|
227
|
-
const warmUpScrollJs = (maxHeight) => `(async () => {
|
|
228
|
-
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
229
|
-
const root = document.scrollingElement || document.documentElement;
|
|
230
|
-
const step = Math.max(200, Math.floor(window.innerHeight * 0.9));
|
|
231
|
-
for (let i = 0, y = 0; i < 100 && y < ${maxHeight}; i++, y += step) {
|
|
232
|
-
if (y > root.scrollHeight - window.innerHeight) break;
|
|
233
|
-
window.scrollTo({ top: y, behavior: 'instant' });
|
|
234
|
-
await sleep(100);
|
|
235
|
-
}
|
|
236
|
-
window.scrollTo({ top: Math.min(root.scrollHeight, ${maxHeight}), behavior: 'instant' });
|
|
237
|
-
await sleep(150);
|
|
238
|
-
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
239
|
-
await sleep(150);
|
|
240
|
-
})()`;
|
|
241
244
|
const SCROLL_WIDTH_JS = `Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0)`;
|
|
242
245
|
const SCROLL_HEIGHT_JS = `Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)`;
|
|
243
246
|
/**
|
|
244
247
|
* Network idle (bounded: dev servers with HMR or analytics beacons may never
|
|
245
248
|
* go idle), web fonts, and pending <img> loads.
|
|
246
249
|
*/
|
|
247
|
-
async function settle(page, timeoutMs) {
|
|
250
|
+
export async function settle(page, timeoutMs) {
|
|
248
251
|
await page
|
|
249
252
|
.waitForLoadState('networkidle', { timeout: Math.min(timeoutMs, 10_000) })
|
|
250
253
|
.catch(() => { });
|
|
251
254
|
await page.evaluate(WAIT_FOR_ASSETS_JS).catch(() => { });
|
|
252
255
|
}
|
|
253
256
|
async function warmUpScroll(page, maxHeight) {
|
|
254
|
-
await page.evaluate(warmUpScrollJs(Math.round(maxHeight)));
|
|
257
|
+
return (await page.evaluate(warmUpScrollJs(Math.round(maxHeight))));
|
|
255
258
|
}
|
|
256
259
|
/** `<host>[-<path>]-<device>`, e.g. `localhost-3000-about-iphone-15`. */
|
|
257
260
|
export function fileStem(url, deviceId) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Text for `rpc check` results: plain lines (pipes, CI, agents), the rich
|
|
2
|
+
// terminal boxes and the MCP summary. No clack here, so `rpc mcp` and the
|
|
3
|
+
// plain paths can use it.
|
|
4
|
+
import { relative } from 'node:path';
|
|
5
|
+
import { CHECKS } from './check.js';
|
|
6
|
+
/** Findings by check, in report order; checks with nothing to say are left out. */
|
|
7
|
+
export function groupFindings(report) {
|
|
8
|
+
return CHECKS.map(c => ({ check: c.id, title: c.title, findings: report.findings.filter(f => f.check === c.id) })).filter(g => g.findings.length > 0);
|
|
9
|
+
}
|
|
10
|
+
/** `2 errors, 1 warning` with zero counts left out, or `no issues`. */
|
|
11
|
+
export function countsPhrase(findings) {
|
|
12
|
+
const n = (s) => findings.filter(f => f.severity === s).length;
|
|
13
|
+
const parts = [
|
|
14
|
+
n('error') ? `${n('error')} error${n('error') === 1 ? '' : 's'}` : '',
|
|
15
|
+
n('warn') ? `${n('warn')} warning${n('warn') === 1 ? '' : 's'}` : '',
|
|
16
|
+
n('info') ? `${n('info')} info` : '',
|
|
17
|
+
].filter(Boolean);
|
|
18
|
+
return parts.length ? parts.join(', ') : 'no issues';
|
|
19
|
+
}
|
|
20
|
+
export function severityMark(s, c, icons) {
|
|
21
|
+
if (icons)
|
|
22
|
+
return s === 'error' ? c.red('✗') : s === 'warn' ? c.yellow('▲') : c.cyan('●');
|
|
23
|
+
return s === 'error' ? c.red('error') : s === 'warn' ? c.yellow('warn ') : c.cyan('info ');
|
|
24
|
+
}
|
|
25
|
+
/** One finding: mark and message, devices when not all of them, items, then the fix. */
|
|
26
|
+
export function findingLines(f, allDevices, c, o) {
|
|
27
|
+
const mark = severityMark(f.severity, c, o.icons);
|
|
28
|
+
const indent = ' '.repeat(o.icons ? 2 : 6);
|
|
29
|
+
const onSome = f.devices.length < allDevices.length ? c.dim(` (${f.devices.join(', ')})`) : '';
|
|
30
|
+
const [first = '', ...rest] = wrap(f.message, o.width - indent.length);
|
|
31
|
+
const out = [`${mark} ${first}${rest.length ? '' : onSome}`];
|
|
32
|
+
rest.forEach((l, i) => out.push(`${indent}${l}${i === rest.length - 1 ? onSome : ''}`));
|
|
33
|
+
const max = o.maxItems ?? 5;
|
|
34
|
+
const items = f.items ?? [];
|
|
35
|
+
if (!items.length && (f.selector || f.url))
|
|
36
|
+
out.push(`${indent}${c.cyan(f.selector ?? f.url)}`);
|
|
37
|
+
for (const item of items.slice(0, max)) {
|
|
38
|
+
const full = item.selector ?? item.url ?? '';
|
|
39
|
+
const extra = item.text && item.text !== full ? item.text : '';
|
|
40
|
+
// Long selectors lose their start: the end names the element.
|
|
41
|
+
const room = Math.max(20, o.width - indent.length - 2 - (extra ? extra.length + 2 : 0));
|
|
42
|
+
const where = full.length > room ? `…${full.slice(full.length - room + 1)}` : full;
|
|
43
|
+
const line = [where ? c.cyan(where) : '', extra ? c.dim(extra) : ''].filter(Boolean).join(' ');
|
|
44
|
+
out.push(`${indent}${c.dim('·')} ${line}`);
|
|
45
|
+
if (item.screenshot)
|
|
46
|
+
out.push(`${indent} ${c.dim('shot:')} ${rel(item.screenshot)}`);
|
|
47
|
+
}
|
|
48
|
+
if (items.length > max)
|
|
49
|
+
out.push(`${indent}${c.dim(`+${items.length - max} more (see --json)`)}`);
|
|
50
|
+
const [fix = '', ...more] = wrap(f.fix, o.width - indent.length - 5);
|
|
51
|
+
out.push(`${indent}${c.dim(`fix: ${fix}`)}`);
|
|
52
|
+
for (const l of more)
|
|
53
|
+
out.push(`${indent}${c.dim(` ${l}`)}`);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** Lines for one check's findings. */
|
|
57
|
+
export function groupLines(g, allDevices, c, o) {
|
|
58
|
+
return g.findings.flatMap(f => findingLines(f, allDevices, c, o));
|
|
59
|
+
}
|
|
60
|
+
/** `iphone-15-pro (393x852 @3x) HTTP 200, CLS 0.000, title "…"` or the failure. */
|
|
61
|
+
export function deviceLine(d, c) {
|
|
62
|
+
const label = `${d.device} (${d.viewport.width}x${d.viewport.height} @${d.dpr}x)`;
|
|
63
|
+
if (d.error)
|
|
64
|
+
return `${c.red('x')} ${label}: ${d.error}`;
|
|
65
|
+
const status = d.status == null ? 'no status' : d.status >= 400 ? c.red(`HTTP ${d.status}`) : `HTTP ${d.status}`;
|
|
66
|
+
const cls = d.cls === undefined ? '' : `, CLS ${d.cls.toFixed(3)}`;
|
|
67
|
+
return `${c.green('ok')} ${label} ${status}${cls}${d.mobile ? '' : c.dim(' (no phone checks)')}`;
|
|
68
|
+
}
|
|
69
|
+
/** Plain output for pipes and CI: device lines, findings by check, the summary line. */
|
|
70
|
+
export function plainReport(report, c, width = 100) {
|
|
71
|
+
const all = report.devices.map(d => d.device);
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const g of groupFindings(report)) {
|
|
74
|
+
out.push('', `${g.title}: ${countsPhrase(g.findings)}`);
|
|
75
|
+
for (const l of groupLines(g, all, c, { icons: false, width }))
|
|
76
|
+
out.push(` ${l}`);
|
|
77
|
+
}
|
|
78
|
+
return out.join('\n');
|
|
79
|
+
}
|
|
80
|
+
/** Short text for agents (MCP): the counts, then one line per error and warning. */
|
|
81
|
+
export function textSummary(report) {
|
|
82
|
+
const s = report.summary;
|
|
83
|
+
const lines = [
|
|
84
|
+
`${report.url}: ${s.errors} error(s), ${s.warnings} warning(s), ${s.info} info on ${report.devices.map(d => d.device).join(', ')}.`,
|
|
85
|
+
];
|
|
86
|
+
for (const d of report.devices) {
|
|
87
|
+
if (d.error)
|
|
88
|
+
lines.push(`FAILED ${d.device}: ${d.error}${d.hint ? `\n hint: ${d.hint}` : ''}`);
|
|
89
|
+
}
|
|
90
|
+
const top = report.findings.filter(f => f.severity !== 'info');
|
|
91
|
+
for (const f of top.slice(0, 25)) {
|
|
92
|
+
const where = f.selector ?? f.url;
|
|
93
|
+
lines.push(`${f.severity === 'error' ? 'ERROR' : 'WARN'} [${f.id}] ${f.message}${where ? ` at ${where}` : ''}`);
|
|
94
|
+
}
|
|
95
|
+
if (top.length > 25)
|
|
96
|
+
lines.push(`… ${top.length - 25} more in the JSON report.`);
|
|
97
|
+
if (!top.length)
|
|
98
|
+
lines.push('No errors or warnings.');
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
function rel(p) {
|
|
102
|
+
const r = relative(process.cwd(), p);
|
|
103
|
+
return r.startsWith('..') ? p : r;
|
|
104
|
+
}
|
|
105
|
+
function wrap(text, width) {
|
|
106
|
+
const out = [];
|
|
107
|
+
let line = '';
|
|
108
|
+
for (const word of text.split(/\s+/)) {
|
|
109
|
+
if (line && line.length + 1 + word.length > width) {
|
|
110
|
+
out.push(line);
|
|
111
|
+
line = word;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
line = line ? `${line} ${word}` : word;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (line)
|
|
118
|
+
out.push(line);
|
|
119
|
+
return out;
|
|
120
|
+
}
|