rpc-agent 0.3.1 → 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 +94 -0
- package/README.md +428 -5
- package/dist/args.js +214 -25
- package/dist/auth.js +416 -0
- package/dist/browser.js +6 -4
- package/dist/capture.js +47 -11
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +336 -0
- package/dist/check.js +677 -0
- package/dist/cli.js +115 -4
- package/dist/help.js +78 -4
- package/dist/login.js +150 -0
- package/dist/mcp.js +152 -7
- package/dist/options.js +14 -0
- package/dist/png.js +47 -0
- package/dist/term.js +20 -0
- package/dist/ui.js +220 -3
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { relative } from 'node:path';
|
|
4
4
|
import { ArgError, parseCli, USAGE } from './args.js';
|
|
5
|
+
import { clearSessions, describeAuth, listSessions, localTime, sessionsDir } from './auth.js';
|
|
5
6
|
import { doctor, installChromium } from './browser.js';
|
|
6
7
|
import { capture } from './capture.js';
|
|
8
|
+
import { checkExitCode, runCheck, summaryLine } from './check.js';
|
|
9
|
+
import { deviceLine, plainReport } from './check-format.js';
|
|
7
10
|
import { CATEGORY_LABELS, CATEGORY_ORDER, DEVICE_ALIASES, DEVICES } from './devices.js';
|
|
8
|
-
import { currentMode, openPath } from './term.js';
|
|
11
|
+
import { currentMode, openPath, waitForEnter } from './term.js';
|
|
9
12
|
const NO_PAINT = { ok: s => s, bad: s => s, warn: s => s };
|
|
10
13
|
let paint = NO_PAINT;
|
|
14
|
+
/** The full palette for check output; identity functions unless FORCE_COLOR. */
|
|
15
|
+
let colours;
|
|
11
16
|
async function plainPaint() {
|
|
17
|
+
const { default: pc } = await import('picocolors');
|
|
18
|
+
colours = pc.createColors(currentMode().color);
|
|
12
19
|
if (!currentMode().color)
|
|
13
20
|
return NO_PAINT;
|
|
14
|
-
const { default: pc } = await import('picocolors');
|
|
15
21
|
const c = pc.createColors(true);
|
|
16
22
|
return { ok: c.green, bad: c.red, warn: c.yellow };
|
|
17
23
|
}
|
|
@@ -78,7 +84,7 @@ async function main(argv) {
|
|
|
78
84
|
return report.ok ? 0 : 1;
|
|
79
85
|
}
|
|
80
86
|
case 'install': {
|
|
81
|
-
const code = await installChromium();
|
|
87
|
+
const code = await installChromium({ headed: Boolean(cmd.headed) });
|
|
82
88
|
if (code !== 0)
|
|
83
89
|
return code;
|
|
84
90
|
if (mode.rich) {
|
|
@@ -94,19 +100,124 @@ async function main(argv) {
|
|
|
94
100
|
const { ui, c } = await loadUi();
|
|
95
101
|
return ui.runShot(cmd.options, { open: cmd.open }, c);
|
|
96
102
|
}
|
|
97
|
-
const result = await capture(cmd.options, cmd.json ? {} : { onDevice: printShot });
|
|
103
|
+
const result = await capture(cmd.options, cmd.json ? {} : { onDevice: printShot, onAuth: a => console.log(`auth: ${describeAuth(a)}`) });
|
|
98
104
|
if (cmd.json)
|
|
99
105
|
console.log(JSON.stringify(result, null, 2));
|
|
100
106
|
if (cmd.open && result.shots.some(s => !s.error))
|
|
101
107
|
openPath(result.outDir);
|
|
102
108
|
return result.shots.some(s => s.error) ? 1 : 0;
|
|
103
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
|
+
}
|
|
127
|
+
case 'login': {
|
|
128
|
+
if (cmd.passwordFromFlag) {
|
|
129
|
+
console.error(`rpc: ${paint.warn('warning:')} --password is visible in shell history and the process list; set RPC_LOGIN_PASSWORD instead`);
|
|
130
|
+
}
|
|
131
|
+
if (mode.rich) {
|
|
132
|
+
const { ui, c } = await loadUi();
|
|
133
|
+
return ui.runLogin(cmd, c);
|
|
134
|
+
}
|
|
135
|
+
return plainLogin(cmd);
|
|
136
|
+
}
|
|
137
|
+
case 'sessions': {
|
|
138
|
+
if (mode.rich) {
|
|
139
|
+
const { ui, c } = await loadUi();
|
|
140
|
+
return ui.runSessions(cmd.action, cmd.origin, c);
|
|
141
|
+
}
|
|
142
|
+
if (cmd.action === 'clear') {
|
|
143
|
+
const gone = await clearSessions(cmd.origin);
|
|
144
|
+
if (cmd.json)
|
|
145
|
+
console.log(JSON.stringify({ removed: gone }, null, 2));
|
|
146
|
+
else if (gone.length === 0)
|
|
147
|
+
console.log(cmd.origin ? `No saved session for ${cmd.origin}` : 'No saved sessions');
|
|
148
|
+
else
|
|
149
|
+
for (const o of gone)
|
|
150
|
+
console.log(`removed ${o}`);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
const list = await listSessions();
|
|
154
|
+
if (cmd.json)
|
|
155
|
+
console.log(JSON.stringify({ dir: sessionsDir(), sessions: list }, null, 2));
|
|
156
|
+
else if (list.length === 0)
|
|
157
|
+
console.log(`No saved sessions in ${sessionsDir()}. Create one with: rpc login <url>`);
|
|
158
|
+
else
|
|
159
|
+
for (const s of list)
|
|
160
|
+
console.log(sessionLine(s));
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
104
163
|
}
|
|
105
164
|
}
|
|
165
|
+
async function plainLogin(cmd) {
|
|
166
|
+
const { formLogin, interactiveLogin } = await import('./login.js');
|
|
167
|
+
let result;
|
|
168
|
+
if (cmd.form) {
|
|
169
|
+
if (!cmd.json)
|
|
170
|
+
console.log(`signing in at ${cmd.url} (headless form)`);
|
|
171
|
+
result = await formLogin(cmd, cmd.form);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const enter = waitForEnter();
|
|
175
|
+
try {
|
|
176
|
+
result = await interactiveLogin(cmd, enter.promise, () => {
|
|
177
|
+
if (!cmd.json)
|
|
178
|
+
console.log(`Opened ${cmd.url}. Sign in in the browser window, then press Enter here (or close the window).`);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
enter.stop();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (cmd.json) {
|
|
186
|
+
console.log(JSON.stringify(result, null, 2));
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
console.log(`${paint.ok('saved')} session for ${result.origin}: ${result.path} (${result.cookies} cookie(s), ${result.localStorage} localStorage origin(s))`);
|
|
190
|
+
if (result.stillOnLogin) {
|
|
191
|
+
console.error(`${paint.warn('warning:')} the page was still on a login URL (${result.finalUrl}); the sign-in may not have finished`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
function sessionLine(s) {
|
|
197
|
+
const when = localTime;
|
|
198
|
+
const parts = [s.origin, `${s.cookies} cookie(s)`];
|
|
199
|
+
if (s.savedAt)
|
|
200
|
+
parts.push(`saved ${when(s.savedAt)}`);
|
|
201
|
+
if (s.expired)
|
|
202
|
+
parts.push(paint.bad(`expired ${when(s.expires)}`));
|
|
203
|
+
else if (s.expires)
|
|
204
|
+
parts.push(`expires ${when(s.expires)}`);
|
|
205
|
+
parts.push(rel(s.path));
|
|
206
|
+
return parts.join(' ');
|
|
207
|
+
}
|
|
106
208
|
function rel(p) {
|
|
107
209
|
const r = relative(process.cwd(), p);
|
|
108
210
|
return r.startsWith('..') ? p : r;
|
|
109
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
|
+
}
|
|
110
221
|
function printDoctor(r) {
|
|
111
222
|
console.log(`node ${r.node}`);
|
|
112
223
|
console.log(`playwright ${r.playwright}`);
|
package/dist/help.js
CHANGED
|
@@ -15,16 +15,80 @@ const SHOT_OPTIONS = [
|
|
|
15
15
|
[' --open', 'open the output folder when done'],
|
|
16
16
|
[' --json', 'print the result as JSON (no colour, no progress)'],
|
|
17
17
|
];
|
|
18
|
+
const AUTH_OPTIONS = [
|
|
19
|
+
[' --cookie <c>', '"name=value[; Domain=…; Path=…]", repeatable; target origin only'],
|
|
20
|
+
[' --header <h>', '"Name: value", repeatable; sent to the target origin only'],
|
|
21
|
+
[' --storage-state <file>', 'load a Playwright storageState JSON (cookies + localStorage)'],
|
|
22
|
+
[' --no-session', "don't use the session saved by `rpc login` for this origin"],
|
|
23
|
+
[' --allow-origin <o>', 'also send headers and loaded cookies to this origin, repeatable'],
|
|
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
|
+
];
|
|
36
|
+
const LOGIN_OPTIONS = [
|
|
37
|
+
[' --save <file>', 'write the storage state here instead of the per-origin session'],
|
|
38
|
+
[' --form', 'headless: fill an email + password form (CI, agents)'],
|
|
39
|
+
[' --email <email>', 'with --form; default $RPC_LOGIN_EMAIL'],
|
|
40
|
+
[' --password <pw>', 'with --form; prefer $RPC_LOGIN_PASSWORD (keeps it out of shell history)'],
|
|
41
|
+
[' --email-selector <css>', `default: input[type=email], input[name=email], …`],
|
|
42
|
+
[' --password-selector <css>', 'default: input[type=password]'],
|
|
43
|
+
[' --submit-selector <css>', 'default: button[type=submit], input[type=submit] (else Enter)'],
|
|
44
|
+
[' --timeout <ms>', 'navigation and form timeout (default: 30000)'],
|
|
45
|
+
[' --json', 'print the result as JSON'],
|
|
46
|
+
];
|
|
18
47
|
export const COMMANDS = {
|
|
19
48
|
shot: {
|
|
20
49
|
usage: 'rpc shot <url> [options]',
|
|
21
50
|
summary: 'Capture a URL at device viewports, optionally framed in device bezels.',
|
|
22
|
-
options: SHOT_OPTIONS,
|
|
51
|
+
options: [...SHOT_OPTIONS, ...AUTH_OPTIONS],
|
|
23
52
|
examples: [
|
|
24
53
|
['rpc shot localhost:3000 -d iphone-15,ipad,desktop-1440 --bezels', ''],
|
|
25
54
|
['rpc shot example.com/pricing --full-page --slices', 'whole page + review slices'],
|
|
26
55
|
['rpc shot localhost:5173 --color-scheme dark --open', 'dark mode, open when done'],
|
|
27
56
|
['rpc shot localhost:3000 -d pixel --json', 'machine-readable result'],
|
|
57
|
+
['rpc shot localhost:3000/billing', 'uses the session saved by rpc login'],
|
|
58
|
+
["rpc shot api.example.com/admin --header 'Authorization: Bearer $TOKEN'", ''],
|
|
59
|
+
],
|
|
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
|
+
},
|
|
73
|
+
login: {
|
|
74
|
+
usage: 'rpc login <url> [--form] [--save <file>]',
|
|
75
|
+
summary: 'Open a browser window at the URL, sign in, then press Enter (or close the window). The session is saved for that ' +
|
|
76
|
+
'origin (~/.config/rpc-agent/sessions, mode 0600) and `rpc shot` uses it automatically.',
|
|
77
|
+
options: LOGIN_OPTIONS,
|
|
78
|
+
examples: [
|
|
79
|
+
['rpc login localhost:3000/sign-in', 'sign in by hand in a window'],
|
|
80
|
+
['RPC_LOGIN_EMAIL=me@x.test RPC_LOGIN_PASSWORD=… rpc login localhost:3000/sign-in --form', 'CI / agents'],
|
|
81
|
+
['rpc login example.com --save auth.json', 'write a storageState file instead'],
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
sessions: {
|
|
85
|
+
usage: 'rpc sessions [list] | rpc sessions clear [origin]',
|
|
86
|
+
summary: 'List the sessions saved by `rpc login` (origins, cookie counts, expiry; never values), or delete them.',
|
|
87
|
+
options: [[' --json', 'print as JSON']],
|
|
88
|
+
examples: [
|
|
89
|
+
['rpc sessions', ''],
|
|
90
|
+
['rpc sessions clear localhost:3000', 'forget one origin'],
|
|
91
|
+
['rpc sessions clear', 'forget all'],
|
|
28
92
|
],
|
|
29
93
|
},
|
|
30
94
|
devices: {
|
|
@@ -43,18 +107,25 @@ export const COMMANDS = {
|
|
|
43
107
|
examples: [['rpc doctor', 'exits 1 when Chromium is missing']],
|
|
44
108
|
},
|
|
45
109
|
install: {
|
|
46
|
-
usage: 'rpc install',
|
|
110
|
+
usage: 'rpc install [--headed]',
|
|
47
111
|
summary: "Download Playwright's Chromium with the Playwright version rpc uses, then run doctor.",
|
|
48
|
-
|
|
112
|
+
options: [[' --headed', 'also download full Chromium, for the window `rpc login` opens']],
|
|
113
|
+
examples: [
|
|
114
|
+
['rpc install', 'headless shell only (captures)'],
|
|
115
|
+
['rpc install --headed', 'for rpc login'],
|
|
116
|
+
],
|
|
49
117
|
},
|
|
50
118
|
mcp: {
|
|
51
119
|
usage: 'rpc mcp',
|
|
52
|
-
summary: 'Run the MCP server on stdio (tools: screenshot, list_devices).',
|
|
120
|
+
summary: 'Run the MCP server on stdio (tools: screenshot, check, list_devices, list_sessions).',
|
|
53
121
|
examples: [['claude mcp add --scope user rpc -- node /abs/path/dist/cli.js mcp', 'add to Claude Code']],
|
|
54
122
|
},
|
|
55
123
|
};
|
|
56
124
|
const TOP = [
|
|
57
125
|
['shot <url>', 'capture screenshots'],
|
|
126
|
+
['check <url>', 'pre-launch audit: meta, layout shift, labels, fonts, mobile'],
|
|
127
|
+
['login <url>', 'sign in once; later shots of that origin use the session'],
|
|
128
|
+
['sessions', 'list or clear saved sessions'],
|
|
58
129
|
['devices', 'list device ids and aliases'],
|
|
59
130
|
['doctor', "check that Playwright's Chromium is installed and launches"],
|
|
60
131
|
['install', "download Playwright's Chromium (same version rpc uses)"],
|
|
@@ -91,6 +162,9 @@ export function richHelp(c) {
|
|
|
91
162
|
c.bold('Shot options:'),
|
|
92
163
|
...table(SHOT_OPTIONS, c),
|
|
93
164
|
'',
|
|
165
|
+
c.bold('Auth options:'),
|
|
166
|
+
...table(AUTH_OPTIONS, c),
|
|
167
|
+
'',
|
|
94
168
|
c.bold('Examples:'),
|
|
95
169
|
...examples(COMMANDS.shot.examples, c),
|
|
96
170
|
'',
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `rpc login`: sign in once, save the browser's storage state for the origin.
|
|
2
|
+
import { chromium } from 'playwright';
|
|
3
|
+
import { isLoginUrl, redact, scopeState, toOrigin, writeSession } from './auth.js';
|
|
4
|
+
import { launchBrowser } from './browser.js';
|
|
5
|
+
export const DEFAULT_SELECTORS = {
|
|
6
|
+
email: 'input[type=email], input[autocomplete=username], input[name=email], input[name=username], input#email',
|
|
7
|
+
password: 'input[type=password]',
|
|
8
|
+
submit: 'button[type=submit], input[type=submit]',
|
|
9
|
+
};
|
|
10
|
+
export class HeadedBrowserMissingError extends Error {
|
|
11
|
+
name = 'HeadedBrowserMissingError';
|
|
12
|
+
constructor() {
|
|
13
|
+
super('rpc login opens a browser window, which needs full Chromium (`rpc install` only downloads the headless shell). ' +
|
|
14
|
+
'Run `rpc install --headed`, or install Google Chrome, or sign in headlessly with `rpc login <url> --form`.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Headed Chromium; falls back to an installed Google Chrome. */
|
|
18
|
+
export async function launchHeaded() {
|
|
19
|
+
try {
|
|
20
|
+
return await chromium.launch({ headless: false });
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (!/Executable doesn't exist|playwright install/i.test(err.message))
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return await chromium.launch({ headless: false, channel: 'chrome' });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new HeadedBrowserMissingError();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function save(state, opts, finalUrl) {
|
|
34
|
+
const origin = toOrigin(opts.url);
|
|
35
|
+
const path = await writeSession(state, { origin, savedAt: new Date().toISOString(), url: opts.url }, opts.save);
|
|
36
|
+
const own = scopeState(state, [origin]);
|
|
37
|
+
return {
|
|
38
|
+
origin,
|
|
39
|
+
path,
|
|
40
|
+
finalUrl,
|
|
41
|
+
cookies: own.cookies.length,
|
|
42
|
+
localStorage: own.origins.length,
|
|
43
|
+
stillOnLogin: isLoginUrl(finalUrl) && new URL(finalUrl).origin === origin,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Open a visible browser at the URL and save the storage state when `done`
|
|
48
|
+
* resolves (the user pressed Enter) or the window is closed.
|
|
49
|
+
*/
|
|
50
|
+
export async function interactiveLogin(opts, done, onOpen) {
|
|
51
|
+
const browser = await launchHeaded();
|
|
52
|
+
try {
|
|
53
|
+
const context = await browser.newContext({ viewport: null, ignoreHTTPSErrors: true });
|
|
54
|
+
const page = await context.newPage();
|
|
55
|
+
let lastUrl = opts.url;
|
|
56
|
+
page.on('framenavigated', f => {
|
|
57
|
+
if (f === page.mainFrame())
|
|
58
|
+
lastUrl = f.url();
|
|
59
|
+
});
|
|
60
|
+
await page.goto(opts.url, { timeout: opts.timeoutMs }).catch(() => { });
|
|
61
|
+
onOpen?.();
|
|
62
|
+
// Snapshot regularly: once the window is closed the context can't be read.
|
|
63
|
+
let snapshot = await context.storageState();
|
|
64
|
+
const timer = setInterval(() => {
|
|
65
|
+
context.storageState().then(s => (snapshot = s), () => { });
|
|
66
|
+
}, 1000);
|
|
67
|
+
const closed = new Promise(res => {
|
|
68
|
+
page.on('close', res);
|
|
69
|
+
browser.on('disconnected', res);
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await Promise.race([done, closed]);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearInterval(timer);
|
|
76
|
+
}
|
|
77
|
+
const state = await context.storageState().catch(() => snapshot);
|
|
78
|
+
if (!page.isClosed())
|
|
79
|
+
lastUrl = page.url();
|
|
80
|
+
return await save(state, opts, lastUrl);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await browser.close().catch(() => { });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Headless: fill an email + password form, submit, wait to leave the login
|
|
88
|
+
* URL, save. For CI and agents.
|
|
89
|
+
*/
|
|
90
|
+
export async function formLogin(opts, form) {
|
|
91
|
+
const browser = await launchBrowser();
|
|
92
|
+
try {
|
|
93
|
+
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
94
|
+
const page = await context.newPage();
|
|
95
|
+
await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
96
|
+
// Let client-side forms hydrate, so submit runs the app's handler rather
|
|
97
|
+
// than a native GET that would put the password in the URL.
|
|
98
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(opts.timeoutMs, 10_000) }).catch(() => { });
|
|
99
|
+
const start = new URL(page.url());
|
|
100
|
+
const email = page.locator(form.emailSelector ?? DEFAULT_SELECTORS.email).first();
|
|
101
|
+
const password = page.locator(form.passwordSelector ?? DEFAULT_SELECTORS.password).first();
|
|
102
|
+
await waitVisible(email, 'email field', form.emailSelector ? '--email-selector' : undefined, opts.timeoutMs);
|
|
103
|
+
await email.fill(form.email);
|
|
104
|
+
await waitVisible(password, 'password field', form.passwordSelector ? '--password-selector' : undefined, opts.timeoutMs);
|
|
105
|
+
await password.fill(form.password);
|
|
106
|
+
const submit = page.locator(form.submitSelector ?? DEFAULT_SELECTORS.submit).first();
|
|
107
|
+
if (form.submitSelector || (await submit.count()) > 0) {
|
|
108
|
+
await waitVisible(submit, 'submit button', form.submitSelector ? '--submit-selector' : undefined, opts.timeoutMs);
|
|
109
|
+
await submit.click();
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
await password.press('Enter');
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await page.waitForURL(u => u.origin + u.pathname !== start.origin + start.pathname, { timeout: opts.timeoutMs });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
const shown = await pageMessage(page);
|
|
119
|
+
const message = shown && redact(shown, [form.password]);
|
|
120
|
+
throw new Error(`Still on ${start.origin}${start.pathname} ${Math.round(opts.timeoutMs / 1000)} s after submitting the form` +
|
|
121
|
+
`${message ? ` (the page says: "${message}")` : ''}. Check the credentials, or the selectors (--email-selector, --password-selector, --submit-selector).`);
|
|
122
|
+
}
|
|
123
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(opts.timeoutMs, 10_000) }).catch(() => { });
|
|
124
|
+
return await save(await context.storageState(), opts, page.url());
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await browser.close().catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function waitVisible(loc, what, flag, timeoutMs) {
|
|
131
|
+
try {
|
|
132
|
+
await loc.waitFor({ state: 'visible', timeout: Math.min(timeoutMs, 15_000) });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new Error(`No ${what} found on the page${flag ? ` for ${flag}` : ''}. Pass ${flag ?? `--${what.split(' ')[0]}-selector`} with a CSS selector that matches it.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** A visible alert or error message, if the login page shows one. */
|
|
139
|
+
async function pageMessage(page) {
|
|
140
|
+
const loc = page.locator('[role=alert], [aria-live=assertive], .error, [data-error]').filter({ hasText: /\S/ }).first();
|
|
141
|
+
try {
|
|
142
|
+
if ((await loc.count()) === 0)
|
|
143
|
+
return undefined;
|
|
144
|
+
const t = (await loc.innerText({ timeout: 1000 })).trim().replace(/\s+/g, ' ');
|
|
145
|
+
return t ? t.slice(0, 160) : undefined;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -2,10 +2,13 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { describeAuth, listSessions, resolveAuth, sessionsDir, toOrigin } from './auth.js';
|
|
5
6
|
import { capture, launchBrowser } from './capture.js';
|
|
7
|
+
import { runCheck } from './check.js';
|
|
8
|
+
import { textSummary } from './check-format.js';
|
|
6
9
|
import { renderPreview } from './composite.js';
|
|
7
10
|
import { CATEGORY_LABELS, CATEGORY_ORDER, DEFAULT_DEVICES, DEVICE_ALIASES, DEVICES, resolveDevices, } from './devices.js';
|
|
8
|
-
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';
|
|
9
12
|
import { DEFAULT_MAX_HEIGHT } from './slices.js';
|
|
10
13
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
14
|
const screenshotInput = {
|
|
@@ -65,6 +68,70 @@ const screenshotInput = {
|
|
|
65
68
|
.optional()
|
|
66
69
|
.describe('Return JPEG previews inline: one per device, or the full-page slices when fullPage. Default true.'),
|
|
67
70
|
};
|
|
71
|
+
const authInput = {
|
|
72
|
+
cookies: z
|
|
73
|
+
.array(z.object({
|
|
74
|
+
name: z.string().min(1),
|
|
75
|
+
value: z.string(),
|
|
76
|
+
domain: z.string().optional().describe('Default: the target host only. Must match the target host (or an allowOrigins host).'),
|
|
77
|
+
path: z.string().optional().describe('Default /.'),
|
|
78
|
+
}))
|
|
79
|
+
.max(50)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Cookies set on the target origin before loading. Values are never echoed back.'),
|
|
82
|
+
headers: z
|
|
83
|
+
.record(z.string(), z.string())
|
|
84
|
+
.optional()
|
|
85
|
+
.describe('Request headers, e.g. {"Authorization": "Bearer …"}. Sent only to the target origin (and allowOrigins), never to third-party requests or redirects. Values are never echoed back.'),
|
|
86
|
+
storageState: z
|
|
87
|
+
.string()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("Path to a Playwright storageState JSON (cookies + localStorage), relative to the server's cwd. Only the target origin's entries are loaded."),
|
|
90
|
+
useSession: z
|
|
91
|
+
.boolean()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('Use the session saved by `rpc login <url>` for the target origin. Default true when one exists (see list_sessions). Ignored when storageState is set.'),
|
|
94
|
+
allowOrigins: z
|
|
95
|
+
.array(z.string())
|
|
96
|
+
.max(10)
|
|
97
|
+
.optional()
|
|
98
|
+
.describe('Extra origins (e.g. an API host) that also get the headers and loaded cookies.'),
|
|
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
|
+
}
|
|
68
135
|
export function createServer() {
|
|
69
136
|
const server = new McpServer({ name: 'rpc', version: pkg.version });
|
|
70
137
|
server.registerTool('screenshot', {
|
|
@@ -72,11 +139,12 @@ export function createServer() {
|
|
|
72
139
|
description: 'Load a URL in headless Chromium at one or more device viewports (with device pixel ratio, mobile user agent and touch) ' +
|
|
73
140
|
'and save PNG screenshots, optionally framed in a device bezel. Use it to check responsive layout of a page you are building, ' +
|
|
74
141
|
'including local dev servers. Returns file paths, page title, HTTP status and console errors, plus inline previews.',
|
|
75
|
-
inputSchema: screenshotInput,
|
|
142
|
+
inputSchema: { ...screenshotInput, ...authInput },
|
|
76
143
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
77
144
|
}, async (args) => {
|
|
78
145
|
let options;
|
|
79
146
|
try {
|
|
147
|
+
const auth = authFrom(args);
|
|
80
148
|
options = {
|
|
81
149
|
url: normalizeUrl(args.url),
|
|
82
150
|
devices: resolveDevices(args.devices ?? DEFAULT_DEVICES).map(d => d.id),
|
|
@@ -93,7 +161,9 @@ export function createServer() {
|
|
|
93
161
|
saveSlices: args.saveSlices,
|
|
94
162
|
maxSlices: args.maxSlices ?? DEFAULT_MAX_SLICES,
|
|
95
163
|
sliceWidth: args.sliceWidth ?? DEFAULT_SLICE_WIDTH,
|
|
164
|
+
...(Object.keys(auth).length ? { auth } : {}),
|
|
96
165
|
};
|
|
166
|
+
resolveAuth(options.url, auth); // bad cookies/headers/storage state: a tool error, before launching
|
|
97
167
|
}
|
|
98
168
|
catch (err) {
|
|
99
169
|
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
@@ -111,9 +181,9 @@ export function createServer() {
|
|
|
111
181
|
const content = [
|
|
112
182
|
{
|
|
113
183
|
type: 'text',
|
|
114
|
-
text: headline(result.shots, options) +
|
|
184
|
+
text: headline(result.shots, options, result.auth) +
|
|
115
185
|
'\n\n' +
|
|
116
|
-
JSON.stringify(summarise(result.url, result.outDir, result.shots), null, 2),
|
|
186
|
+
JSON.stringify(summarise(result.url, result.outDir, result.shots, result.auth), null, 2),
|
|
117
187
|
},
|
|
118
188
|
];
|
|
119
189
|
if (inline) {
|
|
@@ -142,9 +212,58 @@ export function createServer() {
|
|
|
142
212
|
await browser.close();
|
|
143
213
|
}
|
|
144
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
|
+
});
|
|
145
264
|
server.registerTool('list_devices', {
|
|
146
265
|
title: 'List device presets',
|
|
147
|
-
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.',
|
|
148
267
|
inputSchema: {
|
|
149
268
|
category: z
|
|
150
269
|
.enum(CATEGORY_ORDER)
|
|
@@ -168,6 +287,29 @@ export function createServer() {
|
|
|
168
287
|
lines.push(`${a} -> ${id}`);
|
|
169
288
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
170
289
|
});
|
|
290
|
+
server.registerTool('list_sessions', {
|
|
291
|
+
title: 'List saved login sessions',
|
|
292
|
+
description: 'List the origins that have a session saved by `rpc login` (used automatically by screenshot for that origin): ' +
|
|
293
|
+
'cookie count, when it was saved, and whether it has expired. Never returns cookie values.',
|
|
294
|
+
inputSchema: {},
|
|
295
|
+
annotations: { readOnlyHint: true },
|
|
296
|
+
}, async () => {
|
|
297
|
+
const list = await listSessions();
|
|
298
|
+
if (list.length === 0) {
|
|
299
|
+
return {
|
|
300
|
+
content: [
|
|
301
|
+
{
|
|
302
|
+
type: 'text',
|
|
303
|
+
text: `No saved sessions in ${sessionsDir()}. The user can create one with \`rpc login <url>\` ` +
|
|
304
|
+
'(a browser window) or `rpc login <url> --form` with RPC_LOGIN_EMAIL / RPC_LOGIN_PASSWORD set.',
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const lines = list.map(s => `${s.origin}: ${s.cookies} cookie(s)${s.savedAt ? `, saved ${s.savedAt}` : ''}` +
|
|
310
|
+
`${s.expired ? `, EXPIRED ${s.expires} (run \`rpc login ${s.origin}\`)` : s.expires ? `, expires ${s.expires}` : ''}`);
|
|
311
|
+
return { content: [{ type: 'text', text: lines.join('\n') + '\n\n' + JSON.stringify({ dir: sessionsDir(), sessions: list }, null, 2) }] };
|
|
312
|
+
});
|
|
171
313
|
return server;
|
|
172
314
|
}
|
|
173
315
|
/** Framed first screen (the hero), then the first maxSlices slices, each with a label. */
|
|
@@ -187,8 +329,10 @@ async function fullPageImages(browser, s, maxSlices) {
|
|
|
187
329
|
return out;
|
|
188
330
|
}
|
|
189
331
|
/** Plain-language first lines: failures with hints, and what full-page output covers. */
|
|
190
|
-
export function headline(shots, options) {
|
|
332
|
+
export function headline(shots, options, auth) {
|
|
191
333
|
const lines = [];
|
|
334
|
+
if (auth)
|
|
335
|
+
lines.push(`auth: ${describeAuth(auth)}`);
|
|
192
336
|
const failed = shots.filter(s => s.error);
|
|
193
337
|
if (failed.length === shots.length)
|
|
194
338
|
lines.push(`All ${shots.length} device(s) failed.`);
|
|
@@ -232,11 +376,12 @@ export function headline(shots, options) {
|
|
|
232
376
|
}
|
|
233
377
|
return lines.join('\n');
|
|
234
378
|
}
|
|
235
|
-
function summarise(url, outDir, shots) {
|
|
379
|
+
function summarise(url, outDir, shots, auth) {
|
|
236
380
|
return {
|
|
237
381
|
url,
|
|
238
382
|
outDir,
|
|
239
383
|
shots: shots.map(({ buffers: _b, ...s }) => s),
|
|
384
|
+
...(auth ? { auth } : {}),
|
|
240
385
|
};
|
|
241
386
|
}
|
|
242
387
|
export async function runMcpServer() {
|