rpc-agent 0.0.0-stage → 0.3.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +302 -2
  5. package/assets/bezels/imac.svg +63 -0
  6. package/assets/bezels/ipad.svg +27 -0
  7. package/assets/bezels/iphone-11-pro.svg +35 -0
  8. package/assets/bezels/iphone-12-mini.svg +26 -0
  9. package/assets/bezels/iphone-12-pro-max.svg +26 -0
  10. package/assets/bezels/iphone-17-pro.svg +172 -0
  11. package/assets/bezels/iphone-se.svg +37 -0
  12. package/assets/bezels/laptop.svg +25 -0
  13. package/assets/bezels/macbook-air.svg +235 -0
  14. package/assets/bezels/macbook-pro-13.svg +63 -0
  15. package/assets/bezels/macbook-pro-14.svg +115 -0
  16. package/assets/bezels/macbook-pro.svg +32 -0
  17. package/assets/bezels/macbook.svg +25 -0
  18. package/assets/bezels/monitor.svg +57 -0
  19. package/assets/bezels/phone-classic.svg +17 -0
  20. package/assets/bezels/phone-home.svg +12 -0
  21. package/assets/bezels/phone-island.svg +130 -0
  22. package/assets/bezels/phone-notch.svg +26 -0
  23. package/assets/bezels/surface-laptop.svg +84 -0
  24. package/assets/bezels/tablet.svg +25 -0
  25. package/assets/bezels/windows-laptop.svg +84 -0
  26. package/dist/args.js +189 -0
  27. package/dist/bezel.js +74 -0
  28. package/dist/browser.js +80 -0
  29. package/dist/capture.js +241 -0
  30. package/dist/cli.js +175 -0
  31. package/dist/composite.js +115 -0
  32. package/dist/devices.js +407 -0
  33. package/dist/errors.js +115 -0
  34. package/dist/help.js +110 -0
  35. package/dist/mcp.js +245 -0
  36. package/dist/options.js +51 -0
  37. package/dist/png.js +183 -0
  38. package/dist/slices.js +59 -0
  39. package/dist/term.js +46 -0
  40. package/dist/ui.js +488 -0
  41. package/package.json +67 -4
