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/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
- Auth options (values are never printed):
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
- cookie: { type: 'string', multiple: true },
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
- const list = splitList(values.devices);
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
- const s = values['color-scheme'];
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
- resolveAuth(options.url, auth); // validate now: bad input is a usage error
203
- options.auth = auth;
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
- return { command: 'shot', options, json: Boolean(values.json), open: Boolean(values.open) };
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
- async function captureDevice(browser, device, opts, outDir, hooks, auth) {
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
+ }