@guanzhu.me/pw-cli 0.0.16 → 0.0.18
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/README.md +9 -0
- package/bin/pw-cli.js +73 -3
- package/package.json +20 -5
- package/src/browser-manager.js +433 -4
- package/src/cli.js +11 -4
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ Run a local script:
|
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
pw-cli run-script ./scrape.js --url https://example.com
|
|
75
|
+
pw-cli run-script --extension ./scrape.js --url https://example.com
|
|
75
76
|
```
|
|
76
77
|
|
|
77
78
|
`run-script` is intended for multi-step automation. Define an `async function main` that receives Playwright globals as a single object:
|
|
@@ -116,6 +117,13 @@ async function main({ page, args }) {
|
|
|
116
117
|
pw-cli run-script ./scripts/extract-links.js --url https://example.com --output links.json
|
|
117
118
|
```
|
|
118
119
|
|
|
120
|
+
To drive an already-open Chrome/Edge browser through Playwright MCP Bridge, add `--extension`:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pw-cli run-script --extension ./scripts/extract-links.js --url https://example.com
|
|
124
|
+
pw-cli run-script --extension=msedge ./scripts/extract-links.js --url https://example.com
|
|
125
|
+
```
|
|
126
|
+
|
|
119
127
|
Reuse a page that was opened through `pw-cli open`:
|
|
120
128
|
|
|
121
129
|
```bash
|
|
@@ -332,6 +340,7 @@ pw-cli run-code "await page.goto('https://example.com'); return await page.title
|
|
|
332
340
|
echo "return await page.url()" | pw-cli run-code
|
|
333
341
|
pw-cli run-script ./scripts/smoke.js --env prod
|
|
334
342
|
pw-cli run-script ./scripts/extract-links.js --url https://example.com --output links.json
|
|
343
|
+
pw-cli run-script --extension ./scripts/extract-links.js --url https://example.com
|
|
335
344
|
pw-cli click "//button[contains(., 'Submit')]"
|
|
336
345
|
pw-cli queue add goto https://example.com
|
|
337
346
|
pw-cli queue add snapshot
|
package/bin/pw-cli.js
CHANGED
|
@@ -113,12 +113,16 @@ function getCommandAndSession(argv) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function parsePwCliGlobalOptions(argv) {
|
|
116
|
-
const options = { headless: false, profile: 'default', port: 9223 };
|
|
116
|
+
const options = { headless: false, profile: 'default', port: 9223, extension: false };
|
|
117
117
|
|
|
118
118
|
for (let i = 0; i < argv.length; i++) {
|
|
119
119
|
const arg = argv[i];
|
|
120
120
|
if (arg === '--headless') {
|
|
121
121
|
options.headless = true;
|
|
122
|
+
} else if (arg === '--extension') {
|
|
123
|
+
options.extension = true;
|
|
124
|
+
} else if (arg.startsWith('--extension=')) {
|
|
125
|
+
options.extension = arg.split('=')[1] || true;
|
|
122
126
|
} else if (arg === '--profile' && argv[i + 1]) {
|
|
123
127
|
options.profile = argv[++i];
|
|
124
128
|
} else if (arg === '--port' && argv[i + 1]) {
|
|
@@ -260,6 +264,7 @@ Global options:
|
|
|
260
264
|
--version print version
|
|
261
265
|
-s, --session <name> choose browser session
|
|
262
266
|
--headless used by pw-cli-managed browser launches
|
|
267
|
+
--extension[=browser] run scripts/code through Playwright MCP Bridge (default browser: chrome)
|
|
263
268
|
|
|
264
269
|
Requirements:
|
|
265
270
|
Node.js 18+
|
|
@@ -302,9 +307,72 @@ What the script receives:
|
|
|
302
307
|
|
|
303
308
|
Example:
|
|
304
309
|
pw-cli run-script ./scripts/extract-links.js --url https://example.com --output links.json
|
|
310
|
+
pw-cli run-script --extension ./scripts/extract-links.js --url https://example.com
|
|
305
311
|
`;
|
|
306
312
|
}
|
|
307
313
|
|
|
314
|
+
const REQUIRED_POSITIONAL_ARGS = new Map([
|
|
315
|
+
['goto', { count: 1, usage: 'pw-cli goto <url>' }],
|
|
316
|
+
['type', { count: 1, usage: 'pw-cli type <text>' }],
|
|
317
|
+
['click', { count: 1, usage: 'pw-cli click <ref> [button]' }],
|
|
318
|
+
['dblclick', { count: 1, usage: 'pw-cli dblclick <ref> [button]' }],
|
|
319
|
+
['fill', { count: 2, usage: 'pw-cli fill <ref> <text>' }],
|
|
320
|
+
['drag', { count: 2, usage: 'pw-cli drag <startRef> <endRef>' }],
|
|
321
|
+
['hover', { count: 1, usage: 'pw-cli hover <ref>' }],
|
|
322
|
+
['select', { count: 2, usage: 'pw-cli select <ref> <value>' }],
|
|
323
|
+
['upload', { count: 1, usage: 'pw-cli upload <file>' }],
|
|
324
|
+
['check', { count: 1, usage: 'pw-cli check <ref>' }],
|
|
325
|
+
['uncheck', { count: 1, usage: 'pw-cli uncheck <ref>' }],
|
|
326
|
+
['eval', { count: 1, usage: 'pw-cli eval <func> [ref]' }],
|
|
327
|
+
['resize', { count: 2, usage: 'pw-cli resize <width> <height>' }],
|
|
328
|
+
['press', { count: 1, usage: 'pw-cli press <key>' }],
|
|
329
|
+
['keydown', { count: 1, usage: 'pw-cli keydown <key>' }],
|
|
330
|
+
['keyup', { count: 1, usage: 'pw-cli keyup <key>' }],
|
|
331
|
+
['mousemove', { count: 2, usage: 'pw-cli mousemove <x> <y>' }],
|
|
332
|
+
['mousewheel', { count: 2, usage: 'pw-cli mousewheel <dx> <dy>' }],
|
|
333
|
+
['tab-select', { count: 1, usage: 'pw-cli tab-select <index>' }],
|
|
334
|
+
['cookie-get', { count: 1, usage: 'pw-cli cookie-get <name>' }],
|
|
335
|
+
['cookie-set', { count: 2, usage: 'pw-cli cookie-set <name> <value>' }],
|
|
336
|
+
['cookie-delete', { count: 1, usage: 'pw-cli cookie-delete <name>' }],
|
|
337
|
+
['localstorage-get', { count: 1, usage: 'pw-cli localstorage-get <key>' }],
|
|
338
|
+
['localstorage-set', { count: 2, usage: 'pw-cli localstorage-set <key> <value>' }],
|
|
339
|
+
['localstorage-delete', { count: 1, usage: 'pw-cli localstorage-delete <key>' }],
|
|
340
|
+
['sessionstorage-get', { count: 1, usage: 'pw-cli sessionstorage-get <key>' }],
|
|
341
|
+
['sessionstorage-set', { count: 2, usage: 'pw-cli sessionstorage-set <key> <value>' }],
|
|
342
|
+
['sessionstorage-delete', { count: 1, usage: 'pw-cli sessionstorage-delete <key>' }],
|
|
343
|
+
['route', { count: 1, usage: 'pw-cli route <pattern>' }],
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
function getPositionalsAfterCommand(argv, command) {
|
|
347
|
+
const commandIdx = argv.indexOf(command);
|
|
348
|
+
if (commandIdx === -1) return [];
|
|
349
|
+
|
|
350
|
+
const positionals = [];
|
|
351
|
+
for (let i = commandIdx + 1; i < argv.length; i++) {
|
|
352
|
+
const arg = argv[i];
|
|
353
|
+
if (typeof arg !== 'string' || arg.length === 0) continue;
|
|
354
|
+
if (arg === '--') {
|
|
355
|
+
positionals.push(...argv.slice(i + 1).filter(Boolean));
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
if (!arg.startsWith('-')) {
|
|
359
|
+
positionals.push(arg);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return positionals;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validateRequiredArgs(argv, command) {
|
|
366
|
+
const rule = REQUIRED_POSITIONAL_ARGS.get(command);
|
|
367
|
+
if (!rule) return;
|
|
368
|
+
|
|
369
|
+
const positionals = getPositionalsAfterCommand(argv, command);
|
|
370
|
+
if (positionals.length >= rule.count) return;
|
|
371
|
+
|
|
372
|
+
process.stderr.write(`pw-cli: ${command} requires ${rule.count === 1 ? 'an argument' : `${rule.count} arguments`}\n\nUsage: ${rule.usage}\n`);
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
|
|
308
376
|
// Management commands that don't need a running browser
|
|
309
377
|
const MGMT_COMMANDS = new Set([
|
|
310
378
|
'open', 'close', 'list', 'kill-all', 'close-all', 'delete-data',
|
|
@@ -674,7 +742,7 @@ async function handleRunScript(rawArgv) {
|
|
|
674
742
|
process.stderr.write(`pw-cli: ${err.message || err}\n`);
|
|
675
743
|
process.exit(1);
|
|
676
744
|
} finally {
|
|
677
|
-
await conn.browser.close();
|
|
745
|
+
await (conn.close ? conn.close() : conn.browser.close());
|
|
678
746
|
}
|
|
679
747
|
process.exit(0);
|
|
680
748
|
}
|
|
@@ -721,7 +789,7 @@ async function handleRunCode(rawArgv) {
|
|
|
721
789
|
process.stderr.write(`pw-cli: ${err.message || err}\n`);
|
|
722
790
|
process.exit(1);
|
|
723
791
|
} finally {
|
|
724
|
-
await conn.browser.close();
|
|
792
|
+
await (conn.close ? conn.close() : conn.browser.close());
|
|
725
793
|
}
|
|
726
794
|
|
|
727
795
|
process.exit(0);
|
|
@@ -753,6 +821,8 @@ async function main() {
|
|
|
753
821
|
process.exit(1);
|
|
754
822
|
}
|
|
755
823
|
|
|
824
|
+
validateRequiredArgs(rawArgv, command);
|
|
825
|
+
|
|
756
826
|
// ── queue: batch actions and run them together ────────────────────────────
|
|
757
827
|
if (command === 'queue') {
|
|
758
828
|
await handleQueue(rawArgv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guanzhu.me/pw-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "Persistent Playwright browser CLI with headed defaults, profile support, queueing, and script execution",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pw-cli": "./bin/pw-cli.js"
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
"access": "public"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
+
"changeset": "changeset",
|
|
12
13
|
"lint": "node scripts/check-syntax.js",
|
|
13
14
|
"test": "node --test",
|
|
14
|
-
"verify": "npm run lint && npm test"
|
|
15
|
+
"verify": "npm run lint && npm test",
|
|
16
|
+
"version-packages": "changeset version",
|
|
17
|
+
"release": "changeset publish"
|
|
15
18
|
},
|
|
16
19
|
"files": [
|
|
17
20
|
"bin/",
|
|
@@ -23,8 +26,8 @@
|
|
|
23
26
|
"node": ">=18"
|
|
24
27
|
},
|
|
25
28
|
"peerDependencies": {
|
|
26
|
-
"playwright": ">=1.
|
|
27
|
-
"
|
|
29
|
+
"@playwright/cli": ">=0.1.0",
|
|
30
|
+
"playwright": ">=1.40.0"
|
|
28
31
|
},
|
|
29
32
|
"keywords": [
|
|
30
33
|
"playwright",
|
|
@@ -34,5 +37,17 @@
|
|
|
34
37
|
"chromium",
|
|
35
38
|
"testing"
|
|
36
39
|
],
|
|
37
|
-
"
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/wn0x00/pw-cli.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/wn0x00/pw-cli#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/wn0x00/pw-cli/issues"
|
|
47
|
+
},
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@changesets/cli": "^2.30.0",
|
|
51
|
+
"@types/node": "^25.5.2"
|
|
52
|
+
}
|
|
38
53
|
}
|
package/src/browser-manager.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { spawn } = require('child_process');
|
|
4
|
+
const http = require('http');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const os = require('os');
|
|
6
7
|
const fs = require('fs');
|
|
@@ -8,6 +9,7 @@ const crypto = require('crypto');
|
|
|
8
9
|
const { execSync } = require('child_process');
|
|
9
10
|
const { readState, writeState, clearState, getProfileDir } = require('./state');
|
|
10
11
|
const { probeCDP, findFreePort, sleep, fetchActivePageUrl } = require('./utils');
|
|
12
|
+
const { ws, wsServer } = require('../node_modules/@playwright/cli/node_modules/playwright-core/lib/utilsBundleImpl');
|
|
11
13
|
|
|
12
14
|
const DAEMON_SCRIPT = path.join(__dirname, 'launch-daemon.js');
|
|
13
15
|
|
|
@@ -61,6 +63,27 @@ function loadPlaywright() {
|
|
|
61
63
|
throw new Error('playwright is not installed. Run: npm install -g playwright');
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
function normalizeExtensionBrowser(extension) {
|
|
67
|
+
if (typeof extension === 'string' && extension.trim()) {
|
|
68
|
+
return extension.trim();
|
|
69
|
+
}
|
|
70
|
+
return 'chrome';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildExtensionConnectHeaders(extension) {
|
|
74
|
+
const browser = normalizeExtensionBrowser(extension);
|
|
75
|
+
const browserType = 'chromium';
|
|
76
|
+
const launchOptions = browser !== 'chromium' ? { channel: browser } : {};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
browserType,
|
|
80
|
+
headers: {
|
|
81
|
+
'x-playwright-browser': browserType,
|
|
82
|
+
'x-playwright-launch-options': JSON.stringify(launchOptions),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
function pickPage(pages, activeUrl) {
|
|
65
88
|
if (!pages || pages.length === 0) return null;
|
|
66
89
|
if (activeUrl) {
|
|
@@ -87,6 +110,385 @@ async function resolveContextAndPage(browser, cdpPort) {
|
|
|
87
110
|
return { context, page };
|
|
88
111
|
}
|
|
89
112
|
|
|
113
|
+
function getBrowserExecutableCandidates(browser) {
|
|
114
|
+
switch (browser) {
|
|
115
|
+
case 'msedge':
|
|
116
|
+
return process.platform === 'win32'
|
|
117
|
+
? [
|
|
118
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
119
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
120
|
+
]
|
|
121
|
+
: [];
|
|
122
|
+
case 'chromium':
|
|
123
|
+
case 'chrome':
|
|
124
|
+
default:
|
|
125
|
+
return process.platform === 'win32'
|
|
126
|
+
? [
|
|
127
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
128
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
129
|
+
]
|
|
130
|
+
: [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveExtensionExecutablePath(browser) {
|
|
135
|
+
if (process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH)) {
|
|
136
|
+
return process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const candidates = getBrowserExecutableCandidates(browser);
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
if (fs.existsSync(candidate)) {
|
|
142
|
+
return candidate;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new Error(`Unable to find executable for browser channel "${browser}". Set PLAYWRIGHT_MCP_EXECUTABLE_PATH or install ${browser}.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
class ManualPromise {
|
|
150
|
+
constructor() {
|
|
151
|
+
this._settled = false;
|
|
152
|
+
this.promise = new Promise((resolve, reject) => {
|
|
153
|
+
this._resolve = value => {
|
|
154
|
+
if (this._settled) return;
|
|
155
|
+
this._settled = true;
|
|
156
|
+
resolve(value);
|
|
157
|
+
};
|
|
158
|
+
this._reject = error => {
|
|
159
|
+
if (this._settled) return;
|
|
160
|
+
this._settled = true;
|
|
161
|
+
reject(error);
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
resolve(value) {
|
|
167
|
+
this._resolve(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
reject(error) {
|
|
171
|
+
this._reject(error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
class ExtensionConnection {
|
|
176
|
+
constructor(socket) {
|
|
177
|
+
this._socket = socket;
|
|
178
|
+
this._callbacks = new Map();
|
|
179
|
+
this._lastId = 0;
|
|
180
|
+
this._socket.on('message', this._onMessage.bind(this));
|
|
181
|
+
this._socket.on('close', this._onClose.bind(this));
|
|
182
|
+
this._socket.on('error', this._onError.bind(this));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
send(method, params) {
|
|
186
|
+
if (this._socket.readyState !== ws.OPEN) {
|
|
187
|
+
throw new Error(`Unexpected WebSocket state: ${this._socket.readyState}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const id = ++this._lastId;
|
|
191
|
+
this._socket.send(JSON.stringify({ id, method, params }));
|
|
192
|
+
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
this._callbacks.set(id, {
|
|
195
|
+
resolve,
|
|
196
|
+
reject,
|
|
197
|
+
error: new Error(`Protocol error: ${method}`),
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
close(reason) {
|
|
203
|
+
if (this._socket.readyState === ws.OPEN) {
|
|
204
|
+
this._socket.close(1000, reason);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_onMessage(message) {
|
|
209
|
+
const object = JSON.parse(message.toString());
|
|
210
|
+
|
|
211
|
+
if (object.id && this._callbacks.has(object.id)) {
|
|
212
|
+
const callback = this._callbacks.get(object.id);
|
|
213
|
+
this._callbacks.delete(object.id);
|
|
214
|
+
if (object.error) {
|
|
215
|
+
callback.error.message = object.error;
|
|
216
|
+
callback.reject(callback.error);
|
|
217
|
+
} else {
|
|
218
|
+
callback.resolve(object.result);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!object.id) {
|
|
224
|
+
this.onmessage?.(object.method, object.params);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_onClose() {
|
|
229
|
+
for (const callback of this._callbacks.values()) {
|
|
230
|
+
callback.reject(new Error('WebSocket closed'));
|
|
231
|
+
}
|
|
232
|
+
this._callbacks.clear();
|
|
233
|
+
this.onclose?.(this, 'WebSocket closed');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_onError() {
|
|
237
|
+
for (const callback of this._callbacks.values()) {
|
|
238
|
+
callback.reject(new Error('WebSocket error'));
|
|
239
|
+
}
|
|
240
|
+
this._callbacks.clear();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
class CDPRelayServer {
|
|
245
|
+
constructor(server, browserChannel, userDataDir, executablePath) {
|
|
246
|
+
this._playwrightConnection = null;
|
|
247
|
+
this._extensionConnection = null;
|
|
248
|
+
this._nextSessionId = 1;
|
|
249
|
+
this._browserChannel = browserChannel;
|
|
250
|
+
this._userDataDir = userDataDir;
|
|
251
|
+
this._executablePath = executablePath;
|
|
252
|
+
const uuid = crypto.randomUUID();
|
|
253
|
+
const address = server.address();
|
|
254
|
+
this._wsHost = `ws://127.0.0.1:${address.port}`;
|
|
255
|
+
this._cdpPath = `/cdp/${uuid}`;
|
|
256
|
+
this._extensionPath = `/extension/${uuid}`;
|
|
257
|
+
this._resetExtensionConnection();
|
|
258
|
+
this._wss = new wsServer({ server });
|
|
259
|
+
this._wss.on('connection', this._onConnection.bind(this));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
cdpEndpoint() {
|
|
263
|
+
return `${this._wsHost}${this._cdpPath}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
extensionEndpoint() {
|
|
267
|
+
return `${this._wsHost}${this._extensionPath}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async ensureExtensionConnectionForMCPContext(clientName) {
|
|
271
|
+
if (this._extensionConnection) return;
|
|
272
|
+
this._connectBrowser(clientName);
|
|
273
|
+
await Promise.race([
|
|
274
|
+
this._extensionConnectionPromise.promise,
|
|
275
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
276
|
+
reject(new Error('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed and enabled.'));
|
|
277
|
+
}, 5000)),
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
stop() {
|
|
282
|
+
this.closeConnections('Server stopped');
|
|
283
|
+
this._wss.close();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
closeConnections(reason) {
|
|
287
|
+
this._closePlaywrightConnection(reason);
|
|
288
|
+
this._closeExtensionConnection(reason);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_connectBrowser(clientName) {
|
|
292
|
+
const relayUrl = `${this._wsHost}${this._extensionPath}`;
|
|
293
|
+
const url = new URL('chrome-extension://mmlmfjhmonkocbjadbfplnigmagldckm/connect.html');
|
|
294
|
+
url.searchParams.set('mcpRelayUrl', relayUrl);
|
|
295
|
+
url.searchParams.set('client', JSON.stringify({ name: clientName }));
|
|
296
|
+
url.searchParams.set('protocolVersion', '1');
|
|
297
|
+
|
|
298
|
+
const executablePath = this._executablePath || resolveExtensionExecutablePath(this._browserChannel);
|
|
299
|
+
const args = [];
|
|
300
|
+
if (this._userDataDir) {
|
|
301
|
+
args.push(`--user-data-dir=${this._userDataDir}`);
|
|
302
|
+
}
|
|
303
|
+
args.push(url.toString());
|
|
304
|
+
|
|
305
|
+
const child = spawn(executablePath, args, {
|
|
306
|
+
windowsHide: true,
|
|
307
|
+
detached: true,
|
|
308
|
+
shell: false,
|
|
309
|
+
stdio: 'ignore',
|
|
310
|
+
});
|
|
311
|
+
child.unref();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_onConnection(socket, request) {
|
|
315
|
+
const url = new URL(`http://localhost${request.url}`);
|
|
316
|
+
if (url.pathname === this._cdpPath) {
|
|
317
|
+
this._handlePlaywrightConnection(socket);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (url.pathname === this._extensionPath) {
|
|
321
|
+
this._handleExtensionConnection(socket);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
socket.close(4004, 'Invalid path');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_handlePlaywrightConnection(socket) {
|
|
328
|
+
if (this._playwrightConnection) {
|
|
329
|
+
socket.close(1000, 'Another CDP client already connected');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this._playwrightConnection = socket;
|
|
333
|
+
socket.on('message', async data => {
|
|
334
|
+
const message = JSON.parse(data.toString());
|
|
335
|
+
try {
|
|
336
|
+
const result = await this._handleCDPCommand(message.method, message.params, message.sessionId);
|
|
337
|
+
this._sendToPlaywright({ id: message.id, sessionId: message.sessionId, result });
|
|
338
|
+
} catch (error) {
|
|
339
|
+
this._sendToPlaywright({
|
|
340
|
+
id: message.id,
|
|
341
|
+
sessionId: message.sessionId,
|
|
342
|
+
error: { message: error.message },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
socket.on('close', () => {
|
|
347
|
+
if (this._playwrightConnection !== socket) return;
|
|
348
|
+
this._playwrightConnection = null;
|
|
349
|
+
this._closeExtensionConnection('Playwright client disconnected');
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
_handleExtensionConnection(socket) {
|
|
354
|
+
if (this._extensionConnection) {
|
|
355
|
+
socket.close(1000, 'Another extension connection already established');
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this._extensionConnection = new ExtensionConnection(socket);
|
|
359
|
+
this._extensionConnection.onclose = current => {
|
|
360
|
+
if (this._extensionConnection !== current) return;
|
|
361
|
+
this._resetExtensionConnection();
|
|
362
|
+
this._closePlaywrightConnection('Extension disconnected');
|
|
363
|
+
};
|
|
364
|
+
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
|
365
|
+
this._extensionConnectionPromise.resolve();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
_handleExtensionMessage(method, params) {
|
|
369
|
+
if (method !== 'forwardCDPEvent') return;
|
|
370
|
+
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
|
371
|
+
this._sendToPlaywright({
|
|
372
|
+
sessionId,
|
|
373
|
+
method: params.method,
|
|
374
|
+
params: params.params,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async _handleCDPCommand(method, params, sessionId) {
|
|
379
|
+
switch (method) {
|
|
380
|
+
case 'Browser.getVersion':
|
|
381
|
+
return {
|
|
382
|
+
protocolVersion: '1.3',
|
|
383
|
+
product: 'Chrome/Extension-Bridge',
|
|
384
|
+
userAgent: 'CDP-Bridge-Server/1.0.0',
|
|
385
|
+
};
|
|
386
|
+
case 'Browser.setDownloadBehavior':
|
|
387
|
+
return {};
|
|
388
|
+
case 'Target.setAutoAttach': {
|
|
389
|
+
if (sessionId) break;
|
|
390
|
+
const { targetInfo } = await this._extensionConnection.send('attachToTab', {});
|
|
391
|
+
this._connectedTabInfo = {
|
|
392
|
+
targetInfo,
|
|
393
|
+
sessionId: `pw-tab-${this._nextSessionId++}`,
|
|
394
|
+
};
|
|
395
|
+
this._sendToPlaywright({
|
|
396
|
+
method: 'Target.attachedToTarget',
|
|
397
|
+
params: {
|
|
398
|
+
sessionId: this._connectedTabInfo.sessionId,
|
|
399
|
+
targetInfo: {
|
|
400
|
+
...this._connectedTabInfo.targetInfo,
|
|
401
|
+
attached: true,
|
|
402
|
+
},
|
|
403
|
+
waitingForDebugger: false,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
return {};
|
|
407
|
+
}
|
|
408
|
+
case 'Target.getTargetInfo':
|
|
409
|
+
return this._connectedTabInfo?.targetInfo;
|
|
410
|
+
default:
|
|
411
|
+
return this._forwardToExtension(method, params, sessionId);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async _forwardToExtension(method, params, sessionId) {
|
|
416
|
+
if (!this._extensionConnection) {
|
|
417
|
+
throw new Error('Extension not connected');
|
|
418
|
+
}
|
|
419
|
+
if (this._connectedTabInfo?.sessionId === sessionId) {
|
|
420
|
+
sessionId = undefined;
|
|
421
|
+
}
|
|
422
|
+
return this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
_sendToPlaywright(message) {
|
|
426
|
+
if (this._playwrightConnection?.readyState === ws.OPEN) {
|
|
427
|
+
this._playwrightConnection.send(JSON.stringify(message));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
_closeExtensionConnection(reason) {
|
|
432
|
+
this._extensionConnection?.close(reason);
|
|
433
|
+
this._extensionConnectionPromise.reject(new Error(reason));
|
|
434
|
+
this._resetExtensionConnection();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
_resetExtensionConnection() {
|
|
438
|
+
this._connectedTabInfo = undefined;
|
|
439
|
+
this._extensionConnection = null;
|
|
440
|
+
this._extensionConnectionPromise = new ManualPromise();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_closePlaywrightConnection(reason) {
|
|
444
|
+
if (this._playwrightConnection?.readyState === ws.OPEN) {
|
|
445
|
+
this._playwrightConnection.close(1000, reason);
|
|
446
|
+
}
|
|
447
|
+
this._playwrightConnection = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function startExtensionRelay(extension) {
|
|
452
|
+
const browser = normalizeExtensionBrowser(extension);
|
|
453
|
+
if (!['chrome', 'chromium', 'msedge'].includes(browser)) {
|
|
454
|
+
throw new Error(`--extension currently supports Chromium-based channels only (received "${browser}")`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const server = http.createServer();
|
|
458
|
+
await new Promise((resolve, reject) => {
|
|
459
|
+
server.once('error', reject);
|
|
460
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
461
|
+
});
|
|
462
|
+
const relay = new CDPRelayServer(server, browser, null, null);
|
|
463
|
+
await relay.ensureExtensionConnectionForMCPContext('pw-cli');
|
|
464
|
+
return { relay, server };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function getExtensionConnection(extension) {
|
|
468
|
+
const playwright = loadPlaywright();
|
|
469
|
+
const { relay, server } = await startExtensionRelay(extension);
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
|
473
|
+
const { context, page } = await resolveContextAndPage(browser, null);
|
|
474
|
+
return {
|
|
475
|
+
browser,
|
|
476
|
+
context,
|
|
477
|
+
page,
|
|
478
|
+
playwright,
|
|
479
|
+
close: async () => {
|
|
480
|
+
relay.stop();
|
|
481
|
+
await new Promise(resolve => server.close(resolve));
|
|
482
|
+
await browser.close();
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
} catch (error) {
|
|
486
|
+
relay.stop();
|
|
487
|
+
await new Promise(resolve => server.close(resolve));
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
90
492
|
// ---------------------------------------------------------------------------
|
|
91
493
|
// Our own CDP-based browser launcher (fallback when playwright-cli not running)
|
|
92
494
|
// ---------------------------------------------------------------------------
|
|
@@ -147,7 +549,11 @@ async function launchBrowser({ headless = false, profile = 'default', port: pref
|
|
|
147
549
|
// ---------------------------------------------------------------------------
|
|
148
550
|
// getConnection — tries playwright-cli browser first, then our own
|
|
149
551
|
// ---------------------------------------------------------------------------
|
|
150
|
-
async function getConnection({ headless = false, profile = 'default', port: preferredPort = 9223 } = {}) {
|
|
552
|
+
async function getConnection({ headless = false, profile = 'default', port: preferredPort = 9223, extension = false } = {}) {
|
|
553
|
+
if (extension) {
|
|
554
|
+
return getExtensionConnection(extension);
|
|
555
|
+
}
|
|
556
|
+
|
|
151
557
|
const playwright = loadPlaywright();
|
|
152
558
|
|
|
153
559
|
// 1. Try to reuse playwright-cli's browser via its CDP port
|
|
@@ -158,7 +564,15 @@ async function getConnection({ headless = false, profile = 'default', port: pref
|
|
|
158
564
|
try {
|
|
159
565
|
const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${cliCdpPort}`);
|
|
160
566
|
const { context, page } = await resolveContextAndPage(browser, cliCdpPort);
|
|
161
|
-
return {
|
|
567
|
+
return {
|
|
568
|
+
browser,
|
|
569
|
+
context,
|
|
570
|
+
page,
|
|
571
|
+
playwright,
|
|
572
|
+
close: async () => {
|
|
573
|
+
await browser.close();
|
|
574
|
+
},
|
|
575
|
+
};
|
|
162
576
|
} catch {
|
|
163
577
|
// fall through to own browser
|
|
164
578
|
}
|
|
@@ -188,7 +602,15 @@ async function getConnection({ headless = false, profile = 'default', port: pref
|
|
|
188
602
|
const browser = await playwright.chromium.connectOverCDP(cdpUrl);
|
|
189
603
|
const { context, page } = await resolveContextAndPage(browser, state ? state.port : null);
|
|
190
604
|
|
|
191
|
-
return {
|
|
605
|
+
return {
|
|
606
|
+
browser,
|
|
607
|
+
context,
|
|
608
|
+
page,
|
|
609
|
+
playwright,
|
|
610
|
+
close: async () => {
|
|
611
|
+
await browser.close();
|
|
612
|
+
},
|
|
613
|
+
};
|
|
192
614
|
}
|
|
193
615
|
|
|
194
616
|
async function killBrowser() {
|
|
@@ -208,4 +630,11 @@ async function killBrowser() {
|
|
|
208
630
|
return true;
|
|
209
631
|
}
|
|
210
632
|
|
|
211
|
-
module.exports = {
|
|
633
|
+
module.exports = {
|
|
634
|
+
getConnection,
|
|
635
|
+
killBrowser,
|
|
636
|
+
getPlaywrightCliCdpPort,
|
|
637
|
+
pickPage,
|
|
638
|
+
buildExtensionConnectHeaders,
|
|
639
|
+
normalizeExtensionBrowser,
|
|
640
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ const { readState } = require('./state');
|
|
|
6
6
|
const { readStdin, die, probeCDP } = require('./utils');
|
|
7
7
|
|
|
8
8
|
function parseArgs(argv) {
|
|
9
|
-
const global = { headless: false, profile: 'default', port: 9222 };
|
|
9
|
+
const global = { headless: false, profile: 'default', port: 9222, extension: false };
|
|
10
10
|
const rest = [];
|
|
11
11
|
let i = 0;
|
|
12
12
|
|
|
@@ -14,6 +14,10 @@ function parseArgs(argv) {
|
|
|
14
14
|
const arg = argv[i];
|
|
15
15
|
if (arg === '--headless') {
|
|
16
16
|
global.headless = true;
|
|
17
|
+
} else if (arg === '--extension') {
|
|
18
|
+
global.extension = true;
|
|
19
|
+
} else if (arg.startsWith('--extension=')) {
|
|
20
|
+
global.extension = arg.split('=')[1] || true;
|
|
17
21
|
} else if (arg === '--profile' && argv[i + 1]) {
|
|
18
22
|
global.profile = argv[++i];
|
|
19
23
|
} else if (arg === '--port' && argv[i + 1]) {
|
|
@@ -47,7 +51,7 @@ async function cmdRunCode(rest, opts) {
|
|
|
47
51
|
console.log(result);
|
|
48
52
|
}
|
|
49
53
|
} finally {
|
|
50
|
-
await conn.
|
|
54
|
+
await (conn.close ? conn.close() : conn.browser.close());
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -65,7 +69,8 @@ Script format (standard module):
|
|
|
65
69
|
Legacy bare-code scripts (without module.exports) are still supported.
|
|
66
70
|
|
|
67
71
|
Example:
|
|
68
|
-
pw-cli run-script ./scripts/extract-links.js --url https://example.com --output links.json
|
|
72
|
+
pw-cli run-script ./scripts/extract-links.js --url https://example.com --output links.json
|
|
73
|
+
pw-cli run-script --extension ./scripts/extract-links.js --url https://example.com`;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
async function cmdRunScript(rest, opts) {
|
|
@@ -81,7 +86,7 @@ async function cmdRunScript(rest, opts) {
|
|
|
81
86
|
console.log(result);
|
|
82
87
|
}
|
|
83
88
|
} finally {
|
|
84
|
-
await conn.browser.close();
|
|
89
|
+
await (conn.close ? conn.close() : conn.browser.close());
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -117,6 +122,7 @@ USAGE
|
|
|
117
122
|
|
|
118
123
|
GLOBAL OPTIONS
|
|
119
124
|
--headless Run browser headlessly (default: headed)
|
|
125
|
+
--extension[=name] Run code/scripts through Playwright MCP Bridge (default: chrome)
|
|
120
126
|
--profile <name> Named profile to use (default: "default")
|
|
121
127
|
--port <number> CDP port (default: 9222)
|
|
122
128
|
|
|
@@ -134,6 +140,7 @@ EXAMPLES
|
|
|
134
140
|
pw-cli run-code "await page.goto('https://example.com'); console.log(await page.title())"
|
|
135
141
|
echo "await page.screenshot({ path: 'out.png' })" | pw-cli run-code
|
|
136
142
|
pw-cli run-script ./scrape.js --url https://example.com
|
|
143
|
+
pw-cli run-script --extension ./scrape.js --url https://example.com
|
|
137
144
|
pw-cli run-script ./scripts/extract-links.js --url https://example.com --output links.json
|
|
138
145
|
pw-cli --headless run-code "await page.goto('https://example.com')"
|
|
139
146
|
pw-cli --profile work status
|