rpc-agent 0.4.0 → 0.5.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 CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ Ship-check: `rpc check <url>` audits a page before launch, from the CLI, in
6
+ CI or through MCP.
7
+
8
+ ### Added
9
+
10
+ - `rpc check <url>` runs on a phone and a desktop width by default (`-d`
11
+ to change) and reports findings with an id, severity (`error`, `warn`,
12
+ `info`), message, selector or URL, devices and a fix. Checks:
13
+ - Basics: HTTP status, mixed content, uncaught exceptions, console errors,
14
+ `theme-color`.
15
+ - Meta and social: title, description, canonical, `lang`, viewport (and
16
+ zoom blocking), favicon, apple-touch-icon, `og:title`,
17
+ `og:description`, `og:image`, `og:url`, `twitter:card`. `og:image` is
18
+ fetched for its status, content type and pixel size.
19
+ - Layout shift: CLS (largest session window) after load and one scroll,
20
+ with the elements that moved. Warn above 0.1, error above 0.25.
21
+ - Unlabelled controls: buttons, links and `[role=button]` with no
22
+ accessible name, images without `alt`, form fields without a label.
23
+ - Fonts: missing or `block` `font-display`, above-the-fold fonts that
24
+ aren't preloaded, preloads without `crossorigin`, unused preloads,
25
+ font files that fail.
26
+ - Mobile viewport (phones): `100vh`, sideways scroll with the elements
27
+ that cause it, tap targets under 44x44, text fields under 16px.
28
+ - Exit code 1 on errors; `--fail-on warn` fails on warnings too.
29
+ `--json` prints a versioned report (`schema: 1`, documented in the
30
+ README). `--shots` saves PNGs of the elements behind layout-shift and
31
+ overflow findings.
32
+ - Auth works as for `rpc shot`: `--cookie`, `--header`,
33
+ `--storage-state`, `--allow-origin`, saved `rpc login` sessions and
34
+ `AUTH_REQUIRED` on a redirect to sign-in.
35
+ - MCP `check` tool with the same options and the `screenshot` tool's auth
36
+ params. It returns a short text summary and the JSON report.
37
+
38
+ ### Unchanged
39
+
40
+ - `rpc shot`, its output and the MCP `screenshot` tool. The MCP tool list
41
+ gains `check`.
42
+
3
43
  ## 0.4.0
4
44
 
5
45
  Authenticated captures: screenshot logged-in pages without a hand-written
