@xcanwin/manyoyo 5.8.9 → 5.8.10
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/bin/manyoyo.js +24 -66
- package/lib/plugin/playwright.js +1049 -169
- package/lib/web/server.js +2173 -209
- package/package.json +1 -1
- package/lib/plugin/playwright-bootstrap.js +0 -116
- package/lib/plugin/playwright-command-output.js +0 -95
- package/lib/plugin/playwright-container-runtime.js +0 -94
- package/lib/plugin/playwright-extension-manager.js +0 -265
- package/lib/plugin/playwright-extension-paths.js +0 -98
- package/lib/plugin/playwright-host-runtime.js +0 -114
- package/lib/plugin/playwright-scene-config.js +0 -137
- package/lib/plugin/playwright-scene-drivers.js +0 -285
- package/lib/plugin/playwright-scene-state.js +0 -80
- package/lib/web/agent-command.js +0 -153
- package/lib/web/api-route-helpers.js +0 -88
- package/lib/web/container-exec.js +0 -215
- package/lib/web/http-handlers.js +0 -163
- package/lib/web/runtime-state.js +0 -50
- package/lib/web/server-context.js +0 -71
- package/lib/web/server-lifecycle.js +0 -129
- package/lib/web/session-api-routes.js +0 -390
- package/lib/web/structured-output.js +0 -149
- package/lib/web/structured-trace.js +0 -603
- package/lib/web/system-api-routes.js +0 -114
- package/lib/web/terminal-session.js +0 -205
- package/lib/web/upgrade-handler.js +0 -94
package/lib/plugin/playwright.js
CHANGED
|
@@ -5,17 +5,8 @@ const net = require('net');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const crypto = require('crypto');
|
|
8
|
-
const { spawnSync } = require('child_process');
|
|
8
|
+
const { spawn, spawnSync } = require('child_process');
|
|
9
9
|
const { playwrightCliVersion: PLAYWRIGHT_CLI_VERSION } = require('../../package.json');
|
|
10
|
-
const { PlaywrightExtensionManager } = require('./playwright-extension-manager');
|
|
11
|
-
const { createPlaywrightCommandOutputManager } = require('./playwright-command-output');
|
|
12
|
-
const { createPlaywrightContainerRuntimeManager } = require('./playwright-container-runtime');
|
|
13
|
-
const { createPlaywrightExtensionPathManager } = require('./playwright-extension-paths');
|
|
14
|
-
const { createPlaywrightHostRuntimeManager } = require('./playwright-host-runtime');
|
|
15
|
-
const { createPlaywrightBootstrapManager } = require('./playwright-bootstrap');
|
|
16
|
-
const { createPlaywrightSceneConfigManager } = require('./playwright-scene-config');
|
|
17
|
-
const { createPlaywrightSceneDrivers } = require('./playwright-scene-drivers');
|
|
18
|
-
const { createPlaywrightSceneStateManager } = require('./playwright-scene-state');
|
|
19
10
|
|
|
20
11
|
const EXTENSIONS = [
|
|
21
12
|
['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
|
|
@@ -86,12 +77,6 @@ const SCENE_DEFS = {
|
|
|
86
77
|
|
|
87
78
|
const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
|
|
88
79
|
const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
|
|
89
|
-
const DIRECT_ACTION_HANDLERS = {
|
|
90
|
-
ls: (plugin) => plugin.printSummary(),
|
|
91
|
-
'mcp-add': (plugin, options) => plugin.printMcpAdd(options.host),
|
|
92
|
-
'cli-add': (plugin) => plugin.printCliAdd(),
|
|
93
|
-
'ext-download': (plugin, options) => plugin.downloadExtensions({ prodversion: options.prodversion })
|
|
94
|
-
};
|
|
95
80
|
const CONTAINER_EXTENSION_ROOT = '/app/extensions';
|
|
96
81
|
const DEFAULT_FINGERPRINT_PROFILE = {
|
|
97
82
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
|
@@ -169,6 +154,129 @@ function asBoolean(value, fallback = false) {
|
|
|
169
154
|
return fallback;
|
|
170
155
|
}
|
|
171
156
|
|
|
157
|
+
function isHostPermission(value) {
|
|
158
|
+
if (value === '<all_urls>') {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return /^(?:\*|http|https|file|ftp):\/\//.test(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scriptSourcesFromHtml(htmlFile) {
|
|
165
|
+
const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
|
|
166
|
+
const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
|
|
167
|
+
return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function convertManifestV2ToV3(extDir) {
|
|
171
|
+
const manifestFile = path.join(extDir, 'manifest.json');
|
|
172
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
173
|
+
if (manifest.manifest_version !== 2) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
manifest.manifest_version = 3;
|
|
178
|
+
|
|
179
|
+
if (manifest.browser_action && !manifest.action) {
|
|
180
|
+
manifest.action = manifest.browser_action;
|
|
181
|
+
delete manifest.browser_action;
|
|
182
|
+
}
|
|
183
|
+
if (manifest.page_action && !manifest.action) {
|
|
184
|
+
manifest.action = manifest.page_action;
|
|
185
|
+
delete manifest.page_action;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const background = manifest.background;
|
|
189
|
+
if (background && typeof background === 'object' && !Array.isArray(background)) {
|
|
190
|
+
let scripts = [];
|
|
191
|
+
if (Array.isArray(background.scripts)) {
|
|
192
|
+
scripts = background.scripts.filter(s => typeof s === 'string');
|
|
193
|
+
} else if (typeof background.page === 'string') {
|
|
194
|
+
const pagePath = path.join(extDir, background.page);
|
|
195
|
+
if (fs.existsSync(pagePath)) {
|
|
196
|
+
scripts = scriptSourcesFromHtml(pagePath);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (scripts.length > 0) {
|
|
201
|
+
const swName = 'generated_background_sw.js';
|
|
202
|
+
const swFile = path.join(extDir, swName);
|
|
203
|
+
const swLines = [
|
|
204
|
+
'// Auto-generated by manyoyo playwright ext-download for MV3.',
|
|
205
|
+
`importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
|
|
206
|
+
''
|
|
207
|
+
];
|
|
208
|
+
fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
|
|
209
|
+
manifest.background = { service_worker: swName };
|
|
210
|
+
} else {
|
|
211
|
+
delete manifest.background;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof manifest.content_security_policy === 'string') {
|
|
216
|
+
manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (Array.isArray(manifest.permissions)) {
|
|
220
|
+
const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
|
|
221
|
+
const keptPermissions = [];
|
|
222
|
+
|
|
223
|
+
for (const perm of manifest.permissions) {
|
|
224
|
+
if (typeof perm === 'string' && isHostPermission(perm)) {
|
|
225
|
+
if (!hostPermissions.includes(perm)) {
|
|
226
|
+
hostPermissions.push(perm);
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
keptPermissions.push(perm);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
manifest.permissions = keptPermissions;
|
|
234
|
+
if (hostPermissions.length > 0) {
|
|
235
|
+
manifest.host_permissions = hostPermissions;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const war = manifest.web_accessible_resources;
|
|
240
|
+
if (Array.isArray(war) && war.length > 0 && war.every(v => typeof v === 'string')) {
|
|
241
|
+
manifest.web_accessible_resources = [
|
|
242
|
+
{
|
|
243
|
+
resources: war,
|
|
244
|
+
matches: ['<all_urls>']
|
|
245
|
+
}
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildCrxUrl(extId, prodversion) {
|
|
254
|
+
return (
|
|
255
|
+
'https://clients2.google.com/service/update2/crx' +
|
|
256
|
+
`?response=redirect&prodversion=${prodversion}` +
|
|
257
|
+
'&acceptformat=crx2,crx3' +
|
|
258
|
+
`&x=id%3D${extId}%26installsource%3Dondemand%26uc`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function crxZipOffset(data) {
|
|
263
|
+
if (data.subarray(0, 4).toString('ascii') !== 'Cr24') {
|
|
264
|
+
throw new Error('not a CRX file');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const version = data.readUInt32LE(4);
|
|
268
|
+
if (version === 2) {
|
|
269
|
+
const pubLen = data.readUInt32LE(8);
|
|
270
|
+
const sigLen = data.readUInt32LE(12);
|
|
271
|
+
return 16 + pubLen + sigLen;
|
|
272
|
+
}
|
|
273
|
+
if (version === 3) {
|
|
274
|
+
const headerLen = data.readUInt32LE(8);
|
|
275
|
+
return 12 + headerLen;
|
|
276
|
+
}
|
|
277
|
+
throw new Error(`unsupported CRX version: ${version}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
172
280
|
class PlaywrightPlugin {
|
|
173
281
|
constructor(options = {}) {
|
|
174
282
|
this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
|
|
@@ -177,58 +285,6 @@ class PlaywrightPlugin {
|
|
|
177
285
|
this.globalConfig = asObject(options.globalConfig);
|
|
178
286
|
this.runConfig = asObject(options.runConfig);
|
|
179
287
|
this.config = this.resolveConfig();
|
|
180
|
-
this.extensionManager = new PlaywrightExtensionManager({
|
|
181
|
-
extensions: EXTENSIONS,
|
|
182
|
-
ensureCommandAvailable: this.ensureCommandAvailable.bind(this),
|
|
183
|
-
runCmd: this.runCmd.bind(this),
|
|
184
|
-
writeStdout: this.writeStdout.bind(this),
|
|
185
|
-
writeStderr: this.writeStderr.bind(this),
|
|
186
|
-
extensionDirPath: this.extensionDirPath.bind(this),
|
|
187
|
-
extensionTmpDirPath: this.extensionTmpDirPath.bind(this),
|
|
188
|
-
defaultProdversion: this.config.extensionProdversion
|
|
189
|
-
});
|
|
190
|
-
this.containerRuntimeManager = createPlaywrightContainerRuntimeManager({
|
|
191
|
-
plugin: this,
|
|
192
|
-
sceneDefs: SCENE_DEFS
|
|
193
|
-
});
|
|
194
|
-
this.commandOutputManager = createPlaywrightCommandOutputManager({
|
|
195
|
-
plugin: this,
|
|
196
|
-
isMcpScene,
|
|
197
|
-
playwrightCliVersion: PLAYWRIGHT_CLI_VERSION
|
|
198
|
-
});
|
|
199
|
-
this.hostRuntimeManager = createPlaywrightHostRuntimeManager({
|
|
200
|
-
plugin: this,
|
|
201
|
-
isCliScene,
|
|
202
|
-
sleep
|
|
203
|
-
});
|
|
204
|
-
this.extensionPathManager = createPlaywrightExtensionPathManager({
|
|
205
|
-
plugin: this,
|
|
206
|
-
asStringArray,
|
|
207
|
-
containerExtensionRoot: CONTAINER_EXTENSION_ROOT
|
|
208
|
-
});
|
|
209
|
-
this.bootstrapManager = createPlaywrightBootstrapManager({
|
|
210
|
-
plugin: this,
|
|
211
|
-
isCliScene
|
|
212
|
-
});
|
|
213
|
-
this.sceneConfigManager = createPlaywrightSceneConfigManager({
|
|
214
|
-
plugin: this,
|
|
215
|
-
sceneDefs: SCENE_DEFS,
|
|
216
|
-
isCliScene,
|
|
217
|
-
asStringArray,
|
|
218
|
-
defaultFingerprintProfile: DEFAULT_FINGERPRINT_PROFILE,
|
|
219
|
-
disableWebRtcLaunchArgs: DISABLE_WEBRTC_LAUNCH_ARGS
|
|
220
|
-
});
|
|
221
|
-
this.sceneStateManager = createPlaywrightSceneStateManager({
|
|
222
|
-
plugin: this
|
|
223
|
-
});
|
|
224
|
-
this.sceneDrivers = createPlaywrightSceneDrivers({
|
|
225
|
-
plugin: this,
|
|
226
|
-
sceneDefs: SCENE_DEFS,
|
|
227
|
-
isCliScene,
|
|
228
|
-
asStringArray,
|
|
229
|
-
tailText,
|
|
230
|
-
sleep
|
|
231
|
-
});
|
|
232
288
|
}
|
|
233
289
|
|
|
234
290
|
resolveConfig() {
|
|
@@ -416,31 +472,41 @@ class PlaywrightPlugin {
|
|
|
416
472
|
}
|
|
417
473
|
|
|
418
474
|
sceneEndpointPath(sceneName) {
|
|
419
|
-
return this.
|
|
475
|
+
return path.join(this.config.runDir, `${sceneName}.endpoint.json`);
|
|
420
476
|
}
|
|
421
477
|
|
|
422
478
|
readSceneEndpoint(sceneName) {
|
|
423
|
-
|
|
479
|
+
const filePath = this.sceneEndpointPath(sceneName);
|
|
480
|
+
if (!fs.existsSync(filePath)) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
424
488
|
}
|
|
425
489
|
|
|
426
490
|
writeSceneEndpoint(sceneName, payload) {
|
|
427
|
-
this.
|
|
491
|
+
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
492
|
+
fs.writeFileSync(this.sceneEndpointPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
428
493
|
}
|
|
429
494
|
|
|
430
495
|
removeSceneEndpoint(sceneName) {
|
|
431
|
-
this.
|
|
496
|
+
fs.rmSync(this.sceneEndpointPath(sceneName), { force: true });
|
|
432
497
|
}
|
|
433
498
|
|
|
434
499
|
sceneCliAttachConfigPath(sceneName) {
|
|
435
|
-
return this.
|
|
500
|
+
return path.join(this.config.runDir, `${sceneName}.cli-attach.json`);
|
|
436
501
|
}
|
|
437
502
|
|
|
438
503
|
writeSceneCliAttachConfig(sceneName, payload) {
|
|
439
|
-
this.
|
|
504
|
+
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
505
|
+
fs.writeFileSync(this.sceneCliAttachConfigPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
440
506
|
}
|
|
441
507
|
|
|
442
508
|
removeSceneCliAttachConfig(sceneName) {
|
|
443
|
-
this.
|
|
509
|
+
fs.rmSync(this.sceneCliAttachConfigPath(sceneName), { force: true });
|
|
444
510
|
}
|
|
445
511
|
|
|
446
512
|
sceneInitScriptPath(sceneName) {
|
|
@@ -449,23 +515,81 @@ class PlaywrightPlugin {
|
|
|
449
515
|
}
|
|
450
516
|
|
|
451
517
|
buildInitScriptContent() {
|
|
452
|
-
|
|
518
|
+
const lines = [
|
|
519
|
+
"'use strict';",
|
|
520
|
+
'(function () {',
|
|
521
|
+
` const platformValue = ${JSON.stringify(this.config.navigatorPlatform)};`,
|
|
522
|
+
' try {',
|
|
523
|
+
' const navProto = Object.getPrototypeOf(navigator);',
|
|
524
|
+
" Object.defineProperty(navProto, 'platform', {",
|
|
525
|
+
' configurable: true,',
|
|
526
|
+
' get: () => platformValue',
|
|
527
|
+
' });',
|
|
528
|
+
' } catch (_) {}'
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
if (this.config.disableWebRTC) {
|
|
532
|
+
lines.push(
|
|
533
|
+
' try {',
|
|
534
|
+
' const scope = globalThis;',
|
|
535
|
+
" const blocked = ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCIceCandidate', 'RTCRtpSender', 'RTCRtpReceiver', 'RTCRtpTransceiver', 'RTCDataChannel'];",
|
|
536
|
+
' for (const name of blocked) {',
|
|
537
|
+
" Object.defineProperty(scope, name, { configurable: true, writable: true, value: undefined });",
|
|
538
|
+
' }',
|
|
539
|
+
' if (navigator.mediaDevices) {',
|
|
540
|
+
' const errorFactory = () => {',
|
|
541
|
+
' try {',
|
|
542
|
+
" return new DOMException('WebRTC is disabled', 'NotAllowedError');",
|
|
543
|
+
' } catch (_) {',
|
|
544
|
+
" const error = new Error('WebRTC is disabled');",
|
|
545
|
+
" error.name = 'NotAllowedError';",
|
|
546
|
+
' return error;',
|
|
547
|
+
' }',
|
|
548
|
+
' };',
|
|
549
|
+
" Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {",
|
|
550
|
+
' configurable: true,',
|
|
551
|
+
' writable: true,',
|
|
552
|
+
' value: async () => { throw errorFactory(); }',
|
|
553
|
+
' });',
|
|
554
|
+
' }',
|
|
555
|
+
' } catch (_) {}'
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
lines.push('})();', '');
|
|
560
|
+
return lines.join('\n');
|
|
453
561
|
}
|
|
454
562
|
|
|
455
563
|
ensureSceneInitScript(sceneName) {
|
|
456
|
-
|
|
564
|
+
const filePath = this.sceneInitScriptPath(sceneName);
|
|
565
|
+
const content = this.buildInitScriptContent();
|
|
566
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
567
|
+
return filePath;
|
|
457
568
|
}
|
|
458
569
|
|
|
459
570
|
defaultBrowserName(sceneName) {
|
|
460
|
-
|
|
571
|
+
if (isCliScene(sceneName)) {
|
|
572
|
+
return 'chromium';
|
|
573
|
+
}
|
|
574
|
+
const cfg = this.buildSceneConfig(sceneName);
|
|
575
|
+
const browserName = cfg && cfg.browser && cfg.browser.browserName;
|
|
576
|
+
return String(browserName || 'chromium');
|
|
461
577
|
}
|
|
462
578
|
|
|
463
579
|
ensureContainerScenePrerequisites(sceneName) {
|
|
464
|
-
this.
|
|
580
|
+
if (!this.sceneConfigMissing(sceneName)) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const tag = String(this.config.dockerTag || 'latest').trim() || 'latest';
|
|
584
|
+
const image = `mcr.microsoft.com/playwright/mcp:${tag}`;
|
|
585
|
+
this.runCmd([this.config.containerRuntime, 'pull', image], { check: true });
|
|
465
586
|
}
|
|
466
587
|
|
|
467
588
|
ensureHostScenePrerequisites(sceneName) {
|
|
468
|
-
this.
|
|
589
|
+
if (!isCliScene(sceneName) && !this.sceneConfigMissing(sceneName)) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.runCmd([this.playwrightBinPath(sceneName), 'install', '--with-deps', this.defaultBrowserName(sceneName)], { check: true });
|
|
469
593
|
}
|
|
470
594
|
|
|
471
595
|
scenePidFile(sceneName) {
|
|
@@ -477,11 +601,32 @@ class PlaywrightPlugin {
|
|
|
477
601
|
}
|
|
478
602
|
|
|
479
603
|
localBinPath(binName) {
|
|
480
|
-
|
|
604
|
+
const filename = process.platform === 'win32' ? `${binName}.cmd` : binName;
|
|
605
|
+
const binPath = path.join(this.projectRoot, 'node_modules', '.bin', filename);
|
|
606
|
+
if (!fs.existsSync(binPath)) {
|
|
607
|
+
throw new Error(`local binary not found: ${binPath}. Run npm install first.`);
|
|
608
|
+
}
|
|
609
|
+
return binPath;
|
|
481
610
|
}
|
|
482
611
|
|
|
483
612
|
playwrightBinPath(sceneName) {
|
|
484
|
-
|
|
613
|
+
if (!isCliScene(sceneName)) {
|
|
614
|
+
return this.localBinPath('playwright');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const filename = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
|
|
618
|
+
const candidates = [
|
|
619
|
+
path.join(this.projectRoot, 'node_modules', '@playwright', 'mcp', 'node_modules', '.bin', filename),
|
|
620
|
+
path.join(this.projectRoot, 'node_modules', '.bin', filename)
|
|
621
|
+
];
|
|
622
|
+
|
|
623
|
+
for (const candidate of candidates) {
|
|
624
|
+
if (fs.existsSync(candidate)) {
|
|
625
|
+
return candidate;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
throw new Error(`local binary not found for ${sceneName}. Run npm install first.`);
|
|
485
630
|
}
|
|
486
631
|
|
|
487
632
|
extensionDirPath() {
|
|
@@ -535,55 +680,216 @@ class PlaywrightPlugin {
|
|
|
535
680
|
}
|
|
536
681
|
|
|
537
682
|
resolveExtensionPaths(extensionArgs = []) {
|
|
538
|
-
|
|
683
|
+
const inputs = asStringArray(extensionArgs, []);
|
|
684
|
+
const uniquePaths = [];
|
|
685
|
+
const seen = new Set();
|
|
686
|
+
|
|
687
|
+
for (const item of inputs) {
|
|
688
|
+
const absPath = path.resolve(item);
|
|
689
|
+
if (!fs.existsSync(absPath)) {
|
|
690
|
+
throw new Error(`扩展路径不存在: ${absPath}`);
|
|
691
|
+
}
|
|
692
|
+
const stat = fs.statSync(absPath);
|
|
693
|
+
if (!stat.isDirectory()) {
|
|
694
|
+
throw new Error(`扩展路径必须是目录: ${absPath}`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const manifestPath = path.join(absPath, 'manifest.json');
|
|
698
|
+
if (fs.existsSync(manifestPath)) {
|
|
699
|
+
if (!seen.has(absPath)) {
|
|
700
|
+
seen.add(absPath);
|
|
701
|
+
uniquePaths.push(absPath);
|
|
702
|
+
}
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const children = fs.readdirSync(absPath, { withFileTypes: true })
|
|
707
|
+
.filter(dirent => dirent.isDirectory())
|
|
708
|
+
.map(dirent => path.join(absPath, dirent.name))
|
|
709
|
+
.filter(child => fs.existsSync(path.join(child, 'manifest.json')));
|
|
710
|
+
|
|
711
|
+
if (children.length === 0) {
|
|
712
|
+
throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
for (const childPath of children) {
|
|
716
|
+
if (!seen.has(childPath)) {
|
|
717
|
+
seen.add(childPath);
|
|
718
|
+
uniquePaths.push(childPath);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return uniquePaths;
|
|
539
724
|
}
|
|
540
725
|
|
|
541
726
|
resolveNamedExtensionPaths(extensionNames = []) {
|
|
542
|
-
|
|
727
|
+
const names = asStringArray(extensionNames, []);
|
|
728
|
+
const extensionRoot = path.resolve(this.extensionDirPath());
|
|
729
|
+
|
|
730
|
+
return names.map(name => {
|
|
731
|
+
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
|
|
732
|
+
throw new Error(`扩展名称无效: ${name}`);
|
|
733
|
+
}
|
|
734
|
+
return path.join(extensionRoot, name);
|
|
735
|
+
});
|
|
543
736
|
}
|
|
544
737
|
|
|
545
738
|
resolveExtensionInputs(options = {}) {
|
|
546
|
-
|
|
739
|
+
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
740
|
+
const namedPaths = this.resolveNamedExtensionPaths(options.extensionNames || []);
|
|
741
|
+
return this.resolveExtensionPaths([...extensionPaths, ...namedPaths]);
|
|
547
742
|
}
|
|
548
743
|
|
|
549
744
|
buildExtensionLaunchArgs(extensionPaths) {
|
|
550
|
-
|
|
745
|
+
const joined = extensionPaths.join(',');
|
|
746
|
+
return [
|
|
747
|
+
`--disable-extensions-except=${joined}`,
|
|
748
|
+
`--load-extension=${joined}`
|
|
749
|
+
];
|
|
551
750
|
}
|
|
552
751
|
|
|
553
752
|
sanitizeExtensionMountName(value) {
|
|
554
|
-
|
|
753
|
+
const sanitized = String(value || '')
|
|
754
|
+
.trim()
|
|
755
|
+
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
756
|
+
.replace(/-+/g, '-')
|
|
757
|
+
.replace(/^-|-$/g, '');
|
|
758
|
+
return sanitized || 'ext';
|
|
555
759
|
}
|
|
556
760
|
|
|
557
761
|
buildContainerExtensionMounts(extensionPaths = []) {
|
|
558
|
-
|
|
559
|
-
|
|
762
|
+
const hostPaths = asStringArray(extensionPaths, []);
|
|
763
|
+
const containerPaths = [];
|
|
764
|
+
const volumeMounts = [];
|
|
765
|
+
|
|
766
|
+
hostPaths.forEach((hostPath, idx) => {
|
|
767
|
+
const safeName = this.sanitizeExtensionMountName(path.basename(hostPath));
|
|
768
|
+
const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
|
|
769
|
+
containerPaths.push(containerPath);
|
|
770
|
+
volumeMounts.push(`${hostPath}:${containerPath}:ro`);
|
|
771
|
+
});
|
|
560
772
|
|
|
561
|
-
|
|
562
|
-
return this.sceneConfigManager.baseLaunchArgs();
|
|
773
|
+
return { containerPaths, volumeMounts };
|
|
563
774
|
}
|
|
564
775
|
|
|
565
|
-
|
|
566
|
-
return
|
|
776
|
+
baseLaunchArgs() {
|
|
777
|
+
return [
|
|
778
|
+
`--user-agent=${DEFAULT_FINGERPRINT_PROFILE.userAgent}`,
|
|
779
|
+
`--lang=${DEFAULT_FINGERPRINT_PROFILE.locale}`,
|
|
780
|
+
`--window-size=${DEFAULT_FINGERPRINT_PROFILE.width},${DEFAULT_FINGERPRINT_PROFILE.height}`,
|
|
781
|
+
'--disable-blink-features=AutomationControlled',
|
|
782
|
+
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
|
|
783
|
+
];
|
|
567
784
|
}
|
|
568
785
|
|
|
569
786
|
buildMcpSceneConfig(sceneName, options = {}) {
|
|
570
|
-
|
|
787
|
+
const def = SCENE_DEFS[sceneName];
|
|
788
|
+
const port = this.scenePort(sceneName);
|
|
789
|
+
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
790
|
+
const initScript = asStringArray(options.initScript, []);
|
|
791
|
+
const launchOptions = {
|
|
792
|
+
channel: 'chromium',
|
|
793
|
+
headless: def.headless,
|
|
794
|
+
args: [...this.baseLaunchArgs()]
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
if (extensionPaths.length > 0) {
|
|
798
|
+
launchOptions.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
|
|
799
|
+
}
|
|
800
|
+
if (this.config.disableWebRTC) {
|
|
801
|
+
launchOptions.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const contextOptions = {
|
|
805
|
+
userAgent: DEFAULT_FINGERPRINT_PROFILE.userAgent,
|
|
806
|
+
locale: DEFAULT_FINGERPRINT_PROFILE.locale,
|
|
807
|
+
timezoneId: DEFAULT_FINGERPRINT_PROFILE.timezoneId,
|
|
808
|
+
extraHTTPHeaders: {
|
|
809
|
+
'Accept-Language': DEFAULT_FINGERPRINT_PROFILE.acceptLanguage
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
if (sceneName !== 'mcp-host-headed') {
|
|
813
|
+
contextOptions.viewport = {
|
|
814
|
+
width: DEFAULT_FINGERPRINT_PROFILE.width,
|
|
815
|
+
height: DEFAULT_FINGERPRINT_PROFILE.height
|
|
816
|
+
};
|
|
817
|
+
contextOptions.screen = {
|
|
818
|
+
width: DEFAULT_FINGERPRINT_PROFILE.width,
|
|
819
|
+
height: DEFAULT_FINGERPRINT_PROFILE.height
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
outputDir: '/tmp/.playwright-mcp',
|
|
825
|
+
server: {
|
|
826
|
+
host: def.listenHost,
|
|
827
|
+
port,
|
|
828
|
+
allowedHosts: [
|
|
829
|
+
`localhost:${port}`,
|
|
830
|
+
`127.0.0.1:${port}`,
|
|
831
|
+
`host.docker.internal:${port}`,
|
|
832
|
+
`host.containers.internal:${port}`
|
|
833
|
+
]
|
|
834
|
+
},
|
|
835
|
+
browser: {
|
|
836
|
+
chromiumSandbox: true,
|
|
837
|
+
browserName: 'chromium',
|
|
838
|
+
initScript,
|
|
839
|
+
launchOptions,
|
|
840
|
+
contextOptions
|
|
841
|
+
}
|
|
842
|
+
};
|
|
571
843
|
}
|
|
572
844
|
|
|
573
845
|
buildCliSceneConfig(sceneName, options = {}) {
|
|
574
|
-
|
|
575
|
-
|
|
846
|
+
const def = SCENE_DEFS[sceneName];
|
|
847
|
+
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
848
|
+
const payload = {
|
|
849
|
+
host: def.listenHost,
|
|
850
|
+
port: this.scenePort(sceneName),
|
|
851
|
+
wsPath: `/${sceneName}-${crypto.randomBytes(8).toString('hex')}`,
|
|
852
|
+
headless: def.headless,
|
|
853
|
+
channel: 'chromium',
|
|
854
|
+
chromiumSandbox: true,
|
|
855
|
+
args: [...this.baseLaunchArgs()]
|
|
856
|
+
};
|
|
576
857
|
|
|
577
|
-
|
|
578
|
-
|
|
858
|
+
if (extensionPaths.length > 0) {
|
|
859
|
+
payload.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
|
|
860
|
+
}
|
|
861
|
+
if (this.config.disableWebRTC) {
|
|
862
|
+
payload.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return payload;
|
|
579
866
|
}
|
|
580
867
|
|
|
581
|
-
|
|
582
|
-
|
|
868
|
+
buildSceneConfig(sceneName, options = {}) {
|
|
869
|
+
if (isCliScene(sceneName)) {
|
|
870
|
+
return this.buildCliSceneConfig(sceneName, options);
|
|
871
|
+
}
|
|
872
|
+
return this.buildMcpSceneConfig(sceneName, options);
|
|
583
873
|
}
|
|
584
874
|
|
|
585
875
|
ensureSceneConfig(sceneName, options = {}) {
|
|
586
|
-
|
|
876
|
+
fs.mkdirSync(this.config.configDir, { recursive: true });
|
|
877
|
+
if (isCliScene(sceneName)) {
|
|
878
|
+
const payload = this.buildCliSceneConfig(sceneName, options);
|
|
879
|
+
const filePath = this.sceneConfigPath(sceneName);
|
|
880
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
881
|
+
return filePath;
|
|
882
|
+
}
|
|
883
|
+
const initScriptPath = this.ensureSceneInitScript(sceneName);
|
|
884
|
+
const configuredInitScript = asStringArray(options.initScript, []);
|
|
885
|
+
const initScript = configuredInitScript.length > 0 ? configuredInitScript : [initScriptPath];
|
|
886
|
+
const payload = this.buildSceneConfig(sceneName, {
|
|
887
|
+
...options,
|
|
888
|
+
initScript
|
|
889
|
+
});
|
|
890
|
+
const filePath = this.sceneConfigPath(sceneName);
|
|
891
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
892
|
+
return filePath;
|
|
587
893
|
}
|
|
588
894
|
|
|
589
895
|
async portReady(port) {
|
|
@@ -620,99 +926,465 @@ class PlaywrightPlugin {
|
|
|
620
926
|
}
|
|
621
927
|
|
|
622
928
|
containerEnv(sceneName, cfgPath, options = {}) {
|
|
623
|
-
|
|
929
|
+
const def = SCENE_DEFS[sceneName];
|
|
930
|
+
const requireVncPassword = options.requireVncPassword === true;
|
|
931
|
+
const env = {
|
|
932
|
+
...process.env,
|
|
933
|
+
PLAYWRIGHT_MCP_DOCKER_TAG: this.config.dockerTag,
|
|
934
|
+
PLAYWRIGHT_MCP_PORT: String(this.scenePort(sceneName)),
|
|
935
|
+
PLAYWRIGHT_MCP_CONFIG_PATH: cfgPath,
|
|
936
|
+
PLAYWRIGHT_MCP_CONTAINER_NAME: def.containerName,
|
|
937
|
+
PLAYWRIGHT_MCP_IMAGE: this.config.headedImage,
|
|
938
|
+
PLAYWRIGHT_MCP_NOVNC_PORT: String(this.config.ports.mcpContHeadedNoVnc)
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
if (sceneName === 'mcp-cont-headed') {
|
|
942
|
+
const envKey = this.config.vncPasswordEnvKey;
|
|
943
|
+
let password = process.env[envKey];
|
|
944
|
+
if (!password) {
|
|
945
|
+
password = this.randomAlnum(16);
|
|
946
|
+
if (requireVncPassword) {
|
|
947
|
+
this.writeStdout(`[up] mcp-cont-headed ${envKey} not set; generated random 16-char password: ${password}`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
env.VNC_PASSWORD = password;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return env;
|
|
624
954
|
}
|
|
625
955
|
|
|
626
956
|
containerComposePath(sceneName) {
|
|
627
|
-
|
|
957
|
+
const def = SCENE_DEFS[sceneName];
|
|
958
|
+
return path.join(this.config.composeDir, def.composeFile);
|
|
628
959
|
}
|
|
629
960
|
|
|
630
961
|
sceneComposeOverridePath(sceneName) {
|
|
631
|
-
return this.
|
|
962
|
+
return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
|
|
632
963
|
}
|
|
633
964
|
|
|
634
965
|
ensureContainerComposeOverride(sceneName, volumeMounts = []) {
|
|
635
|
-
|
|
966
|
+
const overridePath = this.sceneComposeOverridePath(sceneName);
|
|
967
|
+
if (!Array.isArray(volumeMounts) || volumeMounts.length === 0) {
|
|
968
|
+
fs.rmSync(overridePath, { force: true });
|
|
969
|
+
return '';
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
973
|
+
const lines = [
|
|
974
|
+
'services:',
|
|
975
|
+
' playwright:',
|
|
976
|
+
' volumes:'
|
|
977
|
+
];
|
|
978
|
+
volumeMounts.forEach(item => {
|
|
979
|
+
lines.push(` - ${JSON.stringify(String(item))}`);
|
|
980
|
+
});
|
|
981
|
+
fs.writeFileSync(overridePath, `${lines.join('\n')}\n`, 'utf8');
|
|
982
|
+
return overridePath;
|
|
636
983
|
}
|
|
637
984
|
|
|
638
985
|
buildCliSessionIntegration(dockerCmd) {
|
|
639
|
-
|
|
986
|
+
const sceneName = this.config.cliSessionScene;
|
|
987
|
+
if (!sceneName) {
|
|
988
|
+
return { envEntries: [], extraArgs: [], volumeEntries: [] };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const endpoint = this.readSceneEndpoint(sceneName);
|
|
992
|
+
if (!endpoint || !Number.isInteger(endpoint.port) || endpoint.port <= 0 || typeof endpoint.wsPath !== 'string' || !endpoint.wsPath) {
|
|
993
|
+
return { envEntries: [], extraArgs: [], volumeEntries: [] };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const normalizedDockerCmd = String(dockerCmd || '').trim().toLowerCase();
|
|
997
|
+
const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
|
998
|
+
const remoteEndpoint = `ws://${connectHost}:${endpoint.port}${endpoint.wsPath}`;
|
|
999
|
+
const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
|
|
1000
|
+
const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
|
|
1001
|
+
this.writeSceneCliAttachConfig(sceneName, {
|
|
1002
|
+
browser: {
|
|
1003
|
+
remoteEndpoint
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
const envEntries = [
|
|
1007
|
+
`PLAYWRIGHT_MCP_CONFIG=${containerConfigPath}`
|
|
1008
|
+
];
|
|
1009
|
+
const extraArgs = normalizedDockerCmd === 'docker'
|
|
1010
|
+
? ['--add-host', 'host.docker.internal:host-gateway']
|
|
1011
|
+
: [];
|
|
1012
|
+
const volumeEntries = ['--volume', `${hostConfigPath}:${containerConfigPath}:ro`];
|
|
1013
|
+
return { envEntries, extraArgs, volumeEntries };
|
|
640
1014
|
}
|
|
641
1015
|
|
|
642
1016
|
async startContainer(sceneName, options = {}) {
|
|
643
|
-
|
|
1017
|
+
const runtime = this.config.containerRuntime;
|
|
1018
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
1019
|
+
this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
|
|
1020
|
+
return 1;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
this.ensureContainerScenePrerequisites(sceneName);
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
return error.returncode || 1;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const incomingExtensionPaths = asStringArray(options.extensionPaths, []);
|
|
1030
|
+
const hostInitScriptPath = this.sceneInitScriptPath(sceneName);
|
|
1031
|
+
const containerInitScriptPath = path.posix.join('/app/config', path.basename(hostInitScriptPath));
|
|
1032
|
+
let configOptions = {
|
|
1033
|
+
...options,
|
|
1034
|
+
extensionPaths: incomingExtensionPaths,
|
|
1035
|
+
initScript: [containerInitScriptPath]
|
|
1036
|
+
};
|
|
1037
|
+
const composeFiles = [this.containerComposePath(sceneName)];
|
|
1038
|
+
const volumeMounts = [`${hostInitScriptPath}:${containerInitScriptPath}:ro`];
|
|
1039
|
+
|
|
1040
|
+
if (incomingExtensionPaths.length > 0) {
|
|
1041
|
+
const mapped = this.buildContainerExtensionMounts(incomingExtensionPaths);
|
|
1042
|
+
volumeMounts.push(...mapped.volumeMounts);
|
|
1043
|
+
configOptions = {
|
|
1044
|
+
...options,
|
|
1045
|
+
extensionPaths: mapped.containerPaths,
|
|
1046
|
+
initScript: [containerInitScriptPath]
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
const cfgPath = this.ensureSceneConfig(sceneName, configOptions);
|
|
1050
|
+
const overridePath = this.ensureContainerComposeOverride(sceneName, volumeMounts);
|
|
1051
|
+
if (overridePath) {
|
|
1052
|
+
composeFiles.push(overridePath);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
|
|
1056
|
+
const def = SCENE_DEFS[sceneName];
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
const args = [
|
|
1060
|
+
runtime,
|
|
1061
|
+
'compose',
|
|
1062
|
+
'-p',
|
|
1063
|
+
def.projectName
|
|
1064
|
+
];
|
|
1065
|
+
composeFiles.forEach(filePath => {
|
|
1066
|
+
args.push('-f', filePath);
|
|
1067
|
+
});
|
|
1068
|
+
args.push('up', '-d');
|
|
1069
|
+
this.runCmd(args, { env, check: true });
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
return error.returncode || 1;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const port = this.scenePort(sceneName);
|
|
1075
|
+
if (await this.waitForPort(port)) {
|
|
1076
|
+
this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port}`);
|
|
1077
|
+
return 0;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
this.writeStderr(`[up] ${sceneName} did not become ready on 127.0.0.1:${port}`);
|
|
1081
|
+
return 1;
|
|
644
1082
|
}
|
|
645
1083
|
|
|
646
1084
|
stopContainer(sceneName) {
|
|
647
|
-
|
|
1085
|
+
const runtime = this.config.containerRuntime;
|
|
1086
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
1087
|
+
this.writeStderr(`[down] ${sceneName} failed: ${runtime} command not found.`);
|
|
1088
|
+
return 1;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
this.ensureContainerComposeOverride(sceneName, []);
|
|
1092
|
+
const cfgPath = this.sceneConfigPath(sceneName);
|
|
1093
|
+
const env = this.containerEnv(sceneName, cfgPath);
|
|
1094
|
+
const def = SCENE_DEFS[sceneName];
|
|
1095
|
+
|
|
1096
|
+
try {
|
|
1097
|
+
this.runCmd([
|
|
1098
|
+
runtime,
|
|
1099
|
+
'compose',
|
|
1100
|
+
'-p',
|
|
1101
|
+
def.projectName,
|
|
1102
|
+
'-f',
|
|
1103
|
+
this.containerComposePath(sceneName),
|
|
1104
|
+
'down'
|
|
1105
|
+
], { env, check: true });
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
return error.returncode || 1;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
this.writeStdout(`[down] ${sceneName}`);
|
|
1111
|
+
return 0;
|
|
648
1112
|
}
|
|
649
1113
|
|
|
650
1114
|
statusContainer(sceneName) {
|
|
651
|
-
|
|
1115
|
+
const runtime = this.config.containerRuntime;
|
|
1116
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
1117
|
+
this.writeStderr(`[status] ${sceneName} failed: ${runtime} command not found.`);
|
|
1118
|
+
return 1;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const def = SCENE_DEFS[sceneName];
|
|
1122
|
+
const cp = this.runCmd([
|
|
1123
|
+
runtime,
|
|
1124
|
+
'ps',
|
|
1125
|
+
'--filter',
|
|
1126
|
+
`name=${def.containerName}`,
|
|
1127
|
+
'--format',
|
|
1128
|
+
'{{.Names}}'
|
|
1129
|
+
], { captureOutput: true, check: false });
|
|
1130
|
+
|
|
1131
|
+
const names = new Set(
|
|
1132
|
+
cp.stdout
|
|
1133
|
+
.split(/\r?\n/)
|
|
1134
|
+
.map(line => line.trim())
|
|
1135
|
+
.filter(Boolean)
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
if (names.has(def.containerName)) {
|
|
1139
|
+
this.writeStdout(`[status] ${sceneName} running`);
|
|
1140
|
+
} else {
|
|
1141
|
+
this.writeStdout(`[status] ${sceneName} stopped`);
|
|
1142
|
+
}
|
|
1143
|
+
return 0;
|
|
652
1144
|
}
|
|
653
1145
|
|
|
654
1146
|
logsContainer(sceneName) {
|
|
655
|
-
|
|
656
|
-
|
|
1147
|
+
const runtime = this.config.containerRuntime;
|
|
1148
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
1149
|
+
this.writeStderr(`[logs] ${sceneName} failed: ${runtime} command not found.`);
|
|
1150
|
+
return 1;
|
|
1151
|
+
}
|
|
657
1152
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1153
|
+
const def = SCENE_DEFS[sceneName];
|
|
1154
|
+
const cp = this.runCmd([
|
|
1155
|
+
runtime,
|
|
1156
|
+
'logs',
|
|
1157
|
+
'--tail',
|
|
1158
|
+
'80',
|
|
1159
|
+
def.containerName
|
|
1160
|
+
], { captureOutput: true, check: false });
|
|
1161
|
+
|
|
1162
|
+
const output = cp.stdout || cp.stderr;
|
|
1163
|
+
if (output.trim()) {
|
|
1164
|
+
this.writeStdout(output.trimEnd());
|
|
1165
|
+
} else {
|
|
1166
|
+
this.writeStdout(`[logs] ${sceneName} no logs`);
|
|
1167
|
+
}
|
|
661
1168
|
|
|
662
|
-
|
|
663
|
-
return this.containerRuntimeManager.buildContainerComposeCommand(sceneName, composeFiles, trailingArgs);
|
|
1169
|
+
return cp.returncode === 0 ? 0 : 1;
|
|
664
1170
|
}
|
|
665
1171
|
|
|
666
1172
|
hostLaunchCommand(sceneName, cfgPath) {
|
|
667
|
-
|
|
1173
|
+
if (isCliScene(sceneName)) {
|
|
1174
|
+
return {
|
|
1175
|
+
command: this.playwrightBinPath(sceneName),
|
|
1176
|
+
args: ['launch-server', '--browser', this.defaultBrowserName(sceneName), '--config', String(cfgPath)]
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
command: this.localBinPath('playwright-mcp'),
|
|
1181
|
+
args: ['--config', String(cfgPath)]
|
|
1182
|
+
};
|
|
668
1183
|
}
|
|
669
1184
|
|
|
670
1185
|
spawnHostProcess(command, args, logFd) {
|
|
671
|
-
return
|
|
1186
|
+
return spawn(command, args, {
|
|
1187
|
+
detached: true,
|
|
1188
|
+
stdio: ['ignore', logFd, logFd]
|
|
1189
|
+
});
|
|
672
1190
|
}
|
|
673
1191
|
|
|
674
1192
|
stopHostStarter(pid) {
|
|
675
|
-
|
|
1193
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
process.kill(-pid, 'SIGTERM');
|
|
1198
|
+
return;
|
|
1199
|
+
} catch {
|
|
1200
|
+
// no-op
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
process.kill(pid, 'SIGTERM');
|
|
1204
|
+
} catch {
|
|
1205
|
+
// no-op
|
|
1206
|
+
}
|
|
676
1207
|
}
|
|
677
1208
|
|
|
678
1209
|
hostScenePids(sceneName) {
|
|
679
|
-
|
|
1210
|
+
const cfgPath = this.sceneConfigPath(sceneName);
|
|
1211
|
+
const pattern = isCliScene(sceneName)
|
|
1212
|
+
? `playwright.*launch-server.*--config ${cfgPath}`
|
|
1213
|
+
: `playwright-mcp.*--config ${cfgPath}`;
|
|
1214
|
+
const cp = this.runCmd(['pgrep', '-f', pattern], { captureOutput: true, check: false });
|
|
1215
|
+
|
|
1216
|
+
if (cp.returncode !== 0 || !cp.stdout.trim()) {
|
|
1217
|
+
return [];
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const pids = [];
|
|
1221
|
+
for (const line of cp.stdout.split(/\r?\n/)) {
|
|
1222
|
+
const text = line.trim();
|
|
1223
|
+
if (/^\d+$/.test(text)) {
|
|
1224
|
+
pids.push(Number(text));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return pids;
|
|
680
1228
|
}
|
|
681
1229
|
|
|
682
1230
|
async waitForHostPids(sceneName, fallbackPid) {
|
|
683
|
-
|
|
1231
|
+
for (let i = 0; i < 5; i += 1) {
|
|
1232
|
+
const pids = this.hostScenePids(sceneName);
|
|
1233
|
+
if (pids.length > 0) {
|
|
1234
|
+
return pids;
|
|
1235
|
+
}
|
|
1236
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1237
|
+
await sleep(100);
|
|
1238
|
+
}
|
|
1239
|
+
if (Number.isInteger(fallbackPid) && fallbackPid > 0) {
|
|
1240
|
+
return [fallbackPid];
|
|
1241
|
+
}
|
|
1242
|
+
return [];
|
|
684
1243
|
}
|
|
685
1244
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1245
|
+
async startHost(sceneName, options = {}) {
|
|
1246
|
+
try {
|
|
1247
|
+
this.ensureCliHostHeadedCacheDir(sceneName);
|
|
1248
|
+
this.ensureHostScenePrerequisites(sceneName);
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
|
|
1251
|
+
return error.returncode || 1;
|
|
1252
|
+
}
|
|
689
1253
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1254
|
+
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
1255
|
+
const cfgPath = this.ensureSceneConfig(sceneName, options);
|
|
1256
|
+
const pidFile = this.scenePidFile(sceneName);
|
|
1257
|
+
const logFile = this.sceneLogFile(sceneName);
|
|
1258
|
+
const port = this.scenePort(sceneName);
|
|
1259
|
+
this.removeSceneEndpoint(sceneName);
|
|
1260
|
+
this.removeSceneCliAttachConfig(sceneName);
|
|
693
1261
|
|
|
694
|
-
|
|
695
|
-
this.
|
|
696
|
-
|
|
1262
|
+
let managedPids = this.hostScenePids(sceneName);
|
|
1263
|
+
if (managedPids.length > 0 && (await this.portReady(port))) {
|
|
1264
|
+
this.writeStdout(`[up] ${sceneName} already running (pid(s) ${managedPids.join(' ')})`);
|
|
1265
|
+
return 0;
|
|
1266
|
+
}
|
|
697
1267
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1268
|
+
if (await this.portReady(port)) {
|
|
1269
|
+
this.writeStderr(`[up] ${sceneName} failed: port ${port} is already in use by another process.`);
|
|
1270
|
+
this.writeStderr('Stop the conflicting process first, then retry.');
|
|
1271
|
+
return 1;
|
|
1272
|
+
}
|
|
701
1273
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1274
|
+
fs.rmSync(pidFile, { force: true });
|
|
1275
|
+
const logFd = fs.openSync(logFile, 'a');
|
|
1276
|
+
let launchCommand = null;
|
|
1277
|
+
try {
|
|
1278
|
+
launchCommand = this.hostLaunchCommand(sceneName, cfgPath);
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
fs.closeSync(logFd);
|
|
1281
|
+
this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
|
|
1282
|
+
return 1;
|
|
1283
|
+
}
|
|
705
1284
|
|
|
706
|
-
|
|
707
|
-
|
|
1285
|
+
const starter = this.spawnHostProcess(launchCommand.command, launchCommand.args, logFd);
|
|
1286
|
+
fs.closeSync(logFd);
|
|
1287
|
+
if (typeof starter.unref === 'function') {
|
|
1288
|
+
starter.unref();
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (await this.waitForPort(port)) {
|
|
1292
|
+
if (isCliScene(sceneName)) {
|
|
1293
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
1294
|
+
this.writeSceneEndpoint(sceneName, {
|
|
1295
|
+
port,
|
|
1296
|
+
wsPath: String(cfg.wsPath || '')
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
managedPids = await this.waitForHostPids(sceneName, starter.pid);
|
|
1300
|
+
if (managedPids.length > 0) {
|
|
1301
|
+
fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
|
|
1302
|
+
this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port} (pid(s) ${managedPids.join(' ')})`);
|
|
1303
|
+
this.remindCliSessionScene(sceneName);
|
|
1304
|
+
return 0;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
this.writeStderr(`[up] ${sceneName} failed to start. tail ${logFile}:`);
|
|
1309
|
+
const tail = tailText(logFile, 30);
|
|
1310
|
+
if (tail) {
|
|
1311
|
+
this.writeStderr(tail);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (starter.exitCode === null && !starter.killed) {
|
|
1315
|
+
this.stopHostStarter(starter.pid);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return 1;
|
|
708
1319
|
}
|
|
709
1320
|
|
|
710
1321
|
async stopHost(sceneName) {
|
|
711
|
-
|
|
1322
|
+
const pidFile = this.scenePidFile(sceneName);
|
|
1323
|
+
const port = this.scenePort(sceneName);
|
|
1324
|
+
const managedPids = this.hostScenePids(sceneName);
|
|
1325
|
+
this.removeSceneEndpoint(sceneName);
|
|
1326
|
+
this.removeSceneCliAttachConfig(sceneName);
|
|
1327
|
+
|
|
1328
|
+
for (const pid of managedPids) {
|
|
1329
|
+
try {
|
|
1330
|
+
process.kill(pid, 'SIGTERM');
|
|
1331
|
+
} catch {
|
|
1332
|
+
// no-op
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (managedPids.length > 0) {
|
|
1337
|
+
await sleep(300);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (fs.existsSync(pidFile)) {
|
|
1341
|
+
const text = fs.readFileSync(pidFile, 'utf8').trim();
|
|
1342
|
+
if (/^\d+$/.test(text)) {
|
|
1343
|
+
try {
|
|
1344
|
+
process.kill(Number(text), 'SIGTERM');
|
|
1345
|
+
} catch {
|
|
1346
|
+
// no-op
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
fs.rmSync(pidFile, { force: true });
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (await this.portReady(port)) {
|
|
1353
|
+
this.writeStderr(`[down] ${sceneName} warning: port ${port} is still in use (possibly unmanaged process)`);
|
|
1354
|
+
return 1;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
this.writeStdout(`[down] ${sceneName}`);
|
|
1358
|
+
return 0;
|
|
712
1359
|
}
|
|
713
1360
|
|
|
714
1361
|
async statusHost(sceneName) {
|
|
715
|
-
|
|
1362
|
+
const pidFile = this.scenePidFile(sceneName);
|
|
1363
|
+
const port = this.scenePort(sceneName);
|
|
1364
|
+
const managedPids = this.hostScenePids(sceneName);
|
|
1365
|
+
|
|
1366
|
+
if (managedPids.length > 0 && (await this.portReady(port))) {
|
|
1367
|
+
this.writeStdout(`[status] ${sceneName} running (pid(s) ${managedPids.join(' ')})`);
|
|
1368
|
+
const pidfileValid = fs.existsSync(pidFile) && /^\d+$/.test(fs.readFileSync(pidFile, 'utf8').trim());
|
|
1369
|
+
if (!pidfileValid) {
|
|
1370
|
+
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
1371
|
+
fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
|
|
1372
|
+
}
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (managedPids.length > 0 && !(await this.portReady(port))) {
|
|
1377
|
+
this.writeStdout(`[status] ${sceneName} degraded (pid(s) ${managedPids.join(' ')}, port ${port} not reachable)`);
|
|
1378
|
+
return 0;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
fs.rmSync(pidFile, { force: true });
|
|
1382
|
+
if (await this.portReady(port)) {
|
|
1383
|
+
this.writeStdout(`[status] ${sceneName} conflict (port ${port} in use by unmanaged process)`);
|
|
1384
|
+
} else {
|
|
1385
|
+
this.writeStdout(`[status] ${sceneName} stopped`);
|
|
1386
|
+
}
|
|
1387
|
+
return 0;
|
|
716
1388
|
}
|
|
717
1389
|
|
|
718
1390
|
async healthScene(sceneName) {
|
|
@@ -726,61 +1398,259 @@ class PlaywrightPlugin {
|
|
|
726
1398
|
}
|
|
727
1399
|
|
|
728
1400
|
logsHost(sceneName) {
|
|
729
|
-
|
|
1401
|
+
const logFile = this.sceneLogFile(sceneName);
|
|
1402
|
+
if (!fs.existsSync(logFile)) {
|
|
1403
|
+
this.writeStdout(`[logs] ${sceneName} no log file: ${logFile}`);
|
|
1404
|
+
return 0;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const tail = tailText(logFile, 80);
|
|
1408
|
+
if (tail) {
|
|
1409
|
+
this.writeStdout(tail);
|
|
1410
|
+
}
|
|
1411
|
+
return 0;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async downloadFile(url, output, retries = 3, timeoutMs = 60_000) {
|
|
1415
|
+
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
1416
|
+
if (!this.ensureCommandAvailable('curl')) {
|
|
1417
|
+
throw new Error('curl command not found');
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
let lastError = null;
|
|
1421
|
+
for (let i = 1; i <= retries; i += 1) {
|
|
1422
|
+
try {
|
|
1423
|
+
const result = this.runCmd([
|
|
1424
|
+
'curl',
|
|
1425
|
+
'--fail',
|
|
1426
|
+
'--location',
|
|
1427
|
+
'--silent',
|
|
1428
|
+
'--show-error',
|
|
1429
|
+
'--connect-timeout',
|
|
1430
|
+
String(timeoutSec),
|
|
1431
|
+
'--max-time',
|
|
1432
|
+
String(timeoutSec),
|
|
1433
|
+
'--output',
|
|
1434
|
+
output,
|
|
1435
|
+
url
|
|
1436
|
+
], { captureOutput: true, check: false });
|
|
1437
|
+
if (result.returncode !== 0) {
|
|
1438
|
+
throw new Error(result.stderr || `curl failed with exit code ${result.returncode}`);
|
|
1439
|
+
}
|
|
1440
|
+
return;
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
lastError = error;
|
|
1443
|
+
if (i < retries) {
|
|
1444
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1445
|
+
await sleep(1000);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
extractZipBuffer(zipBuffer, outDir) {
|
|
1454
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1455
|
+
const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
|
|
1456
|
+
fs.writeFileSync(tempZip, zipBuffer);
|
|
1457
|
+
|
|
1458
|
+
const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
|
|
1459
|
+
fs.rmSync(tempZip, { force: true });
|
|
1460
|
+
|
|
1461
|
+
if (result.error) {
|
|
1462
|
+
throw result.error;
|
|
1463
|
+
}
|
|
1464
|
+
if (result.status !== 0) {
|
|
1465
|
+
throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
extractCrx(crxFile, outDir) {
|
|
1470
|
+
const data = fs.readFileSync(crxFile);
|
|
1471
|
+
const offset = crxZipOffset(data);
|
|
1472
|
+
const zipBuffer = data.subarray(offset);
|
|
1473
|
+
|
|
1474
|
+
this.extractZipBuffer(zipBuffer, outDir);
|
|
1475
|
+
|
|
1476
|
+
const manifest = path.join(outDir, 'manifest.json');
|
|
1477
|
+
if (!fs.existsSync(manifest)) {
|
|
1478
|
+
throw new Error(`${crxFile} extracted but manifest.json missing`);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
if (convertManifestV2ToV3(outDir)) {
|
|
1482
|
+
this.writeStdout(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
|
|
1483
|
+
}
|
|
730
1484
|
}
|
|
731
1485
|
|
|
732
1486
|
async downloadExtensions(options = {}) {
|
|
733
|
-
|
|
1487
|
+
if (!this.ensureCommandAvailable('unzip')) {
|
|
1488
|
+
this.writeStderr('[ext-download] failed: unzip command not found.');
|
|
1489
|
+
return 1;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const prodversion = String(options.prodversion || this.config.extensionProdversion || '132.0.0.0').trim();
|
|
1493
|
+
const extDir = path.resolve(this.extensionDirPath());
|
|
1494
|
+
const tmpDir = path.resolve(this.extensionTmpDirPath());
|
|
1495
|
+
|
|
1496
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1497
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
1498
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1499
|
+
|
|
1500
|
+
try {
|
|
1501
|
+
this.writeStdout(`[info] ext dir: ${extDir}`);
|
|
1502
|
+
this.writeStdout(`[info] tmp dir: ${tmpDir}`);
|
|
1503
|
+
|
|
1504
|
+
for (const [name, extId] of EXTENSIONS) {
|
|
1505
|
+
const url = buildCrxUrl(extId, prodversion);
|
|
1506
|
+
const crxFile = path.join(tmpDir, `${name}.crx`);
|
|
1507
|
+
const outDir = path.join(extDir, name);
|
|
1508
|
+
|
|
1509
|
+
this.writeStdout(`[download] ${name}`);
|
|
1510
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1511
|
+
await this.downloadFile(url, crxFile);
|
|
1512
|
+
|
|
1513
|
+
this.writeStdout(`[extract] ${name}`);
|
|
1514
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
1515
|
+
this.extractCrx(crxFile, outDir);
|
|
1516
|
+
}
|
|
1517
|
+
} finally {
|
|
1518
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1519
|
+
this.writeStdout(`[cleanup] removed ${tmpDir}`);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
this.writeStdout(`[done] all extensions are ready: ${extDir}`);
|
|
1523
|
+
return 0;
|
|
734
1524
|
}
|
|
735
1525
|
|
|
736
1526
|
detectCurrentIPv4() {
|
|
737
|
-
|
|
1527
|
+
const interfaces = os.networkInterfaces();
|
|
1528
|
+
for (const values of Object.values(interfaces)) {
|
|
1529
|
+
if (!Array.isArray(values)) {
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
for (const item of values) {
|
|
1533
|
+
if (!item || item.internal) {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (item.family === 'IPv4') {
|
|
1537
|
+
return item.address;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return '';
|
|
738
1542
|
}
|
|
739
1543
|
|
|
740
1544
|
resolveMcpAddHost(hostArg) {
|
|
741
|
-
|
|
1545
|
+
if (!hostArg) {
|
|
1546
|
+
return this.config.mcpDefaultHost;
|
|
1547
|
+
}
|
|
1548
|
+
const value = String(hostArg).trim();
|
|
1549
|
+
if (!value) {
|
|
1550
|
+
return '';
|
|
1551
|
+
}
|
|
1552
|
+
if (value === 'current-ip') {
|
|
1553
|
+
return this.detectCurrentIPv4();
|
|
1554
|
+
}
|
|
1555
|
+
return value;
|
|
742
1556
|
}
|
|
743
1557
|
|
|
744
1558
|
printMcpAdd(hostArg) {
|
|
745
|
-
|
|
1559
|
+
const host = this.resolveMcpAddHost(hostArg);
|
|
1560
|
+
if (!host) {
|
|
1561
|
+
this.writeStderr('[mcp-add] failed: cannot determine host. Use --host <host> to set one explicitly.');
|
|
1562
|
+
return 1;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const scenes = this.resolveTargets('all').filter(sceneName => isMcpScene(sceneName));
|
|
1566
|
+
for (const sceneName of scenes) {
|
|
1567
|
+
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
1568
|
+
this.writeStdout(`claude mcp add -t http -s user playwright-${sceneName} ${url}`);
|
|
1569
|
+
}
|
|
1570
|
+
this.writeStdout('');
|
|
1571
|
+
for (const sceneName of scenes) {
|
|
1572
|
+
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
1573
|
+
this.writeStdout(`codex mcp add playwright-${sceneName} --url ${url}`);
|
|
1574
|
+
}
|
|
1575
|
+
this.writeStdout('');
|
|
1576
|
+
for (const sceneName of scenes) {
|
|
1577
|
+
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
1578
|
+
this.writeStdout(`gemini mcp add -t http -s user playwright-${sceneName} ${url}`);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
return 0;
|
|
746
1582
|
}
|
|
747
1583
|
|
|
748
1584
|
printCliAdd() {
|
|
749
|
-
|
|
1585
|
+
const lines = [
|
|
1586
|
+
'PLAYWRIGHT_CLI_INSTALL_DIR="${TMPDIR:-/tmp}/manyoyo-playwright-cli-install-$$"',
|
|
1587
|
+
'mkdir -p "$PLAYWRIGHT_CLI_INSTALL_DIR/.playwright"',
|
|
1588
|
+
'echo \'{"browser":{"browserName":"chromium","launchOptions":{"channel":"chromium"}}}\' > "$PLAYWRIGHT_CLI_INSTALL_DIR/.playwright/cli.config.json"',
|
|
1589
|
+
'cd "$PLAYWRIGHT_CLI_INSTALL_DIR"',
|
|
1590
|
+
`npm install -g @playwright/cli@${PLAYWRIGHT_CLI_VERSION}`,
|
|
1591
|
+
'playwright-cli install --skills',
|
|
1592
|
+
'PLAYWRIGHT_CLI_SKILL_SOURCE="$PLAYWRIGHT_CLI_INSTALL_DIR/.claude/skills/playwright-cli"',
|
|
1593
|
+
'for target in ~/.claude/skills/playwright-cli ~/.codex/skills/playwright-cli ~/.gemini/skills/playwright-cli; do',
|
|
1594
|
+
' mkdir -p "$target"',
|
|
1595
|
+
' cp -R "$PLAYWRIGHT_CLI_SKILL_SOURCE/." "$target/"',
|
|
1596
|
+
'done',
|
|
1597
|
+
'cd "$OLDPWD"',
|
|
1598
|
+
'rm -rf "$PLAYWRIGHT_CLI_INSTALL_DIR"'
|
|
1599
|
+
];
|
|
1600
|
+
this.writeStdout(lines.join('\n'));
|
|
1601
|
+
return 0;
|
|
750
1602
|
}
|
|
751
1603
|
|
|
752
1604
|
printSummary() {
|
|
753
|
-
|
|
1605
|
+
const scenes = this.resolveTargets('all');
|
|
1606
|
+
this.writeStdout(`playwright\truntime=${this.config.runtime}\tscenes=${scenes.join(',')}`);
|
|
1607
|
+
return 0;
|
|
754
1608
|
}
|
|
755
1609
|
|
|
756
1610
|
async runOnScene(action, sceneName, options = {}) {
|
|
757
1611
|
const def = SCENE_DEFS[sceneName];
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
this.writeStderr(`unknown action: ${action}`);
|
|
763
|
-
return 1;
|
|
1612
|
+
if (action === 'up') {
|
|
1613
|
+
return def.type === 'container'
|
|
1614
|
+
? await this.startContainer(sceneName, options)
|
|
1615
|
+
: await this.startHost(sceneName, options);
|
|
764
1616
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
let rc = 0;
|
|
770
|
-
for (const sceneName of targets) {
|
|
771
|
-
// eslint-disable-next-line no-await-in-loop
|
|
772
|
-
const code = await this.runOnScene(action, sceneName, options);
|
|
773
|
-
if (code !== 0) {
|
|
774
|
-
rc = 1;
|
|
775
|
-
}
|
|
1617
|
+
if (action === 'down') {
|
|
1618
|
+
return def.type === 'container'
|
|
1619
|
+
? this.stopContainer(sceneName)
|
|
1620
|
+
: await this.stopHost(sceneName);
|
|
776
1621
|
}
|
|
777
|
-
|
|
1622
|
+
if (action === 'status') {
|
|
1623
|
+
return def.type === 'container'
|
|
1624
|
+
? this.statusContainer(sceneName)
|
|
1625
|
+
: await this.statusHost(sceneName);
|
|
1626
|
+
}
|
|
1627
|
+
if (action === 'health') {
|
|
1628
|
+
return await this.healthScene(sceneName);
|
|
1629
|
+
}
|
|
1630
|
+
if (action === 'logs') {
|
|
1631
|
+
return def.type === 'container'
|
|
1632
|
+
? this.logsContainer(sceneName)
|
|
1633
|
+
: this.logsHost(sceneName);
|
|
1634
|
+
}
|
|
1635
|
+
this.writeStderr(`unknown action: ${action}`);
|
|
1636
|
+
return 1;
|
|
778
1637
|
}
|
|
779
1638
|
|
|
780
1639
|
async run({ action, scene = 'mcp-host-headless', host = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1640
|
+
if (action === 'ls') {
|
|
1641
|
+
return this.printSummary();
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
if (action === 'mcp-add') {
|
|
1645
|
+
return this.printMcpAdd(host);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (action === 'cli-add') {
|
|
1649
|
+
return this.printCliAdd();
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
if (action === 'ext-download') {
|
|
1653
|
+
return await this.downloadExtensions({ prodversion });
|
|
784
1654
|
}
|
|
785
1655
|
|
|
786
1656
|
if (!VALID_ACTIONS.has(action)) {
|
|
@@ -796,7 +1666,17 @@ class PlaywrightPlugin {
|
|
|
796
1666
|
const resolvedExtensionPaths = action === 'up'
|
|
797
1667
|
? this.resolveExtensionInputs({ extensionPaths, extensionNames })
|
|
798
1668
|
: [];
|
|
799
|
-
|
|
1669
|
+
|
|
1670
|
+
let rc = 0;
|
|
1671
|
+
for (const sceneName of targets) {
|
|
1672
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1673
|
+
const code = await this.runOnScene(action, sceneName, { extensionPaths: resolvedExtensionPaths });
|
|
1674
|
+
if (code !== 0) {
|
|
1675
|
+
rc = 1;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
return rc;
|
|
800
1680
|
}
|
|
801
1681
|
}
|
|
802
1682
|
|