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/cli.js
CHANGED
|
@@ -5,14 +5,21 @@ import { ArgError, parseCli, USAGE } from './args.js';
|
|
|
5
5
|
import { clearSessions, describeAuth, listSessions, localTime, sessionsDir } from './auth.js';
|
|
6
6
|
import { doctor, installChromium } from './browser.js';
|
|
7
7
|
import { capture } from './capture.js';
|
|
8
|
+
import { checkExitCode, runCheck, summaryLine } from './check.js';
|
|
9
|
+
import { deviceLine, plainReport } from './check-format.js';
|
|
8
10
|
import { CATEGORY_LABELS, CATEGORY_ORDER, DEVICE_ALIASES, DEVICES } from './devices.js';
|
|
11
|
+
import { unpinnedLines, warningWhere } from './motion.js';
|
|
12
|
+
import { record } from './record.js';
|
|
9
13
|
import { currentMode, openPath, waitForEnter } from './term.js';
|
|
10
14
|
const NO_PAINT = { ok: s => s, bad: s => s, warn: s => s };
|
|
11
15
|
let paint = NO_PAINT;
|
|
16
|
+
/** The full palette for check output; identity functions unless FORCE_COLOR. */
|
|
17
|
+
let colours;
|
|
12
18
|
async function plainPaint() {
|
|
19
|
+
const { default: pc } = await import('picocolors');
|
|
20
|
+
colours = pc.createColors(currentMode().color);
|
|
13
21
|
if (!currentMode().color)
|
|
14
22
|
return NO_PAINT;
|
|
15
|
-
const { default: pc } = await import('picocolors');
|
|
16
23
|
const c = pc.createColors(true);
|
|
17
24
|
return { ok: c.green, bad: c.red, warn: c.yellow };
|
|
18
25
|
}
|
|
@@ -102,6 +109,42 @@ async function main(argv) {
|
|
|
102
109
|
openPath(result.outDir);
|
|
103
110
|
return result.shots.some(s => s.error) ? 1 : 0;
|
|
104
111
|
}
|
|
112
|
+
case 'check': {
|
|
113
|
+
if (mode.rich) {
|
|
114
|
+
const { ui, c } = await loadUi();
|
|
115
|
+
return ui.runCheck(cmd.options, c);
|
|
116
|
+
}
|
|
117
|
+
const report = await runCheck(cmd.options, cmd.json
|
|
118
|
+
? {}
|
|
119
|
+
: {
|
|
120
|
+
onAuth: a => console.log(`auth: ${describeAuth(a)}`),
|
|
121
|
+
onDevice: d => console.log(deviceLine(d, colours)),
|
|
122
|
+
});
|
|
123
|
+
if (cmd.json)
|
|
124
|
+
console.log(JSON.stringify(report, null, 2));
|
|
125
|
+
else
|
|
126
|
+
printCheck(report);
|
|
127
|
+
return checkExitCode(report, cmd.options.failOn);
|
|
128
|
+
}
|
|
129
|
+
case 'record': {
|
|
130
|
+
if (mode.rich) {
|
|
131
|
+
const { ui, c } = await loadUi();
|
|
132
|
+
return ui.runRecord(cmd.options, { open: cmd.open }, c);
|
|
133
|
+
}
|
|
134
|
+
const result = await record(cmd.options, cmd.json
|
|
135
|
+
? {}
|
|
136
|
+
: {
|
|
137
|
+
onAuth: a => console.log(`auth: ${describeAuth(a)}`),
|
|
138
|
+
onRecording: s => console.log(`recording ${s}s of ${cmd.options.selector ?? 'the viewport'} on ${cmd.options.device}`),
|
|
139
|
+
});
|
|
140
|
+
if (cmd.json)
|
|
141
|
+
console.log(JSON.stringify(result, null, 2));
|
|
142
|
+
else
|
|
143
|
+
printRecord(result);
|
|
144
|
+
if (cmd.open && !result.error)
|
|
145
|
+
openPath(result.outDir);
|
|
146
|
+
return result.error ? 1 : 0;
|
|
147
|
+
}
|
|
105
148
|
case 'login': {
|
|
106
149
|
if (cmd.passwordFromFlag) {
|
|
107
150
|
console.error(`rpc: ${paint.warn('warning:')} --password is visible in shell history and the process list; set RPC_LOGIN_PASSWORD instead`);
|
|
@@ -187,12 +230,26 @@ function rel(p) {
|
|
|
187
230
|
const r = relative(process.cwd(), p);
|
|
188
231
|
return r.startsWith('..') ? p : r;
|
|
189
232
|
}
|
|
233
|
+
function printCheck(report) {
|
|
234
|
+
const body = plainReport(report, colours);
|
|
235
|
+
if (body)
|
|
236
|
+
console.log(body);
|
|
237
|
+
console.log('');
|
|
238
|
+
const s = report.summary;
|
|
239
|
+
const line = summaryLine(s);
|
|
240
|
+
console.log(s.errors ? paint.bad(line) : s.warnings ? paint.warn(line) : paint.ok(line));
|
|
241
|
+
}
|
|
190
242
|
function printDoctor(r) {
|
|
191
243
|
console.log(`node ${r.node}`);
|
|
192
244
|
console.log(`playwright ${r.playwright}`);
|
|
193
245
|
console.log(`browsers ${r.browsersPath}`);
|
|
246
|
+
const ff = r.ffmpeg;
|
|
247
|
+
console.log(`ffmpeg ${ff.source === 'missing' ? `${paint.warn('missing')} (only rpc record needs it)` : `${paint.ok('ok')} (${ff.source}, ${ff.path})`}`);
|
|
248
|
+
console.log(`mp4 ${r.mp4.ok ? `${paint.ok('ok')} (${r.mp4.encoder})` : 'no (optional: ffmpeg with H.264 on PATH, for rpc record --mp4)'}`);
|
|
194
249
|
if (r.ok) {
|
|
195
250
|
console.log(`chromium ${paint.ok('ok')} (${r.version})`);
|
|
251
|
+
if (ff.fix)
|
|
252
|
+
console.error(`rpc: ${ff.fix}`);
|
|
196
253
|
return;
|
|
197
254
|
}
|
|
198
255
|
console.log(`chromium ${paint.bad(r.chromium)}`);
|
|
@@ -224,6 +281,36 @@ function printShot(s) {
|
|
|
224
281
|
}
|
|
225
282
|
if (s.consoleErrors?.length)
|
|
226
283
|
lines.push(` ${s.consoleErrors.length} console error(s), first: ${s.consoleErrors[0]}`);
|
|
284
|
+
for (const u of unpinnedLines(s.motion?.unpinned))
|
|
285
|
+
lines.push(` unpinned ${u}`);
|
|
286
|
+
for (const w of s.warnings ?? []) {
|
|
287
|
+
const where = warningWhere(w);
|
|
288
|
+
lines.push(` ${paint.warn('warn:')} ${w.message}${where ? `: ${where}` : ''}`);
|
|
289
|
+
}
|
|
290
|
+
console.log(lines.join('\n'));
|
|
291
|
+
}
|
|
292
|
+
function printRecord(r) {
|
|
293
|
+
const label = `${r.device} (${r.viewport.width}x${r.viewport.height} @${r.dpr}x)`;
|
|
294
|
+
if (r.error) {
|
|
295
|
+
console.error(`${paint.bad('x')} ${label}: ${r.error}`);
|
|
296
|
+
if (r.hint)
|
|
297
|
+
console.error(` ${paint.warn('hint:')} ${r.hint}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const lines = [`${paint.ok('ok')} ${label}${r.selector ? ` ${r.selector}` : ''}${r.status && r.status >= 400 ? ` HTTP ${r.status}` : ''}`];
|
|
301
|
+
const v = r.video;
|
|
302
|
+
if (v)
|
|
303
|
+
lines.push(` ${rel(v.path)} ${v.width}x${v.height}, ${v.duration}s at ${v.fps} fps (${v.captured} frames captured, ${r.ffmpeg} ffmpeg)`);
|
|
304
|
+
if (r.poster)
|
|
305
|
+
lines.push(` ${rel(r.poster.path)} ${r.poster.width}x${r.poster.height} poster`);
|
|
306
|
+
if (r.gif)
|
|
307
|
+
lines.push(` ${rel(r.gif.path)} ${r.gif.width}x${r.gif.height}, ${r.gif.frames} frames`);
|
|
308
|
+
if (r.mp4)
|
|
309
|
+
lines.push(` ${rel(r.mp4.path)} ${r.mp4.encoder}`);
|
|
310
|
+
for (const w of r.warnings ?? [])
|
|
311
|
+
lines.push(` ${paint.warn('warn:')} ${w}`);
|
|
312
|
+
if (r.consoleErrors?.length)
|
|
313
|
+
lines.push(` ${r.consoleErrors.length} console error(s), first: ${r.consoleErrors[0]}`);
|
|
227
314
|
console.log(lines.join('\n'));
|
|
228
315
|
}
|
|
229
316
|
export function formatDeviceTable() {
|
package/dist/errors.js
CHANGED
|
@@ -113,3 +113,11 @@ export function explainError(err, ctx) {
|
|
|
113
113
|
}
|
|
114
114
|
return { code: 'UNKNOWN', error: first };
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Page-supplied text (console errors, uncaught exceptions) with control
|
|
118
|
+
* characters removed, so a page can't send terminal escape sequences through
|
|
119
|
+
* rpc's output. Newlines and tabs stay.
|
|
120
|
+
*/
|
|
121
|
+
export function plainText(text) {
|
|
122
|
+
return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
123
|
+
}
|
package/dist/gif.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** Palette entries for colours; the last index is transparent. */
|
|
2
|
+
const COLOURS = 255;
|
|
3
|
+
const TRANSPARENT = 255;
|
|
4
|
+
/** Pixels sampled for the palette, across all frames. */
|
|
5
|
+
const SAMPLES = 250_000;
|
|
6
|
+
const bucket = (r, g, b) => ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
|
|
7
|
+
/** Median cut over a 15-bit colour histogram: up to COLOURS RGB triples. */
|
|
8
|
+
export function buildPalette(frames) {
|
|
9
|
+
const counts = new Uint32Array(32768);
|
|
10
|
+
const sums = new Float64Array(32768 * 3);
|
|
11
|
+
const total = frames.reduce((n, f) => n + f.width * f.height, 0);
|
|
12
|
+
const step = Math.max(1, Math.floor(total / SAMPLES));
|
|
13
|
+
for (const f of frames) {
|
|
14
|
+
for (let i = 0; i < f.width * f.height; i += step) {
|
|
15
|
+
const o = i * f.channels;
|
|
16
|
+
const r = f.data[o], g = f.data[o + 1], b = f.data[o + 2];
|
|
17
|
+
const k = bucket(r, g, b);
|
|
18
|
+
counts[k]++;
|
|
19
|
+
sums[k * 3] = sums[k * 3] + r;
|
|
20
|
+
sums[k * 3 + 1] = sums[k * 3 + 1] + g;
|
|
21
|
+
sums[k * 3 + 2] = sums[k * 3 + 2] + b;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const used = [];
|
|
25
|
+
for (let k = 0; k < 32768; k++)
|
|
26
|
+
if (counts[k])
|
|
27
|
+
used.push(k);
|
|
28
|
+
const chan = (k, c) => (k >> (10 - 5 * c)) & 31;
|
|
29
|
+
const measure = (keys) => {
|
|
30
|
+
let best = 0;
|
|
31
|
+
let channel = 0;
|
|
32
|
+
for (let c = 0; c < 3; c++) {
|
|
33
|
+
let lo = 31, hi = 0;
|
|
34
|
+
for (const k of keys) {
|
|
35
|
+
const v = chan(k, c);
|
|
36
|
+
if (v < lo)
|
|
37
|
+
lo = v;
|
|
38
|
+
if (v > hi)
|
|
39
|
+
hi = v;
|
|
40
|
+
}
|
|
41
|
+
if (hi - lo > best) {
|
|
42
|
+
best = hi - lo;
|
|
43
|
+
channel = c;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { keys, range: best, channel };
|
|
47
|
+
};
|
|
48
|
+
const boxes = [measure(used)];
|
|
49
|
+
while (boxes.length < COLOURS) {
|
|
50
|
+
let pick = -1;
|
|
51
|
+
for (let i = 0; i < boxes.length; i++)
|
|
52
|
+
if (boxes[i].range > 0 && (pick < 0 || boxes[i].range > boxes[pick].range))
|
|
53
|
+
pick = i;
|
|
54
|
+
if (pick < 0)
|
|
55
|
+
break;
|
|
56
|
+
const box = boxes[pick];
|
|
57
|
+
box.keys.sort((a, b) => chan(a, box.channel) - chan(b, box.channel));
|
|
58
|
+
const half = box.keys.reduce((n, k) => n + counts[k], 0) / 2;
|
|
59
|
+
let acc = 0;
|
|
60
|
+
let cut = 1;
|
|
61
|
+
for (; cut < box.keys.length - 1; cut++) {
|
|
62
|
+
acc += counts[box.keys[cut - 1]];
|
|
63
|
+
if (acc >= half)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
boxes.splice(pick, 1, measure(box.keys.slice(0, cut)), measure(box.keys.slice(cut)));
|
|
67
|
+
}
|
|
68
|
+
const palette = new Uint8Array(256 * 3);
|
|
69
|
+
boxes.forEach((box, i) => {
|
|
70
|
+
let n = 0, r = 0, g = 0, b = 0;
|
|
71
|
+
for (const k of box.keys) {
|
|
72
|
+
n += counts[k];
|
|
73
|
+
r += sums[k * 3];
|
|
74
|
+
g += sums[k * 3 + 1];
|
|
75
|
+
b += sums[k * 3 + 2];
|
|
76
|
+
}
|
|
77
|
+
palette[i * 3] = Math.round(r / n);
|
|
78
|
+
palette[i * 3 + 1] = Math.round(g / n);
|
|
79
|
+
palette[i * 3 + 2] = Math.round(b / n);
|
|
80
|
+
});
|
|
81
|
+
return palette.subarray(0, Math.max(1, boxes.length) * 3);
|
|
82
|
+
}
|
|
83
|
+
/** Nearest palette entry per 15-bit bucket, filled on first use. */
|
|
84
|
+
function mapper(palette) {
|
|
85
|
+
const cache = new Int16Array(32768).fill(-1);
|
|
86
|
+
const n = palette.length / 3;
|
|
87
|
+
return (r, g, b) => {
|
|
88
|
+
const k = bucket(r, g, b);
|
|
89
|
+
let hit = cache[k];
|
|
90
|
+
if (hit >= 0)
|
|
91
|
+
return hit;
|
|
92
|
+
const cr = ((k >> 10) << 3) | 4, cg = (((k >> 5) & 31) << 3) | 4, cb = ((k & 31) << 3) | 4;
|
|
93
|
+
let best = Infinity;
|
|
94
|
+
for (let i = 0; i < n; i++) {
|
|
95
|
+
const dr = palette[i * 3] - cr, dg = palette[i * 3 + 1] - cg, db = palette[i * 3 + 2] - cb;
|
|
96
|
+
const d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
|
|
97
|
+
if (d < best) {
|
|
98
|
+
best = d;
|
|
99
|
+
hit = i;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
cache[k] = hit;
|
|
103
|
+
return hit;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** GIF LZW with 8-bit minimum code size, split into data sub-blocks. */
|
|
107
|
+
export function lzw(indices) {
|
|
108
|
+
const min = 8;
|
|
109
|
+
const clear = 1 << min;
|
|
110
|
+
const eoi = clear + 1;
|
|
111
|
+
const out = [];
|
|
112
|
+
let acc = 0;
|
|
113
|
+
let bits = 0;
|
|
114
|
+
let size = min + 1;
|
|
115
|
+
const emit = (code) => {
|
|
116
|
+
acc |= code << bits;
|
|
117
|
+
bits += size;
|
|
118
|
+
while (bits >= 8) {
|
|
119
|
+
out.push(acc & 0xff);
|
|
120
|
+
acc >>>= 8;
|
|
121
|
+
bits -= 8;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
let table = new Map();
|
|
125
|
+
let next = eoi + 1;
|
|
126
|
+
emit(clear);
|
|
127
|
+
let prefix = indices[0] ?? 0;
|
|
128
|
+
for (let i = 1; i < indices.length; i++) {
|
|
129
|
+
const k = indices[i];
|
|
130
|
+
const key = prefix * 256 + k;
|
|
131
|
+
const code = table.get(key);
|
|
132
|
+
if (code !== undefined) {
|
|
133
|
+
prefix = code;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
emit(prefix);
|
|
137
|
+
if (next === 4096) {
|
|
138
|
+
emit(clear);
|
|
139
|
+
table = new Map();
|
|
140
|
+
next = eoi + 1;
|
|
141
|
+
size = min + 1;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
if (next >= 1 << size)
|
|
145
|
+
size++;
|
|
146
|
+
table.set(key, next++);
|
|
147
|
+
}
|
|
148
|
+
prefix = k;
|
|
149
|
+
}
|
|
150
|
+
emit(prefix);
|
|
151
|
+
emit(eoi);
|
|
152
|
+
if (bits > 0)
|
|
153
|
+
out.push(acc & 0xff);
|
|
154
|
+
const blocks = [min];
|
|
155
|
+
for (let i = 0; i < out.length; i += 255) {
|
|
156
|
+
const chunk = out.slice(i, i + 255);
|
|
157
|
+
blocks.push(chunk.length, ...chunk);
|
|
158
|
+
}
|
|
159
|
+
blocks.push(0);
|
|
160
|
+
return Buffer.from(blocks);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A looping GIF built one frame at a time, `delayCs` hundredths of a second
|
|
164
|
+
* each, with a palette made beforehand (buildPalette on all or some frames).
|
|
165
|
+
* Only the previous frame's indices are kept, not the frames themselves.
|
|
166
|
+
*/
|
|
167
|
+
export function gifWriter(width, height, palette, delayCs) {
|
|
168
|
+
const nearest = mapper(palette);
|
|
169
|
+
const table = Buffer.alloc(256 * 3);
|
|
170
|
+
table.set(palette);
|
|
171
|
+
const u16 = (n) => [n & 0xff, (n >> 8) & 0xff];
|
|
172
|
+
const parts = [
|
|
173
|
+
Buffer.from('GIF89a', 'latin1'),
|
|
174
|
+
Buffer.from([...u16(width), ...u16(height), 0xf7, 0, 0]), // global table, 256 entries
|
|
175
|
+
table,
|
|
176
|
+
Buffer.from([0x21, 0xff, 11, ...Buffer.from('NETSCAPE2.0', 'latin1'), 3, 1, 0, 0, 0]), // loop forever
|
|
177
|
+
];
|
|
178
|
+
let shown;
|
|
179
|
+
return {
|
|
180
|
+
add(f) {
|
|
181
|
+
if (f.width !== width || f.height !== height)
|
|
182
|
+
throw new Error('GIF frames must all be the same size');
|
|
183
|
+
const idx = new Uint8Array(width * height);
|
|
184
|
+
const now = new Uint8Array(width * height);
|
|
185
|
+
for (let i = 0; i < width * height; i++) {
|
|
186
|
+
const o = i * f.channels;
|
|
187
|
+
now[i] = nearest(f.data[o], f.data[o + 1], f.data[o + 2]);
|
|
188
|
+
idx[i] = shown && shown[i] === now[i] ? TRANSPARENT : now[i];
|
|
189
|
+
}
|
|
190
|
+
// Graphic control: keep the previous frame under this one, transparent index after the first frame.
|
|
191
|
+
parts.push(Buffer.from([0x21, 0xf9, 4, shown ? 0x05 : 0x04, ...u16(delayCs), TRANSPARENT, 0]));
|
|
192
|
+
parts.push(Buffer.from([0x2c, 0, 0, 0, 0, ...u16(width), ...u16(height), 0]));
|
|
193
|
+
parts.push(lzw(idx));
|
|
194
|
+
shown = now;
|
|
195
|
+
},
|
|
196
|
+
end() {
|
|
197
|
+
if (!shown)
|
|
198
|
+
throw new Error('No frames to encode');
|
|
199
|
+
return Buffer.concat([...parts, Buffer.from([0x3b])]);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Encode same-sized frames as a looping GIF, `delayCs` hundredths of a second each. */
|
|
204
|
+
export function encodeGif(frames, delayCs) {
|
|
205
|
+
if (!frames.length)
|
|
206
|
+
throw new Error('No frames to encode');
|
|
207
|
+
const { width, height } = frames[0];
|
|
208
|
+
const gif = gifWriter(width, height, buildPalette(frames), delayCs);
|
|
209
|
+
for (const f of frames)
|
|
210
|
+
gif.add(f);
|
|
211
|
+
return gif.end();
|
|
212
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -6,6 +6,8 @@ const SHOT_OPTIONS = [
|
|
|
6
6
|
[' --slice-width <px>', 'max slice width (default: 768)'],
|
|
7
7
|
[' --max-height <px>', 'full-page cap in CSS px (default: 60000)'],
|
|
8
8
|
[' --reduced-motion', 'emulate prefers-reduced-motion: reduce'],
|
|
9
|
+
[' --finish-animations', 'jump GSAP tweens on window (and Web Animations) to their end first'],
|
|
10
|
+
[' --no-unpin', 'with --full-page: keep pinned sections (default: collapse each once)'],
|
|
9
11
|
['-o, --out <dir>', 'output directory (default: ./.rpc-shots)'],
|
|
10
12
|
[' --wait-for <selector>', 'wait for a CSS selector before capturing'],
|
|
11
13
|
[' --wait <ms>', 'extra settle delay after load (default: 0)'],
|
|
@@ -22,6 +24,33 @@ const AUTH_OPTIONS = [
|
|
|
22
24
|
[' --no-session', "don't use the session saved by `rpc login` for this origin"],
|
|
23
25
|
[' --allow-origin <o>', 'also send headers and loaded cookies to this origin, repeatable'],
|
|
24
26
|
];
|
|
27
|
+
const CHECK_OPTIONS = [
|
|
28
|
+
['-d, --devices <ids>', 'comma-separated device ids or aliases (default: iphone-15-pro,desktop-1440)'],
|
|
29
|
+
[' --fail-on <level>', 'error (default) or warn: also exit 1 on warnings'],
|
|
30
|
+
[' --shots', 'screenshot the elements behind layout-shift and overflow findings'],
|
|
31
|
+
['-o, --out <dir>', 'where --shots writes (default: ./.rpc-shots)'],
|
|
32
|
+
[' --wait-for <selector>', 'wait for a CSS selector before checking'],
|
|
33
|
+
[' --wait <ms>', 'extra settle delay after the scroll (default: 0)'],
|
|
34
|
+
[' --color-scheme <s>', 'light | dark | no-preference'],
|
|
35
|
+
[' --timeout <ms>', 'navigation timeout (default: 30000)'],
|
|
36
|
+
[' --json', 'print the report as JSON (schema in the README)'],
|
|
37
|
+
];
|
|
38
|
+
const RECORD_OPTIONS = [
|
|
39
|
+
['-d, --device <id>', 'one device id or alias (default: desktop-1440)'],
|
|
40
|
+
[' --selector <css>', 'crop to this element; it is scrolled into view first'],
|
|
41
|
+
[' --duration <s>', 'seconds of video, 0.5 to 30 (default: 5)'],
|
|
42
|
+
[' --gif', 'also write a GIF (10 fps, at most 640px wide)'],
|
|
43
|
+
[' --mp4', 'also write an mp4; needs ffmpeg with H.264 on PATH, skipped otherwise'],
|
|
44
|
+
['-o, --out <dir>', 'output directory (default: ./.rpc-shots)'],
|
|
45
|
+
[' --wait-for <selector>', 'wait for a CSS selector before recording'],
|
|
46
|
+
[' --wait <ms>', 'extra delay before recording (default: 0)'],
|
|
47
|
+
[' --color-scheme <s>', 'light | dark | no-preference'],
|
|
48
|
+
[' --reduced-motion', 'emulate prefers-reduced-motion: reduce'],
|
|
49
|
+
[' --timeout <ms>', 'navigation timeout (default: 30000)'],
|
|
50
|
+
[' --no-emulate', 'viewport + DPR only (no mobile UA / touch)'],
|
|
51
|
+
[' --open', 'open the output folder when done'],
|
|
52
|
+
[' --json', 'print the result as JSON'],
|
|
53
|
+
];
|
|
25
54
|
const LOGIN_OPTIONS = [
|
|
26
55
|
[' --save <file>', 'write the storage state here instead of the per-origin session'],
|
|
27
56
|
[' --form', 'headless: fill an email + password form (CI, agents)'],
|
|
@@ -47,6 +76,29 @@ export const COMMANDS = {
|
|
|
47
76
|
["rpc shot api.example.com/admin --header 'Authorization: Bearer $TOKEN'", ''],
|
|
48
77
|
],
|
|
49
78
|
},
|
|
79
|
+
check: {
|
|
80
|
+
usage: 'rpc check <url> [options]',
|
|
81
|
+
summary: 'Audit one page before launch: meta and social tags, layout shift (CLS), unlabelled controls, web fonts, ' +
|
|
82
|
+
'mobile viewport bugs and basics (status, mixed content, console errors). Exits 1 when there are errors.',
|
|
83
|
+
options: [...CHECK_OPTIONS, ...AUTH_OPTIONS],
|
|
84
|
+
examples: [
|
|
85
|
+
['rpc check localhost:3000', 'phone + desktop'],
|
|
86
|
+
['rpc check localhost:3000 --fail-on warn --json', 'CI: fail on warnings too'],
|
|
87
|
+
['rpc check example.com -d pixel,iphone-se,desktop --shots', 'more devices, element screenshots'],
|
|
88
|
+
['rpc check localhost:3000/billing', 'uses the session saved by rpc login'],
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
record: {
|
|
92
|
+
usage: 'rpc record <url> [options]',
|
|
93
|
+
summary: 'Record a short video of a page, or of one element, for what a still cannot show: carousels, marquees, ' +
|
|
94
|
+
'pinned scroll sections, hover loops. Writes a VP9 webm and a poster PNG, plus a GIF or mp4 on request.',
|
|
95
|
+
options: [...RECORD_OPTIONS, ...AUTH_OPTIONS],
|
|
96
|
+
examples: [
|
|
97
|
+
["rpc record localhost:3000 --selector '.hero-carousel'", '5 s of one element'],
|
|
98
|
+
['rpc record localhost:3000 -d iphone --duration 10 --gif', 'whole phone viewport, plus a GIF'],
|
|
99
|
+
["rpc record example.com --selector '#logos' --mp4", 'mp4 too, when ffmpeg is on PATH'],
|
|
100
|
+
],
|
|
101
|
+
},
|
|
50
102
|
login: {
|
|
51
103
|
usage: 'rpc login <url> [--form] [--save <file>]',
|
|
52
104
|
summary: 'Open a browser window at the URL, sign in, then press Enter (or close the window). The session is saved for that ' +
|
|
@@ -79,7 +131,7 @@ export const COMMANDS = {
|
|
|
79
131
|
},
|
|
80
132
|
doctor: {
|
|
81
133
|
usage: 'rpc doctor [--json]',
|
|
82
|
-
summary: "Check that Playwright's Chromium is installed and launches.",
|
|
134
|
+
summary: "Check that Playwright's Chromium is installed and launches, and which ffmpeg `rpc record` would use.",
|
|
83
135
|
options: [[' --json', 'print the report as JSON']],
|
|
84
136
|
examples: [['rpc doctor', 'exits 1 when Chromium is missing']],
|
|
85
137
|
},
|
|
@@ -94,12 +146,14 @@ export const COMMANDS = {
|
|
|
94
146
|
},
|
|
95
147
|
mcp: {
|
|
96
148
|
usage: 'rpc mcp',
|
|
97
|
-
summary: 'Run the MCP server on stdio (tools: screenshot, list_devices, list_sessions).',
|
|
149
|
+
summary: 'Run the MCP server on stdio (tools: screenshot, check, record, list_devices, list_sessions).',
|
|
98
150
|
examples: [['claude mcp add --scope user rpc -- node /abs/path/dist/cli.js mcp', 'add to Claude Code']],
|
|
99
151
|
},
|
|
100
152
|
};
|
|
101
153
|
const TOP = [
|
|
102
154
|
['shot <url>', 'capture screenshots'],
|
|
155
|
+
['check <url>', 'pre-launch audit: meta, layout shift, labels, fonts, mobile, motion'],
|
|
156
|
+
['record <url>', 'record a short video of the page or one element'],
|
|
103
157
|
['login <url>', 'sign in once; later shots of that origin use the session'],
|
|
104
158
|
['sessions', 'list or clear saved sessions'],
|
|
105
159
|
['devices', 'list device ids and aliases'],
|