package/README.md CHANGED
@@ -63,6 +63,7 @@ these docs say `rpc`, or `pnpm rpc` from inside the repo.
63
63
  ```sh
64
64
  rpc # interactive: pick a URL, devices and options
65
65
  rpc shot <url> [options]
66
+ rpc check <url> [options] # pre-launch audit, see Ship-check below
66
67
  rpc login <url> [--form] # sign in once; later shots of that origin use the session
67
68
  rpc sessions [list|clear [origin]]
68
69
  rpc devices [--json]
@@ -299,9 +300,231 @@ rpc shot localhost:3000/app --storage-state auth.json
299
300
  - **Use a test account.** Sign in as a user whose data you're happy for
300
301
  screenshots (and the agent reading them) to see.
301
302
 
303
+ ## Ship-check
304
+
305
+ `rpc check <url>` audits one page before you call it done. It loads the page
306
+ on a phone and a desktop width, the way `rpc shot` does, and lists what's
307
+ wrong: missing or broken social tags, layout shift, buttons a screen reader
308
+ can't name, fonts that hide text while they load, and the mobile viewport
309
+ bugs that only show up on a phone.
310
+
311
+ ```sh
312
+ rpc check localhost:3000
313
+ rpc check localhost:3000 --fail-on warn --json # CI
314
+ rpc check example.com/pricing -d pixel,iphone-se,desktop --shots
315
+ ```
316
+
317
+ <img alt="rpc check in a terminal on turbo-start-paystack.vercel.app: both devices load with HTTP 200 and CLS 0.000, then a Meta and social box (no canonical link, no og:url) and a Mobile viewport box (16 of 54 tap targets under 44x44 px, with selectors and sizes), each finding with a fix, and the line 0 errors, 3 warnings, 0 info" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/terminal-check.png" width="720">
318
+
319
+ URLs, devices and auth work as in `rpc shot`: `localhost:3000` needs no
320
+ scheme, `-d` takes ids or aliases (default `iphone-15-pro,desktop-1440`),
321
+ and `--cookie`, `--header`, `--storage-state` and the session saved by
322
+ `rpc login` all apply. A page that redirects to a login URL fails with
323
+ `AUTH_REQUIRED`. Each run checks one URL. It isn't Lighthouse: CLS is the
324
+ only performance number it reports.
325
+
326
+ | Option | Meaning |
327
+ | --- | --- |
328
+ | `-d, --devices <ids>` | Devices to check on. The mobile checks run on phones only |
329
+ | `--fail-on <level>` | `error` (default) exits 1 on errors; `warn` exits 1 on warnings too |
330
+ | `--shots` | Save PNGs of the elements behind layout-shift and overflow findings into `--out` |
331
+ | `-o, --out <dir>` | Where `--shots` writes. Default `./.rpc-shots` |
332
+ | `--json` | Print the report as JSON (see below) |
333
+ | `--wait-for`, `--wait`, `--timeout`, `--color-scheme` | As in `rpc shot` |
334
+ | `--cookie`, `--header`, `--storage-state`, `--no-session`, `--allow-origin` | As in `rpc shot` |
335
+
336
+ The exit code is 0 when there are no errors (with `--fail-on warn`, no
337
+ warnings either), 1 otherwise and 2 for a usage error.
338
+
339
+ ### How a check runs
340
+
341
+ Each device gets a fresh browser context, the same one `rpc shot` uses.
342
+ Before the page's own scripts run, rpc installs a `PerformanceObserver` for
343
+ `layout-shift`. It loads the page and waits for network idle (up to 10 s),
344
+ web fonts and pending images. Then it waits a fixed 500 ms, scrolls down one
345
+ viewport and back with a 300 ms pause each way, and reads the page. Because
346
+ the sequence is fixed, CLS comes out the same on every run; the tests check
347
+ the same page twice and allow 0.005 of difference.
348
+
349
+ When a finding is identical on several devices it's reported once, with
350
+ every device listed, so a missing `lang` appears one time, not two.
351
+
352
+ ### What it checks
353
+
354
+ Severity decides the exit code. `error` fails the run, `warn` fails it only
355
+ with `--fail-on warn`, and `info` never does.
356
+
357
+ #### Basics
358
+
359
+ | id | Severity | When | Fix |
360
+ | --- | --- | --- | --- |
361
+ | `http-status` | error | The page answered 400 or above | Serve it with a 200 |
362
+ | `page-load-failed`, `auth-required` | error | The page didn't load on a device (DNS, refused, timeout) or landed on a login page | The error's own hint, e.g. ``run `rpc login <origin>` `` |
363
+ | `mixed-content` | error (warn if the browser upgraded every request) | `http://` requests on an https page | Load everything over https |
364
+ | `uncaught-exception` | error | Exceptions thrown during load, first three listed | Fix the first one; the rest often follow from it |
365
+ | `console-errors` | warn | `console.error` during load: the count and the first three | Fix or silence them |
366
+ | `theme-color-missing` | info | No `<meta name="theme-color">` | Add one to tint the mobile browser bar |
367
+
368
+ #### Meta and social
369
+
370
+ | id | Severity | When | Fix |
371
+ | --- | --- | --- | --- |
372
+ | `title-missing`, `title-long` | error, warn | No `<title>`, or one over 60 characters | 50 to 60 characters, key words first |
373
+ | `description-missing`, `description-long` | warn, info | No meta description, or one over 160 characters | One or two sentences |
374
+ | `canonical-missing` | warn | No `<link rel="canonical">` | The absolute URL to index |
375
+ | `lang-missing` | error | No `lang` on `<html>` | `<html lang="en">` |
376
+ | `viewport-missing`, `viewport-no-zoom` | error | No viewport meta, or one with `user-scalable=no` or `maximum-scale` under 2 | `width=device-width, initial-scale=1` and nothing that blocks zoom |
377
+ | `favicon-missing` | warn | No `<link rel="icon">`, and `/favicon.ico` doesn't load | Add an icon link |
378
+ | `apple-touch-icon-missing` | info | No `apple-touch-icon` | A 180x180 PNG |
379
+ | `og-title-missing`, `og-description-missing`, `og-image-missing`, `og-url-missing` | warn | That Open Graph tag is absent | Add it; link previews read it |
380
+ | `twitter-card-missing` | warn | No `twitter:card` | `summary_large_image` |
381
+ | `og-image-relative` | warn | `og:image` isn't an absolute URL | Make it absolute; most scrapers don't resolve relative URLs |
382
+ | `og-image-broken`, `og-image-not-image` | error | `og:image` doesn't return 200, or isn't served as an image | Point it at the image file |
383
+ | `og-image-small`, `og-image-ratio` | warn, info | Smaller than 600x315, or not the 1.91:1 shape of 1200x630 | Export it at 1200x630 |
384
+
385
+ rpc fetches `og:image` once per run and reads its pixel size from the file
386
+ header (PNG, JPEG, GIF or WebP). The result is in the report's `ogImage`.
387
+
388
+ #### Layout shift
389
+
390
+ | id | Severity | When | Fix |
391
+ | --- | --- | --- | --- |
392
+ | `layout-shift` | error above 0.25, warn above 0.1, info above 0 | CLS for the load and the scroll, with the three elements that moved most and each one's share | Reserve space for late content: `width`/`height` or `aspect-ratio` on media, `min-height` on async sections, nothing inserted above what's on screen |
393
+
394
+ The score is computed the way Chrome computes it: the largest session
395
+ window, meaning shifts less than 1 s apart and at most 5 s in total. Chrome
396
+ marks shifts that follow input as `hadRecentInput`, including any in the
397
+ first 500 ms after navigation, and those don't count. Totals under 0.001
398
+ count as zero. Each device's score is also in `devices[].cls`.
399
+
400
+ #### Unlabelled controls
401
+
402
+ | id | Severity | When | Fix |
403
+ | --- | --- | --- | --- |
404
+ | `button-no-name`, `link-no-name` | error | A button, link or `[role=button]` with no accessible name, such as an icon-only button | `aria-label`, visually hidden text, or alt text on the image inside |
405
+ | `img-no-alt` | error | An `<img>` with no `alt` attribute (`alt=""` is fine) | Describe the image, or `alt=""` if it's decorative |
406
+ | `field-no-label` | error | An input, select or textarea with no label | `<label for>`, a wrapping `<label>`, or `aria-label` |
407
+ | `field-placeholder-only` | warn | A field labelled only by its placeholder | Add a visible label |
408
+
409
+ rpc works out accessible names in the page from `aria-labelledby`,
410
+ `aria-label`, `<label>`, `alt`, the text inside (minus anything
411
+ `aria-hidden`), an SVG `<title>` and `title`. It skips elements assistive
412
+ tech can't reach: `display: none`, `visibility: hidden`, `aria-hidden` and
413
+ `inert`.
414
+
415
+ #### Fonts
416
+
417
+ | id | Severity | When | Fix |
418
+ | --- | --- | --- | --- |
419
+ | `font-display-missing`, `font-display-block` | warn | A loaded `@font-face` has no `font-display` (Chrome then hides its text for up to 3 s) or uses `block`. The message says when it sets text above the fold (FOIT) | `font-display: swap` or `optional`; `&display=swap` for Google Fonts |
420
+ | `font-not-preloaded` | warn | A font file that sets text in the first viewport isn't preloaded | `<link rel="preload" as="font" type="font/woff2" crossorigin>` |
421
+ | `font-preload-no-crossorigin` | warn | A font preload without `crossorigin`, so the file downloads twice | Add `crossorigin` |
422
+ | `font-preload-unused` | info | A preloaded font that no `@font-face` uses | Remove it, or match the URL exactly |
423
+ | `font-failed` | error | A font file returned 400 or above | Fix the `src` URL |
424
+
425
+ Every font finding names the file URL. The `@font-face` rules come from the
426
+ page's stylesheets; the page can't read cross-origin ones (Google Fonts, a
427
+ CDN), so rpc downloads and parses those itself. Faces with only `local()`
428
+ sources, like next/font's size-adjusted fallbacks, never block text and are
429
+ skipped.
430
+
431
+ #### Mobile viewport
432
+
433
+ These run on phone devices only.
434
+
435
+ | id | Severity | When | Fix |
436
+ | --- | --- | --- | --- |
437
+ | `horizontal-overflow` | error | `scrollWidth` is wider than the layout viewport. Lists the outermost elements that stick out, furthest first | `max-width: 100%` on the element, or `overflow-x: auto` on its wrapper |
438
+ | `vh-units` | warn | A rendered element sized with `100vh` in `height`, `min-height` or `max-height`, from a stylesheet rule that applies or an inline style | `100dvh` or `100svh`, with `100vh` before it as a fallback |
439
+ | `tap-target-small` | warn | Links, buttons and form controls smaller than 44x44 CSS px. Links inside running text are exempt | Padding, `min-height`, or a bigger hit area |
440
+ | `input-zoom` | warn | A text field with `font-size` under 16px; iOS Safari zooms the page when it gets focus | `font-size: 16px` or more |
441
+
442
+ A headless phone has no browser bars, so `100vh` and `100dvh` measure the
443
+ same there. That's why `vh-units` reads the CSS instead of measuring
444
+ elements. The `height: 100vh; height: 100dvh` fallback pattern isn't
445
+ flagged: the browser keeps only the last value it understands.
446
+
447
+ ### JSON report
448
+
449
+ `--json` prints this object, and the MCP `check` tool returns the same one.
450
+ `schema` goes up only when a field changes meaning or is removed; new fields
451
+ can appear without it.
452
+
453
+ ```jsonc
454
+ {
455
+ "schema": 1,
456
+ "url": "https://example.com/",
457
+ "devices": [
458
+ {
459
+ "device": "iphone-15-pro",
460
+ "name": "iPhone 15 Pro",
461
+ "viewport": { "width": 393, "height": 852 },
462
+ "dpr": 3,
463
+ "mobile": true, // the phone-only checks ran here
464
+ "status": 200,
465
+ "finalUrl": "https://example.com/",
466
+ "title": "Example",
467
+ "cls": 0.012,
468
+ "layoutShifts": 2,
469
+ // only when the page didn't load on this device:
470
+ "error": "…", "code": "ERR_NAME_NOT_RESOLVED", "hint": "…"
471
+ }
472
+ ],
473
+ "summary": { "errors": 1, "warnings": 4, "info": 2 },
474
+ "findings": [
475
+ {
476
+ "id": "horizontal-overflow", // stable; see the tables above
477
+ "check": "mobile", // basics | meta | layout-shift | labels | fonts | mobile
478
+ "severity": "error", // error | warn | info
479
+ "message": "The page scrolls sideways: 601px wide in a 393px viewport",
480
+ "fix": "Find the element that sticks out …",
481
+ "devices": ["iphone-15-pro"],
482
+ "selector": "div.pricing-table", // when one element is the answer
483
+ "url": "…", // when a file is (a font, og:image)
484
+ "value": 208, // the number behind it, if there is one
485
+ "items": [ // elements, files or log lines, when there are several
486
+ {
487
+ "selector": "div.pricing-table",
488
+ "text": "600px wide, 207px past the edge",
489
+ "value": 207,
490
+ "screenshot": "/abs/.rpc-shots/…-check-horizontal-overflow-1.png" // with --shots
491
+ }
492
+ ]
493
+ }
494
+ ],
495
+ "ogImage": { "url": "…", "status": 200, "contentType": "image/png", "width": 1200, "height": 630 },
496
+ "auth": { … } // as in rpc shot, values masked
497
+ }
498
+ ```
499
+
500
+ Findings are sorted by check, in the order of the tables above, then by
501
+ severity. `id`, `check`, `severity`, `message`, `fix` and `devices` are
502
+ always there; the other fields appear when they apply.
503
+
504
+ ### In CI
505
+
506
+ ```sh
507
+ npx rpc-agent install
508
+ npx rpc-agent check https://staging.example.com --fail-on warn --json > ship-check.json
509
+ ```
510
+
511
+ The exit code fails the job, and the JSON can go into a PR comment or a
512
+ build artifact. For a page behind a login, sign in first with
513
+ `rpc login <url> --form` (see [CI and agents](#ci-and-agents-rpc-login---form)).
514
+
515
+ ### With a coding agent
516
+
517
+ Add the MCP server (below) and tell the agent to run `check` before it
518
+ reports UI work as finished, for example in `CLAUDE.md` or `AGENTS.md`:
519
+
520
+ ```md
521
+ Before you say UI work is done, run the rpc `check` tool on each page you
522
+ changed and fix every error. Fix the warnings too, or say why one doesn't apply.
523
+ ```
524
+
302
525
  ## MCP server
303
526
 
304
- `rpc mcp` runs an MCP server on stdio with three tools.
527
+ `rpc mcp` runs an MCP server on stdio with four tools.
305
528
 
306
529
  ### `screenshot`
307
530
 
@@ -355,6 +578,25 @@ session, cookies and headers (values masked), and the JSON summary gains an
355
578
  `AUTH_REQUIRED` and the `rpc login` hint. Signing in is something the user
356
579
  does in a terminal (`rpc login`); the agent then just calls `screenshot`.
357
580
 
581
+ ### `check`
582
+
583
+ Runs [Ship-check](#ship-check) on one URL.
584
+
585
+ | Param | Default | |
586
+ | --- | --- | --- |
587
+ | `url` | required | http(s), file, bare host or local path |
588
+ | `devices` | `["iphone-15-pro", "desktop-1440"]` | ids or aliases; the mobile checks run on phones |
589
+ | `shots` | false | save PNGs of the elements behind layout-shift and overflow findings |
590
+ | `outDir`, `waitFor`, `waitMs`, `colorScheme`, `timeoutMs` | | as for `screenshot` |
591
+ | `cookies`, `headers`, `storageState`, `useSession`, `allowOrigins` | | as for `screenshot` |
592
+
593
+ The result is two text blocks. The first is a short summary: the counts,
594
+ then one line per error and warning with its id and selector, like
595
+ `ERROR [button-no-name] 2 buttons with no accessible name at button.icon-menu`.
596
+ The second is the JSON report described under [JSON report](#json-report).
597
+ The call is a tool error only when the page failed to load on every device;
598
+ findings, even errors, come back as a normal result for the agent to act on.
599
+
358
600
  ### `list_devices`
359
601
 
360
602
  Takes an optional `category` (`apple-phone`, `android-phone`, `tablet`,
@@ -445,6 +687,13 @@ appear once, that the IntersectionObserver section rendered, and the slice
445
687
  count and labels, including through the MCP server. The missing-browser tests
446
688
  point `PLAYWRIGHT_BROWSERS_PATH` at an empty temp dir.
447
689
 
690
+ The Ship-check tests serve `test/fixtures/check`: one page per check
691
+ (`meta.html`, `shift.html`, `labels.html`, `fonts.html`, `mobile.html`,
692
+ `basics.html`) and `clean.html`, which must produce no errors or warnings.
693
+ `shift.html` inserts a 300 px banner 800 ms after load, so CLS is known in
694
+ advance (300/852 on the phone, 300/1440 on desktop); the test runs it twice
695
+ and compares.
696
+
448
697
  The README media are built from real captures by `scripts/media/build.ts`;
449
698
  `docs/media.md` explains how. The CLI's design notes, and what it borrows
450
699
  from the `skills` CLI, are in `docs/cli-design.md`.
@@ -466,6 +715,11 @@ from the `skills` CLI, are in `docs/cli-design.md`.
466
715
  - Login-page detection goes by the URL path. An app that shows its sign-in
467
716
  form at the requested URL without redirecting is captured as is.
468
717
  - Every MCP call launches a fresh Chromium, which adds about a second.
718
+ - Ship-check reads `100vh` only from stylesheets the page can read and from
719
+ inline styles; a `100vh` rule in a cross-origin stylesheet is missed. Tap
720
+ targets are measured by their box, so a hit area enlarged with a
721
+ pseudo-element still counts as small. CLS covers the load and one scroll,
722
+ not shifts caused by clicking around.
469
723
 
470
724
  ## License
471
725
 
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, defaultShotOptions, FAIL_ON, normalizeUrl, } from './options.js';
5
+ export const HELP_TOPICS = ['shot', 'check', 'login', 'sessions', 'devices', 'doctor', 'install', 'mcp'];
6
6
  function isTopic(s) {
7
7
  return HELP_TOPICS.includes(s ?? '');
8
8
  }
@@ -16,6 +16,7 @@ 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
19
20
  rpc login <url> [--form] sign in once and save the session for that origin
20
21
  rpc sessions [list|clear [origin]]
21
22
  list or delete saved sessions
@@ -43,7 +44,12 @@ Shot options:
43
44
  --json print the result as JSON
44
45
  --open open the output folder when done
45
46
 
46
- Auth options (values are never printed):
47
+ Check options (plus -d, --wait-for, --wait, --timeout, --color-scheme, -o, --json):
48
+ --fail-on <level> error (default) | warn: exit 1 on warnings too
49
+ --shots screenshot the elements behind layout-shift and
50
+ overflow findings (into --out)
51
+
52
+ Auth options (values are never printed, shot and check):
47
53
  --cookie <c> "name=value[; Domain=…; Path=…]", repeatable;
48
54
  sent to the target origin only
49
55
  --header <h> "Name: value", repeatable; target origin only
@@ -58,6 +64,7 @@ Examples:
58
64
  rpc shot localhost:3000 -d iphone-15,ipad,desktop-1440 --bezels
59
65
  rpc shot https://example.com --full-page --color-scheme dark
60
66
  rpc login localhost:3000/sign-in && rpc shot localhost:3000/dashboard
67
+ rpc check localhost:3000 --fail-on warn
61
68
  `;
62
69
  export function parseCli(argv, env = process.env) {
63
70
  const [cmd, ...rest] = argv;
@@ -104,6 +111,8 @@ export function parseCli(argv, env = process.env) {
104
111
  }
105
112
  case 'shot':
106
113
  return parseShot(rest);
114
+ case 'check':
115
+ return parseCheck(rest);
107
116
  default:
108
117
  throw new ArgError(`Unknown command "${cmd}". Run \`rpc --help\`.`);
109
118
  }
@@ -127,11 +136,7 @@ function parseShot(args) {
127
136
  'reduced-motion': { type: 'boolean' },
128
137
  json: { type: 'boolean' },
129
138
  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 },
139
+ ...AUTH_FLAGS,
135
140
  }, true);
