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 +40 -0
- package/README.md +255 -1
- package/dist/args.js +101 -31
- package/dist/capture.js +20 -17
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +336 -0
- package/dist/check.js +677 -0
- package/dist/cli.js +32 -1
- package/dist/help.js +25 -1
- package/dist/mcp.js +92 -14
- package/dist/options.js +14 -0
- package/dist/png.js +47 -0
- package/dist/ui.js +106 -0
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() => { });
|