@@ -0,0 +1,84 @@
1
+ <svg width="1488" height="831" viewBox="0 0 1488 831" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g filter="url(#filter0_f_5_6085)">
3
+ <rect x="12" y="817" width="1464" height="2" rx="1" fill="black"/>
4
+ </g>
5
+ <g filter="url(#filter1_f_5_6085)">
6
+ <rect x="13" y="818" width="1462" height="1" rx="0.5" fill="black" fill-opacity="0.6"/>
7
+ </g>
8
+ <g filter="url(#filter2_ii_5_6085)">
9
+ <rect x="12" y="791" width="1464" height="26" rx="1" fill="url(#paint0_linear_5_6085)"/>
10
+ <rect x="12" y="791" width="1464" height="26" rx="1" fill="url(#paint1_linear_5_6085)" fill-opacity="0.2"/>
11
+ </g>
12
+ <path d="M181 0.5H1307C1318.87 0.5 1328.5 10.1259 1328.5 22V790.5H159.5V22C159.5 10.1259 169.126 0.5 181 0.5Z" fill="#D6D6D6" stroke="#CCCCCC"/>
13
+ <g filter="url(#filter3_i_5_6085)">
14
+ <rect x="180" y="21" width="1128" height="754" rx="4" fill="white" fill-opacity="0.01"/>
15
+ </g>
16
+ <g filter="url(#filter4_i_5_6085)">
17
+ <path d="M162 23C162 11.9543 170.954 3 182 3H1306C1317.05 3 1326 11.9543 1326 23V791H162V23Z" fill="url(#paint2_radial_5_6085)"/>
18
+ </g>
19
+ <defs>
20
+ <filter id="filter0_f_5_6085" x="0" y="805" width="1488" height="26" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
21
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
22
+ <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
23
+ <feGaussianBlur stdDeviation="6" result="effect1_foregroundBlur_5_6085"/>
24
+ </filter>
25
+ <filter id="filter1_f_5_6085" x="10" y="815" width="1468" height="7" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
26
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
27
+ <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
28
+ <feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_5_6085"/>
29
+ </filter>
30
+ <filter id="filter2_ii_5_6085" x="12" y="790" width="1464" height="27" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
31
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
32
+ <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
33
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
34
+ <feOffset dy="-2"/>
35
+ <feGaussianBlur stdDeviation="0.5"/>
36
+ <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
37
+ <feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0"/>
38
+ <feBlend mode="normal" in2="shape" result="effect1_innerShadow_5_6085"/>
39
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
40
+ <feOffset dy="-1"/>
41
+ <feGaussianBlur stdDeviation="0.5"/>
42
+ <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
43
+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0"/>
44
+ <feBlend mode="normal" in2="effect1_innerShadow_5_6085" result="effect2_innerShadow_5_6085"/>
45
+ </filter>
46
+ <filter id="filter3_i_5_6085" x="180" y="21" width="1128" height="754" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
47
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
48
+ <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
49
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
50
+ <feOffset/>
51
+ <feGaussianBlur stdDeviation="1.5"/>
52
+ <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
53
+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
54
+ <feBlend mode="normal" in2="shape" result="effect1_innerShadow_5_6085"/>
55
+ </filter>
56
+ <filter id="filter4_i_5_6085" x="162" y="3" width="1164" height="788" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
57
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
58
+ <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
59
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
60
+ <feMorphology radius="1" operator="erode" in="SourceAlpha" result="effect1_innerShadow_5_6085"/>
61
+ <feOffset/>
62
+ <feGaussianBlur stdDeviation="1"/>
63
+ <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
64
+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4 0"/>
65
+ <feBlend mode="normal" in2="shape" result="effect1_innerShadow_5_6085"/>
66
+ </filter>
67
+ <linearGradient id="paint0_linear_5_6085" x1="12" y1="804" x2="1476" y2="804" gradientUnits="userSpaceOnUse">
68
+ <stop stop-color="#C6C5C5"/>
69
+ <stop offset="0.00868664" stop-color="#FEFEFE"/>
70
+ <stop offset="0.0311419" stop-color="#E3E3E3"/>
71
+ <stop offset="0.962505" stop-color="#E7E6E6"/>
72
+ <stop offset="0.992057" stop-color="white"/>
73
+ <stop offset="1" stop-color="#C6C5C5"/>
74
+ </linearGradient>
75
+ <linearGradient id="paint1_linear_5_6085" x1="744" y1="791" x2="744" y2="817" gradientUnits="userSpaceOnUse">
76
+ <stop stop-color="#EFEFEF"/>
77
+ <stop offset="1" stop-color="#DBDBDB"/>
78
+ </linearGradient>
79
+ <radialGradient id="paint2_radial_5_6085" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(248 83.5) rotate(33.6577) scale(1470.51 2172.18)">
80
+ <stop offset="0.155831" stop-color="white" stop-opacity="0.24"/>
81
+ <stop offset="1" stop-color="white" stop-opacity="0"/>
82
+ </radialGradient>
83
+ </defs>
84
+ </svg>
package/dist/args.js ADDED
@@ -0,0 +1,189 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { resolveDevices } from './devices.js';
3
+ import { COLOR_SCHEMES, defaultShotOptions, } from './options.js';
4
+ export const HELP_TOPICS = ['shot', 'devices', 'doctor', 'install', 'mcp'];
5
+ function isTopic(s) {
6
+ return HELP_TOPICS.includes(s ?? '');
7
+ }
8
+ function wantsHelp(args) {
9
+ return args.includes('--help') || args.includes('-h');
10
+ }
11
+ export class ArgError extends Error {
12
+ name = 'ArgError';
13
+ }
14
+ export const USAGE = `rpc - screenshot a URL at device viewports, optionally in device bezels
15
+
16
+ Usage:
17
+ rpc shot <url> [options] capture screenshots
18
+ rpc devices [--json] list device ids and aliases
19
+ rpc mcp run the MCP server on stdio
20
+ rpc doctor [--json] check that Playwright's Chromium is installed and launches
21
+ rpc install download Playwright's Chromium (same version rpc uses)
22
+
23
+ Shot options:
24
+ -d, --devices <ids> comma-separated device ids or aliases
25
+ (default: iphone-15-pro,desktop-1440)
26
+ -b, --bezels frame each shot in its device bezel
27
+ -f, --full-page capture the full scroll height (stitched, any height)
28
+ --slices with --full-page: also write viewport-tall JPEG
29
+ review slices (<stem>-slice-NN.jpg)
30
+ --slice-width <px> max slice width (default: 768)
31
+ --max-height <px> full-page cap in CSS px (default: 60000)
32
+ --reduced-motion emulate prefers-reduced-motion: reduce
33
+ -o, --out <dir> output directory (default: ./.rpc-shots)
34
+ --wait-for <selector> wait for a CSS selector before capturing
35
+ --wait <ms> extra settle delay after load (default: 0)
36
+ --color-scheme <s> light | dark | no-preference
37
+ --timeout <ms> navigation timeout (default: 30000)
38
+ --no-emulate viewport + DPR only (no mobile UA / touch)
39
+ --json print the result as JSON
40
+ --open open the output folder when done
41
+
42
+ Run \`rpc help <command>\` for one command's options and examples.
43
+ Run \`rpc\` with no arguments in a terminal for an interactive capture.
44
+
45
+ Examples:
46
+ rpc shot localhost:3000 -d iphone-15,ipad,desktop-1440 --bezels
47
+ rpc shot https://example.com --full-page --color-scheme dark
48
+ `;
49
+ export function parseCli(argv) {
50
+ const [cmd, ...rest] = argv;
51
+ switch (cmd) {
52
+ case undefined:
53
+ case 'help':
54
+ case '-h':
55
+ case '--help': {
56
+ const topic = rest[0];
57
+ if (topic === undefined)
58
+ return { command: 'help' };
59
+ if (!isTopic(topic))
60
+ throw new ArgError(`No help for "${topic}". Topics: ${HELP_TOPICS.join(', ')}`);
61
+ return { command: 'help', topic };
62
+ }
63
+ case '-v':
64
+ case '--version':
65
+ return { command: 'version' };
66
+ case 'mcp':
67
+ if (wantsHelp(rest))
68
+ return { command: 'help', topic: 'mcp' };
69
+ return { command: 'mcp' };
70
+ case 'install':
71
+ if (wantsHelp(rest))
72
+ return { command: 'help', topic: 'install' };
73
+ return { command: 'install' };
74
+ case 'doctor': {
75
+ if (wantsHelp(rest))
76
+ return { command: 'help', topic: 'doctor' };
77
+ const { values } = parse(rest, { json: { type: 'boolean' } }, false);
78
+ return { command: 'doctor', json: Boolean(values.json) };
79
+ }
80
+ case 'devices': {
81
+ if (wantsHelp(rest))
82
+ return { command: 'help', topic: 'devices' };
83
+ const { values } = parse(rest, { json: { type: 'boolean' } }, false);
84
+ return { command: 'devices', json: Boolean(values.json) };
85
+ }
86
+ case 'shot':
87
+ return parseShot(rest);
88
+ default:
89
+ throw new ArgError(`Unknown command "${cmd}". Run \`rpc --help\`.`);
90
+ }
91
+ }
92
+ function parseShot(args) {
93
+ if (wantsHelp(args))
94
+ return { command: 'help', topic: 'shot' };
95
+ const { values, positionals } = parse(args, {
96
+ devices: { type: 'string', short: 'd' },
97
+ bezels: { type: 'boolean', short: 'b' },
98
+ 'full-page': { type: 'boolean', short: 'f' },
99
+ out: { type: 'string', short: 'o' },
100
+ 'wait-for': { type: 'string' },
101
+ wait: { type: 'string' },
102
+ 'color-scheme': { type: 'string' },
103
+ timeout: { type: 'string' },
104
+ 'no-emulate': { type: 'boolean' },
105
+ slices: { type: 'boolean' },
106
+ 'slice-width': { type: 'string' },
107
+ 'max-height': { type: 'string' },
108
+ 'reduced-motion': { type: 'boolean' },
109
+ json: { type: 'boolean' },
110
+ open: { type: 'boolean' },
111
+ }, true);
112
+ if (positionals.length === 0)
113
+ throw new ArgError('Missing <url>. Usage: rpc shot <url> [options]');
114
+ if (positionals.length > 1)
115
+ throw new ArgError(`Expected one URL, got: ${positionals.join(' ')}`);
116
+ let options;
117
+ try {
118
+ options = defaultShotOptions(positionals[0]);
119
+ }
120
+ catch (err) {
121
+ throw new ArgError(err.message);
122
+ }
123
+ if (typeof values.devices === 'string') {
124
+ const list = splitList(values.devices);
125
+ if (list.length === 0)
126
+ throw new ArgError('--devices is empty');
127
+ try {
128
+ options.devices = resolveDevices(list).map(d => d.id);
129
+ }
130
+ catch (err) {
131
+ throw new ArgError(err.message);
132
+ }
133
+ }
134
+ if (values.bezels)
135
+ options.bezels = true;
136
+ if (values['full-page'])
137
+ options.fullPage = true;
138
+ if (typeof values.out === 'string')
139
+ options.outDir = values.out;
140
+ if (typeof values['wait-for'] === 'string')
141
+ options.waitFor = values['wait-for'];
142
+ if (typeof values.wait === 'string')
143
+ options.waitMs = parseMs('--wait', values.wait);
144
+ if (typeof values.timeout === 'string')
145
+ options.timeoutMs = parseMs('--timeout', values.timeout);
146
+ if (typeof values['color-scheme'] === 'string') {
147
+ const s = values['color-scheme'];
148
+ if (!COLOR_SCHEMES.includes(s)) {
149
+ throw new ArgError(`--color-scheme must be one of ${COLOR_SCHEMES.join(', ')}`);
150
+ }
151
+ options.colorScheme = s;
152
+ }
153
+ if (values['no-emulate'])
154
+ options.emulate = false;
155
+ if (values.slices)
156
+ options.saveSlices = true;
157
+ if (values['reduced-motion'])
158
+ options.reducedMotion = true;
159
+ if (typeof values['slice-width'] === 'string') {
160
+ options.sliceWidth = parseInt_('--slice-width', values['slice-width'], 256, 4096);
161
+ }
162
+ if (typeof values['max-height'] === 'string') {
163
+ options.maxHeight = parseInt_('--max-height', values['max-height'], 500, 200_000);
164
+ }
165
+ return { command: 'shot', options, json: Boolean(values.json), open: Boolean(values.open) };
166
+ }
167
+ function parse(args, options, allowPositionals) {
168
+ try {
169
+ return parseArgs({ args, options: options, allowPositionals, strict: true });
170
+ }
171
+ catch (err) {
172
+ throw new ArgError(err.message);
173
+ }
174
+ }
175
+ function splitList(s) {
176
+ return s.split(',').map(x => x.trim()).filter(Boolean);
177
+ }
178
+ function parseInt_(flag, raw, min, max) {
179
+ const n = Number(raw);
180
+ if (!Number.isInteger(n) || n < min || n > max)
181
+ throw new ArgError(`${flag} must be an integer from ${min} to ${max}`);
182
+ return n;
183
+ }
184
+ function parseMs(flag, raw) {
185
+ const n = Number(raw);
186
+ if (!Number.isFinite(n) || n < 0)
187
+ throw new ArgError(`${flag} must be a non-negative number of ms`);
188
+ return Math.round(n);
189
+ }
package/dist/bezel.js ADDED
@@ -0,0 +1,74 @@
1
+ // Ported from responsive-point-check lib/bezel-asset.ts (MIT). Portrait only;
2
+ // the browser-extension fetch/cache is replaced by reading from assets/bezels.
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ /** assets/ sits one level above both src/ (tsx) and dist/ (built). */
7
+ export const ASSETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'assets');
8
+ /** Per-kind padding around the viewport, verbatim from RPC. Ratios are
9
+ * fractions of the viewport dimension so the artwork scales uniformly. */
10
+ export const BEZEL_PADDING = {
11
+ 'phone-island': {
12
+ topRatio: 32 / 874,
13
+ bottomRatio: 38 / 874,
14
+ leftRatio: 22 / 402,
15
+ rightRatio: 22 / 402,
16
+ radiusRatio: 50 / 402,
17
+ },
18
+ 'phone-notch': {
19
+ topRatio: 28 / 848,
20
+ bottomRatio: 36 / 848,
21
+ leftRatio: 20 / 407,
22
+ rightRatio: 20 / 407,
23
+ radiusRatio: 50 / 407,
24
+ },
25
+ 'phone-classic': {
26
+ topRatio: 28 / 850,
27
+ bottomRatio: 36 / 850,
28
+ leftRatio: 18 / 410,
29
+ rightRatio: 22 / 410,
30
+ radiusRatio: 50 / 410,
31
+ },
32
+ 'phone-home': { top: 60, right: 14, bottom: 60, left: 14, radius: 4 },
33
+ tablet: { top: 56, right: 50, bottom: 56, left: 50, radius: 36 },
34
+ laptop: {
35
+ top: 10, right: 180, bottom: 60, left: 180,
36
+ radius: 22,
37
+ radiusRatio: 32 / 2032.8,
38
+ },
39
+ monitor: {
40
+ top: 0, right: 0, bottom: 320, left: 0,
41
+ radius: 20,
42
+ radiusRatio: 20 / 3082,
43
+ },
44
+ };
45
+ export function bezelLayoutFor(device) {
46
+ const src = device.bezelSrc ?? `bezels/${device.bezel}.svg`;
47
+ if (device.bezelLayout) {
48
+ const o = device.bezelLayout;
49
+ return { src, outerWidth: o.outerWidth, outerHeight: o.outerHeight, screen: { ...o.screen } };
50
+ }
51
+ const w = device.width;
52
+ const h = device.height;
53
+ const p = BEZEL_PADDING[device.bezel];
54
+ const top = p.topRatio !== undefined ? Math.round(h * p.topRatio) : p.top ?? 0;
55
+ const bottom = p.bottomRatio !== undefined ? Math.round(h * p.bottomRatio) : p.bottom ?? 0;
56
+ const left = p.leftRatio !== undefined ? Math.round(w * p.leftRatio) : p.left ?? 0;
57
+ const right = p.rightRatio !== undefined ? Math.round(w * p.rightRatio) : p.right ?? 0;
58
+ const radius = p.radiusRatio !== undefined ? Math.round(w * p.radiusRatio) : p.radius ?? 0;
59
+ return {
60
+ src,
61
+ outerWidth: w + left + right,
62
+ outerHeight: h + top + bottom,
63
+ screen: { x: left, y: top, width: w, height: h, radius },
64
+ };
65
+ }
66
+ const svgCache = new Map();
67
+ export function loadBezelSvg(src) {
68
+ let svg = svgCache.get(src);
69
+ if (svg === undefined) {
70
+ svg = readFileSync(resolve(ASSETS_DIR, src), 'utf8');
71
+ svgCache.set(src, svg);
72
+ }
73
+ return svg;
74
+ }
@@ -0,0 +1,80 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ const require = createRequire(import.meta.url);
7
+ /** Playwright's own CLI, from this package's dependency (so versions match). */
8
+ function playwrightCli() {
9
+ return resolve(dirname(require.resolve('playwright/package.json')), 'cli.js');
10
+ }
11
+ export function playwrightVersion() {
12
+ return JSON.parse(readFileSync(require.resolve('playwright/package.json'), 'utf8')).version;
13
+ }
14
+ /**
15
+ * The command that fixes a missing browser. `rpc install` runs this package's
16
+ * own Playwright CLI, so the downloaded Chromium matches the version rpc
17
+ * launches, whether rpc came from npx, a global install or a clone.
18
+ */
19
+ export function installHint() {
20
+ return ('Fix: run `npx rpc-agent install` (or `rpc install` if rpc is on your PATH). ' +
21
+ `It downloads Chromium for Playwright ${playwrightVersion()}, the version rpc uses.`);
22
+ }
23
+ export class BrowserMissingError extends Error {
24
+ executablePath;
25
+ name = 'BrowserMissingError';
26
+ constructor(executablePath) {
27
+ super(`Playwright's Chromium is not installed${executablePath ? ` (expected ${executablePath})` : ''}. ` + installHint());
28
+ this.executablePath = executablePath;
29
+ }
30
+ }
31
+ /** Launch headless Chromium, turning "Executable doesn't exist" into BrowserMissingError. */
32
+ export async function launchBrowser() {
33
+ try {
34
+ return await chromium.launch({ headless: true });
35
+ }
36
+ catch (err) {
37
+ const msg = err.message;
38
+ if (/Executable doesn't exist|playwright install/i.test(msg)) {
39
+ throw new BrowserMissingError(/Executable doesn't exist at (\S.*?)\s*$/m.exec(msg)?.[1]);
40
+ }
41
+ throw err;
42
+ }
43
+ }
44
+ /** Check that Chromium is installed and actually launches. */
45
+ export async function doctor() {
46
+ const base = {
47
+ node: process.version,
48
+ playwright: playwrightVersion(),
49
+ browsersPath: process.env.PLAYWRIGHT_BROWSERS_PATH || '(Playwright default cache)',
50
+ };
51
+ try {
52
+ const browser = await launchBrowser();
53
+ const version = browser.version();
54
+ await browser.close();
55
+ return { ok: true, ...base, chromium: 'ok', version };
56
+ }
57
+ catch (err) {
58
+ if (err instanceof BrowserMissingError) {
59
+ return { ok: false, ...base, chromium: 'missing', error: err.message, fix: installHint() };
60
+ }
61
+ return { ok: false, ...base, chromium: 'launch-failed', error: err.message.split('\n')[0] ?? String(err) };
62
+ }
63
+ }
64
+ /**
65
+ * Run `playwright install --only-shell chromium` with this package's
66
+ * Playwright, streaming its output. rpc always launches headless, which uses
67
+ * the headless shell, so the full Chrome for Testing build (about 370 MB on
68
+ * disk) would never run.
69
+ */
70
+ export function installChromium() {
71
+ return new Promise((res, rej) => {
72
+ // Load the CLI through -e with a plain argv[1]. Under npx the real path is
73
+ // in the `_npx` cache, and Playwright then prints a box telling the user
74
+ // to install their project's dependencies, which doesn't apply here.
75
+ const loader = `process.argv.splice(1, 0, 'playwright'); require(${JSON.stringify(playwrightCli())});`;
76
+ const child = spawn(process.execPath, ['-e', loader, 'install', '--only-shell', 'chromium'], { stdio: 'inherit' });
77
+ child.on('error', rej);
78
+ child.on('exit', code => res(code ?? 1));
79
+ });
80
+ }
@@ -0,0 +1,241 @@
1
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import { basename, extname, resolve } from 'node:path';
3
+ import { launchBrowser } from './browser.js';
4
+ import { renderFramed, renderSlices } from './composite.js';
5
+ import { explainError } from './errors.js';
6
+ import { emulationFor, resolveDevices } from './devices.js';
7
+ import { pngSize, stitchPngs } from './png.js';
8
+ import { fitsOneCapture, planSegments, planSlices, sliceScale } from './slices.js';
9
+ export { MAX_CAPTURE_PX } from './slices.js';
10
+ export { launchBrowser } from './browser.js';
11
+ export async function capture(opts, hooks = {}) {
12
+ const devices = resolveDevices(opts.devices);
13
+ const outDir = resolve(opts.outDir);
14
+ await mkdir(outDir, { recursive: true });
15
+ const browser = hooks.browser ?? (await launchBrowser());
16
+ try {
17
+ const shots = [];
18
+ for (const device of devices) {
19
+ let shot;
20
+ hooks.onDeviceStart?.(device);
21
+ try {
22
+ shot = await captureDevice(browser, device, opts, outDir, hooks);
23
+ }
24
+ catch (err) {
25
+ shot = baseShot(device);
26
+ const e = explainError(err, { url: opts.url, timeoutMs: opts.timeoutMs, waitFor: opts.waitFor });
27
+ shot.error = e.error;
28
+ shot.code = e.code;
29
+ if (e.hint)
30
+ shot.hint = e.hint;
31
+ }
32
+ if (!hooks.keepBuffers)
33
+ delete shot.buffers;
34
+ hooks.onDevice?.(shot);
35
+ shots.push(shot);
36
+ }
37
+ return { url: opts.url, outDir, shots };
38
+ }
39
+ finally {
40
+ if (!hooks.browser)
41
+ await browser.close();
42
+ }
43
+ }
44
+ function baseShot(device) {
45
+ return {
46
+ device: device.id,
47
+ name: device.name,
48
+ viewport: { width: device.width, height: device.height },
49
+ dpr: device.dpr,
50
+ };
51
+ }
52
+ async function captureDevice(browser, device, opts, outDir, hooks) {
53
+ const emu = opts.emulate ? emulationFor(device) : undefined;
54
+ const context = await browser.newContext({
55
+ viewport: { width: device.width, height: device.height },
56
+ deviceScaleFactor: device.dpr,
57
+ isMobile: emu?.isMobile ?? false,
58
+ hasTouch: emu?.hasTouch ?? false,
59
+ ...(emu?.userAgent ? { userAgent: emu.userAgent } : {}),
60
+ ...(opts.colorScheme ? { colorScheme: opts.colorScheme } : {}),
61
+ ...(opts.reducedMotion ? { reducedMotion: 'reduce' } : {}),
62
+ ignoreHTTPSErrors: true, // self-signed local dev certs
63
+ });
64
+ const result = baseShot(device);
65
+ const errors = new Set();
66
+ try {
67
+ const page = await context.newPage();
68
+ page.on('pageerror', e => errors.add(`pageerror: ${e.message}`));
69
+ page.on('console', m => {
70
+ if (m.type() === 'error')
71
+ errors.add(`console: ${m.text()}`);
72
+ });
73
+ const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
74
+ result.status = response?.status() ?? null;
75
+ await settle(page, opts.timeoutMs);
76
+ if (opts.waitFor) {
77
+ await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
78
+ }
79
+ if (opts.fullPage) {
80
+ await warmUpScroll(page, opts.maxHeight);
81
+ await settle(page, opts.timeoutMs);
82
+ }
83
+ if (opts.waitMs > 0)
84
+ await page.waitForTimeout(opts.waitMs);
85
+ result.finalUrl = page.url();
86
+ result.title = await page.title();
87
+ result.scrollHeight = await page.evaluate(SCROLL_HEIGHT_JS);
88
+ const base = fileStem(opts.url, device.id);
89
+ const shotOpts = { animations: 'disabled', caret: 'hide', type: 'png' };
90
+ // Viewport shot: the raw output when !fullPage, and the bezel input either way.
91
+ let viewportPng;
92
+ if (!opts.fullPage || opts.bezels) {
93
+ viewportPng = await page.screenshot(shotOpts);
94
+ }
95
+ let shotPng;
96
+ let segments;
97
+ if (opts.fullPage) {
98
+ const total = Math.min(result.scrollHeight, opts.maxHeight);
99
+ result.clipped = result.scrollHeight > opts.maxHeight;
100
+ result.capturedHeight = total;
101
+ if (fitsOneCapture(total, device.dpr) && !result.clipped) {
102
+ // One pass, as in v0.1: no seams, Playwright sizes it.
103
+ shotPng = await page.screenshot({ ...shotOpts, fullPage: true });
104
+ segments = [{ y: 0, height: total, png: shotPng }];
105
+ }
106
+ else {
107
+ // Clipped full-page captures render exactly what one tall capture
108
+ // would (sticky/fixed elements at their scroll-0 position, nothing
109
+ // scrolls), so the segments stitch without repeated headers.
110
+ const width = Math.max(device.width, await page.evaluate(SCROLL_WIDTH_JS));
111
+ segments = [];
112
+ for (const band of planSegments(total, device.dpr)) {
113
+ const png = await page.screenshot({
114
+ ...shotOpts,
115
+ fullPage: true,
116
+ clip: { x: 0, y: band.y, width, height: band.height },
117
+ });
118
+ segments.push({ ...band, png });
119
+ }
120
+ shotPng = await stitchPngs(segments.map(s => s.png));
121
+ }
122
+ result.segments = segments.length;
123
+ }
124
+ else {
125
+ shotPng = viewportPng;
126
+ }
127
+ result.shot = await save(resolve(outDir, `${base}.png`), shotPng);
128
+ result.buffers = { shot: shotPng };
129
+ if (segments && (opts.saveSlices || hooks.inlineSlices)) {
130
+ const cssWidth = result.shot.width / device.dpr;
131
+ const scale = sliceScale(cssWidth, device.dpr, opts.sliceWidth);
132
+ const all = planSlices(device.id, result.capturedHeight, device.height);
133
+ const save = opts.saveSlices === true ||
134
+ (opts.saveSlices === undefined && Boolean(hooks.inlineSlices) && all.length > opts.maxSlices);
135
+ const wanted = save ? all : all.slice(0, opts.maxSlices);
136
+ const rendered = await renderSlices(browser, segments, wanted, cssWidth, scale);
137
+ if (save)
138
+ await removeOldSlices(outDir, base);
139
+ const items = [];
140
+ for (const { data, ...info } of rendered) {
141
+ const item = { ...info };
142
+ if (save) {
143
+ item.path = resolve(outDir, `${base}-slice-${String(info.index).padStart(2, '0')}.jpg`);
144
+ await writeFile(item.path, data);
145
+ }
146
+ items.push(item);
147
+ }
148
+ result.slices = { total: all.length, sliceHeight: device.height, items, saved: save };
149
+ result.buffers.slices = rendered.map(r => r.data);
150
+ }
151
+ if (opts.bezels && viewportPng) {
152
+ const framedPng = await renderFramed(browser, device, viewportPng);
153
+ result.framed = await save(resolve(outDir, `${base}-framed.png`), framedPng);
154
+ result.buffers.framed = framedPng;
155
+ }
156
+ }
157
+ finally {
158
+ result.consoleErrors = [...errors].slice(0, 20);
159
+ await context.close();
160
+ }
161
+ return result;
162
+ }
163
+ /** Drop `<stem>-slice-NN.jpg` files from earlier runs so a shorter page doesn't leave stale slices. */
164
+ async function removeOldSlices(outDir, base) {
165
+ const prefix = `${base}-slice-`; // base is a [a-z0-9-] slug
166
+ for (const f of await readdir(outDir)) {
167
+ if (f.startsWith(prefix) && /^\d+\.jpg$/.test(f.slice(prefix.length)))
168
+ await rm(resolve(outDir, f), { force: true });
169
+ }
170
+ }
171
+ async function save(path, png) {
172
+ await writeFile(path, png);
173
+ return { path, ...pngSize(png) };
174
+ }
175
+ // In-page scripts are plain strings: when this file runs through tsx/vitest,
176
+ // esbuild wraps named arrow functions in a `__name()` helper that doesn't
177
+ // exist inside the page, so a serialized function would throw there.
178
+ const WAIT_FOR_ASSETS_JS = `(async () => {
179
+ const imgs = Array.from(document.images).filter(i => !i.complete);
180
+ await Promise.race([
181
+ Promise.all([
182
+ document.fonts ? document.fonts.ready : null,
183
+ ...imgs.map(i => new Promise(r => { i.addEventListener('load', r); i.addEventListener('error', r); })),
184
+ ]),
185
+ new Promise(r => setTimeout(r, 5000)),
186
+ ]);
187
+ })()`;
188
+ /**
189
+ * Walk the page one viewport at a time so loading="lazy" images and
190
+ * IntersectionObserver-driven content render, then return to the top.
191
+ * Replaces the 120 ms per-segment pause in RPC's scroll-and-stitch.
192
+ * Bounded to 100 steps and the full-page height cap.
193
+ */
194
+ const warmUpScrollJs = (maxHeight) => `(async () => {
195
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
196
+ const root = document.scrollingElement || document.documentElement;
197
+ const step = Math.max(200, Math.floor(window.innerHeight * 0.9));
198
+ for (let i = 0, y = 0; i < 100 && y < ${maxHeight}; i++, y += step) {
199
+ if (y > root.scrollHeight - window.innerHeight) break;
200
+ window.scrollTo({ top: y, behavior: 'instant' });
201
+ await sleep(100);
202
+ }
203
+ window.scrollTo({ top: Math.min(root.scrollHeight, ${maxHeight}), behavior: 'instant' });
204
+ await sleep(150);
205
+ window.scrollTo({ top: 0, behavior: 'instant' });
206
+ await sleep(150);
207
+ })()`;
208
+ const SCROLL_WIDTH_JS = `Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0)`;
209
+ const SCROLL_HEIGHT_JS = `Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)`;
210
+ /**
211
+ * Network idle (bounded: dev servers with HMR or analytics beacons may never
212
+ * go idle), web fonts, and pending <img> loads.
213
+ */
214
+ async function settle(page, timeoutMs) {
215
+ await page
216
+ .waitForLoadState('networkidle', { timeout: Math.min(timeoutMs, 10_000) })
217
+ .catch(() => { });
218
+ await page.evaluate(WAIT_FOR_ASSETS_JS).catch(() => { });
219
+ }
220
+ async function warmUpScroll(page, maxHeight) {
221
+ await page.evaluate(warmUpScrollJs(Math.round(maxHeight)));
222
+ }
223
+ /** `<host>[-<path>]-<device>`, e.g. `localhost-3000-about-iphone-15`. */
224
+ export function fileStem(url, deviceId) {
225
+ const u = new URL(url);
226
+ let parts;
227
+ if (u.protocol === 'file:') {
228
+ parts = `file-${basename(u.pathname, extname(u.pathname))}`;
229
+ }
230
+ else {
231
+ parts = `${u.hostname}${u.port ? `-${u.port}` : ''}${u.pathname}`;
232
+ }
233
+ const slug = parts
234
+ .toLowerCase()
235
+ .replace(/^www\./, '')
236
+ .replace(/[^a-z0-9]+/g, '-')
237
+ .replace(/^-+|-+$/g, '')
238
+ .slice(0, 80)
239
+ .replace(/-+$/, '');
240
+ return `${slug || 'page'}-${deviceId}`;
241
+ }