136
141
  if (positionals.length === 0)
137
142
  throw new ArgError('Missing <url>. Usage: rpc shot <url> [options]');
@@ -144,17 +149,8 @@ function parseShot(args) {
144
149
  catch (err) {
145
150
  throw new ArgError(err.message);
146
151
  }
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
- }
152
+ if (typeof values.devices === 'string')
153
+ options.devices = parseDevices(values.devices);
158
154
  if (values.bezels)
159
155
  options.bezels = true;
160
156
  if (values['full-page'])
@@ -167,13 +163,8 @@ function parseShot(args) {
167
163
  options.waitMs = parseMs('--wait', values.wait);
168
164
  if (typeof values.timeout === 'string')
169
165
  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
- }
166
+ if (typeof values['color-scheme'] === 'string')
167
+ options.colorScheme = parseColorScheme(values['color-scheme']);
177
168
  if (values['no-emulate'])
178
169
  options.emulate = false;
179
170
  if (values.slices)
@@ -186,6 +177,20 @@ function parseShot(args) {
186
177
  if (typeof values['max-height'] === 'string') {
187
178
  options.maxHeight = parseInt_('--max-height', values['max-height'], 500, 200_000);
188
179
  }
180
+ const auth = parseAuth(values, options.url);
181
+ if (auth)
182
+ options.auth = auth;
183
+ return { command: 'shot', options, json: Boolean(values.json), open: Boolean(values.open) };
184
+ }
185
+ const AUTH_FLAGS = {
186
+ cookie: { type: 'string', multiple: true },
187
+ header: { type: 'string', multiple: true },
188
+ 'storage-state': { type: 'string' },
189
+ 'no-session': { type: 'boolean' },
190
+ 'allow-origin': { type: 'string', multiple: true },
191
+ };
192
+ /** Cookie/header/session flags, validated against the URL now: bad input is a usage error. */
193
+ function parseAuth(values, url) {
189
194
  const auth = {};
190
195
  try {
191
196
  if (values.cookie?.length)
@@ -198,15 +203,80 @@ function parseShot(args) {
198
203
  auth.useSession = false;
199
204
  if (values['allow-origin']?.length)
200
205
  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
- }
206
+ if (!Object.keys(auth).length)
207
+ return undefined;
208
+ resolveAuth(url, auth);
209
+ return auth;
205
210
  }
206
211
  catch (err) {
207
212
  throw new ArgError(err.message);
208
213
  }
209
- return { command: 'shot', options, json: Boolean(values.json), open: Boolean(values.open) };
214
+ }
215
+ function parseDevices(raw) {
216
+ const list = splitList(raw);
217
+ if (list.length === 0)
218
+ throw new ArgError('--devices is empty');
219
+ try {
220
+ return resolveDevices(list).map(d => d.id);
221
+ }
222
+ catch (err) {
223
+ throw new ArgError(err.message);
224
+ }
225
+ }
226
+ function parseCheck(args) {
227
+ if (wantsHelp(args))
228
+ return { command: 'help', topic: 'check' };
229
+ const { values, positionals } = parse(args, {
230
+ devices: { type: 'string', short: 'd' },
231
+ out: { type: 'string', short: 'o' },
232
+ 'wait-for': { type: 'string' },
233
+ wait: { type: 'string' },
234
+ 'color-scheme': { type: 'string' },
235
+ timeout: { type: 'string' },
236
+ 'fail-on': { type: 'string' },
237
+ shots: { type: 'boolean' },
238
+ json: { type: 'boolean' },
239
+ ...AUTH_FLAGS,
240
+ }, true);
241
+ if (positionals.length === 0)
242
+ throw new ArgError('Missing <url>. Usage: rpc check <url> [options]');
243
+ if (positionals.length > 1)
244
+ throw new ArgError(`Expected one URL, got: ${positionals.join(' ')}`);
245
+ let options;
246
+ try {
247
+ options = defaultCheckOptions(positionals[0]);
248
+ }
249
+ catch (err) {
250
+ throw new ArgError(err.message);
251
+ }
252
+ if (typeof values.devices === 'string')
253
+ options.devices = parseDevices(values.devices);
254
+ if (typeof values.out === 'string')
255
+ options.outDir = values.out;
256
+ if (typeof values['wait-for'] === 'string')
257
+ options.waitFor = values['wait-for'];
258
+ if (typeof values.wait === 'string')
259
+ options.waitMs = parseMs('--wait', values.wait);
260
+ if (typeof values.timeout === 'string')
261
+ options.timeoutMs = parseMs('--timeout', values.timeout);
262
+ if (typeof values['color-scheme'] === 'string')
263
+ options.colorScheme = parseColorScheme(values['color-scheme']);
264
+ if (typeof values['fail-on'] === 'string') {
265
+ if (!FAIL_ON.includes(values['fail-on']))
266
+ throw new ArgError(`--fail-on must be one of ${FAIL_ON.join(', ')}`);
267
+ options.failOn = values['fail-on'];
268
+ }
269
+ if (values.shots)
270
+ options.shots = true;
271
+ const auth = parseAuth(values, options.url);
272
+ if (auth)
273
+ options.auth = auth;
274
+ return { command: 'check', options, json: Boolean(values.json) };
275
+ }
276
+ function parseColorScheme(s) {
277
+ if (!COLOR_SCHEMES.includes(s))
278
+ throw new ArgError(`--color-scheme must be one of ${COLOR_SCHEMES.join(', ')}`);
279
+ return s;
210
280
  }
