@xcanwin/manyoyo 5.1.0 → 5.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/manyoyo.js +34 -10
- package/config.example.json +2 -0
- package/lib/{services → plugin}/index.js +4 -1
- package/lib/{services → plugin}/playwright.js +476 -27
- package/package.json +1 -1
- /package/lib/{services → plugin}/playwright-assets/compose-headed.yaml +0 -0
- /package/lib/{services → plugin}/playwright-assets/compose-headless.yaml +0 -0
- /package/lib/{services → plugin}/playwright-assets/headed.Dockerfile +0 -0
- /package/lib/{services → plugin}/playwright-assets/init-headed.sh +0 -0
package/bin/manyoyo.js
CHANGED
|
@@ -14,7 +14,7 @@ const { buildContainerRunArgs, buildContainerRunCommand } = require('../lib/cont
|
|
|
14
14
|
const { initAgentConfigs } = require('../lib/init-config');
|
|
15
15
|
const { buildImage } = require('../lib/image-build');
|
|
16
16
|
const { resolveAgentResumeArg, buildAgentResumeCommand } = require('../lib/agent-resume');
|
|
17
|
-
const { runPluginCommand } = require('../lib/
|
|
17
|
+
const { runPluginCommand } = require('../lib/plugin');
|
|
18
18
|
const { version: BIN_VERSION, imageVersion: IMAGE_VERSION_DEFAULT } = require('../package.json');
|
|
19
19
|
const IMAGE_VERSION_BASE = String(IMAGE_VERSION_DEFAULT || '1.0.0').split('-')[0];
|
|
20
20
|
const IMAGE_VERSION_HELP_EXAMPLE = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
|
|
@@ -873,7 +873,10 @@ async function setupCommander() {
|
|
|
873
873
|
pluginAction: params.action || 'ls',
|
|
874
874
|
pluginName: params.pluginName || 'playwright',
|
|
875
875
|
pluginScene: params.scene || 'host-headless',
|
|
876
|
-
pluginHost: params.host || ''
|
|
876
|
+
pluginHost: params.host || '',
|
|
877
|
+
pluginExtensionPaths: Array.isArray(params.extensionPaths) ? params.extensionPaths : [],
|
|
878
|
+
pluginExtensionNames: Array.isArray(params.extensionNames) ? params.extensionNames : [],
|
|
879
|
+
pluginProdversion: params.prodversion || ''
|
|
877
880
|
});
|
|
878
881
|
};
|
|
879
882
|
|
|
@@ -889,14 +892,22 @@ async function setupCommander() {
|
|
|
889
892
|
|
|
890
893
|
const actions = ['up', 'down', 'status', 'health', 'logs'];
|
|
891
894
|
actions.forEach(action => {
|
|
892
|
-
command.command(`${action} [scene]`)
|
|
895
|
+
const sceneCommand = command.command(`${action} [scene]`)
|
|
893
896
|
.description(`${action} playwright 场景,scene 默认 host-headless`)
|
|
894
|
-
.option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
897
|
+
.option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)');
|
|
898
|
+
|
|
899
|
+
if (action === 'up') {
|
|
900
|
+
appendArrayOption(sceneCommand, '--ext-path <path>', '追加浏览器扩展目录(可多次传入;目录需包含 manifest.json)');
|
|
901
|
+
appendArrayOption(sceneCommand, '--ext-name <name>', '追加 ~/.manyoyo/plugin/playwright/extensions/ 下的扩展目录名(可多次传入)');
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
sceneCommand.action((scene, options) => selectPluginAction({
|
|
905
|
+
action,
|
|
906
|
+
pluginName: 'playwright',
|
|
907
|
+
scene: scene || 'host-headless',
|
|
908
|
+
extensionPaths: action === 'up' ? (options.extPath || []) : [],
|
|
909
|
+
extensionNames: action === 'up' ? (options.extName || []) : []
|
|
910
|
+
}, options));
|
|
900
911
|
});
|
|
901
912
|
|
|
902
913
|
command.command('mcp-add')
|
|
@@ -909,6 +920,16 @@ async function setupCommander() {
|
|
|
909
920
|
scene: 'all',
|
|
910
921
|
host: options.host || ''
|
|
911
922
|
}, options));
|
|
923
|
+
|
|
924
|
+
command.command('ext-download')
|
|
925
|
+
.description('下载并解压 Playwright 扩展到 ~/.manyoyo/plugin/playwright/extensions/')
|
|
926
|
+
.option('--prodversion <ver>', 'CRX 下载使用的 Chrome 版本号 (默认 132.0.0.0)')
|
|
927
|
+
.action(options => selectPluginAction({
|
|
928
|
+
action: 'ext-download',
|
|
929
|
+
pluginName: 'playwright',
|
|
930
|
+
scene: 'all',
|
|
931
|
+
prodversion: options.prodversion || ''
|
|
932
|
+
}, options));
|
|
912
933
|
};
|
|
913
934
|
|
|
914
935
|
program
|
|
@@ -1095,7 +1116,10 @@ async function setupCommander() {
|
|
|
1095
1116
|
action: options.pluginAction,
|
|
1096
1117
|
pluginName: options.pluginName,
|
|
1097
1118
|
scene: options.pluginScene || 'host-headless',
|
|
1098
|
-
host: options.pluginHost || ''
|
|
1119
|
+
host: options.pluginHost || '',
|
|
1120
|
+
extensionPaths: Array.isArray(options.pluginExtensionPaths) ? options.pluginExtensionPaths : [],
|
|
1121
|
+
extensionNames: Array.isArray(options.pluginExtensionNames) ? options.pluginExtensionNames : [],
|
|
1122
|
+
prodversion: options.pluginProdversion || ''
|
|
1099
1123
|
},
|
|
1100
1124
|
pluginGlobalConfig: config,
|
|
1101
1125
|
pluginRunConfig: runConfig
|
package/config.example.json
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"mcpDefaultHost": "host.docker.internal",
|
|
37
37
|
// cont-headed 场景读取的密码环境变量名(默认 VNC_PASSWORD)
|
|
38
38
|
"vncPasswordEnvKey": "VNC_PASSWORD",
|
|
39
|
+
// playwright ext-download 的 CRX prodversion 参数
|
|
40
|
+
"extensionProdversion": "132.0.0.0",
|
|
39
41
|
"ports": {
|
|
40
42
|
"contHeadless": 8931,
|
|
41
43
|
"contHeaded": 8932,
|
|
@@ -52,7 +52,10 @@ async function runPluginCommand(request, options = {}) {
|
|
|
52
52
|
return await plugin.run({
|
|
53
53
|
action,
|
|
54
54
|
scene: request.scene,
|
|
55
|
-
host: request.host
|
|
55
|
+
host: request.host,
|
|
56
|
+
extensionPaths: request.extensionPaths,
|
|
57
|
+
extensionNames: request.extensionNames,
|
|
58
|
+
prodversion: request.prodversion
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -6,6 +6,14 @@ const os = require('os');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { spawn, spawnSync } = require('child_process');
|
|
8
8
|
|
|
9
|
+
const EXTENSIONS = [
|
|
10
|
+
['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
|
|
11
|
+
['adguard', 'bgnkhhnnamicmpeenaelnjfhikgbkllg'],
|
|
12
|
+
['privacy-badger', 'pkehgijcmpdhfbdbbnkijodmdjhbjlgp'],
|
|
13
|
+
['webrtc-leak-shield', 'bppamachkoflopbagkdoflbgfjflfnfl'],
|
|
14
|
+
['webgl-fingerprint-defender', 'olnbjpaejebpnokblkepbphhembdicik']
|
|
15
|
+
];
|
|
16
|
+
|
|
9
17
|
const SCENE_ORDER = ['cont-headless', 'cont-headed', 'host-headless', 'host-headed'];
|
|
10
18
|
|
|
11
19
|
const SCENE_DEFS = {
|
|
@@ -47,6 +55,7 @@ const SCENE_DEFS = {
|
|
|
47
55
|
|
|
48
56
|
const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
|
|
49
57
|
const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
|
|
58
|
+
const CONTAINER_EXTENSION_ROOT = '/app/extensions';
|
|
50
59
|
|
|
51
60
|
function sleep(ms) {
|
|
52
61
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -76,6 +85,129 @@ function asStringArray(value, fallback) {
|
|
|
76
85
|
.filter(Boolean);
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
function isHostPermission(value) {
|
|
89
|
+
if (value === '<all_urls>') {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return /^(?:\*|http|https|file|ftp):\/\//.test(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scriptSourcesFromHtml(htmlFile) {
|
|
96
|
+
const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
|
|
97
|
+
const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
|
|
98
|
+
return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function convertManifestV2ToV3(extDir) {
|
|
102
|
+
const manifestFile = path.join(extDir, 'manifest.json');
|
|
103
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
104
|
+
if (manifest.manifest_version !== 2) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
manifest.manifest_version = 3;
|
|
109
|
+
|
|
110
|
+
if (manifest.browser_action && !manifest.action) {
|
|
111
|
+
manifest.action = manifest.browser_action;
|
|
112
|
+
delete manifest.browser_action;
|
|
113
|
+
}
|
|
114
|
+
if (manifest.page_action && !manifest.action) {
|
|
115
|
+
manifest.action = manifest.page_action;
|
|
116
|
+
delete manifest.page_action;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const background = manifest.background;
|
|
120
|
+
if (background && typeof background === 'object' && !Array.isArray(background)) {
|
|
121
|
+
let scripts = [];
|
|
122
|
+
if (Array.isArray(background.scripts)) {
|
|
123
|
+
scripts = background.scripts.filter(s => typeof s === 'string');
|
|
124
|
+
} else if (typeof background.page === 'string') {
|
|
125
|
+
const pagePath = path.join(extDir, background.page);
|
|
126
|
+
if (fs.existsSync(pagePath)) {
|
|
127
|
+
scripts = scriptSourcesFromHtml(pagePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (scripts.length > 0) {
|
|
132
|
+
const swName = 'generated_background_sw.js';
|
|
133
|
+
const swFile = path.join(extDir, swName);
|
|
134
|
+
const swLines = [
|
|
135
|
+
'// Auto-generated by manyoyo playwright ext-download for MV3.',
|
|
136
|
+
`importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
|
|
137
|
+
''
|
|
138
|
+
];
|
|
139
|
+
fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
|
|
140
|
+
manifest.background = { service_worker: swName };
|
|
141
|
+
} else {
|
|
142
|
+
delete manifest.background;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof manifest.content_security_policy === 'string') {
|
|
147
|
+
manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (Array.isArray(manifest.permissions)) {
|
|
151
|
+
const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
|
|
152
|
+
const keptPermissions = [];
|
|
153
|
+
|
|
154
|
+
for (const perm of manifest.permissions) {
|
|
155
|
+
if (typeof perm === 'string' && isHostPermission(perm)) {
|
|
156
|
+
if (!hostPermissions.includes(perm)) {
|
|
157
|
+
hostPermissions.push(perm);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
keptPermissions.push(perm);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
manifest.permissions = keptPermissions;
|
|
165
|
+
if (hostPermissions.length > 0) {
|
|
166
|
+
manifest.host_permissions = hostPermissions;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const war = manifest.web_accessible_resources;
|
|
171
|
+
if (Array.isArray(war) && war.length > 0 && war.every(v => typeof v === 'string')) {
|
|
172
|
+
manifest.web_accessible_resources = [
|
|
173
|
+
{
|
|
174
|
+
resources: war,
|
|
175
|
+
matches: ['<all_urls>']
|
|
176
|
+
}
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildCrxUrl(extId, prodversion) {
|
|
185
|
+
return (
|
|
186
|
+
'https://clients2.google.com/service/update2/crx' +
|
|
187
|
+
`?response=redirect&prodversion=${prodversion}` +
|
|
188
|
+
'&acceptformat=crx2,crx3' +
|
|
189
|
+
`&x=id%3D${extId}%26installsource%3Dondemand%26uc`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function crxZipOffset(data) {
|
|
194
|
+
if (data.subarray(0, 4).toString('ascii') !== 'Cr24') {
|
|
195
|
+
throw new Error('not a CRX file');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const version = data.readUInt32LE(4);
|
|
199
|
+
if (version === 2) {
|
|
200
|
+
const pubLen = data.readUInt32LE(8);
|
|
201
|
+
const sigLen = data.readUInt32LE(12);
|
|
202
|
+
return 16 + pubLen + sigLen;
|
|
203
|
+
}
|
|
204
|
+
if (version === 3) {
|
|
205
|
+
const headerLen = data.readUInt32LE(8);
|
|
206
|
+
return 12 + headerLen;
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`unsupported CRX version: ${version}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
79
211
|
class PlaywrightPlugin {
|
|
80
212
|
constructor(options = {}) {
|
|
81
213
|
this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
|
|
@@ -88,6 +220,7 @@ class PlaywrightPlugin {
|
|
|
88
220
|
|
|
89
221
|
resolveConfig() {
|
|
90
222
|
const homeDir = os.homedir();
|
|
223
|
+
const pluginRootDir = path.join(homeDir, '.manyoyo', 'plugin', 'playwright');
|
|
91
224
|
const defaultConfig = {
|
|
92
225
|
runtime: 'mixed',
|
|
93
226
|
enabledScenes: [...SCENE_ORDER],
|
|
@@ -98,8 +231,9 @@ class PlaywrightPlugin {
|
|
|
98
231
|
containerRuntime: 'podman',
|
|
99
232
|
vncPasswordEnvKey: 'VNC_PASSWORD',
|
|
100
233
|
headedImage: 'localhost/xcanwin/manyoyo-playwright-headed',
|
|
101
|
-
configDir: path.join(
|
|
102
|
-
runDir: path.join(
|
|
234
|
+
configDir: path.join(pluginRootDir, 'config'),
|
|
235
|
+
runDir: path.join(pluginRootDir, 'run'),
|
|
236
|
+
extensionProdversion: '132.0.0.0',
|
|
103
237
|
composeDir: path.join(__dirname, 'playwright-assets'),
|
|
104
238
|
ports: {
|
|
105
239
|
contHeadless: 8931,
|
|
@@ -184,8 +318,15 @@ class PlaywrightPlugin {
|
|
|
184
318
|
}
|
|
185
319
|
|
|
186
320
|
ensureCommandAvailable(command) {
|
|
187
|
-
const
|
|
188
|
-
|
|
321
|
+
const name = String(command || '').trim();
|
|
322
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
const result = spawnSync('sh', ['-c', `command -v ${name}`], {
|
|
326
|
+
encoding: 'utf8',
|
|
327
|
+
stdio: ['ignore', 'ignore', 'ignore']
|
|
328
|
+
});
|
|
329
|
+
return result.status === 0;
|
|
189
330
|
}
|
|
190
331
|
|
|
191
332
|
scenePort(sceneName) {
|
|
@@ -198,6 +339,32 @@ class PlaywrightPlugin {
|
|
|
198
339
|
return path.join(this.config.configDir, def.configFile);
|
|
199
340
|
}
|
|
200
341
|
|
|
342
|
+
sceneConfigMissing(sceneName) {
|
|
343
|
+
return !fs.existsSync(this.sceneConfigPath(sceneName));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
defaultBrowserName(sceneName) {
|
|
347
|
+
const cfg = this.buildSceneConfig(sceneName);
|
|
348
|
+
const browserName = cfg && cfg.browser && cfg.browser.browserName;
|
|
349
|
+
return String(browserName || 'chromium');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
ensureContainerScenePrerequisites(sceneName) {
|
|
353
|
+
if (!this.sceneConfigMissing(sceneName)) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const tag = String(this.config.dockerTag || 'latest').trim() || 'latest';
|
|
357
|
+
const image = `mcr.microsoft.com/playwright/mcp:${tag}`;
|
|
358
|
+
this.runCmd([this.config.containerRuntime, 'pull', image], { check: true });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
ensureHostScenePrerequisites(sceneName) {
|
|
362
|
+
if (!this.sceneConfigMissing(sceneName)) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.runCmd(['npx', '-y', 'playwright-core', 'install', this.defaultBrowserName(sceneName)], { check: true });
|
|
366
|
+
}
|
|
367
|
+
|
|
201
368
|
scenePidFile(sceneName) {
|
|
202
369
|
return path.join(this.config.runDir, `${sceneName}.pid`);
|
|
203
370
|
}
|
|
@@ -206,6 +373,14 @@ class PlaywrightPlugin {
|
|
|
206
373
|
return path.join(this.config.runDir, `${sceneName}.log`);
|
|
207
374
|
}
|
|
208
375
|
|
|
376
|
+
extensionDirPath() {
|
|
377
|
+
return path.join(os.homedir(), '.manyoyo', 'plugin', 'playwright', 'extensions');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
extensionTmpDirPath() {
|
|
381
|
+
return path.join(os.homedir(), '.manyoyo', 'plugin', 'playwright', 'tmp-crx');
|
|
382
|
+
}
|
|
383
|
+
|
|
209
384
|
resolveTargets(sceneName = 'all') {
|
|
210
385
|
const requested = String(sceneName || 'all').trim();
|
|
211
386
|
const enabledSet = new Set(this.config.enabledScenes);
|
|
@@ -237,9 +412,112 @@ class PlaywrightPlugin {
|
|
|
237
412
|
.filter(scene => isAllowedByRuntime(scene));
|
|
238
413
|
}
|
|
239
414
|
|
|
240
|
-
|
|
415
|
+
resolveExtensionPaths(extensionArgs = []) {
|
|
416
|
+
const inputs = asStringArray(extensionArgs, []);
|
|
417
|
+
const uniquePaths = [];
|
|
418
|
+
const seen = new Set();
|
|
419
|
+
|
|
420
|
+
for (const item of inputs) {
|
|
421
|
+
const absPath = path.resolve(item);
|
|
422
|
+
if (!fs.existsSync(absPath)) {
|
|
423
|
+
throw new Error(`扩展路径不存在: ${absPath}`);
|
|
424
|
+
}
|
|
425
|
+
const stat = fs.statSync(absPath);
|
|
426
|
+
if (!stat.isDirectory()) {
|
|
427
|
+
throw new Error(`扩展路径必须是目录: ${absPath}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const manifestPath = path.join(absPath, 'manifest.json');
|
|
431
|
+
if (fs.existsSync(manifestPath)) {
|
|
432
|
+
if (!seen.has(absPath)) {
|
|
433
|
+
seen.add(absPath);
|
|
434
|
+
uniquePaths.push(absPath);
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const children = fs.readdirSync(absPath, { withFileTypes: true })
|
|
440
|
+
.filter(dirent => dirent.isDirectory())
|
|
441
|
+
.map(dirent => path.join(absPath, dirent.name))
|
|
442
|
+
.filter(child => fs.existsSync(path.join(child, 'manifest.json')));
|
|
443
|
+
|
|
444
|
+
if (children.length === 0) {
|
|
445
|
+
throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
for (const childPath of children) {
|
|
449
|
+
if (!seen.has(childPath)) {
|
|
450
|
+
seen.add(childPath);
|
|
451
|
+
uniquePaths.push(childPath);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return uniquePaths;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
resolveNamedExtensionPaths(extensionNames = []) {
|
|
460
|
+
const names = asStringArray(extensionNames, []);
|
|
461
|
+
const extensionRoot = path.resolve(this.extensionDirPath());
|
|
462
|
+
|
|
463
|
+
return names.map(name => {
|
|
464
|
+
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
|
|
465
|
+
throw new Error(`扩展名称无效: ${name}`);
|
|
466
|
+
}
|
|
467
|
+
return path.join(extensionRoot, name);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
resolveExtensionInputs(options = {}) {
|
|
472
|
+
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
473
|
+
const namedPaths = this.resolveNamedExtensionPaths(options.extensionNames || []);
|
|
474
|
+
return this.resolveExtensionPaths([...extensionPaths, ...namedPaths]);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
buildExtensionLaunchArgs(extensionPaths) {
|
|
478
|
+
const joined = extensionPaths.join(',');
|
|
479
|
+
return [
|
|
480
|
+
`--disable-extensions-except=${joined}`,
|
|
481
|
+
`--load-extension=${joined}`
|
|
482
|
+
];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
sanitizeExtensionMountName(value) {
|
|
486
|
+
const sanitized = String(value || '')
|
|
487
|
+
.trim()
|
|
488
|
+
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
489
|
+
.replace(/-+/g, '-')
|
|
490
|
+
.replace(/^-|-$/g, '');
|
|
491
|
+
return sanitized || 'ext';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
buildContainerExtensionMounts(extensionPaths = []) {
|
|
495
|
+
const hostPaths = asStringArray(extensionPaths, []);
|
|
496
|
+
const containerPaths = [];
|
|
497
|
+
const volumeMounts = [];
|
|
498
|
+
|
|
499
|
+
hostPaths.forEach((hostPath, idx) => {
|
|
500
|
+
const safeName = this.sanitizeExtensionMountName(path.basename(hostPath));
|
|
501
|
+
const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
|
|
502
|
+
containerPaths.push(containerPath);
|
|
503
|
+
volumeMounts.push(`${hostPath}:${containerPath}:ro`);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
return { containerPaths, volumeMounts };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
buildSceneConfig(sceneName, options = {}) {
|
|
241
510
|
const def = SCENE_DEFS[sceneName];
|
|
242
511
|
const port = this.scenePort(sceneName);
|
|
512
|
+
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
513
|
+
const launchOptions = {
|
|
514
|
+
channel: 'chromium',
|
|
515
|
+
headless: def.headless
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
if (extensionPaths.length > 0) {
|
|
519
|
+
launchOptions.args = this.buildExtensionLaunchArgs(extensionPaths);
|
|
520
|
+
}
|
|
243
521
|
|
|
244
522
|
return {
|
|
245
523
|
server: {
|
|
@@ -255,10 +533,7 @@ class PlaywrightPlugin {
|
|
|
255
533
|
browser: {
|
|
256
534
|
chromiumSandbox: true,
|
|
257
535
|
browserName: 'chromium',
|
|
258
|
-
launchOptions
|
|
259
|
-
channel: 'chromium',
|
|
260
|
-
headless: def.headless
|
|
261
|
-
},
|
|
536
|
+
launchOptions,
|
|
262
537
|
contextOptions: {
|
|
263
538
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36'
|
|
264
539
|
}
|
|
@@ -266,9 +541,9 @@ class PlaywrightPlugin {
|
|
|
266
541
|
};
|
|
267
542
|
}
|
|
268
543
|
|
|
269
|
-
ensureSceneConfig(sceneName) {
|
|
544
|
+
ensureSceneConfig(sceneName, options = {}) {
|
|
270
545
|
fs.mkdirSync(this.config.configDir, { recursive: true });
|
|
271
|
-
const payload = this.buildSceneConfig(sceneName);
|
|
546
|
+
const payload = this.buildSceneConfig(sceneName, options);
|
|
272
547
|
const filePath = this.sceneConfigPath(sceneName);
|
|
273
548
|
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
274
549
|
return filePath;
|
|
@@ -339,28 +614,74 @@ class PlaywrightPlugin {
|
|
|
339
614
|
return path.join(this.config.composeDir, def.composeFile);
|
|
340
615
|
}
|
|
341
616
|
|
|
342
|
-
|
|
617
|
+
sceneComposeOverridePath(sceneName) {
|
|
618
|
+
return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
ensureContainerComposeOverride(sceneName, volumeMounts = []) {
|
|
622
|
+
const overridePath = this.sceneComposeOverridePath(sceneName);
|
|
623
|
+
if (!Array.isArray(volumeMounts) || volumeMounts.length === 0) {
|
|
624
|
+
fs.rmSync(overridePath, { force: true });
|
|
625
|
+
return '';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
629
|
+
const lines = [
|
|
630
|
+
'services:',
|
|
631
|
+
' playwright:',
|
|
632
|
+
' volumes:'
|
|
633
|
+
];
|
|
634
|
+
volumeMounts.forEach(item => {
|
|
635
|
+
lines.push(` - ${JSON.stringify(String(item))}`);
|
|
636
|
+
});
|
|
637
|
+
fs.writeFileSync(overridePath, `${lines.join('\n')}\n`, 'utf8');
|
|
638
|
+
return overridePath;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async startContainer(sceneName, options = {}) {
|
|
343
642
|
const runtime = this.config.containerRuntime;
|
|
344
643
|
if (!this.ensureCommandAvailable(runtime)) {
|
|
345
644
|
this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
|
|
346
645
|
return 1;
|
|
347
646
|
}
|
|
348
647
|
|
|
349
|
-
|
|
648
|
+
try {
|
|
649
|
+
this.ensureContainerScenePrerequisites(sceneName);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
return error.returncode || 1;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const incomingExtensionPaths = asStringArray(options.extensionPaths, []);
|
|
655
|
+
let configOptions = { ...options, extensionPaths: incomingExtensionPaths };
|
|
656
|
+
const composeFiles = [this.containerComposePath(sceneName)];
|
|
657
|
+
|
|
658
|
+
if (incomingExtensionPaths.length > 0) {
|
|
659
|
+
const mapped = this.buildContainerExtensionMounts(incomingExtensionPaths);
|
|
660
|
+
const overridePath = this.ensureContainerComposeOverride(sceneName, mapped.volumeMounts);
|
|
661
|
+
if (overridePath) {
|
|
662
|
+
composeFiles.push(overridePath);
|
|
663
|
+
}
|
|
664
|
+
configOptions = { ...options, extensionPaths: mapped.containerPaths };
|
|
665
|
+
} else {
|
|
666
|
+
this.ensureContainerComposeOverride(sceneName, []);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const cfgPath = this.ensureSceneConfig(sceneName, configOptions);
|
|
350
670
|
const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
|
|
351
671
|
const def = SCENE_DEFS[sceneName];
|
|
352
672
|
|
|
353
673
|
try {
|
|
354
|
-
|
|
674
|
+
const args = [
|
|
355
675
|
runtime,
|
|
356
676
|
'compose',
|
|
357
677
|
'-p',
|
|
358
|
-
def.projectName
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
'
|
|
362
|
-
|
|
363
|
-
|
|
678
|
+
def.projectName
|
|
679
|
+
];
|
|
680
|
+
composeFiles.forEach(filePath => {
|
|
681
|
+
args.push('-f', filePath);
|
|
682
|
+
});
|
|
683
|
+
args.push('up', '-d');
|
|
684
|
+
this.runCmd(args, { env, check: true });
|
|
364
685
|
} catch (error) {
|
|
365
686
|
return error.returncode || 1;
|
|
366
687
|
}
|
|
@@ -382,6 +703,7 @@ class PlaywrightPlugin {
|
|
|
382
703
|
return 1;
|
|
383
704
|
}
|
|
384
705
|
|
|
706
|
+
this.ensureContainerComposeOverride(sceneName, []);
|
|
385
707
|
const cfgPath = this.sceneConfigPath(sceneName);
|
|
386
708
|
const env = this.containerEnv(sceneName, cfgPath);
|
|
387
709
|
const def = SCENE_DEFS[sceneName];
|
|
@@ -520,14 +842,20 @@ class PlaywrightPlugin {
|
|
|
520
842
|
return [];
|
|
521
843
|
}
|
|
522
844
|
|
|
523
|
-
async startHost(sceneName) {
|
|
845
|
+
async startHost(sceneName, options = {}) {
|
|
524
846
|
if (!this.ensureCommandAvailable('npx')) {
|
|
525
847
|
this.writeStderr(`[up] ${sceneName} failed: npx command not found.`);
|
|
526
848
|
return 1;
|
|
527
849
|
}
|
|
528
850
|
|
|
851
|
+
try {
|
|
852
|
+
this.ensureHostScenePrerequisites(sceneName);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
return error.returncode || 1;
|
|
855
|
+
}
|
|
856
|
+
|
|
529
857
|
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
530
|
-
const cfgPath = this.ensureSceneConfig(sceneName);
|
|
858
|
+
const cfgPath = this.ensureSceneConfig(sceneName, options);
|
|
531
859
|
const pidFile = this.scenePidFile(sceneName);
|
|
532
860
|
const logFile = this.sceneLogFile(sceneName);
|
|
533
861
|
const port = this.scenePort(sceneName);
|
|
@@ -665,6 +993,118 @@ class PlaywrightPlugin {
|
|
|
665
993
|
return 0;
|
|
666
994
|
}
|
|
667
995
|
|
|
996
|
+
async downloadFile(url, output, retries = 3, timeoutMs = 60_000) {
|
|
997
|
+
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
998
|
+
if (!this.ensureCommandAvailable('curl')) {
|
|
999
|
+
throw new Error('curl command not found');
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
let lastError = null;
|
|
1003
|
+
for (let i = 1; i <= retries; i += 1) {
|
|
1004
|
+
try {
|
|
1005
|
+
const result = this.runCmd([
|
|
1006
|
+
'curl',
|
|
1007
|
+
'--fail',
|
|
1008
|
+
'--location',
|
|
1009
|
+
'--silent',
|
|
1010
|
+
'--show-error',
|
|
1011
|
+
'--connect-timeout',
|
|
1012
|
+
String(timeoutSec),
|
|
1013
|
+
'--max-time',
|
|
1014
|
+
String(timeoutSec),
|
|
1015
|
+
'--output',
|
|
1016
|
+
output,
|
|
1017
|
+
url
|
|
1018
|
+
], { captureOutput: true, check: false });
|
|
1019
|
+
if (result.returncode !== 0) {
|
|
1020
|
+
throw new Error(result.stderr || `curl failed with exit code ${result.returncode}`);
|
|
1021
|
+
}
|
|
1022
|
+
return;
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
lastError = error;
|
|
1025
|
+
if (i < retries) {
|
|
1026
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1027
|
+
await sleep(1000);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
extractZipBuffer(zipBuffer, outDir) {
|
|
1036
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1037
|
+
const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
|
|
1038
|
+
fs.writeFileSync(tempZip, zipBuffer);
|
|
1039
|
+
|
|
1040
|
+
const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
|
|
1041
|
+
fs.rmSync(tempZip, { force: true });
|
|
1042
|
+
|
|
1043
|
+
if (result.error) {
|
|
1044
|
+
throw result.error;
|
|
1045
|
+
}
|
|
1046
|
+
if (result.status !== 0) {
|
|
1047
|
+
throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
extractCrx(crxFile, outDir) {
|
|
1052
|
+
const data = fs.readFileSync(crxFile);
|
|
1053
|
+
const offset = crxZipOffset(data);
|
|
1054
|
+
const zipBuffer = data.subarray(offset);
|
|
1055
|
+
|
|
1056
|
+
this.extractZipBuffer(zipBuffer, outDir);
|
|
1057
|
+
|
|
1058
|
+
const manifest = path.join(outDir, 'manifest.json');
|
|
1059
|
+
if (!fs.existsSync(manifest)) {
|
|
1060
|
+
throw new Error(`${crxFile} extracted but manifest.json missing`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (convertManifestV2ToV3(outDir)) {
|
|
1064
|
+
this.writeStdout(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async downloadExtensions(options = {}) {
|
|
1069
|
+
if (!this.ensureCommandAvailable('unzip')) {
|
|
1070
|
+
this.writeStderr('[ext-download] failed: unzip command not found.');
|
|
1071
|
+
return 1;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const prodversion = String(options.prodversion || this.config.extensionProdversion || '132.0.0.0').trim();
|
|
1075
|
+
const extDir = path.resolve(this.extensionDirPath());
|
|
1076
|
+
const tmpDir = path.resolve(this.extensionTmpDirPath());
|
|
1077
|
+
|
|
1078
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1079
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
1080
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1081
|
+
|
|
1082
|
+
try {
|
|
1083
|
+
this.writeStdout(`[info] ext dir: ${extDir}`);
|
|
1084
|
+
this.writeStdout(`[info] tmp dir: ${tmpDir}`);
|
|
1085
|
+
|
|
1086
|
+
for (const [name, extId] of EXTENSIONS) {
|
|
1087
|
+
const url = buildCrxUrl(extId, prodversion);
|
|
1088
|
+
const crxFile = path.join(tmpDir, `${name}.crx`);
|
|
1089
|
+
const outDir = path.join(extDir, name);
|
|
1090
|
+
|
|
1091
|
+
this.writeStdout(`[download] ${name}`);
|
|
1092
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1093
|
+
await this.downloadFile(url, crxFile);
|
|
1094
|
+
|
|
1095
|
+
this.writeStdout(`[extract] ${name}`);
|
|
1096
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
1097
|
+
this.extractCrx(crxFile, outDir);
|
|
1098
|
+
}
|
|
1099
|
+
} finally {
|
|
1100
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1101
|
+
this.writeStdout(`[cleanup] removed ${tmpDir}`);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
this.writeStdout(`[done] all extensions are ready: ${extDir}`);
|
|
1105
|
+
return 0;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
668
1108
|
detectCurrentIPv4() {
|
|
669
1109
|
const interfaces = os.networkInterfaces();
|
|
670
1110
|
for (const values of Object.values(interfaces)) {
|
|
@@ -724,12 +1164,12 @@ class PlaywrightPlugin {
|
|
|
724
1164
|
return 0;
|
|
725
1165
|
}
|
|
726
1166
|
|
|
727
|
-
async runOnScene(action, sceneName) {
|
|
1167
|
+
async runOnScene(action, sceneName, options = {}) {
|
|
728
1168
|
const def = SCENE_DEFS[sceneName];
|
|
729
1169
|
if (action === 'up') {
|
|
730
1170
|
return def.type === 'container'
|
|
731
|
-
? await this.startContainer(sceneName)
|
|
732
|
-
: await this.startHost(sceneName);
|
|
1171
|
+
? await this.startContainer(sceneName, options)
|
|
1172
|
+
: await this.startHost(sceneName, options);
|
|
733
1173
|
}
|
|
734
1174
|
if (action === 'down') {
|
|
735
1175
|
return def.type === 'container'
|
|
@@ -753,7 +1193,7 @@ class PlaywrightPlugin {
|
|
|
753
1193
|
return 1;
|
|
754
1194
|
}
|
|
755
1195
|
|
|
756
|
-
async run({ action, scene = 'host-headless', host = '' }) {
|
|
1196
|
+
async run({ action, scene = 'host-headless', host = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
|
|
757
1197
|
if (action === 'ls') {
|
|
758
1198
|
return this.printSummary();
|
|
759
1199
|
}
|
|
@@ -762,6 +1202,10 @@ class PlaywrightPlugin {
|
|
|
762
1202
|
return this.printMcpAdd(host);
|
|
763
1203
|
}
|
|
764
1204
|
|
|
1205
|
+
if (action === 'ext-download') {
|
|
1206
|
+
return await this.downloadExtensions({ prodversion });
|
|
1207
|
+
}
|
|
1208
|
+
|
|
765
1209
|
if (!VALID_ACTIONS.has(action)) {
|
|
766
1210
|
throw new Error(`未知 plugin 动作: ${action}`);
|
|
767
1211
|
}
|
|
@@ -772,10 +1216,14 @@ class PlaywrightPlugin {
|
|
|
772
1216
|
return 1;
|
|
773
1217
|
}
|
|
774
1218
|
|
|
1219
|
+
const resolvedExtensionPaths = action === 'up'
|
|
1220
|
+
? this.resolveExtensionInputs({ extensionPaths, extensionNames })
|
|
1221
|
+
: [];
|
|
1222
|
+
|
|
775
1223
|
let rc = 0;
|
|
776
1224
|
for (const sceneName of targets) {
|
|
777
1225
|
// eslint-disable-next-line no-await-in-loop
|
|
778
|
-
const code = await this.runOnScene(action, sceneName);
|
|
1226
|
+
const code = await this.runOnScene(action, sceneName, { extensionPaths: resolvedExtensionPaths });
|
|
779
1227
|
if (code !== 0) {
|
|
780
1228
|
rc = 1;
|
|
781
1229
|
}
|
|
@@ -786,6 +1234,7 @@ class PlaywrightPlugin {
|
|
|
786
1234
|
}
|
|
787
1235
|
|
|
788
1236
|
module.exports = {
|
|
1237
|
+
EXTENSIONS,
|
|
789
1238
|
SCENE_ORDER,
|
|
790
1239
|
SCENE_DEFS,
|
|
791
1240
|
PlaywrightPlugin
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|