rpc-agent 0.4.0 → 0.5.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 +40 -0
- package/README.md +255 -1
- package/dist/args.js +101 -31
- package/dist/capture.js +20 -17
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +336 -0
- package/dist/check.js +677 -0
- package/dist/cli.js +32 -1
- package/dist/help.js +25 -1
- package/dist/mcp.js +92 -14
- package/dist/options.js +14 -0
- package/dist/png.js +47 -0
- package/dist/ui.js +106 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -5,14 +5,19 @@ 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';
|
|
9
11
|
import { currentMode, openPath, waitForEnter } from './term.js';
|
|
10
12
|
const NO_PAINT = { ok: s => s, bad: s => s, warn: s => s };
|
|
11
13
|
let paint = NO_PAINT;
|
|
14
|
+
/** The full palette for check output; identity functions unless FORCE_COLOR. */
|
|
15
|
+
let colours;
|
|
12
16
|
async function plainPaint() {
|
|
17
|
+
const { default: pc } = await import('picocolors');
|
|
18
|
+
colours = pc.createColors(currentMode().color);
|
|
13
19
|
if (!currentMode().color)
|
|
14
20
|
return NO_PAINT;
|
|
15
|
-
const { default: pc } = await import('picocolors');
|
|
16
21
|
const c = pc.createColors(true);
|
|
17
22
|
return { ok: c.green, bad: c.red, warn: c.yellow };
|
|
18
23
|
}
|
|
@@ -102,6 +107,23 @@ async function main(argv) {
|
|
|
102
107
|
openPath(result.outDir);
|
|
103
108
|
return result.shots.some(s => s.error) ? 1 : 0;
|
|
104
109
|
}
|
|
110
|
+
case 'check': {
|
|
111
|
+
if (mode.rich) {
|
|
112
|
+
const { ui, c } = await loadUi();
|
|
113
|
+
return ui.runCheck(cmd.options, c);
|
|
114
|
+
}
|
|
115
|
+
const report = await runCheck(cmd.options, cmd.json
|
|
116
|
+
? {}
|
|
117
|
+
: {
|
|
118
|
+
onAuth: a => console.log(`auth: ${describeAuth(a)}`),
|
|
119
|
+
onDevice: d => console.log(deviceLine(d, colours)),
|
|
120
|
+
});
|
|
121
|
+
if (cmd.json)
|
|
122
|
+
console.log(JSON.stringify(report, null, 2));
|
|
123
|
+
else
|
|
124
|
+
printCheck(report);
|
|
125
|
+
return checkExitCode(report, cmd.options.failOn);
|
|
126
|
+
}
|
|
105
127
|
case 'login': {
|
|
106
128
|
if (cmd.passwordFromFlag) {
|
|
107
129
|
console.error(`rpc: ${paint.warn('warning:')} --password is visible in shell history and the process list; set RPC_LOGIN_PASSWORD instead`);
|
|
@@ -187,6 +209,15 @@ function rel(p) {
|
|
|
187
209
|
const r = relative(process.cwd(), p);
|
|
188
210
|
return r.startsWith('..') ? p : r;
|
|
189
211
|
}
|
|
212
|
+
function printCheck(report) {
|
|
213
|
+
const body = plainReport(report, colours);
|
|
214
|
+
if (body)
|
|
215
|
+
console.log(body);
|
|
216
|
+
console.log('');
|
|
217
|
+
const s = report.summary;
|
|
218
|
+
const line = summaryLine(s);
|
|
219
|
+
console.log(s.errors ? paint.bad(line) : s.warnings ? paint.warn(line) : paint.ok(line));
|
|
220
|
+
}
|
|
190
221
|
function printDoctor(r) {
|
|
191
222
|
console.log(`node ${r.node}`);
|
|
192
223
|
console.log(`playwright ${r.playwright}`);
|
package/dist/help.js
CHANGED
|
@@ -22,6 +22,17 @@ const AUTH_OPTIONS = [
|
|
|
22
22
|
[' --no-session', "don't use the session saved by `rpc login` for this origin"],
|
|
23
23
|
[' --allow-origin <o>', 'also send headers and loaded cookies to this origin, repeatable'],
|
|
24
24
|
];
|
|
25
|
+
const CHECK_OPTIONS = [
|
|
26
|
+
['-d, --devices <ids>', 'comma-separated device ids or aliases (default: iphone-15-pro,desktop-1440)'],
|
|
27
|
+
[' --fail-on <level>', 'error (default) or warn: also exit 1 on warnings'],
|
|
28
|
+
[' --shots', 'screenshot the elements behind layout-shift and overflow findings'],
|
|
29
|
+
['-o, --out <dir>', 'where --shots writes (default: ./.rpc-shots)'],
|
|
30
|
+
[' --wait-for <selector>', 'wait for a CSS selector before checking'],
|
|
31
|
+
[' --wait <ms>', 'extra settle delay after the scroll (default: 0)'],
|
|
32
|
+
[' --color-scheme <s>', 'light | dark | no-preference'],
|
|
33
|
+
[' --timeout <ms>', 'navigation timeout (default: 30000)'],
|
|
34
|
+
[' --json', 'print the report as JSON (schema in the README)'],
|
|
35
|
+
];
|
|
25
36
|
const LOGIN_OPTIONS = [
|
|
26
37
|
[' --save <file>', 'write the storage state here instead of the per-origin session'],
|
|
27
38
|
[' --form', 'headless: fill an email + password form (CI, agents)'],
|
|
@@ -47,6 +58,18 @@ export const COMMANDS = {
|
|
|
47
58
|
["rpc shot api.example.com/admin --header 'Authorization: Bearer $TOKEN'", ''],
|
|
48
59
|
],
|
|
49
60
|
},
|
|
61
|
+
check: {
|
|
62
|
+
usage: 'rpc check <url> [options]',
|
|
63
|
+
summary: 'Audit one page before launch: meta and social tags, layout shift (CLS), unlabelled controls, web fonts, ' +
|
|
64
|
+
'mobile viewport bugs and basics (status, mixed content, console errors). Exits 1 when there are errors.',
|
|
65
|
+
options: [...CHECK_OPTIONS, ...AUTH_OPTIONS],
|
|
66
|
+
examples: [
|
|
67
|
+
['rpc check localhost:3000', 'phone + desktop'],
|
|
68
|
+
['rpc check localhost:3000 --fail-on warn --json', 'CI: fail on warnings too'],
|
|
69
|
+
['rpc check example.com -d pixel,iphone-se,desktop --shots', 'more devices, element screenshots'],
|
|
70
|
+
['rpc check localhost:3000/billing', 'uses the session saved by rpc login'],
|
|
71
|
+
],
|
|
72
|
+
},
|
|
50
73
|
login: {
|
|
51
74
|
usage: 'rpc login <url> [--form] [--save <file>]',
|
|
52
75
|
summary: 'Open a browser window at the URL, sign in, then press Enter (or close the window). The session is saved for that ' +
|
|
@@ -94,12 +117,13 @@ export const COMMANDS = {
|
|
|
94
117
|
},
|
|
95
118
|
mcp: {
|
|
96
119
|
usage: 'rpc mcp',
|
|
97
|
-
summary: 'Run the MCP server on stdio (tools: screenshot, list_devices, list_sessions).',
|
|
120
|
+
summary: 'Run the MCP server on stdio (tools: screenshot, check, list_devices, list_sessions).',
|
|
98
121
|
examples: [['claude mcp add --scope user rpc -- node /abs/path/dist/cli.js mcp', 'add to Claude Code']],
|
|
99
122
|
},
|
|
100
123
|
};
|
|
101
124
|
const TOP = [
|
|
102
125
|
['shot <url>', 'capture screenshots'],
|
|
126
|
+
['check <url>', 'pre-launch audit: meta, layout shift, labels, fonts, mobile'],
|
|
103
127
|
['login <url>', 'sign in once; later shots of that origin use the session'],
|
|
104
128
|
['sessions', 'list or clear saved sessions'],
|
|
105
129
|
['devices', 'list device ids and aliases'],
|
package/dist/mcp.js
CHANGED
|
@@ -4,9 +4,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { describeAuth, listSessions, resolveAuth, sessionsDir, toOrigin } from './auth.js';
|
|
6
6
|
import { capture, launchBrowser } from './capture.js';
|
|
7
|
+
import { runCheck } from './check.js';
|
|
8
|
+
import { textSummary } from './check-format.js';
|
|
7
9
|
import { renderPreview } from './composite.js';
|
|
8
10
|
import { CATEGORY_LABELS, CATEGORY_ORDER, DEFAULT_DEVICES, DEVICE_ALIASES, DEVICES, resolveDevices, } from './devices.js';
|
|
9
|
-
import { COLOR_SCHEMES, DEFAULT_MAX_SLICES, DEFAULT_OUT_DIR, DEFAULT_SLICE_WIDTH, DEFAULT_TIMEOUT_MS, normalizeUrl, } from './options.js';
|
|
11
|
+
import { COLOR_SCHEMES, DEFAULT_MAX_SLICES, DEFAULT_OUT_DIR, DEFAULT_SLICE_WIDTH, DEFAULT_TIMEOUT_MS, defaultCheckOptions, normalizeUrl, } from './options.js';
|
|
10
12
|
import { DEFAULT_MAX_HEIGHT } from './slices.js';
|
|
11
13
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
14
|
const screenshotInput = {
|
|
@@ -65,6 +67,8 @@ const screenshotInput = {
|
|
|
65
67
|
.boolean()
|
|
66
68
|
.optional()
|
|
67
69
|
.describe('Return JPEG previews inline: one per device, or the full-page slices when fullPage. Default true.'),
|
|
70
|
+
};
|
|
71
|
+
const authInput = {
|
|
68
72
|
cookies: z
|
|
69
73
|
.array(z.object({
|
|
70
74
|
name: z.string().min(1),
|
|
@@ -93,6 +97,41 @@ const screenshotInput = {
|
|
|
93
97
|
.optional()
|
|
94
98
|
.describe('Extra origins (e.g. an API host) that also get the headers and loaded cookies.'),
|
|
95
99
|
};
|
|
100
|
+
const checkInput = {
|
|
101
|
+
url: z
|
|
102
|
+
.string()
|
|
103
|
+
.describe('Page to audit. http(s)://, file://, a bare host like localhost:3000, or a path to a local .html file.'),
|
|
104
|
+
devices: z
|
|
105
|
+
.array(z.string())
|
|
106
|
+
.min(1)
|
|
107
|
+
.max(8)
|
|
108
|
+
.optional()
|
|
109
|
+
.describe(`Device ids or aliases (see list_devices). Default: ${DEFAULT_DEVICES.join(', ')}. ` +
|
|
110
|
+
'The mobile viewport checks (100vh, sideways scroll, tap targets, input zoom) run on phones only.'),
|
|
111
|
+
shots: z
|
|
112
|
+
.boolean()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe('Save PNGs of the elements behind layout-shift and horizontal-overflow findings (paths in the report). Default false.'),
|
|
115
|
+
outDir: z.string().optional().describe(`Where shots go, relative to the server's cwd. Default ${DEFAULT_OUT_DIR}.`),
|
|
116
|
+
waitFor: z.string().optional().describe('CSS selector to wait for (visible) before checking.'),
|
|
117
|
+
waitMs: z.number().int().min(0).max(30_000).optional().describe('Extra settle delay after the scroll, in ms.'),
|
|
118
|
+
colorScheme: z.enum(COLOR_SCHEMES).optional().describe('Emulate prefers-color-scheme.'),
|
|
119
|
+
timeoutMs: z.number().int().min(1000).max(120_000).optional().describe(`Navigation timeout. Default ${DEFAULT_TIMEOUT_MS}.`),
|
|
120
|
+
};
|
|
121
|
+
function authFrom(args) {
|
|
122
|
+
const auth = {};
|
|
123
|
+
if (args.cookies?.length)
|
|
124
|
+
auth.cookies = args.cookies;
|
|
125
|
+
if (args.headers && Object.keys(args.headers).length)
|
|
126
|
+
auth.headers = args.headers;
|
|
127
|
+
if (args.storageState)
|
|
128
|
+
auth.storageState = args.storageState;
|
|
129
|
+
if (args.useSession === false)
|
|
130
|
+
auth.useSession = false;
|
|
131
|
+
if (args.allowOrigins?.length)
|
|
132
|
+
auth.allowOrigins = args.allowOrigins.map(toOrigin);
|
|
133
|
+
return auth;
|
|
134
|
+
}
|
|
96
135
|
export function createServer() {
|
|
97
136
|
const server = new McpServer({ name: 'rpc', version: pkg.version });
|
|
98
137
|
server.registerTool('screenshot', {
|
|
@@ -100,22 +139,12 @@ export function createServer() {
|
|
|
100
139
|
description: 'Load a URL in headless Chromium at one or more device viewports (with device pixel ratio, mobile user agent and touch) ' +
|
|
101
140
|
'and save PNG screenshots, optionally framed in a device bezel. Use it to check responsive layout of a page you are building, ' +
|
|
102
141
|
'including local dev servers. Returns file paths, page title, HTTP status and console errors, plus inline previews.',
|
|
103
|
-
inputSchema: screenshotInput,
|
|
142
|
+
inputSchema: { ...screenshotInput, ...authInput },
|
|
104
143
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
105
144
|
}, async (args) => {
|
|
106
145
|
let options;
|
|
107
146
|
try {
|
|
108
|
-
const auth =
|
|
109
|
-
if (args.cookies?.length)
|
|
110
|
-
auth.cookies = args.cookies;
|
|
111
|
-
if (args.headers && Object.keys(args.headers).length)
|
|
112
|
-
auth.headers = args.headers;
|
|
113
|
-
if (args.storageState)
|
|
114
|
-
auth.storageState = args.storageState;
|
|
115
|
-
if (args.useSession === false)
|
|
116
|
-
auth.useSession = false;
|
|
117
|
-
if (args.allowOrigins?.length)
|
|
118
|
-
auth.allowOrigins = args.allowOrigins.map(toOrigin);
|
|
147
|
+
const auth = authFrom(args);
|
|
119
148
|
options = {
|
|
120
149
|
url: normalizeUrl(args.url),
|
|
121
150
|
devices: resolveDevices(args.devices ?? DEFAULT_DEVICES).map(d => d.id),
|
|
@@ -183,9 +212,58 @@ export function createServer() {
|
|
|
183
212
|
await browser.close();
|
|
184
213
|
}
|
|
185
214
|
});
|
|
215
|
+
server.registerTool('check', {
|
|
216
|
+
title: 'Pre-launch check of a page',
|
|
217
|
+
description: 'Audit one URL before calling the work done: meta and social tags (title, description, canonical, lang, viewport zoom, ' +
|
|
218
|
+
'favicon, Open Graph, twitter:card, and whether og:image loads and its size), layout shift (CLS with the elements that moved), ' +
|
|
219
|
+
'buttons/links/images/fields without an accessible name, web fonts (font-display, preloads), mobile viewport bugs on phones ' +
|
|
220
|
+
'(100vh, sideways scroll, tap targets under 44px, inputs under 16px) and basics (HTTP status, mixed content, console errors). ' +
|
|
221
|
+
'Returns a short summary, then the JSON report: each finding has id, severity (error|warn|info), message, selector or url, ' +
|
|
222
|
+
'devices and a fix. Fix the errors (and ideally warnings), then run it again.',
|
|
223
|
+
inputSchema: { ...checkInput, ...authInput },
|
|
224
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
225
|
+
}, async (args) => {
|
|
226
|
+
let options;
|
|
227
|
+
try {
|
|
228
|
+
const auth = authFrom(args);
|
|
229
|
+
options = {
|
|
230
|
+
...defaultCheckOptions(args.url),
|
|
231
|
+
devices: resolveDevices(args.devices ?? DEFAULT_DEVICES).map(d => d.id),
|
|
232
|
+
outDir: args.outDir ?? DEFAULT_OUT_DIR,
|
|
233
|
+
waitFor: args.waitFor,
|
|
234
|
+
waitMs: args.waitMs ?? 0,
|
|
235
|
+
colorScheme: args.colorScheme,
|
|
236
|
+
timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
237
|
+
shots: args.shots ?? false,
|
|
238
|
+
...(Object.keys(auth).length ? { auth } : {}),
|
|
239
|
+
};
|
|
240
|
+
resolveAuth(options.url, auth);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
244
|
+
}
|
|
245
|
+
let browser;
|
|
246
|
+
try {
|
|
247
|
+
browser = await launchBrowser();
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
return { isError: true, content: [{ type: 'text', text: `Could not start the browser: ${err.message}` }] };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const report = await runCheck(options, { browser });
|
|
254
|
+
const content = [
|
|
255
|
+
{ type: 'text', text: (report.auth ? `auth: ${describeAuth(report.auth)}\n` : '') + textSummary(report) },
|
|
256
|
+
{ type: 'text', text: JSON.stringify(report, null, 2) },
|
|
257
|
+
];
|
|
258
|
+
return report.devices.every(d => d.error) ? { isError: true, content } : { content };
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
await browser.close();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
186
264
|
server.registerTool('list_devices', {
|
|
187
265
|
title: 'List device presets',
|
|
188
|
-
description: 'List device ids, CSS viewport sizes and pixel ratios usable in screenshot.devices, plus short aliases.',
|
|
266
|
+
description: 'List device ids, CSS viewport sizes and pixel ratios usable in screenshot.devices and check.devices, plus short aliases.',
|
|
189
267
|
inputSchema: {
|
|
190
268
|
category: z
|
|
191
269
|
.enum(CATEGORY_ORDER)
|
package/dist/options.js
CHANGED
|
@@ -24,6 +24,20 @@ export function defaultShotOptions(url) {
|
|
|
24
24
|
sliceWidth: DEFAULT_SLICE_WIDTH,
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
+
export const FAIL_ON = ['error', 'warn'];
|
|
28
|
+
export function defaultCheckOptions(url) {
|
|
29
|
+
return {
|
|
30
|
+
url: normalizeUrl(url),
|
|
31
|
+
devices: [...DEFAULT_DEVICES],
|
|
32
|
+
outDir: DEFAULT_OUT_DIR,
|
|
33
|
+
waitMs: 0,
|
|
34
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
35
|
+
emulate: true,
|
|
36
|
+
reducedMotion: false,
|
|
37
|
+
shots: false,
|
|
38
|
+
failOn: 'error',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
27
41
|
/**
|
|
28
42
|
* Accept what an agent is likely to type:
|
|
29
43
|
* http(s)://…, file://… -> unchanged
|
package/dist/png.js
CHANGED
|
@@ -8,6 +8,53 @@ export function pngSize(buf) {
|
|
|
8
8
|
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
9
9
|
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
10
10
|
}
|
|
11
|
+
/** Width/height of a PNG, JPEG, GIF or WebP from its header; undefined for anything else. */
|
|
12
|
+
export function imageSize(buf) {
|
|
13
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
14
|
+
const ascii = (at, len) => String.fromCharCode(...buf.subarray(at, at + len));
|
|
15
|
+
try {
|
|
16
|
+
if (buf.length >= 24 && SIGNATURE.every((b, i) => buf[i] === b))
|
|
17
|
+
return pngSize(buf);
|
|
18
|
+
if (ascii(0, 4) === 'GIF8' && buf.length >= 10) {
|
|
19
|
+
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
20
|
+
}
|
|
21
|
+
if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
|
|
22
|
+
const kind = ascii(12, 4);
|
|
23
|
+
if (kind === 'VP8 ')
|
|
24
|
+
return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
|
|
25
|
+
if (kind === 'VP8L') {
|
|
26
|
+
const bits = view.getUint32(21, true);
|
|
27
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
|
|
28
|
+
}
|
|
29
|
+
if (kind === 'VP8X') {
|
|
30
|
+
const u24 = (at) => buf[at] | (buf[at + 1] << 8) | (buf[at + 2] << 16);
|
|
31
|
+
return { width: u24(24) + 1, height: u24(27) + 1 };
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (buf[0] === 0xff && buf[1] === 0xd8) {
|
|
36
|
+
// Walk the JPEG segments to the first start-of-frame marker.
|
|
37
|
+
for (let at = 2; at + 9 < buf.length;) {
|
|
38
|
+
if (buf[at] !== 0xff)
|
|
39
|
+
return undefined;
|
|
40
|
+
const marker = buf[at + 1];
|
|
41
|
+
if (marker === 0xff) {
|
|
42
|
+
at++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const len = view.getUint16(at + 2);
|
|
46
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
47
|
+
return { width: view.getUint16(at + 7), height: view.getUint16(at + 5) };
|
|
48
|
+
}
|
|
49
|
+
at += 2 + len;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Truncated header.
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
11
58
|
/**
|
|
12
59
|
* Decode an 8-bit RGB/RGBA non-interlaced PNG, which is what Chromium
|
|
13
60
|
* screenshots are. Anything else throws: this is not a general decoder.
|
package/dist/ui.js
CHANGED
|
@@ -9,6 +9,8 @@ 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';
|
|
13
15
|
import { defaultShotOptions, normalizeUrl } from './options.js';
|
|
14
16
|
import { openCommand, openPath, waitForEnter } from './term.js';
|
|
@@ -389,6 +391,110 @@ export async function runShot(options, run, c) {
|
|
|
389
391
|
p.outro(`${c.green('Done!')} ${c.dim(advice)}`);
|
|
390
392
|
return failed > 0 ? 1 : 0;
|
|
391
393
|
}
|
|
394
|
+
// ------------------------------------------------------------------ check
|
|
395
|
+
/** Lines for the "Check Summary" box. */
|
|
396
|
+
export function checkSummaryLines(o, c, auth) {
|
|
397
|
+
const rows = [
|
|
398
|
+
['devices', formatList(o.devices.map(d => c.cyan(d)))],
|
|
399
|
+
['fail on', o.failOn === 'warn' ? c.yellow('errors and warnings') : 'errors'],
|
|
400
|
+
];
|
|
401
|
+
if (o.colorScheme)
|
|
402
|
+
rows.push(['colour scheme', o.colorScheme]);
|
|
403
|
+
if (o.waitFor)
|
|
404
|
+
rows.push(['wait for', o.waitFor]);
|
|
405
|
+
if (auth)
|
|
406
|
+
rows.push(...authRows(auth, c));
|
|
407
|
+
if (o.shots)
|
|
408
|
+
rows.push(['shots', `${rel(o.outDir)}/`]);
|
|
409
|
+
const w = Math.max(...rows.map(([k]) => k.length)) + 1;
|
|
410
|
+
return [c.cyan(o.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)];
|
|
411
|
+
}
|
|
412
|
+
/** The spinner line once a device is done: status, CLS, time. */
|
|
413
|
+
function checkDeviceLine(d, ms, label, c) {
|
|
414
|
+
if (d.error)
|
|
415
|
+
return `${label} ${c.red(d.code ?? 'error')} ${c.dim(formatMs(ms))}`;
|
|
416
|
+
const status = d.status == null ? c.dim('--') : d.status >= 400 ? c.red(String(d.status)) : c.green(String(d.status));
|
|
417
|
+
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));
|
|
418
|
+
return `${label} ${status} ${c.dim('CLS')} ${cls} ${c.dim(formatMs(ms))}`;
|
|
419
|
+
}
|
|
420
|
+
/** One box per check that found something, titled with its counts. */
|
|
421
|
+
export function checkBoxes(report, c) {
|
|
422
|
+
const all = report.devices.map(d => d.device);
|
|
423
|
+
return groupFindings(report).map(g => {
|
|
424
|
+
const counts = countsPhrase(g.findings);
|
|
425
|
+
const tint = g.findings.some(f => f.severity === 'error') ? c.red : g.findings.some(f => f.severity === 'warn') ? c.yellow : c.cyan;
|
|
426
|
+
return { title: `${g.title} ${c.dim('·')} ${tint(counts)}`, lines: groupLines(g, all, c, { icons: true, width: textWidth() }) };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
export async function runCheck(options, c) {
|
|
430
|
+
const auth = resolveAuth(options.url, options.auth ?? {});
|
|
431
|
+
intro(c);
|
|
432
|
+
p.note(checkSummaryLines(options, c, auth.summary).join('\n'), 'Check Summary');
|
|
433
|
+
const s = p.spinner();
|
|
434
|
+
s.start('Starting Chromium');
|
|
435
|
+
let browser;
|
|
436
|
+
try {
|
|
437
|
+
browser = await launchBrowser();
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
if (err instanceof BrowserMissingError) {
|
|
441
|
+
s.error(c.red("Playwright's Chromium is not installed"));
|
|
442
|
+
p.note(wrap(installHint().replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
|
|
443
|
+
p.outro(`${c.red('Nothing checked.')} ${c.dim('Run: rpc install')}`);
|
|
444
|
+
return 1;
|
|
445
|
+
}
|
|
446
|
+
s.error(c.red(`Chromium did not start: ${err.message.split('\n')[0]}`));
|
|
447
|
+
p.outro(c.red('Nothing checked.'));
|
|
448
|
+
return 1;
|
|
449
|
+
}
|
|
450
|
+
s.stop(`Chromium ${c.dim(browser.version())}`);
|
|
451
|
+
let current;
|
|
452
|
+
let started = 0;
|
|
453
|
+
let report;
|
|
454
|
+
const picked = options.devices.map(id => DEVICES.find(d => d.id === id)).filter((d) => Boolean(d));
|
|
455
|
+
const idW = Math.max(0, ...picked.map(d => d.id.length));
|
|
456
|
+
const vpW = Math.max(0, ...picked.map(d => viewport(d).length));
|
|
457
|
+
const label = (id, vp) => `${id.padEnd(idW)} ${c.dim(vp.padEnd(vpW))}`;
|
|
458
|
+
try {
|
|
459
|
+
report = await audit(options, {
|
|
460
|
+
browser,
|
|
461
|
+
auth,
|
|
462
|
+
onDeviceStart: (d) => {
|
|
463
|
+
started = performance.now();
|
|
464
|
+
current = p.spinner();
|
|
465
|
+
current.start(label(d.id, viewport(d)));
|
|
466
|
+
},
|
|
467
|
+
onDevice: d => {
|
|
468
|
+
const line = checkDeviceLine(d, performance.now() - started, label(d.device, viewport({ ...d.viewport, dpr: d.dpr })), c);
|
|
469
|
+
if (d.error)
|
|
470
|
+
current?.error(line);
|
|
471
|
+
else
|
|
472
|
+
current?.stop(line);
|
|
473
|
+
current = undefined;
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
current?.error(c.red(err.message));
|
|
479
|
+
p.outro(c.red('Check failed.'));
|
|
480
|
+
return 1;
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
await browser.close().catch(() => { });
|
|
484
|
+
}
|
|
485
|
+
for (const box of checkBoxes(report, c))
|
|
486
|
+
p.note(box.lines.join('\n'), box.title);
|
|
487
|
+
const code = checkExitCode(report, options.failOn);
|
|
488
|
+
const line = summaryLine(report.summary);
|
|
489
|
+
const sum = report.summary;
|
|
490
|
+
if (sum.errors)
|
|
491
|
+
p.outro(`${c.red(line)} ${c.dim('Exit code 1.')}`);
|
|
492
|
+
else if (sum.warnings)
|
|
493
|
+
p.outro(`${c.yellow(line)} ${c.dim(code ? 'Exit code 1 (--fail-on warn).' : 'No errors.')}`);
|
|
494
|
+
else
|
|
495
|
+
p.outro(`${c.green(line)} ${c.dim('Ready to ship.')}`);
|
|
496
|
+
return code;
|
|
497
|
+
}
|
|
392
498
|
// ------------------------------------------------------------------ interactive
|
|
393
499
|
const PRESELECT = ['iphone-15-pro', 'ipad-air-11', 'desktop-1440'];
|
|
394
500
|
const DEV_PORTS = [3000, 5173, 4321, 8080];
|
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.5.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",
|