@xcanwin/manyoyo 5.8.5 → 5.8.9

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,8 +5,17 @@ const net = require('net');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
  const crypto = require('crypto');
8
- const { spawn, spawnSync } = require('child_process');
8
+ const { 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');
10
19
 
11
20
  const EXTENSIONS = [
12
21
  ['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
@@ -77,6 +86,12 @@ const SCENE_DEFS = {
77
86
 
78
87
  const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
79
88
  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
+ };
80
95
  const CONTAINER_EXTENSION_ROOT = '/app/extensions';
81
96
  const DEFAULT_FINGERPRINT_PROFILE = {
82
97
  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',
@@ -154,129 +169,6 @@ function asBoolean(value, fallback = false) {
154
169
  return fallback;
155
170
  }
156
171
 
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
-
280
172
  class PlaywrightPlugin {
281
173
  constructor(options = {}) {
282
174
  this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
@@ -285,6 +177,58 @@ class PlaywrightPlugin {
285
177
  this.globalConfig = asObject(options.globalConfig);
286
178
  this.runConfig = asObject(options.runConfig);
287
179
  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
+ });
288
232
  }
289
233
 
290
234
  resolveConfig() {
@@ -472,41 +416,31 @@ class PlaywrightPlugin {
472
416
  }
473
417
 
474
418
  sceneEndpointPath(sceneName) {
475
- return path.join(this.config.runDir, `${sceneName}.endpoint.json`);
419
+ return this.sceneStateManager.sceneEndpointPath(sceneName);
476
420
  }
477
421
 
478
422
  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
- }
423
+ return this.sceneStateManager.readSceneEndpoint(sceneName);
488
424
  }
489
425
 
490
426
  writeSceneEndpoint(sceneName, payload) {
491
- fs.mkdirSync(this.config.runDir, { recursive: true });
492
- fs.writeFileSync(this.sceneEndpointPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
427
+ this.sceneStateManager.writeSceneEndpoint(sceneName, payload);
493
428
  }
494
429
 
495
430
  removeSceneEndpoint(sceneName) {
496
- fs.rmSync(this.sceneEndpointPath(sceneName), { force: true });
431
+ this.sceneStateManager.removeSceneEndpoint(sceneName);
497
432
  }
498
433
 
499
434
  sceneCliAttachConfigPath(sceneName) {
500
- return path.join(this.config.runDir, `${sceneName}.cli-attach.json`);
435
+ return this.sceneStateManager.sceneCliAttachConfigPath(sceneName);
501
436
  }
502
437
 
503
438
  writeSceneCliAttachConfig(sceneName, payload) {
504
- fs.mkdirSync(this.config.runDir, { recursive: true });
505
- fs.writeFileSync(this.sceneCliAttachConfigPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
439
+ this.sceneStateManager.writeSceneCliAttachConfig(sceneName, payload);
506
440
  }
507
441
 
508
442
  removeSceneCliAttachConfig(sceneName) {
509
- fs.rmSync(this.sceneCliAttachConfigPath(sceneName), { force: true });
443
+ this.sceneStateManager.removeSceneCliAttachConfig(sceneName);
510
444
  }
511
445
 
512
446
  sceneInitScriptPath(sceneName) {
@@ -515,81 +449,23 @@ class PlaywrightPlugin {
515
449
  }
516
450
 
517
451
  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');
452
+ return this.bootstrapManager.buildInitScriptContent();
561
453
  }
562
454
 
563
455
  ensureSceneInitScript(sceneName) {
564
- const filePath = this.sceneInitScriptPath(sceneName);
565
- const content = this.buildInitScriptContent();
566
- fs.writeFileSync(filePath, content, 'utf8');
567
- return filePath;
456
+ return this.bootstrapManager.ensureSceneInitScript(sceneName);
568
457
  }
569
458
 
570
459
  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');
460
+ return this.bootstrapManager.defaultBrowserName(sceneName);
577
461
  }
578
462
 
579
463
  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 });
464
+ this.bootstrapManager.ensureContainerScenePrerequisites(sceneName);
586
465
  }
587
466
 
588
467
  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 });
