rpc-agent 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +198 -8
- package/dist/args.js +100 -4
- package/dist/browser.js +57 -3
- package/dist/capture.js +25 -25
- package/dist/check-page.js +23 -8
- package/dist/check.js +11 -3
- package/dist/cli.js +56 -0
- package/dist/errors.js +8 -0
- package/dist/gif.js +212 -0
- package/dist/help.js +33 -3
- package/dist/mcp.js +127 -3
- package/dist/motion.js +487 -0
- package/dist/options.js +19 -0
- package/dist/record.js +333 -0
- package/dist/ui.js +93 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
Motion: captures that match what a person scrolling sees, a Motion check,
|
|
6
|
+
and `rpc record`.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `rpc record <url>` writes a few seconds of video (VP9 webm) of the page
|
|
11
|
+
or of one element (`--selector`, scrolled into view and cropped to its
|
|
12
|
+
box), plus a poster PNG of the first frame. `--duration` (default 5, up
|
|
13
|
+
to 30 s), `-d` for one device (default `desktop-1440`), `--gif`, `--mp4`
|
|
14
|
+
(needs an ffmpeg with H.264 on PATH; skipped with a warning otherwise),
|
|
15
|
+
and the same auth flags as `rpc shot`. Frames come from a screenshot loop
|
|
16
|
+
at device pixels, encoded at a constant 25 fps by Playwright's own ffmpeg
|
|
17
|
+
build (already downloaded by `rpc install`), with a system ffmpeg as the
|
|
18
|
+
fallback. GIFs are encoded in Node with no new dependency.
|
|
19
|
+
- MCP `record` tool with the same options; it returns the paths, a summary
|
|
20
|
+
and the poster inline.
|
|
21
|
+
- `rpc shot --finish-animations` (MCP `finishAnimations`) jumps GSAP tweens
|
|
22
|
+
and non-scrubbed ScrollTriggers to their end when `gsap` / `ScrollTrigger`
|
|
23
|
+
are on `window`, and finite Web Animations, CSS animations and transitions
|
|
24
|
+
too. Counts are in `motion.finished`.
|
|
25
|
+
- `rpc shot --no-unpin` (MCP `unpin: false`) keeps pinned sections as they
|
|
26
|
+
are in full-page shots.
|
|
27
|
+
- Each shot has `warnings` when motion made the capture differ from the
|
|
28
|
+
page in use: elements still animating, a pinned section left in place,
|
|
29
|
+
content still hidden by a reveal, and carousels ("use `rpc record` for
|
|
30
|
+
motion"). The terminal prints them under the device and the MCP headline
|
|
31
|
+
lists them with a fix.
|
|
32
|
+
- `rpc check` has a Motion group: `motion-running` and
|
|
33
|
+
`motion-reveal-hidden` (warn), `motion-loop`, `motion-pinned` and
|
|
34
|
+
`motion-carousel` (info). Never an error.
|
|
35
|
+
- `rpc doctor` reports the ffmpeg `rpc record` would use and whether mp4 is
|
|
36
|
+
available. Its exit code still depends on Chromium only.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `rpc shot` waits for the visible page to settle before capturing, and at
|
|
41
|
+
each warm-up scroll step: no style or class change on a visible element
|
|
42
|
+
for a short quiet period (and at the warm-up steps no finite animation
|
|
43
|
+
running on one; the screenshot ends CSS and Web Animations itself),
|
|
44
|
+
capped (2 s before capture, 800 ms per step, 8 s for the warm-up).
|
|
45
|
+
The result is in `motion.settle`.
|
|
46
|
+
- Full-page shots collapse pinned sections (GSAP-style `.pin-spacer`
|
|
47
|
+
wrappers, pins `ScrollTrigger.getAll()` reports, and sticky or fixed
|
|
48
|
+
sections that are the only child of a much taller parent), so each
|
|
49
|
+
section shows once instead of over a blank spacer. Each one is listed in
|
|
50
|
+
`motion.unpinned` with the CSS px removed.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- `rpc check`: the tap-target check skips inline links in a sentence (WCAG
|
|
55
|
+
2.5.8 inline exception). A link is skipped when its computed `display` is
|
|
56
|
+
`inline` and its block of text has letters or digits outside any
|
|
57
|
+
control. Buttons, `[role=button]`, links laid out as boxes, nav links and
|
|
58
|
+
icon links still count, and the threshold stays 44x44.
|
|
59
|
+
- Test servers answer 500 with the error when a fixture file is missing
|
|
60
|
+
instead of hanging, and the check suite stops early with "run pnpm
|
|
61
|
+
install" when `@fontsource/ibm-plex-mono` isn't installed.
|
|
62
|
+
|
|
3
63
|
## 0.5.0
|
|
4
64
|
|
|
5
65
|
Ship-check: `rpc check <url>` audits a page before launch, from the CLI, in
|
package/README.md
CHANGED
|
@@ -64,6 +64,7 @@ these docs say `rpc`, or `pnpm rpc` from inside the repo.
|
|
|
64
64
|
rpc # interactive: pick a URL, devices and options
|
|
65
65
|
rpc shot <url> [options]
|
|
66
66
|
rpc check <url> [options] # pre-launch audit, see Ship-check below
|
|
67
|
+
rpc record <url> [options] # a few seconds of video of the page or one element
|
|
67
68
|
rpc login <url> [--form] # sign in once; later shots of that origin use the session
|
|
68
69
|
rpc sessions [list|clear [origin]]
|
|
69
70
|
rpc devices [--json]
|
|
@@ -94,6 +95,8 @@ the files it wrote:
|
|
|
94
95
|
| `--slice-width <px>` | Maximum slice width. Default 768 |
|
|
95
96
|
| `--max-height <px>` | Full-page cap in CSS px. Default 60000 |
|
|
96
97
|
| `--reduced-motion` | Emulate `prefers-reduced-motion: reduce` |
|
|
98
|
+
| `--finish-animations` | Jump animations to their end state before capturing. See [Animation-aware capture](#animation-aware-capture) |
|
|
99
|
+
| `--no-unpin` | With `--full-page`, keep pinned sections as they are instead of collapsing them |
|
|
97
100
|
| `-o, --out <dir>` | Output directory. Default `./.rpc-shots` |
|
|
98
101
|
| `--wait-for <selector>` | Wait for a visible element before capturing |
|
|
99
102
|
| `--wait <ms>` | Extra settle delay after load |
|
|
@@ -149,6 +152,90 @@ and `100vh` sections keep their real height.
|
|
|
149
152
|
Bezels frame the above-the-fold viewport. With `--bezels --full-page` you get
|
|
150
153
|
both: the full page raw, and the first screen framed.
|
|
151
154
|
|
|
155
|
+
### Animation-aware capture
|
|
156
|
+
|
|
157
|
+
A stitched shot of a page with scroll animations used to come out wrong in
|
|
158
|
+
three ways: pinned scroll sections left tall blank bands, text was caught
|
|
159
|
+
halfway through its reveal, and carousels froze on whatever slide was up.
|
|
160
|
+
|
|
161
|
+
#### Settle
|
|
162
|
+
|
|
163
|
+
Before each capture, and at every step of the full-page warm-up
|
|
164
|
+
scroll, rpc waits until the visible part of the page stops moving: no
|
|
165
|
+
`style` or `class` change on a visible element for 150 ms (120 ms per
|
|
166
|
+
warm-up step), and, at the warm-up steps, no finite animation running on
|
|
167
|
+
one. The screenshot itself ends finite CSS and Web Animations (Playwright's
|
|
168
|
+
`animations: 'disabled'`), so the wait just before it only watches for
|
|
169
|
+
JS-driven changes, the kind a GSAP tween makes. It gives up after 2 s
|
|
170
|
+
(800 ms per step, 8 s for the whole warm-up), so a page that never stops
|
|
171
|
+
can't hang it. Anything still changing or animating when a step runs out
|
|
172
|
+
of time is treated as a loop (a JS marquee, a ticker, a long CSS
|
|
173
|
+
animation), and later steps stop waiting for it. A static page only pays the quiet period. The result is on each shot as
|
|
174
|
+
`motion.settle`: `settled`, `ms`, and the selectors still active.
|
|
175
|
+
|
|
176
|
+
#### Pinned sections
|
|
177
|
+
|
|
178
|
+
GSAP ScrollTrigger pins a section by wrapping it in a
|
|
179
|
+
`.pin-spacer` with padding below it, then fixing the section in place while
|
|
180
|
+
you scroll through that padding. A full-page capture renders the page at
|
|
181
|
+
scroll 0, where the section sits at the top of its spacer and the padding
|
|
182
|
+
is empty, so the shot gets a blank band as tall as the pin's scroll
|
|
183
|
+
distance. With `--full-page`, rpc collapses each pin it finds. The spacer
|
|
184
|
+
loses its padding, the section goes back into normal flow, and the shot
|
|
185
|
+
shows it once with the rest of the page right after it. A sticky section
|
|
186
|
+
that is the only child of a much taller parent gets the same treatment. The
|
|
187
|
+
rules go in an injected stylesheet as `!important`, so when the library
|
|
188
|
+
rewrites its inline styles during the capture, they still win. Each
|
|
189
|
+
collapsed pin is listed in `motion.unpinned` with the CSS px removed, and
|
|
190
|
+
the stitched height is measured after the collapse. `--no-unpin` turns it
|
|
191
|
+
off.
|
|
192
|
+
|
|
193
|
+
| `--no-unpin` (before) | default (after) |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| <img alt="A full-page shot of a fixture with a pinned orange section: a 2100px white band sits between the section and the green outro" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/pinned-before.png" width="240"> | <img alt="The same page with the pin collapsed: blue intro, orange section, green outro, no gap" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/pinned-after.png" width="240"> |
|
|
196
|
+
|
|
197
|
+
#### Finish animations
|
|
198
|
+
|
|
199
|
+
`--finish-animations` is for JS-driven motion, which the screenshot can't
|
|
200
|
+
end on its own. It calls `.finish()` on every finite Web Animation (CSS
|
|
201
|
+
animations and transitions too, so layout measured before the capture is
|
|
202
|
+
the end state). If `window.gsap` exists,
|
|
203
|
+
tweens and timelines on its global timeline that don't repeat forever go to
|
|
204
|
+
`progress(1)`. If `window.ScrollTrigger` exists, triggers that aren't
|
|
205
|
+
scrubbed or pinned get their animation set to `progress(1)`. Scrubbed and
|
|
206
|
+
pinned ones follow the scroll position, so they keep their scroll-0 state.
|
|
207
|
+
Each library step runs in its own try/catch and does nothing when the
|
|
208
|
+
library isn't there. Infinite loops are left alone. The counts are in
|
|
209
|
+
`motion.finished`.
|
|
210
|
+
|
|
211
|
+
#### Reduced motion
|
|
212
|
+
|
|
213
|
+
`--reduced-motion` still emulates
|
|
214
|
+
`prefers-reduced-motion: reduce`. On a site that respects it, that's the
|
|
215
|
+
cleanest fix, because the animations never run.
|
|
216
|
+
|
|
217
|
+
#### Warnings
|
|
218
|
+
|
|
219
|
+
Each shot gets a `warnings` list in the JSON, and the terminal
|
|
220
|
+
prints them under the device:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
ok desktop-1440 (1440x900 @1x)
|
|
224
|
+
.rpc-shots/localhost-3000-desktop-1440.png 1440x900
|
|
225
|
+
warn: 3 elements were still animating when captured: div.reveal:nth-of-type(1), div.reveal:nth-of-type(2), div.reveal:nth-of-type(3)
|
|
226
|
+
warn: carousel: use `rpc record` for motion: #carousel
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| id | When |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| `motion-running` | Elements were still changing (style or class writes, as a JS tween makes) when the settle ran out of time: "N elements were still animating when captured". CSS and Web Animations don't count here, because the screenshot ends them |
|
|
232
|
+
| `motion-pinned` | Full page only. A pin was left in place (`--no-unpin`, or one that couldn't be collapsed): "pinned section: the stitched shot may show blank space" |
|
|
233
|
+
| `motion-reveal-hidden` | Content still at opacity 0, or offset by 24px or more, that looks like a reveal (see [Motion](#motion)) |
|
|
234
|
+
| `motion-carousel` | A carousel or marquee is in the shot: "carousel: use `rpc record` for motion" |
|
|
235
|
+
|
|
236
|
+
Each warning has a `fix` naming the flag to try. Warnings never change the
|
|
237
|
+
exit code.
|
|
238
|
+
|
|
152
239
|
### Terminal, pipes and CI
|
|
153
240
|
|
|
154
241
|
The banner, spinners, prompts and boxes only appear when stdout is an
|
|
@@ -305,8 +392,8 @@ rpc shot localhost:3000/app --storage-state auth.json
|
|
|
305
392
|
`rpc check <url>` audits one page before you call it done. It loads the page
|
|
306
393
|
on a phone and a desktop width, the way `rpc shot` does, and lists what's
|
|
307
394
|
wrong: missing or broken social tags, layout shift, buttons a screen reader
|
|
308
|
-
can't name, fonts that hide text while they load,
|
|
309
|
-
|
|
395
|
+
can't name, fonts that hide text while they load, the mobile viewport bugs
|
|
396
|
+
that only show up on a phone, and motion a screenshot will get wrong.
|
|
310
397
|
|
|
311
398
|
```sh
|
|
312
399
|
rpc check localhost:3000
|
|
@@ -436,7 +523,7 @@ These run on phone devices only.
|
|
|
436
523
|
| --- | --- | --- | --- |
|
|
437
524
|
| `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
525
|
| `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.
|
|
526
|
+
| `tap-target-small` | warn | Links, buttons and form controls smaller than 44x44 CSS px. Inline links in a sentence are exempt, per the WCAG 2.5.8 inline exception: a link with `display: inline` and other text in the same block. Buttons and links laid out as boxes (`inline-block`, `flex`, `block`) still count | Padding, `min-height`, or a bigger hit area |
|
|
440
527
|
| `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
528
|
|
|
442
529
|
A headless phone has no browser bars, so `100vh` and `100dvh` measure the
|
|
@@ -444,6 +531,21 @@ same there. That's why `vh-units` reads the CSS instead of measuring
|
|
|
444
531
|
elements. The `height: 100vh; height: 100dvh` fallback pattern isn't
|
|
445
532
|
flagged: the browser keeps only the last value it understands.
|
|
446
533
|
|
|
534
|
+
#### Motion
|
|
535
|
+
|
|
536
|
+
After the scroll, rpc gives running animations up to 1 s to stop, then
|
|
537
|
+
looks at the viewport plus half a viewport above and below. These findings
|
|
538
|
+
are about whether a screenshot comes out right, and a page can ship with
|
|
539
|
+
them, so they are `warn` or `info` and never `error`.
|
|
540
|
+
|
|
541
|
+
| id | Severity | When | Fix |
|
|
542
|
+
| --- | --- | --- | --- |
|
|
543
|
+
| `motion-running` | warn | Finite animations (from `document.getAnimations()`) or style changes still going on visible elements | `rpc shot --finish-animations`, `--wait`, or `--reduced-motion` |
|
|
544
|
+
| `motion-reveal-hidden` | warn | Elements with text or media at opacity under 0.1, `visibility: hidden`, offset by 24px or more, or scaled under 0.85, that also carry a reveal class (`reveal`, `fade`, `aos`, `in-view`, `appear`, `animated` and similar; utility words such as `scroll`, `visible` or `animate-` don't count), a reveal attribute (`data-aos`, `data-sal`, `data-scroll` and similar) or inline opacity or transform written by a library. Carousel tracks, elements inside a carousel library root, and `translate(-50%)` centring are not offsets. Only the outermost element is listed | Make sure the reveal fires, and that the content shows without JavaScript or animation |
|
|
545
|
+
| `motion-loop` | info | Infinite animations: spinners, pulses, CSS marquees | Stop them under `prefers-reduced-motion: reduce` |
|
|
546
|
+
| `motion-pinned` | info | A `.pin-spacer` taller than its section by a quarter of the viewport, a pin that `ScrollTrigger.getAll()` reports as its parent's only child, or a sticky or fixed section (half a viewport tall or more) that is the only child of a parent at least half a viewport taller | `rpc shot --full-page` collapses it; `rpc record --selector` shows it moving |
|
|
547
|
+
| `motion-carousel` | info | Swiper, Embla, Splide, Keen, Flickity, Slick or Glide roots, `aria-roledescription="carousel"`, horizontal scrollers with `scroll-snap-type` on x, and tracks whose scroll position or transform changes during a 300 ms sample | `rpc record <url> --selector '<the carousel>'` |
|
|
548
|
+
|
|
447
549
|
### JSON report
|
|
448
550
|
|
|
449
551
|
`--json` prints this object, and the MCP `check` tool returns the same one.
|
|
@@ -522,9 +624,62 @@ Before you say UI work is done, run the rpc `check` tool on each page you
|
|
|
522
624
|
changed and fix every error. Fix the warnings too, or say why one doesn't apply.
|
|
523
625
|
```
|
|
524
626
|
|
|
627
|
+
## Recording motion: `rpc record`
|
|
628
|
+
|
|
629
|
+
A still can't show a carousel, a marquee, or a pinned section whose panels
|
|
630
|
+
slide as you scroll. `rpc record` takes a few seconds of video of the page,
|
|
631
|
+
or of one element on it.
|
|
632
|
+
|
|
633
|
+
```sh
|
|
634
|
+
rpc record localhost:3000 --selector '.hero-carousel' # 5 s of one element
|
|
635
|
+
rpc record localhost:3000 -d iphone --duration 10 --gif # whole phone viewport, plus a GIF
|
|
636
|
+
rpc record example.com --selector '#logos' --mp4 # mp4 too, if ffmpeg is on PATH
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
| Option | Meaning |
|
|
640
|
+
| --- | --- |
|
|
641
|
+
| `--selector <css>` | Record this element. rpc scrolls it into view, waits for reveals around it to settle, and crops the video to its box. If it's bigger than the viewport, the visible part is recorded and the result says so |
|
|
642
|
+
| `--duration <s>` | Seconds of video, 0.5 to 30. Default 5 |
|
|
643
|
+
| `-d, --device <id>` | One device id or alias. Default `desktop-1440` |
|
|
644
|
+
| `--gif` | Also write a GIF: 10 fps, at most 640 px wide |
|
|
645
|
+
| `--mp4` | Also write an mp4. This needs an ffmpeg with an H.264 encoder on PATH; without one, rpc skips the mp4 and says so |
|
|
646
|
+
| `-o, --out <dir>`, `--json`, `--open` | As in `rpc shot` |
|
|
647
|
+
| `--wait-for`, `--wait`, `--timeout`, `--color-scheme`, `--reduced-motion`, `--no-emulate` | As in `rpc shot` |
|
|
648
|
+
| `--cookie`, `--header`, `--storage-state`, `--no-session`, `--allow-origin` | As in `rpc shot` |
|
|
649
|
+
|
|
650
|
+
It writes `<host>[-<path>]-<device>[-<selector>].webm` (VP9) and a
|
|
651
|
+
`-poster.png` of the first frame, plus the `.gif` and `.mp4` when asked:
|
|
652
|
+
|
|
653
|
+
```
|
|
654
|
+
ok iphone-15-pro (393x852 @3x) #carousel
|
|
655
|
+
.rpc-shots/localhost-3000-iphone-15-pro-carousel.webm 1080x600, 3s at 25 fps (90 frames captured, playwright ffmpeg)
|
|
656
|
+
.rpc-shots/localhost-3000-iphone-15-pro-carousel-poster.png 1080x600 poster
|
|
657
|
+
.rpc-shots/localhost-3000-iphone-15-pro-carousel.gif 640x356, 32 frames
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
<img alt="A GIF recorded from a fixture's autoplaying carousel: four coloured slides advancing in turn" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/record-carousel.gif" width="360">
|
|
661
|
+
|
|
662
|
+
Frames come from a loop of `page.screenshot()` calls clipped to the
|
|
663
|
+
element's box. Headless Chromium returns those at device pixels and cropped
|
|
664
|
+
exactly, at about 30 fps on a DPR 2 or 3 phone and 60 fps at DPR 1. The two
|
|
665
|
+
obvious alternatives do worse. CDP screencast frames arrive at CSS pixel
|
|
666
|
+
size in headless mode (393x852 for a DPR 3 phone), so they lose the
|
|
667
|
+
pixel density and need a scaled crop. Playwright's `recordVideo` records the
|
|
668
|
+
whole viewport at CSS size with a fixed bitrate, and it can't crop.
|
|
669
|
+
|
|
670
|
+
The frames go to ffmpeg as MJPEG at a constant 25 fps. Each one is repeated
|
|
671
|
+
until the next one's timestamp, so the video is as long as you asked for,
|
|
672
|
+
whatever rate the screenshots came in at. The ffmpeg is Playwright's own
|
|
673
|
+
build, which `rpc install` already downloads next to Chromium. It writes
|
|
674
|
+
VP9 webm and PNG but has no H.264 encoder, which is why webm is the default
|
|
675
|
+
and mp4 needs a system ffmpeg. If Playwright's build is missing, rpc falls
|
|
676
|
+
back to `ffmpeg` on PATH. GIFs are encoded in Node (`src/gif.ts`) from PNG
|
|
677
|
+
frames that ffmpeg decodes out of the webm, so they add no dependency.
|
|
678
|
+
`rpc doctor` says which ffmpeg it found and whether mp4 will work.
|
|
679
|
+
|
|
525
680
|
## MCP server
|
|
526
681
|
|
|
527
|
-
`rpc mcp` runs an MCP server on stdio with
|
|
682
|
+
`rpc mcp` runs an MCP server on stdio with five tools.
|
|
528
683
|
|
|
529
684
|
### `screenshot`
|
|
530
685
|
|
|
@@ -539,6 +694,8 @@ changed and fix every error. Fix the warnings too, or say why one doesn't apply.
|
|
|
539
694
|
| `sliceWidth` | 768 | full page: max slice width in px (raise it for small text on desktop) |
|
|
540
695
|
| `maxHeight` | 60000 | full page: CSS px cap |
|
|
541
696
|
| `reducedMotion` | false | emulate `prefers-reduced-motion: reduce` |
|
|
697
|
+
| `finishAnimations` | false | jump animations (Web Animations, GSAP on `window`) to their end first |
|
|
698
|
+
| `unpin` | true | full page: collapse pinned sections so each shows once |
|
|
542
699
|
| `waitFor`, `waitMs`, `colorScheme`, `outDir`, `timeoutMs` | | as in the CLI |
|
|
543
700
|
| `inline` | true | return images, not just paths |
|
|
544
701
|
| `cookies` | | `[{ name, value, domain?, path? }]`, set on the target origin |
|
|
@@ -550,7 +707,10 @@ changed and fix every error. Fix the warnings too, or say why one doesn't apply.
|
|
|
550
707
|
The result starts with a plain-text block: one line per device (or the error
|
|
551
708
|
and hint), followed by the JSON summary (file paths, pixel sizes,
|
|
552
709
|
`scrollHeight`, `capturedHeight`, `segments`, `clipped`, `slices`, HTTP status,
|
|
553
|
-
final URL, title, console errors, `error`/`code`/`hint`).
|
|
710
|
+
final URL, title, console errors, `motion`, `warnings`, `error`/`code`/`hint`).
|
|
711
|
+
Each motion warning also gets its own line under its device, followed by
|
|
712
|
+
the fix, like ``WARN [motion-carousel] carousel: use `rpc record` for motion
|
|
713
|
+
at #carousel``.
|
|
554
714
|
|
|
555
715
|
Then the images:
|
|
556
716
|
|
|
@@ -597,6 +757,27 @@ The second is the JSON report described under [JSON report](#json-report).
|
|
|
597
757
|
The call is a tool error only when the page failed to load on every device;
|
|
598
758
|
findings, even errors, come back as a normal result for the agent to act on.
|
|
599
759
|
|
|
760
|
+
### `record`
|
|
761
|
+
|
|
762
|
+
Runs [`rpc record`](#recording-motion-rpc-record) on one URL.
|
|
763
|
+
|
|
764
|
+
| Param | Default | |
|
|
765
|
+
| --- | --- | --- |
|
|
766
|
+
| `url` | required | http(s), file, bare host or local path |
|
|
767
|
+
| `device` | `desktop-1440` | one id or alias |
|
|
768
|
+
| `selector` | whole viewport | CSS selector of the element to record |
|
|
769
|
+
| `duration` | 5 | seconds, 0.5 to 30 |
|
|
770
|
+
| `gif` | false | also write a GIF |
|
|
771
|
+
| `mp4` | false | also write an mp4, when a system ffmpeg has H.264 |
|
|
772
|
+
| `reducedMotion`, `waitFor`, `waitMs`, `colorScheme`, `outDir`, `timeoutMs` | | as for `screenshot` |
|
|
773
|
+
| `cookies`, `headers`, `storageState`, `useSession`, `allowOrigins` | | as for `screenshot` |
|
|
774
|
+
|
|
775
|
+
The result starts with a summary line (`ok iphone-15 #carousel: 3s webm
|
|
776
|
+
1080x600 at 25 fps <path>`, then the poster, GIF and mp4 paths) and the
|
|
777
|
+
JSON result. The poster comes back inline as a JPEG, so the agent can see
|
|
778
|
+
what it recorded. An element that isn't there is a tool error with the code
|
|
779
|
+
`SELECTOR_NOT_FOUND`.
|
|
780
|
+
|
|
600
781
|
### `list_devices`
|
|
601
782
|
|
|
602
783
|
Takes an optional `category` (`apple-phone`, `android-phone`, `tablet`,
|
|
@@ -703,10 +884,19 @@ from the `skills` CLI, are in `docs/cli-design.md`.
|
|
|
703
884
|
- Chromium only, portrait only. There is no landscape mode, and none of RPC's
|
|
704
885
|
fake browser chrome bars.
|
|
705
886
|
- Scroll-driven animations are captured in their scroll-top state. Entrance
|
|
706
|
-
animations that run once are
|
|
887
|
+
animations that run once are fine after the warm-up scroll and settle. Ones
|
|
707
888
|
that reverse when scrolled back (GSAP `toggleActions` with reverse, scrubbed
|
|
708
|
-
timelines) may show their initial state
|
|
709
|
-
|
|
889
|
+
timelines) may show their initial state, and a collapsed pin shows its
|
|
890
|
+
section's first frame. `--finish-animations` only reaches GSAP when it's on
|
|
891
|
+
`window`; GSAP bundled into the app's JavaScript isn't, so its tweens stay
|
|
892
|
+
as they are. `--reduced-motion` helps on sites that respect it.
|
|
893
|
+
- Pins are found by GSAP's `.pin-spacer` class, `ScrollTrigger.getAll()`, and
|
|
894
|
+
sticky or fixed sections that are the only child of a tall parent. A pin
|
|
895
|
+
built some other way is still captured with its blank spacer.
|
|
896
|
+
- `rpc record` records what the page does on its own. It doesn't scroll or
|
|
897
|
+
click, so a scrubbed section only moves if something else moves it. Frames
|
|
898
|
+
come at the screenshot rate (about 30 fps on high-DPR phones), and the
|
|
899
|
+
webm repeats frames to hold 25 fps.
|
|
710
900
|
- Very tall stitched PNGs are big files; review them through the slices.
|
|
711
901
|
- The bezel geometry is RPC's, unchanged. On kind-default phones the page
|
|
712
902
|
covers the dynamic island or notch, and generic monitors are drawn edge to
|
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, defaultCheckOptions, defaultShotOptions, FAIL_ON, normalizeUrl, } from './options.js';
|
|
5
|
-
export const HELP_TOPICS = ['shot', 'check', '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,7 +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
|
|
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
|
|
20
22
|
rpc login <url> [--form] sign in once and save the session for that origin
|
|
21
23
|
rpc sessions [list|clear [origin]]
|
|
22
24
|
list or delete saved sessions
|
|
@@ -35,6 +37,10 @@ Shot options:
|
|
|
35
37
|
--slice-width <px> max slice width (default: 768)
|
|
36
38
|
--max-height <px> full-page cap in CSS px (default: 60000)
|
|
37
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)
|
|
38
44
|
-o, --out <dir> output directory (default: ./.rpc-shots)
|
|
39
45
|
--wait-for <selector> wait for a CSS selector before capturing
|
|
40
46
|
--wait <ms> extra settle delay after load (default: 0)
|
|
@@ -49,7 +55,15 @@ Check options (plus -d, --wait-for, --wait, --timeout, --color-scheme, -o, --jso
|
|
|
49
55
|
--shots screenshot the elements behind layout-shift and
|
|
50
56
|
overflow findings (into --out)
|
|
51
57
|
|
|
52
|
-
|
|
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):
|
|
53
67
|
--cookie <c> "name=value[; Domain=…; Path=…]", repeatable;
|
|
54
68
|
sent to the target origin only
|
|
55
69
|
--header <h> "Name: value", repeatable; target origin only
|
|
@@ -65,6 +79,7 @@ Examples:
|
|
|
65
79
|
rpc shot https://example.com --full-page --color-scheme dark
|
|
66
80
|
rpc login localhost:3000/sign-in && rpc shot localhost:3000/dashboard
|
|
67
81
|
rpc check localhost:3000 --fail-on warn
|
|
82
|
+
rpc record localhost:3000 --selector '.carousel' --duration 6 --gif
|
|
68
83
|
`;
|
|
69
84
|
export function parseCli(argv, env = process.env) {
|
|
70
85
|
const [cmd, ...rest] = argv;
|
|
@@ -113,6 +128,8 @@ export function parseCli(argv, env = process.env) {
|
|
|
113
128
|
return parseShot(rest);
|
|
114
129
|
case 'check':
|
|
115
130
|
return parseCheck(rest);
|
|
131
|
+
case 'record':
|
|
132
|
+
return parseRecord(rest);
|
|
116
133
|
default:
|
|
117
134
|
throw new ArgError(`Unknown command "${cmd}". Run \`rpc --help\`.`);
|
|
118
135
|
}
|
|
@@ -134,6 +151,8 @@ function parseShot(args) {
|
|
|
134
151
|
'slice-width': { type: 'string' },
|
|
135
152
|
'max-height': { type: 'string' },
|
|
136
153
|
'reduced-motion': { type: 'boolean' },
|
|
154
|
+
'finish-animations': { type: 'boolean' },
|
|
155
|
+
'no-unpin': { type: 'boolean' },
|
|
137
156
|
json: { type: 'boolean' },
|
|
138
157
|
open: { type: 'boolean' },
|
|
139
158
|
...AUTH_FLAGS,
|
|
@@ -171,6 +190,10 @@ function parseShot(args) {
|
|
|
171
190
|
options.saveSlices = true;
|
|
172
191
|
if (values['reduced-motion'])
|
|
173
192
|
options.reducedMotion = true;
|
|
193
|
+
if (values['finish-animations'])
|
|
194
|
+
options.finishAnimations = true;
|
|
195
|
+
if (values['no-unpin'])
|
|
196
|
+
options.unpin = false;
|
|
174
197
|
if (typeof values['slice-width'] === 'string') {
|
|
175
198
|
options.sliceWidth = parseInt_('--slice-width', values['slice-width'], 256, 4096);
|
|
176
199
|
}
|
|
@@ -273,6 +296,79 @@ function parseCheck(args) {
|
|
|
273
296
|
options.auth = auth;
|
|
274
297
|
return { command: 'check', options, json: Boolean(values.json) };
|
|
275
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
|
+
}
|
|
276
372
|
function parseColorScheme(s) {
|
|
277
373
|
if (!COLOR_SCHEMES.includes(s))
|
|
278
374
|
throw new ArgError(`--color-scheme must be one of ${COLOR_SCHEMES.join(', ')}`);
|
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();
|