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/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'],