211
281
  function parseLogin(args, env) {
212
282
  if (wantsHelp(args))
package/dist/capture.js CHANGED
@@ -26,21 +26,7 @@ export async function capture(opts, hooks = {}) {
26
26
  shot = await captureDevice(browser, device, opts, outDir, hooks, auth);
27
27
  }
28
28
  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
- }
29
+ shot = { ...baseShot(device), ...describeFailure(err, opts, auth) };
44
30
  }
45
31
  if (shot.consoleErrors?.length && auth.secrets.length) {
46
32
  shot.consoleErrors = shot.consoleErrors.map(l => redact(l, auth.secrets));
@@ -57,6 +43,18 @@ export async function capture(opts, hooks = {}) {
57
43
  await browser.close();
58
44
  }
59
45
  }
46
+ /** A device's navigation/capture failure as `{ error, code, hint }`, secrets scrubbed. */
47
+ export function describeFailure(err, opts, auth) {
48
+ if (err instanceof AuthRequiredError) {
49
+ return { status: err.status, finalUrl: err.finalUrl, error: err.message, code: err.code, hint: err.hint };
50
+ }
51
+ const e = explainError(err, { url: opts.url, timeoutMs: opts.timeoutMs, waitFor: opts.waitFor });
52
+ return {
53
+ error: redact(e.error, auth.secrets),
54
+ code: e.code,
55
+ ...(e.hint ? { hint: redact(e.hint, auth.secrets) } : {}),
56
+ };
57
+ }
60
58
  function baseShot(device) {
61
59
  return {
62
60
  device: device.id,
@@ -65,7 +63,8 @@ function baseShot(device) {
65
63
  dpr: device.dpr,
66
64
  };
67
65
  }
68
- async function captureDevice(browser, device, opts, outDir, hooks, auth) {
66
+ /** A fresh context for one device: viewport, DPR, mobile emulation, media features and auth state. */
67
+ export async function newDeviceContext(browser, device, opts, auth) {
69
68
  const emu = opts.emulate ? emulationFor(device) : undefined;
70
69
  const context = await browser.newContext({
71
70
  viewport: { width: device.width, height: device.height },
@@ -80,6 +79,10 @@ async function captureDevice(browser, device, opts, outDir, hooks, auth) {
80
79
  });
81
80
  if (auth.cookies.length)
82
81
  await context.addCookies(auth.cookies);
82
+ return context;
83
+ }
84
+ async function captureDevice(browser, device, opts, outDir, hooks, auth) {
85
+ const context = await newDeviceContext(browser, device, opts, auth);
83
86
  const result = baseShot(device);
84
87
  const errors = new Set();
85
88
  try {
@@ -244,7 +247,7 @@ const SCROLL_HEIGHT_JS = `Math.max(document.documentElement.scrollHeight, docume
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(() => { });