rpc-agent 0.0.0-stage → 0.4.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 +157 -0
- package/LICENSE +21 -0
- package/NOTICE +16 -0
- package/README.md +471 -2
- package/assets/bezels/imac.svg +63 -0
- package/assets/bezels/ipad.svg +27 -0
- package/assets/bezels/iphone-11-pro.svg +35 -0
- package/assets/bezels/iphone-12-mini.svg +26 -0
- package/assets/bezels/iphone-12-pro-max.svg +26 -0
- package/assets/bezels/iphone-17-pro.svg +172 -0
- package/assets/bezels/iphone-se.svg +37 -0
- package/assets/bezels/laptop.svg +25 -0
- package/assets/bezels/macbook-air.svg +235 -0
- package/assets/bezels/macbook-pro-13.svg +63 -0
- package/assets/bezels/macbook-pro-14.svg +115 -0
- package/assets/bezels/macbook-pro.svg +32 -0
- package/assets/bezels/macbook.svg +25 -0
- package/assets/bezels/monitor.svg +57 -0
- package/assets/bezels/phone-classic.svg +17 -0
- package/assets/bezels/phone-home.svg +12 -0
- package/assets/bezels/phone-island.svg +130 -0
- package/assets/bezels/phone-notch.svg +26 -0
- package/assets/bezels/surface-laptop.svg +84 -0
- package/assets/bezels/tablet.svg +25 -0
- package/assets/bezels/windows-laptop.svg +84 -0
- package/dist/args.js +308 -0
- package/dist/auth.js +416 -0
- package/dist/bezel.js +74 -0
- package/dist/browser.js +82 -0
- package/dist/capture.js +274 -0
- package/dist/cli.js +255 -0
- package/dist/composite.js +115 -0
- package/dist/devices.js +407 -0
- package/dist/errors.js +115 -0
- package/dist/help.js +160 -0
- package/dist/login.js +150 -0
- package/dist/mcp.js +312 -0
- package/dist/options.js +51 -0
- package/dist/png.js +183 -0
- package/dist/slices.js +59 -0
- package/dist/term.js +66 -0
- package/dist/ui.js +599 -0
- package/package.json +68 -4
package/dist/capture.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, resolve } from 'node:path';
|
|
3
|
+
import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
|
|
4
|
+
import { launchBrowser } from './browser.js';
|
|
5
|
+
import { renderFramed, renderSlices } from './composite.js';
|
|
6
|
+
import { explainError } from './errors.js';
|
|
7
|
+
import { emulationFor, resolveDevices } from './devices.js';
|
|
8
|
+
import { pngSize, stitchPngs } from './png.js';
|
|
9
|
+
import { fitsOneCapture, planSegments, planSlices, sliceScale } from './slices.js';
|
|
10
|
+
export { MAX_CAPTURE_PX } from './slices.js';
|
|
11
|
+
export { launchBrowser } from './browser.js';
|
|
12
|
+
export async function capture(opts, hooks = {}) {
|
|
13
|
+
const devices = resolveDevices(opts.devices);
|
|
14
|
+
const outDir = resolve(opts.outDir);
|
|
15
|
+
const auth = hooks.auth ?? resolveAuth(opts.url, opts.auth ?? {});
|
|
16
|
+
await mkdir(outDir, { recursive: true });
|
|
17
|
+
if (auth.summary)
|
|
18
|
+
hooks.onAuth?.(auth.summary);
|
|
19
|
+
const browser = hooks.browser ?? (await launchBrowser());
|
|
20
|
+
try {
|
|
21
|
+
const shots = [];
|
|
22
|
+
for (const device of devices) {
|
|
23
|
+
let shot;
|
|
24
|
+
hooks.onDeviceStart?.(device);
|
|
25
|
+
try {
|
|
26
|
+
shot = await captureDevice(browser, device, opts, outDir, hooks, auth);
|
|
27
|
+
}
|
|
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
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (shot.consoleErrors?.length && auth.secrets.length) {
|
|
46
|
+
shot.consoleErrors = shot.consoleErrors.map(l => redact(l, auth.secrets));
|
|
47
|
+
}
|
|
48
|
+
if (!hooks.keepBuffers)
|
|
49
|
+
delete shot.buffers;
|
|
50
|
+
hooks.onDevice?.(shot);
|
|
51
|
+
shots.push(shot);
|
|
52
|
+
}
|
|
53
|
+
return { url: opts.url, outDir, shots, ...(auth.summary ? { auth: auth.summary } : {}) };
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
if (!hooks.browser)
|
|
57
|
+
await browser.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function baseShot(device) {
|
|
61
|
+
return {
|
|
62
|
+
device: device.id,
|
|
63
|
+
name: device.name,
|
|
64
|
+
viewport: { width: device.width, height: device.height },
|
|
65
|
+
dpr: device.dpr,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function captureDevice(browser, device, opts, outDir, hooks, auth) {
|
|
69
|
+
const emu = opts.emulate ? emulationFor(device) : undefined;
|
|
70
|
+
const context = await browser.newContext({
|
|
71
|
+
viewport: { width: device.width, height: device.height },
|
|
72
|
+
deviceScaleFactor: device.dpr,
|
|
73
|
+
isMobile: emu?.isMobile ?? false,
|
|
74
|
+
hasTouch: emu?.hasTouch ?? false,
|
|
75
|
+
...(emu?.userAgent ? { userAgent: emu.userAgent } : {}),
|
|
76
|
+
...(opts.colorScheme ? { colorScheme: opts.colorScheme } : {}),
|
|
77
|
+
...(opts.reducedMotion ? { reducedMotion: 'reduce' } : {}),
|
|
78
|
+
ignoreHTTPSErrors: true, // self-signed local dev certs
|
|
79
|
+
...(auth.state ? { storageState: auth.state } : {}),
|
|
80
|
+
});
|
|
81
|
+
if (auth.cookies.length)
|
|
82
|
+
await context.addCookies(auth.cookies);
|
|
83
|
+
const result = baseShot(device);
|
|
84
|
+
const errors = new Set();
|
|
85
|
+
try {
|
|
86
|
+
const page = await context.newPage();
|
|
87
|
+
await applyHeaders(context, page, auth);
|
|
88
|
+
page.on('pageerror', e => errors.add(`pageerror: ${e.message}`));
|
|
89
|
+
page.on('console', m => {
|
|
90
|
+
if (m.type() === 'error')
|
|
91
|
+
errors.add(`console: ${m.text()}`);
|
|
92
|
+
});
|
|
93
|
+
const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
94
|
+
result.status = response?.status() ?? null;
|
|
95
|
+
await settle(page, opts.timeoutMs);
|
|
96
|
+
// A login page instead of the requested one: say so rather than capture it.
|
|
97
|
+
const checkLogin = () => {
|
|
98
|
+
if (redirectedToLogin(opts.url, page.url()))
|
|
99
|
+
throw new AuthRequiredError(opts.url, page.url(), result.status ?? null);
|
|
100
|
+
};
|
|
101
|
+
checkLogin();
|
|
102
|
+
if (opts.waitFor) {
|
|
103
|
+
try {
|
|
104
|
+
await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
checkLogin();
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (opts.fullPage) {
|
|
112
|
+
await warmUpScroll(page, opts.maxHeight);
|
|
113
|
+
await settle(page, opts.timeoutMs);
|
|
114
|
+
}
|
|
115
|
+
if (opts.waitMs > 0)
|
|
116
|
+
await page.waitForTimeout(opts.waitMs);
|
|
117
|
+
checkLogin();
|
|
118
|
+
result.finalUrl = page.url();
|
|
119
|
+
result.title = await page.title();
|
|
120
|
+
result.scrollHeight = await page.evaluate(SCROLL_HEIGHT_JS);
|
|
121
|
+
const base = fileStem(opts.url, device.id);
|
|
122
|
+
const shotOpts = { animations: 'disabled', caret: 'hide', type: 'png' };
|
|
123
|
+
// Viewport shot: the raw output when !fullPage, and the bezel input either way.
|
|
124
|
+
let viewportPng;
|
|
125
|
+
if (!opts.fullPage || opts.bezels) {
|
|
126
|
+
viewportPng = await page.screenshot(shotOpts);
|
|
127
|
+
}
|
|
128
|
+
let shotPng;
|
|
129
|
+
let segments;
|
|
130
|
+
if (opts.fullPage) {
|
|
131
|
+
const total = Math.min(result.scrollHeight, opts.maxHeight);
|
|
132
|
+
result.clipped = result.scrollHeight > opts.maxHeight;
|
|
133
|
+
result.capturedHeight = total;
|
|
134
|
+
if (fitsOneCapture(total, device.dpr) && !result.clipped) {
|
|
135
|
+
// One pass, as in v0.1: no seams, Playwright sizes it.
|
|
136
|
+
shotPng = await page.screenshot({ ...shotOpts, fullPage: true });
|
|
137
|
+
segments = [{ y: 0, height: total, png: shotPng }];
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// Clipped full-page captures render exactly what one tall capture
|
|
141
|
+
// would (sticky/fixed elements at their scroll-0 position, nothing
|
|
142
|
+
// scrolls), so the segments stitch without repeated headers.
|
|
143
|
+
const width = Math.max(device.width, await page.evaluate(SCROLL_WIDTH_JS));
|
|
144
|
+
segments = [];
|
|
145
|
+
for (const band of planSegments(total, device.dpr)) {
|
|
146
|
+
const png = await page.screenshot({
|
|
147
|
+
...shotOpts,
|
|
148
|
+
fullPage: true,
|
|
149
|
+
clip: { x: 0, y: band.y, width, height: band.height },
|
|
150
|
+
});
|
|
151
|
+
segments.push({ ...band, png });
|
|
152
|
+
}
|
|
153
|
+
shotPng = await stitchPngs(segments.map(s => s.png));
|
|
154
|
+
}
|
|
155
|
+
result.segments = segments.length;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
shotPng = viewportPng;
|
|
159
|
+
}
|
|
160
|
+
result.shot = await save(resolve(outDir, `${base}.png`), shotPng);
|
|
161
|
+
result.buffers = { shot: shotPng };
|
|
162
|
+
if (segments && (opts.saveSlices || hooks.inlineSlices)) {
|
|
163
|
+
const cssWidth = result.shot.width / device.dpr;
|
|
164
|
+
const scale = sliceScale(cssWidth, device.dpr, opts.sliceWidth);
|
|
165
|
+
const all = planSlices(device.id, result.capturedHeight, device.height);
|
|
166
|
+
const save = opts.saveSlices === true ||
|
|
167
|
+
(opts.saveSlices === undefined && Boolean(hooks.inlineSlices) && all.length > opts.maxSlices);
|
|
168
|
+
const wanted = save ? all : all.slice(0, opts.maxSlices);
|
|
169
|
+
const rendered = await renderSlices(browser, segments, wanted, cssWidth, scale);
|
|
170
|
+
if (save)
|
|
171
|
+
await removeOldSlices(outDir, base);
|
|
172
|
+
const items = [];
|
|
173
|
+
for (const { data, ...info } of rendered) {
|
|
174
|
+
const item = { ...info };
|
|
175
|
+
if (save) {
|
|
176
|
+
item.path = resolve(outDir, `${base}-slice-${String(info.index).padStart(2, '0')}.jpg`);
|
|
177
|
+
await writeFile(item.path, data);
|
|
178
|
+
}
|
|
179
|
+
items.push(item);
|
|
180
|
+
}
|
|
181
|
+
result.slices = { total: all.length, sliceHeight: device.height, items, saved: save };
|
|
182
|
+
result.buffers.slices = rendered.map(r => r.data);
|
|
183
|
+
}
|
|
184
|
+
if (opts.bezels && viewportPng) {
|
|
185
|
+
const framedPng = await renderFramed(browser, device, viewportPng);
|
|
186
|
+
result.framed = await save(resolve(outDir, `${base}-framed.png`), framedPng);
|
|
187
|
+
result.buffers.framed = framedPng;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
result.consoleErrors = [...errors].slice(0, 20);
|
|
192
|
+
await context.close();
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
/** Drop `<stem>-slice-NN.jpg` files from earlier runs so a shorter page doesn't leave stale slices. */
|
|
197
|
+
async function removeOldSlices(outDir, base) {
|
|
198
|
+
const prefix = `${base}-slice-`; // base is a [a-z0-9-] slug
|
|
199
|
+
for (const f of await readdir(outDir)) {
|
|
200
|
+
if (f.startsWith(prefix) && /^\d+\.jpg$/.test(f.slice(prefix.length)))
|
|
201
|
+
await rm(resolve(outDir, f), { force: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function save(path, png) {
|
|
205
|
+
await writeFile(path, png);
|
|
206
|
+
return { path, ...pngSize(png) };
|
|
207
|
+
}
|
|
208
|
+
// In-page scripts are plain strings: when this file runs through tsx/vitest,
|
|
209
|
+
// esbuild wraps named arrow functions in a `__name()` helper that doesn't
|
|
210
|
+
// exist inside the page, so a serialized function would throw there.
|
|
211
|
+
const WAIT_FOR_ASSETS_JS = `(async () => {
|
|
212
|
+
const imgs = Array.from(document.images).filter(i => !i.complete);
|
|
213
|
+
await Promise.race([
|
|
214
|
+
Promise.all([
|
|
215
|
+
document.fonts ? document.fonts.ready : null,
|
|
216
|
+
...imgs.map(i => new Promise(r => { i.addEventListener('load', r); i.addEventListener('error', r); })),
|
|
217
|
+
]),
|
|
218
|
+
new Promise(r => setTimeout(r, 5000)),
|
|
219
|
+
]);
|
|
220
|
+
})()`;
|
|
221
|
+
/**
|
|
222
|
+
* Walk the page one viewport at a time so loading="lazy" images and
|
|
223
|
+
* IntersectionObserver-driven content render, then return to the top.
|
|
224
|
+
* Replaces the 120 ms per-segment pause in RPC's scroll-and-stitch.
|
|
225
|
+
* Bounded to 100 steps and the full-page height cap.
|
|
226
|
+
*/
|
|
227
|
+
const warmUpScrollJs = (maxHeight) => `(async () => {
|
|
228
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
229
|
+
const root = document.scrollingElement || document.documentElement;
|
|
230
|
+
const step = Math.max(200, Math.floor(window.innerHeight * 0.9));
|
|
231
|
+
for (let i = 0, y = 0; i < 100 && y < ${maxHeight}; i++, y += step) {
|
|
232
|
+
if (y > root.scrollHeight - window.innerHeight) break;
|
|
233
|
+
window.scrollTo({ top: y, behavior: 'instant' });
|
|
234
|
+
await sleep(100);
|
|
235
|
+
}
|
|
236
|
+
window.scrollTo({ top: Math.min(root.scrollHeight, ${maxHeight}), behavior: 'instant' });
|
|
237
|
+
await sleep(150);
|
|
238
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
239
|
+
await sleep(150);
|
|
240
|
+
})()`;
|
|
241
|
+
const SCROLL_WIDTH_JS = `Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0)`;
|
|
242
|
+
const SCROLL_HEIGHT_JS = `Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)`;
|
|
243
|
+
/**
|
|
244
|
+
* Network idle (bounded: dev servers with HMR or analytics beacons may never
|
|
245
|
+
* go idle), web fonts, and pending <img> loads.
|
|
246
|
+
*/
|
|
247
|
+
async function settle(page, timeoutMs) {
|
|
248
|
+
await page
|
|
249
|
+
.waitForLoadState('networkidle', { timeout: Math.min(timeoutMs, 10_000) })
|
|
250
|
+
.catch(() => { });
|
|
251
|
+
await page.evaluate(WAIT_FOR_ASSETS_JS).catch(() => { });
|
|
252
|
+
}
|
|
253
|
+
async function warmUpScroll(page, maxHeight) {
|
|
254
|
+
await page.evaluate(warmUpScrollJs(Math.round(maxHeight)));
|
|
255
|
+
}
|
|
256
|
+
/** `<host>[-<path>]-<device>`, e.g. `localhost-3000-about-iphone-15`. */
|
|
257
|
+
export function fileStem(url, deviceId) {
|
|
258
|
+
const u = new URL(url);
|
|
259
|
+
let parts;
|
|
260
|
+
if (u.protocol === 'file:') {
|
|
261
|
+
parts = `file-${basename(u.pathname, extname(u.pathname))}`;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
parts = `${u.hostname}${u.port ? `-${u.port}` : ''}${u.pathname}`;
|
|
265
|
+
}
|
|
266
|
+
const slug = parts
|
|
267
|
+
.toLowerCase()
|
|
268
|
+
.replace(/^www\./, '')
|
|
269
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
270
|
+
.replace(/^-+|-+$/g, '')
|
|
271
|
+
.slice(0, 80)
|
|
272
|
+
.replace(/-+$/, '');
|
|
273
|
+
return `${slug || 'page'}-${deviceId}`;
|
|
274
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
import { ArgError, parseCli, USAGE } from './args.js';
|
|
5
|
+
import { clearSessions, describeAuth, listSessions, localTime, sessionsDir } from './auth.js';
|
|
6
|
+
import { doctor, installChromium } from './browser.js';
|
|
7
|
+
import { capture } from './capture.js';
|
|
8
|
+
import { CATEGORY_LABELS, CATEGORY_ORDER, DEVICE_ALIASES, DEVICES } from './devices.js';
|
|
9
|
+
import { currentMode, openPath, waitForEnter } from './term.js';
|
|
10
|
+
const NO_PAINT = { ok: s => s, bad: s => s, warn: s => s };
|
|
11
|
+
let paint = NO_PAINT;
|
|
12
|
+
async function plainPaint() {
|
|
13
|
+
if (!currentMode().color)
|
|
14
|
+
return NO_PAINT;
|
|
15
|
+
const { default: pc } = await import('picocolors');
|
|
16
|
+
const c = pc.createColors(true);
|
|
17
|
+
return { ok: c.green, bad: c.red, warn: c.yellow };
|
|
18
|
+
}
|
|
19
|
+
// Loaded only for rich (interactive terminal) runs; `rpc mcp` and --json
|
|
20
|
+
// never import clack.
|
|
21
|
+
async function loadUi() {
|
|
22
|
+
const ui = await import('./ui.js');
|
|
23
|
+
return { ui, c: ui.colors(currentMode().color) };
|
|
24
|
+
}
|
|
25
|
+
async function main(argv) {
|
|
26
|
+
const cmd = parseCli(argv);
|
|
27
|
+
if (cmd.command === 'mcp') {
|
|
28
|
+
const { runMcpServer } = await import('./mcp.js');
|
|
29
|
+
await runMcpServer();
|
|
30
|
+
return -1; // keep running; stdio transport owns the process
|
|
31
|
+
}
|
|
32
|
+
const json = 'json' in cmd && cmd.json;
|
|
33
|
+
const mode = currentMode(json);
|
|
34
|
+
if (!mode.rich && !json)
|
|
35
|
+
paint = await plainPaint();
|
|
36
|
+
switch (cmd.command) {
|
|
37
|
+
case 'help': {
|
|
38
|
+
if (argv.length === 0 && mode.rich) {
|
|
39
|
+
const { ui, c } = await loadUi();
|
|
40
|
+
return ui.runInteractive(c);
|
|
41
|
+
}
|
|
42
|
+
if (!cmd.topic && !mode.rich) {
|
|
43
|
+
process.stdout.write(USAGE);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const { commandHelp, richHelp } = await import('./help.js');
|
|
47
|
+
const { default: pc } = await import('picocolors');
|
|
48
|
+
const c = pc.createColors(mode.color);
|
|
49
|
+
process.stdout.write(cmd.topic ? commandHelp(cmd.topic, c) : richHelp(c));
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
case 'version': {
|
|
53
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
54
|
+
console.log(pkg.version);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
case 'devices':
|
|
58
|
+
if (cmd.json) {
|
|
59
|
+
console.log(JSON.stringify({ devices: DEVICES, aliases: DEVICE_ALIASES }, null, 2));
|
|
60
|
+
}
|
|
61
|
+
else if (mode.rich) {
|
|
62
|
+
const { ui, c } = await loadUi();
|
|
63
|
+
ui.printDevices(c);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
process.stdout.write(formatDeviceTable());
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
case 'doctor': {
|
|
70
|
+
if (mode.rich) {
|
|
71
|
+
const { ui, c } = await loadUi();
|
|
72
|
+
return ui.runDoctor(doctor, c);
|
|
73
|
+
}
|
|
74
|
+
const report = await doctor();
|
|
75
|
+
if (cmd.json)
|
|
76
|
+
console.log(JSON.stringify(report, null, 2));
|
|
77
|
+
else
|
|
78
|
+
printDoctor(report);
|
|
79
|
+
return report.ok ? 0 : 1;
|
|
80
|
+
}
|
|
81
|
+
case 'install': {
|
|
82
|
+
const code = await installChromium({ headed: Boolean(cmd.headed) });
|
|
83
|
+
if (code !== 0)
|
|
84
|
+
return code;
|
|
85
|
+
if (mode.rich) {
|
|
86
|
+
const { ui, c } = await loadUi();
|
|
87
|
+
return ui.runDoctor(doctor, c);
|
|
88
|
+
}
|
|
89
|
+
const report = await doctor();
|
|
90
|
+
printDoctor(report);
|
|
91
|
+
return report.ok ? 0 : 1;
|
|
92
|
+
}
|
|
93
|
+
case 'shot': {
|
|
94
|
+
if (mode.rich) {
|
|
95
|
+
const { ui, c } = await loadUi();
|
|
96
|
+
return ui.runShot(cmd.options, { open: cmd.open }, c);
|
|
97
|
+
}
|
|
98
|
+
const result = await capture(cmd.options, cmd.json ? {} : { onDevice: printShot, onAuth: a => console.log(`auth: ${describeAuth(a)}`) });
|
|
99
|
+
if (cmd.json)
|
|
100
|
+
console.log(JSON.stringify(result, null, 2));
|
|
101
|
+
if (cmd.open && result.shots.some(s => !s.error))
|
|
102
|
+
openPath(result.outDir);
|
|
103
|
+
return result.shots.some(s => s.error) ? 1 : 0;
|
|
104
|
+
}
|
|
105
|
+
case 'login': {
|
|
106
|
+
if (cmd.passwordFromFlag) {
|
|
107
|
+
console.error(`rpc: ${paint.warn('warning:')} --password is visible in shell history and the process list; set RPC_LOGIN_PASSWORD instead`);
|
|
108
|
+
}
|
|
109
|
+
if (mode.rich) {
|
|
110
|
+
const { ui, c } = await loadUi();
|
|
111
|
+
return ui.runLogin(cmd, c);
|
|
112
|
+
}
|
|
113
|
+
return plainLogin(cmd);
|
|
114
|
+
}
|
|
115
|
+
case 'sessions': {
|
|
116
|
+
if (mode.rich) {
|
|
117
|
+
const { ui, c } = await loadUi();
|
|
118
|
+
return ui.runSessions(cmd.action, cmd.origin, c);
|
|
119
|
+
}
|
|
120
|
+
if (cmd.action === 'clear') {
|
|
121
|
+
const gone = await clearSessions(cmd.origin);
|
|
122
|
+
if (cmd.json)
|
|
123
|
+
console.log(JSON.stringify({ removed: gone }, null, 2));
|
|
124
|
+
else if (gone.length === 0)
|
|
125
|
+
console.log(cmd.origin ? `No saved session for ${cmd.origin}` : 'No saved sessions');
|
|
126
|
+
else
|
|
127
|
+
for (const o of gone)
|
|
128
|
+
console.log(`removed ${o}`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
const list = await listSessions();
|
|
132
|
+
if (cmd.json)
|
|
133
|
+
console.log(JSON.stringify({ dir: sessionsDir(), sessions: list }, null, 2));
|
|
134
|
+
else if (list.length === 0)
|
|
135
|
+
console.log(`No saved sessions in ${sessionsDir()}. Create one with: rpc login <url>`);
|
|
136
|
+
else
|
|
137
|
+
for (const s of list)
|
|
138
|
+
console.log(sessionLine(s));
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function plainLogin(cmd) {
|
|
144
|
+
const { formLogin, interactiveLogin } = await import('./login.js');
|
|
145
|
+
let result;
|
|
146
|
+
if (cmd.form) {
|
|
147
|
+
if (!cmd.json)
|
|
148
|
+
console.log(`signing in at ${cmd.url} (headless form)`);
|
|
149
|
+
result = await formLogin(cmd, cmd.form);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const enter = waitForEnter();
|
|
153
|
+
try {
|
|
154
|
+
result = await interactiveLogin(cmd, enter.promise, () => {
|
|
155
|
+
if (!cmd.json)
|
|
156
|
+
console.log(`Opened ${cmd.url}. Sign in in the browser window, then press Enter here (or close the window).`);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
enter.stop();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (cmd.json) {
|
|
164
|
+
console.log(JSON.stringify(result, null, 2));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.log(`${paint.ok('saved')} session for ${result.origin}: ${result.path} (${result.cookies} cookie(s), ${result.localStorage} localStorage origin(s))`);
|
|
168
|
+
if (result.stillOnLogin) {
|
|
169
|
+
console.error(`${paint.warn('warning:')} the page was still on a login URL (${result.finalUrl}); the sign-in may not have finished`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
function sessionLine(s) {
|
|
175
|
+
const when = localTime;
|
|
176
|
+
const parts = [s.origin, `${s.cookies} cookie(s)`];
|
|
177
|
+
if (s.savedAt)
|
|
178
|
+
parts.push(`saved ${when(s.savedAt)}`);
|
|
179
|
+
if (s.expired)
|
|
180
|
+
parts.push(paint.bad(`expired ${when(s.expires)}`));
|
|
181
|
+
else if (s.expires)
|
|
182
|
+
parts.push(`expires ${when(s.expires)}`);
|
|
183
|
+
parts.push(rel(s.path));
|
|
184
|
+
return parts.join(' ');
|
|
185
|
+
}
|
|
186
|
+
function rel(p) {
|
|
187
|
+
const r = relative(process.cwd(), p);
|
|
188
|
+
return r.startsWith('..') ? p : r;
|
|
189
|
+
}
|
|
190
|
+
function printDoctor(r) {
|
|
191
|
+
console.log(`node ${r.node}`);
|
|
192
|
+
console.log(`playwright ${r.playwright}`);
|
|
193
|
+
console.log(`browsers ${r.browsersPath}`);
|
|
194
|
+
if (r.ok) {
|
|
195
|
+
console.log(`chromium ${paint.ok('ok')} (${r.version})`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
console.log(`chromium ${paint.bad(r.chromium)}`);
|
|
199
|
+
console.error(`rpc: ${r.error}`);
|
|
200
|
+
if (r.chromium === 'missing')
|
|
201
|
+
console.error('Run `rpc install` to download it now.');
|
|
202
|
+
}
|
|
203
|
+
function printShot(s) {
|
|
204
|
+
const label = `${s.device} (${s.viewport.width}x${s.viewport.height} @${s.dpr}x)`;
|
|
205
|
+
if (s.error) {
|
|
206
|
+
console.error(`${paint.bad('x')} ${label}: ${s.error}`);
|
|
207
|
+
if (s.hint)
|
|
208
|
+
console.error(` ${paint.warn('hint:')} ${s.hint}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const lines = [`${paint.ok('ok')} ${label}${s.status && s.status >= 400 ? ` HTTP ${s.status}` : ''}`];
|
|
212
|
+
if (s.shot) {
|
|
213
|
+
const extra = [
|
|
214
|
+
s.segments && s.segments > 1 ? `${s.segments} segments stitched` : '',
|
|
215
|
+
s.clipped ? `clipped at ${s.capturedHeight} of ${s.scrollHeight} css px` : '',
|
|
216
|
+
].filter(Boolean);
|
|
217
|
+
lines.push(` ${rel(s.shot.path)} ${s.shot.width}x${s.shot.height}${extra.length ? ` (${extra.join(', ')})` : ''}`);
|
|
218
|
+
}
|
|
219
|
+
if (s.framed)
|
|
220
|
+
lines.push(` ${rel(s.framed.path)} ${s.framed.width}x${s.framed.height}`);
|
|
221
|
+
if (s.slices?.saved && s.slices.items[0]?.path) {
|
|
222
|
+
const first = rel(s.slices.items[0].path);
|
|
223
|
+
lines.push(` ${first.replace(/-slice-\d+\.jpg$/, '-slice-NN.jpg')} ${s.slices.total} slice(s), ${s.slices.items[0].width}px wide`);
|
|
224
|
+
}
|
|
225
|
+
if (s.consoleErrors?.length)
|
|
226
|
+
lines.push(` ${s.consoleErrors.length} console error(s), first: ${s.consoleErrors[0]}`);
|
|
227
|
+
console.log(lines.join('\n'));
|
|
228
|
+
}
|
|
229
|
+
export function formatDeviceTable() {
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const cat of CATEGORY_ORDER) {
|
|
232
|
+
out.push(CATEGORY_LABELS[cat]);
|
|
233
|
+
for (const d of DEVICES.filter(x => x.category === cat)) {
|
|
234
|
+
out.push(` ${d.id.padEnd(20)} ${`${d.width}x${d.height}`.padEnd(10)} @${String(d.dpr).padEnd(6)} ${d.name}`);
|
|
235
|
+
}
|
|
236
|
+
out.push('');
|
|
237
|
+
}
|
|
238
|
+
out.push('Aliases');
|
|
239
|
+
for (const [alias, id] of Object.entries(DEVICE_ALIASES))
|
|
240
|
+
out.push(` ${alias.padEnd(20)} -> ${id}`);
|
|
241
|
+
return out.join('\n') + '\n';
|
|
242
|
+
}
|
|
243
|
+
main(process.argv.slice(2)).then(code => {
|
|
244
|
+
if (code >= 0)
|
|
245
|
+
process.exitCode = code;
|
|
246
|
+
}, err => {
|
|
247
|
+
if (err instanceof ArgError) {
|
|
248
|
+
console.error(`rpc: ${err.message}`);
|
|
249
|
+
process.exitCode = 2;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.error(`rpc: ${err.message}`);
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { bezelLayoutFor, loadBezelSvg } from './bezel.js';
|
|
2
|
+
/** Any origin works; requests to it never leave the process (page.route). */
|
|
3
|
+
const ORIGIN = 'http://rpc-compositor.invalid';
|
|
4
|
+
/** Long-side cap for framed output so 3x phones and 2x 27" displays stay sane. */
|
|
5
|
+
export const MAX_FRAMED_PX = 4096;
|
|
6
|
+
/**
|
|
7
|
+
* Serve `html` at ORIGIN/ and `images` at ORIGIN/shot.png (the first) and
|
|
8
|
+
* ORIGIN/img/<n>.png from a throwaway page, then run `fn`. Routing avoids
|
|
9
|
+
* stuffing multi-MB data: URLs into setContent.
|
|
10
|
+
*/
|
|
11
|
+
async function withStage(browser, viewport, deviceScaleFactor, html, images, fn) {
|
|
12
|
+
const list = Array.isArray(images) ? images : [images];
|
|
13
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor });
|
|
14
|
+
try {
|
|
15
|
+
const page = await context.newPage();
|
|
16
|
+
await page.route(`${ORIGIN}/**`, route => {
|
|
17
|
+
const path = new URL(route.request().url()).pathname;
|
|
18
|
+
const m = /^\/img\/(\d+)\.png$/.exec(path);
|
|
19
|
+
const img = path === '/shot.png' ? list[0] : m ? list[Number(m[1])] : undefined;
|
|
20
|
+
if (img)
|
|
21
|
+
return route.fulfill({ status: 200, contentType: 'image/png', body: img });
|
|
22
|
+
if (path !== '/')
|
|
23
|
+
return route.fulfill({ status: 404 });
|
|
24
|
+
return route.fulfill({ status: 200, contentType: 'text/html', body: html });
|
|
25
|
+
});
|
|
26
|
+
await page.goto(`${ORIGIN}/`);
|
|
27
|
+
// String predicate: see the note on in-page scripts in capture.ts.
|
|
28
|
+
await page.waitForFunction(`Array.from(document.images).every(i => i.complete)`);
|
|
29
|
+
return await fn(page);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await context.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Strip the root <svg>'s fixed width/height so CSS can size it, as RPC does. */
|
|
36
|
+
function prepareSvg(svg) {
|
|
37
|
+
return svg.replace(/<svg\b[^>]*>/, tag => tag.replace(/\s(width|height)="[^"]*"/g, '').replace('<svg', '<svg style="position:absolute;left:0;top:0;width:100%;height:100%"'));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Composite a viewport screenshot into the device bezel. Mirrors RPC's
|
|
41
|
+
* frame.ts: SVG fills the outer box underneath, the page sits on top at the
|
|
42
|
+
* screen rect with the screen's corner radius. Output has a transparent
|
|
43
|
+
* background and the device's DPR (capped by MAX_FRAMED_PX).
|
|
44
|
+
*/
|
|
45
|
+
export async function renderFramed(browser, device, viewportPng) {
|
|
46
|
+
const layout = bezelLayoutFor(device);
|
|
47
|
+
const { screen } = layout;
|
|
48
|
+
const scale = Math.min(device.dpr, MAX_FRAMED_PX / Math.max(layout.outerWidth, layout.outerHeight));
|
|
49
|
+
const html = `<!doctype html><html><head><style>
|
|
50
|
+
html,body{margin:0;background:transparent}
|
|
51
|
+
#frame{position:relative;width:${layout.outerWidth}px;height:${layout.outerHeight}px}
|
|
52
|
+
#bezel{position:absolute;inset:0;z-index:1}
|
|
53
|
+
#screen{position:absolute;z-index:2;display:block;left:${screen.x}px;top:${screen.y}px;
|
|
54
|
+
width:${screen.width}px;height:${screen.height}px;border-radius:${screen.radius}px;object-fit:fill}
|
|
55
|
+
</style></head><body><div id="frame"><div id="bezel">${prepareSvg(loadBezelSvg(layout.src))}</div>
|
|
56
|
+
<img id="screen" src="/shot.png" alt=""></div></body></html>`;
|
|
57
|
+
return withStage(browser, { width: layout.outerWidth, height: layout.outerHeight }, scale, html, viewportPng, page => page.locator('#frame').screenshot({ omitBackground: true, type: 'png' }));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Small JPEG for returning inline over MCP. Tall full-page shots are cropped
|
|
61
|
+
* to the top `maxAspect` region first so text is still legible after scaling.
|
|
62
|
+
*/
|
|
63
|
+
export async function renderPreview(browser, png, size, { maxDim = 1024, maxAspect = 3, quality = 70 } = {}) {
|
|
64
|
+
const srcH = Math.min(size.height, Math.round(size.width * maxAspect));
|
|
65
|
+
const cropped = srcH < size.height;
|
|
66
|
+
const scale = Math.min(1, maxDim / Math.max(size.width, srcH));
|
|
67
|
+
const width = Math.max(1, Math.round(size.width * scale));
|
|
68
|
+
const height = Math.max(1, Math.round(srcH * scale));
|
|
69
|
+
const html = `<!doctype html><html><head><style>
|
|
70
|
+
html,body{margin:0;background:#fff}
|
|
71
|
+
#c{width:${width}px;height:${height}px;overflow:hidden}
|
|
72
|
+
img{display:block;width:${width}px;height:auto}
|
|
73
|
+
</style></head><body><div id="c"><img src="/shot.png" alt=""></div></body></html>`;
|
|
74
|
+
const data = await withStage(browser, { width, height }, 1, html, png, page => page.locator('#c').screenshot({ type: 'jpeg', quality }));
|
|
75
|
+
return { data, width, height, cropped };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Render review slices (JPEG) from full-page capture segments. The segments
|
|
79
|
+
* are stacked at preview scale in one compositor page and each slice is a
|
|
80
|
+
* clipped view of that stack, so neither the page nor any slice ever needs a
|
|
81
|
+
* bitmap taller than a single segment.
|
|
82
|
+
*
|
|
83
|
+
* `cssWidth` is the captured width in CSS px; `scale` is output px per CSS px.
|
|
84
|
+
*/
|
|
85
|
+
export async function renderSlices(browser, segments, slices, cssWidth, scale, quality = 70) {
|
|
86
|
+
if (slices.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
const width = Math.max(1, Math.round(cssWidth * scale));
|
|
89
|
+
const maxH = Math.max(...slices.map(s => Math.round(s.height * scale)));
|
|
90
|
+
const imgs = segments
|
|
91
|
+
.map((seg, i) =>
|
|
92
|
+
// Whole-pixel edges so adjacent segments meet without a blended seam row.
|
|
93
|
+
`<img src="/img/${i}.png" alt="" style="position:absolute;left:0;top:${Math.round(seg.y * scale)}px;` +
|
|
94
|
+
`width:${width}px;height:${Math.round((seg.y + seg.height) * scale) - Math.round(seg.y * scale)}px">`)
|
|
95
|
+
.join('');
|
|
96
|
+
const html = `<!doctype html><html><head><style>
|
|
97
|
+
html,body{margin:0;background:#fff}
|
|
98
|
+
#c{position:relative;width:${width}px;height:${maxH}px;overflow:hidden}
|
|
99
|
+
#s{position:absolute;left:0;top:0;width:${width}px}
|
|
100
|
+
img{display:block}
|
|
101
|
+
</style></head><body><div id="c"><div id="s">${imgs}</div></div></body></html>`;
|
|
102
|
+
return withStage(browser, { width, height: maxH }, 1, html, segments.map(s => s.png), async (page) => {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const slice of slices) {
|
|
105
|
+
const pxHeight = Math.max(1, Math.round(slice.height * scale));
|
|
106
|
+
await page.evaluate(`(() => {
|
|
107
|
+
document.getElementById('c').style.height = '${pxHeight}px';
|
|
108
|
+
document.getElementById('s').style.transform = 'translateY(${-Math.round(slice.y * scale)}px)';
|
|
109
|
+
})()`);
|
|
110
|
+
const data = await page.locator('#c').screenshot({ type: 'jpeg', quality });
|
|
111
|
+
out.push({ ...slice, data, width, pxHeight });
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
});
|
|
115
|
+
}
|