@guanzhu.me/pw-cli 0.0.17 → 0.0.19
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 +9 -3
- package/package.json +12 -5
- package/src/browser-manager.js +453 -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,6 +307,7 @@ 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
|
|
|
@@ -736,7 +742,7 @@ async function handleRunScript(rawArgv) {
|
|
|
736
742
|
process.stderr.write(`pw-cli: ${err.message || err}\n`);
|
|
737
743
|
process.exit(1);
|
|
738
744
|
} finally {
|
|
739
|
-
await conn.browser.close();
|
|
745
|
+
await (conn.close ? conn.close() : conn.browser.close());
|
|
740
746
|
}
|
|
741
747
|
process.exit(0);
|
|
742
748
|
}
|
|
@@ -783,7 +789,7 @@ async function handleRunCode(rawArgv) {
|
|
|
783
789
|
process.stderr.write(`pw-cli: ${err.message || err}\n`);
|
|
784
790
|
process.exit(1);
|
|
785
791
|
} finally {
|
|
786
|
-
await conn.browser.close();
|
|
792
|
+
await (conn.close ? conn.close() : conn.browser.close());
|
|
787
793
|
}
|
|
788
794
|
|
|
789
795
|
process.exit(0);
|
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.19",
|
|
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",
|
|
@@ -42,5 +45,9 @@
|
|
|
42
45
|
"bugs": {
|
|
43
46
|
"url": "https://github.com/wn0x00/pw-cli/issues"
|
|
44
47
|
},
|
|
45
|
-
"license": "MIT"
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@changesets/cli": "^2.30.0",
|
|
51
|
+
"@types/node": "^25.5.2"
|
|
52
|
+
}
|
|
46
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');
|
|
@@ -9,6 +10,27 @@ const { execSync } = require('child_process');
|
|
|
9
10
|
const { readState, writeState, clearState, getProfileDir } = require('./state');
|
|
10
11
|
const { probeCDP, findFreePort, sleep, fetchActivePageUrl } = require('./utils');
|
|
11
12
|
|
|
13
|
+
function loadPlaywrightUtilsBundle() {
|
|
14
|
+
const candidates = [
|
|
15
|
+
'../node_modules/@playwright/cli/node_modules/playwright-core/lib/utilsBundleImpl',
|
|
16
|
+
'../node_modules/@playwright/cli/node_modules/playwright-core/lib/utilsBundleImpl/index.js',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
for (const candidate of candidates) {
|
|
20
|
+
try {
|
|
21
|
+
return require(candidate);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code !== 'MODULE_NOT_FOUND') {
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error('Unable to load playwright-core utilsBundleImpl from @playwright/cli. Reinstall @playwright/cli or playwright.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { ws, wsServer } = loadPlaywrightUtilsBundle();
|
|
33
|
+
|
|
12
34
|
const DAEMON_SCRIPT = path.join(__dirname, 'launch-daemon.js');
|
|
13
35
|
|
|
14
36
|
// ---------------------------------------------------------------------------
|
|
@@ -61,6 +83,27 @@ function loadPlaywright() {
|
|
|
61
83
|
throw new Error('playwright is not installed. Run: npm install -g playwright');
|
|
62
84
|
}
|
|
63
85
|
|
|
86
|
+
function normalizeExtensionBrowser(extension) {
|
|
87
|
+
if (typeof extension === 'string' && extension.trim()) {
|
|
88
|
+
return extension.trim();
|
|
89
|
+
}
|
|
90
|
+
return 'chrome';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildExtensionConnectHeaders(extension) {
|
|
94
|
+
const browser = normalizeExtensionBrowser(extension);
|
|
95
|
+
const browserType = 'chromium';
|
|
96
|
+
const launchOptions = browser !== 'chromium' ? { channel: browser } : {};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
browserType,
|
|
100
|
+
headers: {
|
|
101
|
+
'x-playwright-browser': browserType,
|
|
102
|
+
'x-playwright-launch-options': JSON.stringify(launchOptions),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
64
107
|
function pickPage(pages, activeUrl) {
|
|
65
108
|
if (!pages || pages.length === 0) return null;
|
|
66
109
|
if (activeUrl) {
|
|
@@ -87,6 +130,385 @@ async function resolveContextAndPage(browser, cdpPort) {
|
|
|
87
130
|
return { context, page };
|
|
88
131
|
}
|
|
89
132
|
|
|
133
|
+
function getBrowserExecutableCandidates(browser) {
|
|
134
|
+
switch (browser) {
|
|
135
|
+
case 'msedge':
|
|
136
|
+
return process.platform === 'win32'
|
|
137
|
+
? [
|
|
138
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
139
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
140
|
+
]
|
|
141
|
+
: [];
|
|
142
|
+
case 'chromium':
|
|
143
|
+
case 'chrome':
|
|
144
|
+
default:
|
|
145
|
+
return process.platform === 'win32'
|
|
146
|
+
? [
|
|
147
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
148
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
149
|
+
]
|
|
150
|
+
: [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resolveExtensionExecutablePath(browser) {
|
|
155
|
+
if (process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH)) {
|
|
156
|
+
return process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const candidates = getBrowserExecutableCandidates(browser);
|
|
160
|
+
for (const candidate of candidates) {
|
|
161
|
+
if (fs.existsSync(candidate)) {
|
|
162
|
+
return candidate;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
throw new Error(`Unable to find executable for browser channel "${browser}". Set PLAYWRIGHT_MCP_EXECUTABLE_PATH or install ${browser}.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class ManualPromise {
|
|
170
|
+
constructor() {
|
|
171
|
+
this._settled = false;
|
|
172
|
+
this.promise = new Promise((resolve, reject) => {
|
|
173
|
+
this._resolve = value => {
|
|
174
|
+
if (this._settled) return;
|
|
175
|
+
this._settled = true;
|
|
176
|
+
resolve(value);
|
|
177
|
+
};
|
|
178
|
+
this._reject = error => {
|
|
179
|
+
if (this._settled) return;
|
|
180
|
+
this._settled = true;
|
|
181
|
+
reject(error);
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
resolve(value) {
|
|
187
|
+
this._resolve(value);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
reject(error) {
|
|
191
|
+
this._reject(error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
class ExtensionConnection {
|
|
196
|
+
constructor(socket) {
|
|
197
|
+
this._socket = socket;
|
|
198
|
+
this._callbacks = new Map();
|
|
199
|
+
this._lastId = 0;
|
|
200
|
+
this._socket.on('message', this._onMessage.bind(this));
|
|
201
|
+
this._socket.on('close', this._onClose.bind(this));
|
|
202
|
+
this._socket.on('error', this._onError.bind(this));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
send(method, params) {
|
|
206
|
+
if (this._socket.readyState !== ws.OPEN) {
|
|
207
|
+
throw new Error(`Unexpected WebSocket state: ${this._socket.readyState}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const id = ++this._lastId;
|
|
211
|
+
this._socket.send(JSON.stringify({ id, method, params }));
|
|
212
|
+
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
this._callbacks.set(id, {
|
|
215
|
+
resolve,
|
|
216
|
+
reject,
|
|
217
|
+
error: new Error(`Protocol error: ${method}`),
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
close(reason) {
|
|
223
|
+
if (this._socket.readyState === ws.OPEN) {
|
|
224
|
+
this._socket.close(1000, reason);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_onMessage(message) {
|
|
229
|
+
const object = JSON.parse(message.toString());
|
|
230
|
+
|
|
231
|
+
if (object.id && this._callbacks.has(object.id)) {
|
|
232
|
+
const callback = this._callbacks.get(object.id);
|
|
233
|
+
this._callbacks.delete(object.id);
|
|
234
|
+
if (object.error) {
|
|
235
|
+
callback.error.message = object.error;
|
|
236
|
+
callback.reject(callback.error);
|
|
237
|
+
} else {
|
|
238
|
+
callback.resolve(object.result);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!object.id) {
|
|
244
|
+
this.onmessage?.(object.method, object.params);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_onClose() {
|
|
249
|
+
for (const callback of this._callbacks.values()) {
|
|
250
|
+
callback.reject(new Error('WebSocket closed'));
|
|
251
|
+
}
|
|
252
|
+
this._callbacks.clear();
|
|
253
|
+
this.onclose?.(this, 'WebSocket closed');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_onError() {
|
|
257
|
+
for (const callback of this._callbacks.values()) {
|
|
258
|
+
callback.reject(new Error('WebSocket error'));
|
|
259
|
+
}
|
|
260
|
+
this._callbacks.clear();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
class CDPRelayServer {
|
|
265
|
+
constructor(server, browserChannel, userDataDir, executablePath) {
|
|
266
|
+
this._playwrightConnection = null;
|
|
267
|
+
this._extensionConnection = null;
|
|
268
|
+
this._nextSessionId = 1;
|
|
269
|
+
this._browserChannel = browserChannel;
|
|
270
|
+
this._userDataDir = userDataDir;
|
|
271
|
+
this._executablePath = executablePath;
|
|
272
|
+
const uuid = crypto.randomUUID();
|
|
273
|
+
const address = server.address();
|
|
274
|
+
this._wsHost = `ws://127.0.0.1:${address.port}`;
|
|
275
|
+
this._cdpPath = `/cdp/${uuid}`;
|
|
276
|
+
this._extensionPath = `/extension/${uuid}`;
|
|
277
|
+
this._resetExtensionConnection();
|
|
278
|
+
this._wss = new wsServer({ server });
|
|
279
|
+
this._wss.on('connection', this._onConnection.bind(this));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
cdpEndpoint() {
|
|
283
|
+
return `${this._wsHost}${this._cdpPath}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
extensionEndpoint() {
|
|
287
|
+
return `${this._wsHost}${this._extensionPath}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async ensureExtensionConnectionForMCPContext(clientName) {
|
|
291
|
+
if (this._extensionConnection) return;
|
|
292
|
+
this._connectBrowser(clientName);
|
|
293
|
+
await Promise.race([
|
|
294
|
+
this._extensionConnectionPromise.promise,
|
|
295
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
296
|
+
reject(new Error('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed and enabled.'));
|
|
297
|
+
}, 5000)),
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
stop() {
|
|
302
|
+
this.closeConnections('Server stopped');
|
|
303
|
+
this._wss.close();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
closeConnections(reason) {
|
|
307
|
+
this._closePlaywrightConnection(reason);
|
|
308
|
+
this._closeExtensionConnection(reason);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_connectBrowser(clientName) {
|
|
312
|
+
const relayUrl = `${this._wsHost}${this._extensionPath}`;
|
|
313
|
+
const url = new URL('chrome-extension://mmlmfjhmonkocbjadbfplnigmagldckm/connect.html');
|
|
314
|
+
url.searchParams.set('mcpRelayUrl', relayUrl);
|
|
315
|
+
url.searchParams.set('client', JSON.stringify({ name: clientName }));
|
|
316
|
+
url.searchParams.set('protocolVersion', '1');
|
|
317
|
+
|
|
318
|
+
const executablePath = this._executablePath || resolveExtensionExecutablePath(this._browserChannel);
|
|
319
|
+
const args = [];
|
|
320
|
+
if (this._userDataDir) {
|
|
321
|
+
args.push(`--user-data-dir=${this._userDataDir}`);
|
|
322
|
+
}
|
|
323
|
+
args.push(url.toString());
|
|
324
|
+
|
|
325
|
+
const child = spawn(executablePath, args, {
|
|
326
|
+
windowsHide: true,
|
|
327
|
+
detached: true,
|
|
328
|
+
shell: false,
|
|
329
|
+
stdio: 'ignore',
|
|
330
|
+
});
|
|
331
|
+
child.unref();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_onConnection(socket, request) {
|
|
335
|
+
const url = new URL(`http://localhost${request.url}`);
|
|
336
|
+
if (url.pathname === this._cdpPath) {
|
|
337
|
+
this._handlePlaywrightConnection(socket);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (url.pathname === this._extensionPath) {
|
|
341
|
+
this._handleExtensionConnection(socket);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
socket.close(4004, 'Invalid path');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
_handlePlaywrightConnection(socket) {
|
|
348
|
+
if (this._playwrightConnection) {
|
|
349
|
+
socket.close(1000, 'Another CDP client already connected');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this._playwrightConnection = socket;
|
|
353
|
+
socket.on('message', async data => {
|
|
354
|
+
const message = JSON.parse(data.toString());
|
|
355
|
+
try {
|
|
356
|
+
const result = await this._handleCDPCommand(message.method, message.params, message.sessionId);
|
|
357
|
+
this._sendToPlaywright({ id: message.id, sessionId: message.sessionId, result });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this._sendToPlaywright({
|
|
360
|
+
id: message.id,
|
|
361
|
+
sessionId: message.sessionId,
|
|
362
|
+
error: { message: error.message },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
socket.on('close', () => {
|
|
367
|
+
if (this._playwrightConnection !== socket) return;
|
|
368
|
+
this._playwrightConnection = null;
|
|
369
|
+
this._closeExtensionConnection('Playwright client disconnected');
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
_handleExtensionConnection(socket) {
|
|
374
|
+
if (this._extensionConnection) {
|
|
375
|
+
socket.close(1000, 'Another extension connection already established');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this._extensionConnection = new ExtensionConnection(socket);
|
|
379
|
+
this._extensionConnection.onclose = current => {
|
|
380
|
+
if (this._extensionConnection !== current) return;
|
|
381
|
+
this._resetExtensionConnection();
|
|
382
|
+
this._closePlaywrightConnection('Extension disconnected');
|
|
383
|
+
};
|
|
384
|
+
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
|
385
|
+
this._extensionConnectionPromise.resolve();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
_handleExtensionMessage(method, params) {
|
|
389
|
+
if (method !== 'forwardCDPEvent') return;
|
|
390
|
+
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
|
391
|
+
this._sendToPlaywright({
|
|
392
|
+
sessionId,
|
|
393
|
+
method: params.method,
|
|
394
|
+
params: params.params,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async _handleCDPCommand(method, params, sessionId) {
|
|
399
|
+
switch (method) {
|
|
400
|
+
case 'Browser.getVersion':
|
|
401
|
+
return {
|
|
402
|
+
protocolVersion: '1.3',
|
|
403
|
+
product: 'Chrome/Extension-Bridge',
|
|
404
|
+
userAgent: 'CDP-Bridge-Server/1.0.0',
|
|
405
|
+
};
|
|
406
|
+
case 'Browser.setDownloadBehavior':
|
|
407
|
+
return {};
|
|
408
|
+
case 'Target.setAutoAttach': {
|
|
409
|
+
if (sessionId) break;
|
|
410
|
+
const { targetInfo } = await this._extensionConnection.send('attachToTab', {});
|
|
411
|
+
this._connectedTabInfo = {
|
|
412
|
+
targetInfo,
|
|
413
|
+
sessionId: `pw-tab-${this._nextSessionId++}`,
|
|
414
|
+
};
|
|
415
|
+
this._sendToPlaywright({
|
|
416
|
+
method: 'Target.attachedToTarget',
|
|
417
|
+
params: {
|
|
418
|
+
sessionId: this._connectedTabInfo.sessionId,
|
|
419
|
+
targetInfo: {
|
|
420
|
+
...this._connectedTabInfo.targetInfo,
|
|
421
|
+
attached: true,
|
|
422
|
+
},
|
|
423
|
+
waitingForDebugger: false,
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
return {};
|
|
427
|
+
}
|
|
428
|
+
case 'Target.getTargetInfo':
|
|
429
|
+
return this._connectedTabInfo?.targetInfo;
|
|
430
|
+
default:
|
|
431
|
+
return this._forwardToExtension(method, params, sessionId);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async _forwardToExtension(method, params, sessionId) {
|
|
436
|
+
if (!this._extensionConnection) {
|
|
437
|
+
throw new Error('Extension not connected');
|
|
438
|
+
}
|
|
439
|
+
if (this._connectedTabInfo?.sessionId === sessionId) {
|
|
440
|
+
sessionId = undefined;
|
|
441
|
+
}
|
|
442
|
+
return this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
_sendToPlaywright(message) {
|
|
446
|
+
if (this._playwrightConnection?.readyState === ws.OPEN) {
|
|
447
|
+
this._playwrightConnection.send(JSON.stringify(message));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_closeExtensionConnection(reason) {
|
|
452
|
+
this._extensionConnection?.close(reason);
|
|
453
|
+
this._extensionConnectionPromise.reject(new Error(reason));
|
|
454
|
+
this._resetExtensionConnection();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_resetExtensionConnection() {
|
|
458
|
+
this._connectedTabInfo = undefined;
|
|
459
|
+
this._extensionConnection = null;
|
|
460
|
+
this._extensionConnectionPromise = new ManualPromise();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
_closePlaywrightConnection(reason) {
|
|
464
|
+
if (this._playwrightConnection?.readyState === ws.OPEN) {
|
|
465
|
+
this._playwrightConnection.close(1000, reason);
|
|
466
|
+
}
|
|
467
|
+
this._playwrightConnection = null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function startExtensionRelay(extension) {
|
|
472
|
+
const browser = normalizeExtensionBrowser(extension);
|
|
473
|
+
if (!['chrome', 'chromium', 'msedge'].includes(browser)) {
|
|
474
|
+
throw new Error(`--extension currently supports Chromium-based channels only (received "${browser}")`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const server = http.createServer();
|
|
478
|
+
await new Promise((resolve, reject) => {
|
|
479
|
+
server.once('error', reject);
|
|
480
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
481
|
+
});
|
|
482
|
+
const relay = new CDPRelayServer(server, browser, null, null);
|
|
483
|
+
await relay.ensureExtensionConnectionForMCPContext('pw-cli');
|
|
484
|
+
return { relay, server };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function getExtensionConnection(extension) {
|
|
488
|
+
const playwright = loadPlaywright();
|
|
489
|
+
const { relay, server } = await startExtensionRelay(extension);
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
|
493
|
+
const { context, page } = await resolveContextAndPage(browser, null);
|
|
494
|
+
return {
|
|
495
|
+
browser,
|
|
496
|
+
context,
|
|
497
|
+
page,
|
|
498
|
+
playwright,
|
|
499
|
+
close: async () => {
|
|
500
|
+
relay.stop();
|
|
501
|
+
await new Promise(resolve => server.close(resolve));
|
|
502
|
+
await browser.close();
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
} catch (error) {
|
|
506
|
+
relay.stop();
|
|
507
|
+
await new Promise(resolve => server.close(resolve));
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
90
512
|
// ---------------------------------------------------------------------------
|
|
91
513
|
// Our own CDP-based browser launcher (fallback when playwright-cli not running)
|
|
92
514
|
// ---------------------------------------------------------------------------
|
|
@@ -147,7 +569,11 @@ async function launchBrowser({ headless = false, profile = 'default', port: pref
|
|
|
147
569
|
// ---------------------------------------------------------------------------
|
|
148
570
|
// getConnection — tries playwright-cli browser first, then our own
|
|
149
571
|
// ---------------------------------------------------------------------------
|
|
150
|
-
async function getConnection({ headless = false, profile = 'default', port: preferredPort = 9223 } = {}) {
|
|
572
|
+
async function getConnection({ headless = false, profile = 'default', port: preferredPort = 9223, extension = false } = {}) {
|
|
573
|
+
if (extension) {
|
|
574
|
+
return getExtensionConnection(extension);
|
|
575
|
+
}
|
|
576
|
+
|
|
151
577
|
const playwright = loadPlaywright();
|
|
152
578
|
|
|
153
579
|
// 1. Try to reuse playwright-cli's browser via its CDP port
|
|
@@ -158,7 +584,15 @@ async function getConnection({ headless = false, profile = 'default', port: pref
|
|
|
158
584
|
try {
|
|
159
585
|
const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${cliCdpPort}`);
|
|
160
586
|
const { context, page } = await resolveContextAndPage(browser, cliCdpPort);
|
|
161
|
-
return {
|
|
587
|
+
return {
|
|
588
|
+
browser,
|
|
589
|
+
context,
|
|
590
|
+
page,
|
|
591
|
+
playwright,
|
|
592
|
+
close: async () => {
|
|
593
|
+
await browser.close();
|
|
594
|
+
},
|
|
595
|
+
};
|
|
162
596
|
} catch {
|
|
163
597
|
// fall through to own browser
|
|
164
598
|
}
|
|
@@ -188,7 +622,15 @@ async function getConnection({ headless = false, profile = 'default', port: pref
|
|
|
188
622
|
const browser = await playwright.chromium.connectOverCDP(cdpUrl);
|
|
189
623
|
const { context, page } = await resolveContextAndPage(browser, state ? state.port : null);
|
|
190
624
|
|
|
191
|
-
return {
|
|
625
|
+
return {
|
|
626
|
+
browser,
|
|
627
|
+
context,
|
|
628
|
+
page,
|
|
629
|
+
playwright,
|
|
630
|
+
close: async () => {
|
|
631
|
+
await browser.close();
|
|
632
|
+
},
|
|
633
|
+
};
|
|
192
634
|
}
|
|
193
635
|
|
|
194
636
|
async function killBrowser() {
|
|
@@ -208,4 +650,11 @@ async function killBrowser() {
|
|
|
208
650
|
return true;
|
|
209
651
|
}
|
|
210
652
|
|
|
211
|
-
module.exports = {
|
|
653
|
+
module.exports = {
|
|
654
|
+
getConnection,
|
|
655
|
+
killBrowser,
|
|
656
|
+
getPlaywrightCliCdpPort,
|
|
657
|
+
pickPage,
|
|
658
|
+
buildExtensionConnectHeaders,
|
|
659
|
+
normalizeExtensionBrowser,
|
|
660
|
+
};
|
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
|