@xcanwin/manyoyo 5.8.9 → 5.8.11

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.
@@ -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.sceneStateManager.sceneEndpointPath(sceneName);
475
+ return path.join(this.config.runDir, `${sceneName}.endpoint.json`);
420
476
  }
421
477
 
422
478
  readSceneEndpoint(sceneName) {
423
- return this.sceneStateManager.readSceneEndpoint(sceneName);
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.sceneStateManager.writeSceneEndpoint(sceneName, payload);
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.sceneStateManager.removeSceneEndpoint(sceneName);
496
+ fs.rmSync(this.sceneEndpointPath(sceneName), { force: true });
432
497
  }
433
498
 
434
499
  sceneCliAttachConfigPath(sceneName) {
435
- return this.sceneStateManager.sceneCliAttachConfigPath(sceneName);
500
+ return path.join(this.config.runDir, `${sceneName}.cli-attach.json`);
436
501
  }
437
502
 
438
503
  writeSceneCliAttachConfig(sceneName, payload) {
439
- this.sceneStateManager.writeSceneCliAttachConfig(sceneName, payload);
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.sceneStateManager.removeSceneCliAttachConfig(sceneName);
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
- return this.bootstrapManager.buildInitScriptContent();
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
- return this.bootstrapManager.ensureSceneInitScript(sceneName);
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
- return this.bootstrapManager.defaultBrowserName(sceneName);
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.bootstrapManager.ensureContainerScenePrerequisites(sceneName);
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.bootstrapManager.ensureHostScenePrerequisites(sceneName);
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
- return this.bootstrapManager.localBinPath(binName);
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
- return this.bootstrapManager.playwrightBinPath(sceneName);
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
- return this.extensionPathManager.resolveExtensionPaths(extensionArgs);
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
- return this.extensionPathManager.resolveNamedExtensionPaths(extensionNames);
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
- return this.extensionPathManager.resolveExtensionInputs(options);
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
- return this.sceneConfigManager.buildExtensionLaunchArgs(extensionPaths);
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
- return this.extensionPathManager.sanitizeExtensionMountName(value);
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
- return this.extensionPathManager.buildContainerExtensionMounts(extensionPaths);
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
- baseLaunchArgs() {
562
- return this.sceneConfigManager.baseLaunchArgs();
773
+ return { containerPaths, volumeMounts };
563
774
  }
564
775
 
565
- buildSceneLaunchArgs(extensionPaths = []) {
566
- return this.sceneConfigManager.buildSceneLaunchArgs(extensionPaths);
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
- return this.sceneConfigManager.buildMcpSceneConfig(sceneName, options);
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
- return this.sceneConfigManager.buildCliSceneConfig(sceneName, options);
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
- buildSceneConfig(sceneName, options = {}) {
578
- return this.sceneConfigManager.buildSceneConfig(sceneName, options);
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
- writeSceneConfigFile(sceneName, payload) {
582
- return this.sceneConfigManager.writeSceneConfigFile(sceneName, payload);
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
- return this.sceneConfigManager.ensureSceneConfig(sceneName, options);
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
- return this.containerRuntimeManager.containerEnv(sceneName, cfgPath, options);
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
- return this.containerRuntimeManager.containerComposePath(sceneName);
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.containerRuntimeManager.sceneComposeOverridePath(sceneName);
962
+ return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
632
963
  }
633
964
 
634
965
  ensureContainerComposeOverride(sceneName, volumeMounts = []) {
635
- return this.containerRuntimeManager.ensureContainerComposeOverride(sceneName, volumeMounts);
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
- return this.sceneStateManager.buildCliSessionIntegration(dockerCmd);
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
- return await this.sceneDrivers.container.up(sceneName, options);
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
- return this.sceneDrivers.container.down(sceneName);
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
- return this.sceneDrivers.container.status(sceneName);
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
- return this.sceneDrivers.container.logs(sceneName);
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
- ensureContainerRuntimeAvailable(action, sceneName) {
659
- return this.containerRuntimeManager.ensureContainerRuntimeAvailable(action, sceneName);
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
- buildContainerComposeCommand(sceneName, composeFiles = [], trailingArgs = []) {
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
- return this.hostRuntimeManager.hostLaunchCommand(sceneName, cfgPath);
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 this.hostRuntimeManager.spawnHostProcess(command, args, logFd);
1186
+ return spawn(command, args, {
1187
+ detached: true,
1188
+ stdio: ['ignore', logFd, logFd]
1189
+ });
672
1190
  }
673
1191
 
674
1192
  stopHostStarter(pid) {
675
- this.hostRuntimeManager.stopHostStarter(pid);
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
- return this.hostRuntimeManager.hostScenePids(sceneName);
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
- return await this.hostRuntimeManager.waitForHostPids(sceneName, fallbackPid);
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
- clearHostSceneRuntimeState(sceneName) {
687
- this.sceneStateManager.clearHostSceneRuntimeState(sceneName);
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
- async getHostSceneRuntimeInfo(sceneName) {
691
- return await this.hostRuntimeManager.getHostSceneRuntimeInfo(sceneName);
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
- signalPids(pids, signal = 'SIGTERM') {
695
- this.hostRuntimeManager.signalPids(pids, signal);
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
- readPidFilePid(pidFile) {
699
- return this.hostRuntimeManager.readPidFilePid(pidFile);
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
- writeScenePidFile(pidFile, pid) {
703
- this.hostRuntimeManager.writeScenePidFile(pidFile, pid);
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
- async startHost(sceneName, options = {}) {
707
- return await this.sceneDrivers.host.up(sceneName, options);
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
- return await this.sceneDrivers.host.down(sceneName);
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
- return await this.sceneDrivers.host.status(sceneName);
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
- return this.sceneDrivers.host.logs(sceneName);
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
- return await this.extensionManager.downloadExtensions(options);
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
- return this.commandOutputManager.detectCurrentIPv4();
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
- return this.commandOutputManager.resolveMcpAddHost(hostArg);
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
- return this.commandOutputManager.printMcpAdd(hostArg);
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
- return this.commandOutputManager.printCliAdd();
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
- return this.commandOutputManager.printSummary();
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
- const handler = action === 'health'
759
- ? () => this.healthScene(sceneName)
760
- : this.sceneDrivers[def.type] && this.sceneDrivers[def.type][action];
761
- if (!handler) {
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
- return await handler(sceneName, options);
766
- }
767
-
768
- async runOnTargets(action, targets, options = {}) {
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
- return rc;
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
- const directHandler = DIRECT_ACTION_HANDLERS[action];
782
- if (directHandler) {
783
- return await directHandler(this, { host, prodversion });
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
- return await this.runOnTargets(action, targets, { extensionPaths: resolvedExtensionPaths });
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