rpc-agent 0.4.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 +100 -0
- package/README.md +449 -5
- package/dist/args.js +197 -31
- package/dist/browser.js +57 -3
- package/dist/capture.js +45 -42
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +351 -0
- package/dist/check.js +685 -0
- package/dist/cli.js +88 -1
- package/dist/errors.js +8 -0
- package/dist/gif.js +212 -0
- package/dist/help.js +56 -2
- package/dist/mcp.js +218 -16
- package/dist/motion.js +487 -0
- package/dist/options.js +33 -0
- package/dist/png.js +47 -0
- package/dist/record.js +333 -0
- package/dist/ui.js +199 -0
- package/package.json +2 -2
package/dist/record.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// `rpc record`: a short video of a page, or of one element on it. Frames
|
|
2
|
+
// come from a screenshot loop on the element's box (device pixels, cropped
|
|
3
|
+
// exactly), and ffmpeg (Playwright's own build, or one on PATH) turns them
|
|
4
|
+
// into a VP9 webm at a constant frame rate. See docs/plan.md, v0.6.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
|
|
10
|
+
import { findFfmpeg, h264Encoder, launchBrowser, systemFfmpeg } from './browser.js';
|
|
11
|
+
import { describeFailure, fileStem, newDeviceContext, settle } from './capture.js';
|
|
12
|
+
import { resolveDevice } from './devices.js';
|
|
13
|
+
import { plainText } from './errors.js';
|
|
14
|
+
import { buildPalette, gifWriter } from './gif.js';
|
|
15
|
+
import { settleMotion } from './motion.js';
|
|
16
|
+
import { decodePng, imageSize, pngSize } from './png.js';
|
|
17
|
+
/** Output frame rate of the webm. */
|
|
18
|
+
export const RECORD_FPS = 25;
|
|
19
|
+
/** GIF frame rate and width cap. */
|
|
20
|
+
export const GIF_FPS = 10;
|
|
21
|
+
export const GIF_MAX_WIDTH = 640;
|
|
22
|
+
/** Frames sampled (evenly) for the GIF's shared palette. */
|
|
23
|
+
const GIF_PALETTE_FRAMES = 12;
|
|
24
|
+
/** A failure with its own code and hint (no element, no ffmpeg). */
|
|
25
|
+
export class RecordError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
hint;
|
|
28
|
+
name = 'RecordError';
|
|
29
|
+
constructor(code, message, hint) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.hint = hint;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function record(opts, hooks = {}) {
|
|
36
|
+
const device = resolveDevice(opts.device);
|
|
37
|
+
const outDir = resolve(opts.outDir);
|
|
38
|
+
const auth = hooks.auth ?? resolveAuth(opts.url, opts.auth ?? {});
|
|
39
|
+
if (auth.summary)
|
|
40
|
+
hooks.onAuth?.(auth.summary);
|
|
41
|
+
const result = {
|
|
42
|
+
url: opts.url,
|
|
43
|
+
outDir,
|
|
44
|
+
device: device.id,
|
|
45
|
+
name: device.name,
|
|
46
|
+
viewport: { width: device.width, height: device.height },
|
|
47
|
+
dpr: device.dpr,
|
|
48
|
+
...(opts.selector ? { selector: opts.selector } : {}),
|
|
49
|
+
...(auth.summary ? { auth: auth.summary } : {}),
|
|
50
|
+
};
|
|
51
|
+
const ffmpeg = hooks.ffmpeg ?? findFfmpeg();
|
|
52
|
+
if (!ffmpeg) {
|
|
53
|
+
return {
|
|
54
|
+
...result,
|
|
55
|
+
code: 'FFMPEG_MISSING',
|
|
56
|
+
error: 'No ffmpeg to encode the video with',
|
|
57
|
+
hint: 'Run `npx rpc-agent install` (Playwright downloads its ffmpeg next to Chromium), or put ffmpeg on PATH.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
result.ffmpeg = ffmpeg.source;
|
|
61
|
+
await mkdir(outDir, { recursive: true });
|
|
62
|
+
const browser = hooks.browser ?? (await launchBrowser());
|
|
63
|
+
const errors = new Set();
|
|
64
|
+
try {
|
|
65
|
+
await recordDevice(browser, device, opts, auth, ffmpeg, result, errors, hooks);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof RecordError)
|
|
69
|
+
Object.assign(result, { code: err.code, error: redact(err.message, auth.secrets), ...(err.hint ? { hint: err.hint } : {}) });
|
|
70
|
+
else
|
|
71
|
+
Object.assign(result, describeFailure(err, opts, auth));
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
result.consoleErrors = [...errors].slice(0, 20).map(l => redact(l, auth.secrets));
|
|
75
|
+
if (!hooks.browser)
|
|
76
|
+
await browser.close();
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
async function recordDevice(browser, device, opts, auth, ffmpeg, result, errors, hooks) {
|
|
81
|
+
const context = await newDeviceContext(browser, device, opts, auth);
|
|
82
|
+
try {
|
|
83
|
+
const page = await context.newPage();
|
|
84
|
+
await applyHeaders(context, page, auth);
|
|
85
|
+
page.on('pageerror', e => errors.add(plainText(`pageerror: ${e.message}`)));
|
|
86
|
+
page.on('console', m => {
|
|
87
|
+
if (m.type() === 'error')
|
|
88
|
+
errors.add(plainText(`console: ${m.text()}`));
|
|
89
|
+
});
|
|
90
|
+
const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
91
|
+
result.status = response?.status() ?? null;
|
|
92
|
+
await settle(page, opts.timeoutMs);
|
|
93
|
+
const checkLogin = () => {
|
|
94
|
+
if (redirectedToLogin(opts.url, page.url()))
|
|
95
|
+
throw new AuthRequiredError(opts.url, page.url(), result.status ?? null);
|
|
96
|
+
};
|
|
97
|
+
checkLogin();
|
|
98
|
+
if (opts.waitFor)
|
|
99
|
+
await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
|
|
100
|
+
let clip = { x: 0, y: 0, width: device.width, height: device.height };
|
|
101
|
+
if (opts.selector) {
|
|
102
|
+
const el = page.locator(opts.selector).first();
|
|
103
|
+
try {
|
|
104
|
+
await el.waitFor({ state: 'visible', timeout: Math.min(opts.timeoutMs, 10_000) });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
checkLogin();
|
|
108
|
+
throw new RecordError('SELECTOR_NOT_FOUND', `No visible element matches "${opts.selector}"`, 'Check the selector in the browser devtools (document.querySelector), or wait for it with --wait-for.');
|
|
109
|
+
}
|
|
110
|
+
await el.scrollIntoViewIfNeeded();
|
|
111
|
+
// Let scroll-triggered reveals around it finish, so the video shows the element itself.
|
|
112
|
+
await settleMotion(page);
|
|
113
|
+
const box = await el.boundingBox();
|
|
114
|
+
if (!box)
|
|
115
|
+
throw new RecordError('SELECTOR_NOT_FOUND', `"${opts.selector}" has no box to record`, 'The element is not rendered (display: none or detached).');
|
|
116
|
+
const x0 = Math.max(0, Math.floor(box.x));
|
|
117
|
+
const y0 = Math.max(0, Math.floor(box.y));
|
|
118
|
+
const x1 = Math.min(device.width, Math.ceil(box.x + box.width));
|
|
119
|
+
const y1 = Math.min(device.height, Math.ceil(box.y + box.height));
|
|
120
|
+
if (x1 - x0 < 2 || y1 - y0 < 2)
|
|
121
|
+
throw new RecordError('SELECTOR_NOT_FOUND', `"${opts.selector}" is outside the viewport`, 'Record a smaller element, or pick a device with a larger viewport.');
|
|
122
|
+
clip = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
123
|
+
if (box.width > clip.width + 1 || box.height > clip.height + 1) {
|
|
124
|
+
result.clipped = true;
|
|
125
|
+
(result.warnings ??= []).push(`"${opts.selector}" is ${Math.round(box.width)}x${Math.round(box.height)} CSS px, larger than the ${device.width}x${device.height} viewport; only the visible part was recorded.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (opts.waitMs > 0)
|
|
129
|
+
await page.waitForTimeout(opts.waitMs);
|
|
130
|
+
checkLogin();
|
|
131
|
+
result.finalUrl = page.url();
|
|
132
|
+
result.title = await page.title();
|
|
133
|
+
result.clip = clip;
|
|
134
|
+
const stem = recordStem(opts.url, device.id, opts.selector);
|
|
135
|
+
const posterPng = await page.screenshot({ clip, type: 'png', animations: 'allow', caret: 'initial' });
|
|
136
|
+
result.poster = { path: resolve(result.outDir, `${stem}-poster.png`), ...pngSize(posterPng) };
|
|
137
|
+
await writeFile(result.poster.path, posterPng);
|
|
138
|
+
hooks.onRecording?.(opts.duration);
|
|
139
|
+
const webm = resolve(result.outDir, `${stem}.webm`);
|
|
140
|
+
let captured;
|
|
141
|
+
try {
|
|
142
|
+
captured = await encodeFrames(ffmpeg.path, webm, opts.duration, () => page.screenshot({ clip, type: 'jpeg', quality: 90, animations: 'allow', caret: 'initial' }));
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
// No video: don't leave its poster behind next to an error result.
|
|
146
|
+
await rm(result.poster.path, { force: true });
|
|
147
|
+
delete result.poster;
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
result.video = { ...(await probeVideo(ffmpeg.path, webm)), path: webm, fps: RECORD_FPS, captured };
|
|
151
|
+
if (opts.gif)
|
|
152
|
+
result.gif = await writeGif(ffmpeg.path, webm, resolve(result.outDir, `${stem}.gif`), result.video);
|
|
153
|
+
if (opts.mp4) {
|
|
154
|
+
const mp4 = await writeMp4(webm, resolve(result.outDir, `${stem}.mp4`));
|
|
155
|
+
if ('path' in mp4)
|
|
156
|
+
result.mp4 = mp4;
|
|
157
|
+
else
|
|
158
|
+
(result.warnings ??= []).push(mp4.skipped);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await context.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** `<host>[-<path>]-<device>[-<selector>]`, e.g. `localhost-3000-desktop-1440-carousel`. */
|
|
166
|
+
export function recordStem(url, deviceId, selector) {
|
|
167
|
+
const base = fileStem(url, deviceId);
|
|
168
|
+
if (!selector)
|
|
169
|
+
return base;
|
|
170
|
+
const slug = selector.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
171
|
+
return slug ? `${base}-${slug}` : base;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Take frames for `seconds` and pipe them to ffmpeg as MJPEG at RECORD_FPS.
|
|
175
|
+
* Each frame is repeated until the next one's timestamp (the middle of its
|
|
176
|
+
* screenshot call), and the last fills the remainder, so the video is
|
|
177
|
+
* exactly `seconds` long whatever the capture rate. Returns the number of
|
|
178
|
+
* screenshots taken. If ffmpeg can't start or dies, or a screenshot fails,
|
|
179
|
+
* the child is stopped and the partial file removed.
|
|
180
|
+
*/
|
|
181
|
+
export async function encodeFrames(ffmpeg, out, seconds, shot) {
|
|
182
|
+
const total = Math.round(seconds * RECORD_FPS);
|
|
183
|
+
const child = spawn(ffmpeg, [
|
|
184
|
+
'-loglevel', 'error', '-y',
|
|
185
|
+
'-f', 'image2pipe', '-c:v', 'mjpeg', '-framerate', String(RECORD_FPS), '-i', 'pipe:0',
|
|
186
|
+
'-an', '-c:v', 'libvpx-vp9', '-deadline', 'realtime', '-cpu-used', '8', '-crf', '30', '-b:v', '0',
|
|
187
|
+
'-pix_fmt', 'yuv420p', out,
|
|
188
|
+
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
189
|
+
let stderr = '';
|
|
190
|
+
child.stderr.on('data', d => (stderr += d));
|
|
191
|
+
// Settles once, on exit or on a spawn failure; never rejects, so a failed
|
|
192
|
+
// spawn can't become an unhandled rejection while frames are being taken.
|
|
193
|
+
let spawnError;
|
|
194
|
+
let dead = false;
|
|
195
|
+
const exited = new Promise(res => {
|
|
196
|
+
child.once('error', err => {
|
|
197
|
+
spawnError = err;
|
|
198
|
+
res(-1);
|
|
199
|
+
});
|
|
200
|
+
child.once('close', code => res(code ?? 1));
|
|
201
|
+
}).finally(() => {
|
|
202
|
+
dead = true;
|
|
203
|
+
});
|
|
204
|
+
// A dead ffmpeg makes writes fail (EPIPE); the exit code and stderr say why.
|
|
205
|
+
child.stdin.on('error', () => {
|
|
206
|
+
dead = true;
|
|
207
|
+
});
|
|
208
|
+
// Wait for room in the pipe, or for ffmpeg to go away ('drain' never comes then).
|
|
209
|
+
const write = (buf) => dead || child.stdin.destroyed
|
|
210
|
+
? Promise.resolve()
|
|
211
|
+
: new Promise(res => {
|
|
212
|
+
if (child.stdin.write(buf))
|
|
213
|
+
return res();
|
|
214
|
+
const done = () => {
|
|
215
|
+
child.stdin.off('drain', done);
|
|
216
|
+
child.stdin.off('close', done);
|
|
217
|
+
res();
|
|
218
|
+
};
|
|
219
|
+
child.stdin.once('drain', done);
|
|
220
|
+
child.stdin.once('close', done);
|
|
221
|
+
void exited.then(done);
|
|
222
|
+
});
|
|
223
|
+
let written = 0;
|
|
224
|
+
let last;
|
|
225
|
+
let captured = 0;
|
|
226
|
+
const fill = async (upTo) => {
|
|
227
|
+
for (; written < Math.min(upTo, total) && last && !dead; written++)
|
|
228
|
+
await write(last);
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
const t0 = performance.now();
|
|
232
|
+
while (performance.now() - t0 < seconds * 1000 && !dead) {
|
|
233
|
+
const before = performance.now();
|
|
234
|
+
const buf = await shot();
|
|
235
|
+
const at = ((before + performance.now()) / 2 - t0) / 1000;
|
|
236
|
+
captured++;
|
|
237
|
+
await fill(Math.round(at * RECORD_FPS));
|
|
238
|
+
last = buf;
|
|
239
|
+
}
|
|
240
|
+
await fill(total);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
child.kill('SIGKILL');
|
|
244
|
+
await exited;
|
|
245
|
+
await rm(out, { force: true });
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
child.stdin.end();
|
|
249
|
+
const code = await exited;
|
|
250
|
+
if (spawnError)
|
|
251
|
+
throw new RecordError('ENCODE_FAILED', `Could not run ffmpeg: ${spawnError.message}`, `ffmpeg: ${ffmpeg}`);
|
|
252
|
+
if (code !== 0) {
|
|
253
|
+
await rm(out, { force: true });
|
|
254
|
+
throw new RecordError('ENCODE_FAILED', `ffmpeg failed (exit ${code}): ${stderr.trim().split('\n').pop() ?? ''}`, `ffmpeg: ${ffmpeg}`);
|
|
255
|
+
}
|
|
256
|
+
return captured;
|
|
257
|
+
}
|
|
258
|
+
/** Run ffmpeg to completion; stderr in the error. */
|
|
259
|
+
function run(ffmpeg, args) {
|
|
260
|
+
return new Promise((res, rej) => {
|
|
261
|
+
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
262
|
+
let stderr = '';
|
|
263
|
+
child.stderr.on('data', d => (stderr += d));
|
|
264
|
+
child.on('error', rej);
|
|
265
|
+
child.on('close', code => (code === 0 ? res(stderr) : rej(new RecordError('ENCODE_FAILED', `ffmpeg failed (exit ${code}): ${stderr.trim().split('\n').pop() ?? ''}`))));
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** Size and duration of a video, from `ffmpeg -i` (which exits 1 without an output; that's expected). */
|
|
269
|
+
export async function probeVideo(ffmpeg, path) {
|
|
270
|
+
const info = await new Promise((res, rej) => {
|
|
271
|
+
const child = spawn(ffmpeg, ['-hide_banner', '-i', path], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
272
|
+
let stderr = '';
|
|
273
|
+
child.stderr.on('data', d => (stderr += d));
|
|
274
|
+
child.on('error', rej);
|
|
275
|
+
child.on('close', () => res(stderr));
|
|
276
|
+
});
|
|
277
|
+
const size = /Video: .*?\b(\d{1,5})x(\d{1,5})\b/.exec(info);
|
|
278
|
+
const dur = /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/.exec(info);
|
|
279
|
+
if (!size || !dur)
|
|
280
|
+
throw new RecordError('ENCODE_FAILED', `Could not read the video ffmpeg wrote: ${path}`);
|
|
281
|
+
return {
|
|
282
|
+
width: Number(size[1]),
|
|
283
|
+
height: Number(size[2]),
|
|
284
|
+
duration: Math.round((Number(dur[1]) * 3600 + Number(dur[2]) * 60 + Number(dur[3])) * 100) / 100,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Decode the webm to PNG frames at GIF_FPS (Playwright's ffmpeg can), then
|
|
289
|
+
* encode them in gif.ts: the palette from a sample of frames, then one frame
|
|
290
|
+
* decoded and encoded at a time, so memory stays at about one frame.
|
|
291
|
+
*/
|
|
292
|
+
async function writeGif(ffmpeg, webm, out, video) {
|
|
293
|
+
const width = Math.min(video.width, GIF_MAX_WIDTH);
|
|
294
|
+
const height = Math.max(1, Math.round((video.height * width) / video.width));
|
|
295
|
+
// `-r` alone pads a frame or two at the edges; cap at the video's length.
|
|
296
|
+
const count = Math.max(1, Math.round(video.duration * GIF_FPS));
|
|
297
|
+
const dir = await mkdtemp(join(tmpdir(), 'rpc-gif-'));
|
|
298
|
+
try {
|
|
299
|
+
await run(ffmpeg, ['-loglevel', 'error', '-y', '-i', webm, '-r', String(GIF_FPS), '-frames:v', String(count), '-vf', `scale=${width}:${height}`, '-f', 'image2', join(dir, 'f-%04d.png')]);
|
|
300
|
+
const files = (await readdir(dir)).filter(f => f.endsWith('.png')).sort();
|
|
301
|
+
if (!files.length)
|
|
302
|
+
throw new RecordError('ENCODE_FAILED', 'ffmpeg wrote no GIF frames');
|
|
303
|
+
const frame = async (f) => decodePng(await readFile(join(dir, f)));
|
|
304
|
+
const step = Math.max(1, Math.ceil(files.length / GIF_PALETTE_FRAMES));
|
|
305
|
+
const sample = [];
|
|
306
|
+
for (let i = 0; i < files.length; i += step)
|
|
307
|
+
sample.push(await frame(files[i]));
|
|
308
|
+
const gif = gifWriter(width, height, buildPalette(sample), Math.round(100 / GIF_FPS));
|
|
309
|
+
sample.length = 0;
|
|
310
|
+
for (const f of files)
|
|
311
|
+
gif.add(await frame(f));
|
|
312
|
+
const data = gif.end();
|
|
313
|
+
await writeFile(out, data);
|
|
314
|
+
return { path: out, ...imageSize(data), frames: files.length };
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
await rm(dir, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** mp4 needs an H.264 encoder, which Playwright's ffmpeg doesn't have: use a system ffmpeg when there is one. */
|
|
321
|
+
async function writeMp4(webm, out) {
|
|
322
|
+
const sys = systemFfmpeg();
|
|
323
|
+
const encoder = sys ? h264Encoder(sys) : undefined;
|
|
324
|
+
if (!sys || !encoder) {
|
|
325
|
+
return { skipped: 'mp4 skipped: it needs an ffmpeg with an H.264 encoder on PATH (e.g. `brew install ffmpeg`); the webm was written.' };
|
|
326
|
+
}
|
|
327
|
+
const quality = encoder === 'h264_videotoolbox' ? ['-b:v', '8M'] : encoder === 'libx264' ? ['-crf', '20', '-preset', 'veryfast'] : [];
|
|
328
|
+
await run(sys, [
|
|
329
|
+
'-loglevel', 'error', '-y', '-i', webm,
|
|
330
|
+
'-c:v', encoder, ...quality, '-pix_fmt', 'yuv420p', '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-movflags', '+faststart', out,
|
|
331
|
+
]);
|
|
332
|
+
return { path: out, encoder };
|
|
333
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -9,7 +9,11 @@ import pc from 'picocolors';
|
|
|
9
9
|
import { clearSessions, listSessions, localTime, MASK, resolveAuth, sessionsDir, } from './auth.js';
|
|
10
10
|
import { BrowserMissingError, installHint, launchBrowser } from './browser.js';
|
|
11
11
|
import { capture } from './capture.js';
|
|
12
|
+
import { checkExitCode, runCheck as audit, summaryLine } from './check.js';
|
|
13
|
+
import { countsPhrase, groupFindings, groupLines } from './check-format.js';
|
|
12
14
|
import { CATEGORY_ORDER, DEVICE_ALIASES, DEVICES, } from './devices.js';
|
|
15
|
+
import { unpinnedLines, warningWhere } from './motion.js';
|
|
16
|
+
import { record } from './record.js';
|
|
13
17
|
import { defaultShotOptions, normalizeUrl } from './options.js';
|
|
14
18
|
import { openCommand, openPath, waitForEnter } from './term.js';
|
|
15
19
|
export function colors(enabled) {
|
|
@@ -99,6 +103,10 @@ export function shotCommand(o) {
|
|
|
99
103
|
parts.push('--color-scheme', o.colorScheme);
|
|
100
104
|
if (o.reducedMotion)
|
|
101
105
|
parts.push('--reduced-motion');
|
|
106
|
+
if (o.finishAnimations)
|
|
107
|
+
parts.push('--finish-animations');
|
|
108
|
+
if (o.fullPage && !o.unpin)
|
|
109
|
+
parts.push('--no-unpin');
|
|
102
110
|
if (o.outDir !== base.outDir)
|
|
103
111
|
parts.push('-o', quote(o.outDir));
|
|
104
112
|
return parts.join(' ');
|
|
@@ -185,6 +193,10 @@ export function summaryLines(o, c, auth) {
|
|
|
185
193
|
];
|
|
186
194
|
if (o.reducedMotion)
|
|
187
195
|
rows.push(['reduced motion', c.green('yes')]);
|
|
196
|
+
if (o.finishAnimations)
|
|
197
|
+
rows.push(['finish animations', c.green('yes')]);
|
|
198
|
+
if (o.fullPage && !o.unpin)
|
|
199
|
+
rows.push(['unpin', c.yellow('no')]);
|
|
188
200
|
if (o.waitFor)
|
|
189
201
|
rows.push(['wait for', o.waitFor]);
|
|
190
202
|
if (!o.emulate)
|
|
@@ -288,6 +300,12 @@ export function capturedLines(result, c) {
|
|
|
288
300
|
const msg = s.consoleErrors[0].length > width - 20 ? `${s.consoleErrors[0].slice(0, width - 21)}…` : s.consoleErrors[0];
|
|
289
301
|
out.push(` ${c.yellow('console:')} ${c.dim(msg)}`);
|
|
290
302
|
}
|
|
303
|
+
for (const u of unpinnedLines(s.motion?.unpinned))
|
|
304
|
+
out.push(` ${c.cyan('unpinned')} ${c.dim(u)}`);
|
|
305
|
+
for (const w of s.warnings ?? []) {
|
|
306
|
+
const where = warningWhere(w);
|
|
307
|
+
out.push(` ${c.yellow('▲')} ${w.message}${where ? ` ${c.dim(where)}` : ''}`);
|
|
308
|
+
}
|
|
291
309
|
}
|
|
292
310
|
for (const group of failures.values()) {
|
|
293
311
|
const first = group[0];
|
|
@@ -389,6 +407,182 @@ export async function runShot(options, run, c) {
|
|
|
389
407
|
p.outro(`${c.green('Done!')} ${c.dim(advice)}`);
|
|
390
408
|
return failed > 0 ? 1 : 0;
|
|
391
409
|
}
|
|
410
|
+
// ------------------------------------------------------------------ record
|
|
411
|
+
/** Lines for the final "Recorded" box. */
|
|
412
|
+
export function recordedLines(r, c) {
|
|
413
|
+
const out = [];
|
|
414
|
+
const file = (path, meta) => out.push(`${c.green('✓')} ${c.cyan(rel(path))} ${c.dim(meta)}`);
|
|
415
|
+
if (r.video)
|
|
416
|
+
file(r.video.path, `${r.video.width}×${r.video.height} · ${r.video.duration}s · ${r.video.fps} fps · ${size(r.video.path)}`);
|
|
417
|
+
if (r.poster)
|
|
418
|
+
file(r.poster.path, `${r.poster.width}×${r.poster.height} · poster`);
|
|
419
|
+
if (r.gif)
|
|
420
|
+
file(r.gif.path, `${r.gif.width}×${r.gif.height} · ${r.gif.frames} frames · ${size(r.gif.path)}`);
|
|
421
|
+
if (r.mp4)
|
|
422
|
+
file(r.mp4.path, `${r.mp4.encoder} · ${size(r.mp4.path)}`);
|
|
423
|
+
for (const w of r.warnings ?? [])
|
|
424
|
+
for (const [i, l] of wrap(w, textWidth() - 4).entries())
|
|
425
|
+
out.push(`${i ? ' ' : c.yellow('▲ ')}${c.dim(l)}`);
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
export async function runRecord(options, run, c) {
|
|
429
|
+
const auth = resolveAuth(options.url, options.auth ?? {});
|
|
430
|
+
printBanner(c);
|
|
431
|
+
intro(c);
|
|
432
|
+
const rows = [
|
|
433
|
+
['device', c.cyan(options.device)],
|
|
434
|
+
['element', options.selector ?? c.dim('whole viewport')],
|
|
435
|
+
['duration', `${options.duration}s`],
|
|
436
|
+
['also', [options.gif ? 'gif' : '', options.mp4 ? 'mp4' : ''].filter(Boolean).join(', ') || c.dim('webm only')],
|
|
437
|
+
];
|
|
438
|
+
if (options.reducedMotion)
|
|
439
|
+
rows.push(['reduced motion', c.green('yes')]);
|
|
440
|
+
if (auth.summary)
|
|
441
|
+
rows.push(...authRows(auth.summary, c));
|
|
442
|
+
rows.push(['output', `${rel(options.outDir)}/`]);
|
|
443
|
+
const w = Math.max(...rows.map(([k]) => k.length)) + 1;
|
|
444
|
+
p.note([c.cyan(options.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)].join('\n'), 'Record Summary');
|
|
445
|
+
const s = p.spinner();
|
|
446
|
+
s.start('Starting Chromium');
|
|
447
|
+
let browser;
|
|
448
|
+
try {
|
|
449
|
+
browser = await launchBrowser();
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
s.error(c.red(err instanceof BrowserMissingError ? "Playwright's Chromium is not installed" : `Chromium did not start: ${err.message.split('\n')[0]}`));
|
|
453
|
+
if (err instanceof BrowserMissingError)
|
|
454
|
+
p.note(wrap(installHint().replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
|
|
455
|
+
p.outro(c.red('Nothing recorded.'));
|
|
456
|
+
return 1;
|
|
457
|
+
}
|
|
458
|
+
s.message(`Loading ${c.cyan(options.url)}`);
|
|
459
|
+
let result;
|
|
460
|
+
try {
|
|
461
|
+
result = await record(options, { browser, auth, onRecording: secs => s.message(`Recording ${secs}s`) });
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
await browser.close().catch(() => { });
|
|
465
|
+
}
|
|
466
|
+
if (result.error) {
|
|
467
|
+
s.error(c.red(result.error));
|
|
468
|
+
if (result.hint)
|
|
469
|
+
p.note(wrap(result.hint, textWidth()).join('\n'), c.yellow('Hint'));
|
|
470
|
+
p.outro(c.red('Nothing recorded.'));
|
|
471
|
+
return 1;
|
|
472
|
+
}
|
|
473
|
+
s.stop(`Recorded ${result.video?.duration ?? options.duration}s on ${result.device}`);
|
|
474
|
+
p.note(recordedLines(result, c).join('\n'), c.green('Recorded'));
|
|
475
|
+
const dir = rel(result.outDir);
|
|
476
|
+
let advice = `Open with: ${openCommand().cmd} ${dir}`;
|
|
477
|
+
if (run.open)
|
|
478
|
+
advice = openPath(result.outDir) ? `Opened ${dir}` : advice;
|
|
479
|
+
p.outro(`${c.green('Done!')} ${c.dim(advice)}`);
|
|
480
|
+
return 0;
|
|
481
|
+
}
|
|
482
|
+
// ------------------------------------------------------------------ check
|
|
483
|
+
/** Lines for the "Check Summary" box. */
|
|
484
|
+
export function checkSummaryLines(o, c, auth) {
|
|
485
|
+
const rows = [
|
|
486
|
+
['devices', formatList(o.devices.map(d => c.cyan(d)))],
|
|
487
|
+
['fail on', o.failOn === 'warn' ? c.yellow('errors and warnings') : 'errors'],
|
|
488
|
+
];
|
|
489
|
+
if (o.colorScheme)
|
|
490
|
+
rows.push(['colour scheme', o.colorScheme]);
|
|
491
|
+
if (o.waitFor)
|
|
492
|
+
rows.push(['wait for', o.waitFor]);
|
|
493
|
+
if (auth)
|
|
494
|
+
rows.push(...authRows(auth, c));
|
|
495
|
+
if (o.shots)
|
|
496
|
+
rows.push(['shots', `${rel(o.outDir)}/`]);
|
|
497
|
+
const w = Math.max(...rows.map(([k]) => k.length)) + 1;
|
|
498
|
+
return [c.cyan(o.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)];
|
|
499
|
+
}
|
|
500
|
+
/** The spinner line once a device is done: status, CLS, time. */
|
|
501
|
+
function checkDeviceLine(d, ms, label, c) {
|
|
502
|
+
if (d.error)
|
|
503
|
+
return `${label} ${c.red(d.code ?? 'error')} ${c.dim(formatMs(ms))}`;
|
|
504
|
+
const status = d.status == null ? c.dim('--') : d.status >= 400 ? c.red(String(d.status)) : c.green(String(d.status));
|
|
505
|
+
const cls = d.cls === undefined ? '' : d.cls > 0.25 ? c.red(d.cls.toFixed(3)) : d.cls > 0.1 ? c.yellow(d.cls.toFixed(3)) : c.green(d.cls.toFixed(3));
|
|
506
|
+
return `${label} ${status} ${c.dim('CLS')} ${cls} ${c.dim(formatMs(ms))}`;
|
|
507
|
+
}
|
|
508
|
+
/** One box per check that found something, titled with its counts. */
|
|
509
|
+
export function checkBoxes(report, c) {
|
|
510
|
+
const all = report.devices.map(d => d.device);
|
|
511
|
+
return groupFindings(report).map(g => {
|
|
512
|
+
const counts = countsPhrase(g.findings);
|
|
513
|
+
const tint = g.findings.some(f => f.severity === 'error') ? c.red : g.findings.some(f => f.severity === 'warn') ? c.yellow : c.cyan;
|
|
514
|
+
return { title: `${g.title} ${c.dim('·')} ${tint(counts)}`, lines: groupLines(g, all, c, { icons: true, width: textWidth() }) };
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
export async function runCheck(options, c) {
|
|
518
|
+
const auth = resolveAuth(options.url, options.auth ?? {});
|
|
519
|
+
intro(c);
|
|
520
|
+
p.note(checkSummaryLines(options, c, auth.summary).join('\n'), 'Check Summary');
|
|
521
|
+
const s = p.spinner();
|
|
522
|
+
s.start('Starting Chromium');
|
|
523
|
+
let browser;
|
|
524
|
+
try {
|
|
525
|
+
browser = await launchBrowser();
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (err instanceof BrowserMissingError) {
|
|
529
|
+
s.error(c.red("Playwright's Chromium is not installed"));
|
|
530
|
+
p.note(wrap(installHint().replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
|
|
531
|
+
p.outro(`${c.red('Nothing checked.')} ${c.dim('Run: rpc install')}`);
|
|
532
|
+
return 1;
|
|
533
|
+
}
|
|
534
|
+
s.error(c.red(`Chromium did not start: ${err.message.split('\n')[0]}`));
|
|
535
|
+
p.outro(c.red('Nothing checked.'));
|
|
536
|
+
return 1;
|
|
537
|
+
}
|
|
538
|
+
s.stop(`Chromium ${c.dim(browser.version())}`);
|
|
539
|
+
let current;
|
|
540
|
+
let started = 0;
|
|
541
|
+
let report;
|
|
542
|
+
const picked = options.devices.map(id => DEVICES.find(d => d.id === id)).filter((d) => Boolean(d));
|
|
543
|
+
const idW = Math.max(0, ...picked.map(d => d.id.length));
|
|
544
|
+
const vpW = Math.max(0, ...picked.map(d => viewport(d).length));
|
|
545
|
+
const label = (id, vp) => `${id.padEnd(idW)} ${c.dim(vp.padEnd(vpW))}`;
|
|
546
|
+
try {
|
|
547
|
+
report = await audit(options, {
|
|
548
|
+
browser,
|
|
549
|
+
auth,
|
|
550
|
+
onDeviceStart: (d) => {
|
|
551
|
+
started = performance.now();
|
|
552
|
+
current = p.spinner();
|
|
553
|
+
current.start(label(d.id, viewport(d)));
|
|
554
|
+
},
|
|
555
|
+
onDevice: d => {
|
|
556
|
+
const line = checkDeviceLine(d, performance.now() - started, label(d.device, viewport({ ...d.viewport, dpr: d.dpr })), c);
|
|
557
|
+
if (d.error)
|
|
558
|
+
current?.error(line);
|
|
559
|
+
else
|
|
560
|
+
current?.stop(line);
|
|
561
|
+
current = undefined;
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
current?.error(c.red(err.message));
|
|
567
|
+
p.outro(c.red('Check failed.'));
|
|
568
|
+
return 1;
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
await browser.close().catch(() => { });
|
|
572
|
+
}
|
|
573
|
+
for (const box of checkBoxes(report, c))
|
|
574
|
+
p.note(box.lines.join('\n'), box.title);
|
|
575
|
+
const code = checkExitCode(report, options.failOn);
|
|
576
|
+
const line = summaryLine(report.summary);
|
|
577
|
+
const sum = report.summary;
|
|
578
|
+
if (sum.errors)
|
|
579
|
+
p.outro(`${c.red(line)} ${c.dim('Exit code 1.')}`);
|
|
580
|
+
else if (sum.warnings)
|
|
581
|
+
p.outro(`${c.yellow(line)} ${c.dim(code ? 'Exit code 1 (--fail-on warn).' : 'No errors.')}`);
|
|
582
|
+
else
|
|
583
|
+
p.outro(`${c.green(line)} ${c.dim('Ready to ship.')}`);
|
|
584
|
+
return code;
|
|
585
|
+
}
|
|
392
586
|
// ------------------------------------------------------------------ interactive
|
|
393
587
|
const PRESELECT = ['iphone-15-pro', 'ipad-air-11', 'desktop-1440'];
|
|
394
588
|
const DEV_PORTS = [3000, 5173, 4321, 8080];
|
|
@@ -586,6 +780,11 @@ export async function runDoctor(check, c) {
|
|
|
586
780
|
s.stop(`Node ${c.dim(r.node)}`);
|
|
587
781
|
p.log.step(`Playwright ${c.dim(r.playwright)}`);
|
|
588
782
|
p.log.step(`Browsers ${c.dim(r.browsersPath)}`);
|
|
783
|
+
if (r.ffmpeg.source === 'missing')
|
|
784
|
+
p.log.warn(`ffmpeg ${c.yellow('missing')} ${c.dim('(only rpc record needs it; rpc install fetches it)')}`);
|
|
785
|
+
else
|
|
786
|
+
p.log.step(`ffmpeg ${c.dim(`${r.ffmpeg.source} · ${r.ffmpeg.path}`)}`);
|
|
787
|
+
p.log.step(`mp4 ${r.mp4.ok ? c.dim(r.mp4.encoder ?? '') : c.dim('no (optional: ffmpeg with H.264 on PATH)')}`);
|
|
589
788
|
if (r.ok) {
|
|
590
789
|
p.log.success(`Chromium ${c.green('launches')} ${c.dim(r.version ?? '')}`);
|
|
591
790
|
p.outro(`${c.green('Ready.')} ${c.dim('Try: rpc shot localhost:3000 --bezels')}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rpc-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Screenshot a URL at device viewports, optionally framed in device bezels, signed in or not. CLI and MCP server for AI coding agents.",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Screenshot a URL at device viewports, optionally framed in device bezels, signed in or not, and audit it before launch. CLI and MCP server for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
7
7
|
"mcp-server",
|