468
+ this.bootstrapManager.ensureHostScenePrerequisites(sceneName);
593
469
  }
594
470
 
595
471
  scenePidFile(sceneName) {
@@ -601,32 +477,11 @@ class PlaywrightPlugin {
601
477
  }
602
478
 
603
479
  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;
480
+ return this.bootstrapManager.localBinPath(binName);
610
481
  }
611
482
 
612
483
  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.`);
484
+ return this.bootstrapManager.playwrightBinPath(sceneName);
630
485
  }
631
486
 
632
487
  extensionDirPath() {
@@ -680,216 +535,55 @@ class PlaywrightPlugin {
680
535
  }
681
536
 
682
537
  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;
538
+ return this.extensionPathManager.resolveExtensionPaths(extensionArgs);
724
539
  }
725
540
 
726
541
  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
- });
542
+ return this.extensionPathManager.resolveNamedExtensionPaths(extensionNames);
736
543
  }
737
544
 
738
545
  resolveExtensionInputs(options = {}) {
739
- const extensionPaths = asStringArray(options.extensionPaths, []);
740
- const namedPaths = this.resolveNamedExtensionPaths(options.extensionNames || []);
741
- return this.resolveExtensionPaths([...extensionPaths, ...namedPaths]);
546
+ return this.extensionPathManager.resolveExtensionInputs(options);
742
547
  }
743
548
 
744
549
  buildExtensionLaunchArgs(extensionPaths) {
745
- const joined = extensionPaths.join(',');
746
- return [
747
- `--disable-extensions-except=${joined}`,
748
- `--load-extension=${joined}`
749
- ];
550
+ return this.sceneConfigManager.buildExtensionLaunchArgs(extensionPaths);
750
551
  }
751
552
 
752
553
  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';
554
+ return this.extensionPathManager.sanitizeExtensionMountName(value);
759
555
  }
760
556
 
761
557
  buildContainerExtensionMounts(extensionPaths = []) {
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
- });
772
-
773
- return { containerPaths, volumeMounts };
558
+ return this.extensionPathManager.buildContainerExtensionMounts(extensionPaths);
774
559
  }
775
560
 
776
561
  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
- ];
562
+ return this.sceneConfigManager.baseLaunchArgs();
784
563
  }
785
564
 
786
- 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
- }
565
+ buildSceneLaunchArgs(extensionPaths = []) {
566
+ return this.sceneConfigManager.buildSceneLaunchArgs(extensionPaths);
567
+ }
822
568
 
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
- };
569
+ buildMcpSceneConfig(sceneName, options = {}) {
570
+ return this.sceneConfigManager.buildMcpSceneConfig(sceneName, options);
843
571
  }
844
572
 
845
573
  buildCliSceneConfig(sceneName, options = {}) {
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
- };
857
-
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;
574
+ return this.sceneConfigManager.buildCliSceneConfig(sceneName, options);
866
575
  }
867
576
 
868
577
  buildSceneConfig(sceneName, options = {}) {
869
- if (isCliScene(sceneName)) {
870
- return this.buildCliSceneConfig(sceneName, options);
871
- }
872
- return this.buildMcpSceneConfig(sceneName, options);
578
+ return this.sceneConfigManager.buildSceneConfig(sceneName, options);
579
+ }
580
+
581
+ writeSceneConfigFile(sceneName, payload) {
582
+ return this.sceneConfigManager.writeSceneConfigFile(sceneName, payload);
873
583
  }
874
584
 
875
585
  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;
586
+ return this.sceneConfigManager.ensureSceneConfig(sceneName, options);
893
587
  }
894
588
 
895
589
  async portReady(port) {
@@ -926,465 +620,99 @@ class PlaywrightPlugin {
926
620
  }
927
621
 
928
622
  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;
623
+ return this.containerRuntimeManager.containerEnv(sceneName, cfgPath, options);
954
624
  }
955
625
 
956
626
  containerComposePath(sceneName) {
957
- const def = SCENE_DEFS[sceneName];
958
- return path.join(this.config.composeDir, def.composeFile);
627
+ return this.containerRuntimeManager.containerComposePath(sceneName);
959
628
  }
960
629
 
961
630
  sceneComposeOverridePath(sceneName) {
962
- return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
631
+ return this.containerRuntimeManager.sceneComposeOverridePath(sceneName);
963
632
  }
964
633
 
965
634
  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;
635
+ return this.containerRuntimeManager.ensureContainerComposeOverride(sceneName, volumeMounts);
983
636
  }
984
637
 
985
638
  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 };
639
+ return this.sceneStateManager.buildCliSessionIntegration(dockerCmd);
1014
640
  }
1015
641
 
1016
642
  async startContainer(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;
643
+ return await this.sceneDrivers.container.up(sceneName, options);
1082
644
  }
1083
645
 
1084
646
  stopContainer(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;
647
+ return this.sceneDrivers.container.down(sceneName);
1112
648
  }
1113
649
 
1114
650
  statusContainer(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;
651
+ return this.sceneDrivers.container.status(sceneName);
1144
652
  }
1145
653
 
1146
654
  logsContainer(sceneName) {
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
- }
655
+ return this.sceneDrivers.container.logs(sceneName);
656
+ }
1152
657
 
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
- }
658
+ ensureContainerRuntimeAvailable(action, sceneName) {
659
+ return this.containerRuntimeManager.ensureContainerRuntimeAvailable(action, sceneName);
660
+ }
1168
661
 
1169
- return cp.returncode === 0 ? 0 : 1;
662
+ buildContainerComposeCommand(sceneName, composeFiles = [], trailingArgs = []) {
663
+ return this.containerRuntimeManager.buildContainerComposeCommand(sceneName, composeFiles, trailingArgs);
1170
664
  }
1171
665
 
1172
666
  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
- };
667
+ return this.hostRuntimeManager.hostLaunchCommand(sceneName, cfgPath);
1183
668
  }
1184
669
 
1185
670
  spawnHostProcess(command, args, logFd) {
1186
- return spawn(command, args, {
1187
- detached: true,
1188
- stdio: ['ignore', logFd, logFd]
1189
- });
671
+ return this.hostRuntimeManager.spawnHostProcess(command, args, logFd);
1190
672
  }
1191
673
 
1192
674
  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
- }
675
+ this.hostRuntimeManager.stopHostStarter(pid);
1207
676
  }
1208
677
 
1209
678
  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;
679
+ return this.hostRuntimeManager.hostScenePids(sceneName);
1228
680
  }
1229
681
 
1230
682
  async 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 [];
683
+ return await this.hostRuntimeManager.waitForHostPids(sceneName, fallbackPid);
1243
684
  }
1244
685
 
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
- }
1253
-
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);
1261
-
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
- }
1267
-
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
- }
1273
-
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
- }
686
+ clearHostSceneRuntimeState(sceneName) {
687
+ this.sceneStateManager.clearHostSceneRuntimeState(sceneName);
688
+ }
1284
689
 
1285
- const starter = this.spawnHostProcess(launchCommand.command, launchCommand.args, logFd);
1286
- fs.closeSync(logFd);
1287
- if (typeof starter.unref === 'function') {
1288
- starter.unref();
1289
- }
690
+ async getHostSceneRuntimeInfo(sceneName) {
691
+ return await this.hostRuntimeManager.getHostSceneRuntimeInfo(sceneName);
692
+ }
1290
693
 
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
- }
694
+ signalPids(pids, signal = 'SIGTERM') {
695
+ this.hostRuntimeManager.signalPids(pids, signal);
696
+ }
1307
697
 
1308
- this.writeStderr(`[up] ${sceneName} failed to start. tail ${logFile}:`);
1309
- const tail = tailText(logFile, 30);
1310
- if (tail) {
1311
- this.writeStderr(tail);
1312
- }
698
+ readPidFilePid(pidFile) {
699
+ return this.hostRuntimeManager.readPidFilePid(pidFile);
700
+ }
1313
701
 
1314
- if (starter.exitCode === null && !starter.killed) {
1315
- this.stopHostStarter(starter.pid);
1316
- }
702
+ writeScenePidFile(pidFile, pid) {
703
+ this.hostRuntimeManager.writeScenePidFile(pidFile, pid);
704
+ }
1317
705
 
1318
- return 1;
706
+ async startHost(sceneName, options = {}) {
707
+ return await this.sceneDrivers.host.up(sceneName, options);
1319
708
  }
1320
709
 
1321
710
  async stopHost(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;
711
+ return await this.sceneDrivers.host.down(sceneName);
1359
712
  }
1360
713
 
1361
714
  async statusHost(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;
715
+ return await this.sceneDrivers.host.status(sceneName);
1388
716
  }
1389
717
 
1390
718
  async healthScene(sceneName) {
@@ -1398,259 +726,61 @@ class PlaywrightPlugin {
1398
726
  }
1399
727
 
1400
728
  logsHost(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
- }
729
+ return this.sceneDrivers.host.logs(sceneName);
1484
730
  }
1485
731
 
1486
732
  async 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;
733
+ return await this.extensionManager.downloadExtensions(options);
1524
734
  }
1525
735
 
1526
736
  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 '';
737
+ return this.commandOutputManager.detectCurrentIPv4();
1542
738
  }
1543
739
 
1544
740
  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;
741
+ return this.commandOutputManager.resolveMcpAddHost(hostArg);
1556
742
  }
1557
743
 
1558
744
  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;
745
+ return this.commandOutputManager.printMcpAdd(hostArg);
1582
746
  }
1583
747
 
1584
748
  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;
749
+ return this.commandOutputManager.printCliAdd();
1602
750
  }
1603
751
 
1604
752
  printSummary() {
1605
- const scenes = this.resolveTargets('all');
1606
- this.writeStdout(`playwright\truntime=${this.config.runtime}\tscenes=${scenes.join(',')}`);
1607
- return 0;
753
+ return this.commandOutputManager.printSummary();
1608
754
  }
1609
755
 
1610
756
  async runOnScene(action, sceneName, options = {}) {
1611
757
  const def = SCENE_DEFS[sceneName];
1612
- if (action === 'up') {
1613
- return def.type === 'container'
1614
- ? await this.startContainer(sceneName, options)
1615
- : await this.startHost(sceneName, options);
1616
- }
1617
- if (action === 'down') {
1618
- return def.type === 'container'
1619
- ? this.stopContainer(sceneName)
1620
- : await this.stopHost(sceneName);
1621
- }
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);
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;
1634
764
  }
1635
- this.writeStderr(`unknown action: ${action}`);
1636
- return 1;
765
+ return await handler(sceneName, options);
1637
766
  }
1638
767
 
1639
- async run({ action, scene = 'mcp-host-headless', host = '', extensionPaths = [], extensionNames = [], 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();
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
+ }
1650
776
  }
777
+ return rc;
778
+ }
1651
779
 
1652
- if (action === 'ext-download') {
1653
- return await this.downloadExtensions({ prodversion });
780
+ 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 });
1654
784
  }
1655
785
 
1656
786
  if (!VALID_ACTIONS.has(action)) {
@@ -1666,17 +796,7 @@ class PlaywrightPlugin {
1666
796
  const resolvedExtensionPaths = action === 'up'
1667
797
  ? this.resolveExtensionInputs({ extensionPaths, extensionNames })
1668
798
  : [];
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;
799
+ return await this.runOnTargets(action, targets, { extensionPaths: resolvedExtensionPaths });
1680
800
  }
1681
801
  }
1682
802