@xcanwin/manyoyo 8.3.0 → 8.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/bin/manyoyo.js +70 -164
- package/docker/manyoyo.Dockerfile +16 -4
- package/docker/res/playwright/browser.json +28 -0
- package/docker/res/playwright/playwright-cli.sh +26 -0
- package/docker/res/playwright/stealth.init.js +14 -0
- package/lib/global-config.js +8 -33
- package/lib/init-config.js +11 -2
- package/lib/json5-text-edit.js +6 -3
- package/lib/plugin/fingerprint.js +176 -0
- package/lib/plugin/index.js +62 -47
- package/lib/plugin/playwright-assets/vnc-entry.sh +21 -0
- package/lib/plugin/playwright-extensions.js +299 -0
- package/lib/plugin/playwright-probe.js +53 -0
- package/lib/plugin/playwright-relay.js +88 -0
- package/lib/plugin/playwright-server.js +23 -0
- package/lib/plugin/playwright.js +731 -1852
- package/lib/web/index.html +1 -1
- package/lib/web/server.js +27 -3
- package/manyoyo.example.json +16 -28
- package/package.json +5 -5
- package/docker/res/playwright/cli-cont-headless.init.js +0 -12
- package/docker/res/playwright/cli-cont-headless.json +0 -37
- package/lib/plugin/playwright-assets/compose-headed.yaml +0 -19
- package/lib/plugin/playwright-assets/compose-headless.yaml +0 -11
- package/lib/plugin/playwright-assets/headed.Dockerfile +0 -15
- package/lib/plugin/playwright-assets/init-headed.sh +0 -17
package/lib/plugin/playwright.js
CHANGED
|
@@ -1,128 +1,43 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Playwright 插件:容器内的浏览器怎么来,只有 4 种模式、同一时间只有一个。
|
|
4
|
+
// default 容器内 Xvfb 里的有头浏览器(无需宿主机进程)
|
|
5
|
+
// headed 宿主机有窗口的浏览器
|
|
6
|
+
// chrome 用户正在使用的 Chrome(经本机中继)
|
|
7
|
+
// vnc 独立容器里的浏览器,用 noVNC 网页观看
|
|
8
|
+
// 宿主机目录 current/ 以目录方式只读挂进容器的 /run/manyoyo-playwright/,里面的 config.json 一律原子替换,
|
|
9
|
+
// 因此切换模式后已运行的容器自动跟随,不需要重建。
|
|
10
|
+
|
|
3
11
|
const fs = require('fs');
|
|
4
12
|
const net = require('net');
|
|
5
13
|
const os = require('os');
|
|
6
14
|
const path = require('path');
|
|
7
15
|
const crypto = require('crypto');
|
|
8
16
|
const { spawn, spawnSync } = require('child_process');
|
|
9
|
-
const {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
['
|
|
17
|
-
['
|
|
17
|
+
const { writeConfigFileSecure } = require('../secure-file');
|
|
18
|
+
const fingerprint = require('./fingerprint');
|
|
19
|
+
const extensions = require('./playwright-extensions');
|
|
20
|
+
const { findDevToolsActivePort } = require('./playwright-relay');
|
|
21
|
+
|
|
22
|
+
const MODES = ['headed', 'chrome', 'vnc'];
|
|
23
|
+
const MODE_HELP = [
|
|
24
|
+
['(默认)', '容器内虚拟屏里的有头浏览器,无需任何命令'],
|
|
25
|
+
['headed', '宿主机上有窗口的浏览器,亲眼看着 Agent 操作'],
|
|
26
|
+
['chrome', '你正在用的 Chrome,沿用登录状态(有风险)'],
|
|
27
|
+
['vnc', '独立容器里的浏览器,通过 noVNC 网页观看(无桌面的机器)']
|
|
18
28
|
];
|
|
29
|
+
const VNC_CONTAINER = 'my-playwright-vnc';
|
|
30
|
+
const VNC_ASSET_DIR = path.join(__dirname, 'playwright-assets');
|
|
31
|
+
const VNC_MOUNT_DIR = '/run/manyoyo-playwright-vnc';
|
|
32
|
+
const DEFAULT_PORTS = { port: 8935, vncPort: 5900, novncPort: 6080 };
|
|
33
|
+
const PROXY_BYPASS_HOSTS = ['host.docker.internal', 'host.containers.internal'];
|
|
34
|
+
const SERVER_START_TIMEOUT_MS = 30000;
|
|
35
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
36
|
+
// chrome 首次连接要等用户在 Chrome 弹出的“要允许远程调试吗?”里点允许
|
|
37
|
+
const CHROME_APPROVAL_TIMEOUT_MS = 60000;
|
|
19
38
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const SCENE_DEFS = {
|
|
23
|
-
'mcp-cont-headless': {
|
|
24
|
-
type: 'container',
|
|
25
|
-
engine: 'mcp',
|
|
26
|
-
configFile: 'mcp-cont-headless.json',
|
|
27
|
-
composeFile: 'compose-headless.yaml',
|
|
28
|
-
projectName: 'my-playwright-mcp-cont-headless',
|
|
29
|
-
containerName: 'my-playwright-mcp-cont-headless',
|
|
30
|
-
portKey: 'mcpContHeadless',
|
|
31
|
-
headless: true,
|
|
32
|
-
listenHost: '0.0.0.0'
|
|
33
|
-
},
|
|
34
|
-
'mcp-cont-headed': {
|
|
35
|
-
type: 'container',
|
|
36
|
-
engine: 'mcp',
|
|
37
|
-
configFile: 'mcp-cont-headed.json',
|
|
38
|
-
composeFile: 'compose-headed.yaml',
|
|
39
|
-
projectName: 'my-playwright-mcp-cont-headed',
|
|
40
|
-
containerName: 'my-playwright-mcp-cont-headed',
|
|
41
|
-
portKey: 'mcpContHeaded',
|
|
42
|
-
headless: false,
|
|
43
|
-
listenHost: '0.0.0.0'
|
|
44
|
-
},
|
|
45
|
-
'mcp-host-headless': {
|
|
46
|
-
type: 'host',
|
|
47
|
-
engine: 'mcp',
|
|
48
|
-
configFile: 'mcp-host-headless.json',
|
|
49
|
-
portKey: 'mcpHostHeadless',
|
|
50
|
-
headless: true,
|
|
51
|
-
listenHost: '127.0.0.1'
|
|
52
|
-
},
|
|
53
|
-
'mcp-host-headed': {
|
|
54
|
-
type: 'host',
|
|
55
|
-
engine: 'mcp',
|
|
56
|
-
configFile: 'mcp-host-headed.json',
|
|
57
|
-
portKey: 'mcpHostHeaded',
|
|
58
|
-
headless: false,
|
|
59
|
-
listenHost: '127.0.0.1'
|
|
60
|
-
},
|
|
61
|
-
'cli-host-headless': {
|
|
62
|
-
type: 'host',
|
|
63
|
-
engine: 'cli',
|
|
64
|
-
configFile: 'cli-host-headless.json',
|
|
65
|
-
portKey: 'cliHostHeadless',
|
|
66
|
-
headless: true,
|
|
67
|
-
listenHost: '0.0.0.0'
|
|
68
|
-
},
|
|
69
|
-
'cli-host-headed': {
|
|
70
|
-
type: 'host',
|
|
71
|
-
engine: 'cli',
|
|
72
|
-
configFile: 'cli-host-headed.json',
|
|
73
|
-
portKey: 'cliHostHeaded',
|
|
74
|
-
headless: false,
|
|
75
|
-
listenHost: '0.0.0.0'
|
|
76
|
-
},
|
|
77
|
-
'dev-host-headed': {
|
|
78
|
-
type: 'host',
|
|
79
|
-
engine: 'dev',
|
|
80
|
-
configFile: 'dev-host-headed.json',
|
|
81
|
-
headless: false
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
|
|
86
|
-
const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
|
|
87
|
-
const CONTAINER_EXTENSION_ROOT = '/app/extensions';
|
|
88
|
-
const DEFAULT_FINGERPRINT_PROFILE = {
|
|
89
|
-
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',
|
|
90
|
-
locale: 'zh-CN',
|
|
91
|
-
acceptLanguage: 'zh-CN,zh;q=0.9',
|
|
92
|
-
timezoneId: 'Asia/Shanghai',
|
|
93
|
-
width: 1366,
|
|
94
|
-
height: 768
|
|
95
|
-
};
|
|
96
|
-
const DISABLE_WEBRTC_LAUNCH_ARGS = ['--disable-webrtc'];
|
|
97
|
-
|
|
98
|
-
function isMcpScene(sceneName) {
|
|
99
|
-
return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'mcp');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function isCliScene(sceneName) {
|
|
103
|
-
return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'cli');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function isDevScene(sceneName) {
|
|
107
|
-
return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'dev');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function isCliSessionScene(sceneName) {
|
|
111
|
-
return isCliScene(sceneName) || isDevScene(sceneName);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function platformFromUserAgent(userAgent) {
|
|
115
|
-
const ua = String(userAgent || '').toLowerCase();
|
|
116
|
-
if (ua.includes('macintosh') || ua.includes('mac os x')) {
|
|
117
|
-
return 'MacIntel';
|
|
118
|
-
}
|
|
119
|
-
if (ua.includes('windows')) {
|
|
120
|
-
return 'Win32';
|
|
121
|
-
}
|
|
122
|
-
if (ua.includes('android') || ua.includes('linux')) {
|
|
123
|
-
return 'Linux x86_64';
|
|
124
|
-
}
|
|
125
|
-
return 'MacIntel';
|
|
39
|
+
function asObject(value) {
|
|
40
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
126
41
|
}
|
|
127
42
|
|
|
128
43
|
function sleep(ms) {
|
|
@@ -130,1913 +45,877 @@ function sleep(ms) {
|
|
|
130
45
|
}
|
|
131
46
|
|
|
132
47
|
function tailText(filePath, lineCount) {
|
|
133
|
-
|
|
48
|
+
try {
|
|
49
|
+
return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).slice(-lineCount).join('\n');
|
|
50
|
+
} catch {
|
|
134
51
|
return '';
|
|
135
52
|
}
|
|
136
|
-
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
137
|
-
return lines.slice(-lineCount).join('\n');
|
|
138
53
|
}
|
|
139
54
|
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
55
|
+
function randomAlnum(length) {
|
|
56
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
57
|
+
let out = '';
|
|
58
|
+
for (let i = 0; i < length; i += 1) {
|
|
59
|
+
out += chars[crypto.randomInt(0, chars.length)];
|
|
143
60
|
}
|
|
144
|
-
return
|
|
61
|
+
return out;
|
|
145
62
|
}
|
|
146
63
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
return value
|
|
152
|
-
.map(item => String(item || '').trim())
|
|
153
|
-
.filter(Boolean);
|
|
64
|
+
function shellValue(value) {
|
|
65
|
+
const text = String(value);
|
|
66
|
+
return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
|
|
154
67
|
}
|
|
155
68
|
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
return fallback;
|
|
69
|
+
function portReady(port, host = '127.0.0.1', timeout = 300) {
|
|
70
|
+
return new Promise(resolve => {
|
|
71
|
+
const socket = net.createConnection({ host, port });
|
|
72
|
+
const finish = value => {
|
|
73
|
+
socket.destroy();
|
|
74
|
+
resolve(value);
|
|
75
|
+
};
|
|
76
|
+
socket.setTimeout(timeout);
|
|
77
|
+
socket.once('connect', () => finish(true));
|
|
78
|
+
socket.once('timeout', () => finish(false));
|
|
79
|
+
socket.once('error', () => finish(false));
|
|
80
|
+
});
|
|
170
81
|
}
|
|
171
82
|
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
for (const value of values) {
|
|
176
|
-
const text = String(value || '').trim();
|
|
177
|
-
if (!text || seen.has(text)) {
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
seen.add(text);
|
|
181
|
-
out.push(text);
|
|
83
|
+
function pidAlive(pid) {
|
|
84
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
85
|
+
return false;
|
|
182
86
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
function isHostPermission(value) {
|
|
187
|
-
if (value === '<all_urls>') {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
188
89
|
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return error.code === 'EPERM';
|
|
189
92
|
}
|
|
190
|
-
return /^(?:\*|http|https|file|ftp):\/\//.test(value);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function scriptSourcesFromHtml(htmlFile) {
|
|
194
|
-
const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
|
|
195
|
-
const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
|
|
196
|
-
return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
|
|
197
93
|
}
|
|
198
94
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (manifest.manifest_version !== 2) {
|
|
95
|
+
// pid 可能在重启后被不相干的进程复用:确认它的命令行还是我们起的服务脚本
|
|
96
|
+
function pidIsOurs(pid) {
|
|
97
|
+
if (!pidAlive(pid)) {
|
|
203
98
|
return false;
|
|
204
99
|
}
|
|
100
|
+
const result = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8' });
|
|
101
|
+
return /playwright-(server|relay)\.js/.test(result.stdout || '');
|
|
102
|
+
}
|
|
205
103
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
delete manifest.browser_action;
|
|
211
|
-
}
|
|
212
|
-
if (manifest.page_action && !manifest.action) {
|
|
213
|
-
manifest.action = manifest.page_action;
|
|
214
|
-
delete manifest.page_action;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const background = manifest.background;
|
|
218
|
-
if (background && typeof background === 'object' && !Array.isArray(background)) {
|
|
219
|
-
let scripts = [];
|
|
220
|
-
if (Array.isArray(background.scripts)) {
|
|
221
|
-
scripts = background.scripts.filter(s => typeof s === 'string');
|
|
222
|
-
} else if (typeof background.page === 'string') {
|
|
223
|
-
const pagePath = path.join(extDir, background.page);
|
|
224
|
-
if (fs.existsSync(pagePath)) {
|
|
225
|
-
scripts = scriptSourcesFromHtml(pagePath);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (scripts.length > 0) {
|
|
230
|
-
const swName = 'generated_background_sw.js';
|
|
231
|
-
const swFile = path.join(extDir, swName);
|
|
232
|
-
const swLines = [
|
|
233
|
-
'// Auto-generated by manyoyo playwright ext-download for MV3.',
|
|
234
|
-
`importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
|
|
235
|
-
''
|
|
236
|
-
];
|
|
237
|
-
fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
|
|
238
|
-
manifest.background = { service_worker: swName };
|
|
239
|
-
} else {
|
|
240
|
-
delete manifest.background;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (typeof manifest.content_security_policy === 'string') {
|
|
245
|
-
manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (Array.isArray(manifest.permissions)) {
|
|
249
|
-
const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
|
|
250
|
-
const keptPermissions = [];
|
|
251
|
-
|
|
252
|
-
for (const perm of manifest.permissions) {
|
|
253
|
-
if (typeof perm === 'string' && isHostPermission(perm)) {
|
|
254
|
-
if (!hostPermissions.includes(perm)) {
|
|
255
|
-
hostPermissions.push(perm);
|
|
256
|
-
}
|
|
257
|
-
} else {
|
|
258
|
-
keptPermissions.push(perm);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
manifest.permissions = keptPermissions;
|
|
263
|
-
if (hostPermissions.length > 0) {
|
|
264
|
-
manifest.host_permissions = hostPermissions;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
104
|
+
// 宿主机监听地址:macOS 的容器能访问宿主机 loopback;Linux 容器只能访问 0.0.0.0
|
|
105
|
+
function defaultListenHost(platform = process.platform) {
|
|
106
|
+
return platform === 'darwin' ? '127.0.0.1' : '0.0.0.0';
|
|
107
|
+
}
|
|
267
108
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
matches: ['<all_urls>']
|
|
274
|
-
}
|
|
275
|
-
];
|
|
276
|
-
}
|
|
109
|
+
// headed 的浏览器服务必须监听 0.0.0.0:playwright 只绑 loopback 时会按 Host 头做 DNS 重绑定防护,
|
|
110
|
+
// 容器用 host.containers.internal 访问会被 403(macOS 上实测);监听 0.0.0.0 则不检查 Host,靠 token 保护
|
|
111
|
+
function headedListenHost() {
|
|
112
|
+
return '0.0.0.0';
|
|
113
|
+
}
|
|
277
114
|
|
|
278
|
-
|
|
279
|
-
return
|
|
115
|
+
function runtimeKind(runtimeCommand) {
|
|
116
|
+
return path.basename(String(runtimeCommand || '').trim()).toLowerCase() === 'podman' ? 'podman' : 'docker';
|
|
280
117
|
}
|
|
281
118
|
|
|
282
|
-
function
|
|
283
|
-
return (
|
|
284
|
-
'https://clients2.google.com/service/update2/crx' +
|
|
285
|
-
`?response=redirect&prodversion=${prodversion}` +
|
|
286
|
-
'&acceptformat=crx2,crx3' +
|
|
287
|
-
`&x=id%3D${extId}%26installsource%3Dondemand%26uc`
|
|
288
|
-
);
|
|
119
|
+
function connectHostFor(runtimeCommand) {
|
|
120
|
+
return runtimeKind(runtimeCommand) === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
|
289
121
|
}
|
|
290
122
|
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
|
|
123
|
+
function appendNoProxy(existing) {
|
|
124
|
+
const parts = String(existing || '').split(',').map(item => item.trim()).filter(Boolean);
|
|
125
|
+
for (const host of PROXY_BYPASS_HOSTS) {
|
|
126
|
+
if (!parts.includes(host)) {
|
|
127
|
+
parts.push(host);
|
|
128
|
+
}
|
|
294
129
|
}
|
|
130
|
+
return parts.join(',');
|
|
131
|
+
}
|
|
295
132
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (version === 3) {
|
|
303
|
-
const headerLen = data.readUInt32LE(8);
|
|
304
|
-
return 12 + headerLen;
|
|
133
|
+
function envEntryValue(entries, key) {
|
|
134
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
135
|
+
const text = String(entries[i]);
|
|
136
|
+
if (text.startsWith(`${key}=`)) {
|
|
137
|
+
return text.slice(key.length + 1);
|
|
138
|
+
}
|
|
305
139
|
}
|
|
306
|
-
|
|
140
|
+
return undefined;
|
|
307
141
|
}
|
|
308
142
|
|
|
309
143
|
class PlaywrightPlugin {
|
|
310
144
|
constructor(options = {}) {
|
|
311
|
-
this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
|
|
312
145
|
this.stdout = options.stdout || process.stdout;
|
|
313
146
|
this.stderr = options.stderr || process.stderr;
|
|
314
|
-
this.globalConfig = asObject(options.globalConfig);
|
|
315
|
-
this.runConfig = asObject(options.runConfig);
|
|
316
147
|
this.rootGlobalConfig = asObject(options.rootGlobalConfig);
|
|
317
148
|
this.rootRunConfig = asObject(options.rootRunConfig);
|
|
318
|
-
this.
|
|
319
|
-
this.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
configDir: path.join(pluginRootDir, 'config'),
|
|
336
|
-
runDir: path.join(pluginRootDir, 'run'),
|
|
337
|
-
extensionProdversion: '132.0.0.0',
|
|
338
|
-
navigatorPlatform: platformFromUserAgent(DEFAULT_FINGERPRINT_PROFILE.userAgent),
|
|
339
|
-
disableWebRTC: false,
|
|
340
|
-
devtoolsActivePortPath: '',
|
|
341
|
-
devtoolsCdpTimeout: 60000,
|
|
342
|
-
composeDir: path.join(__dirname, 'playwright-assets'),
|
|
343
|
-
ports: {
|
|
344
|
-
mcpContHeadless: 8931,
|
|
345
|
-
mcpContHeaded: 8932,
|
|
346
|
-
mcpHostHeadless: 8933,
|
|
347
|
-
mcpHostHeaded: 8934,
|
|
348
|
-
cliHostHeadless: 8935,
|
|
349
|
-
cliHostHeaded: 8936,
|
|
350
|
-
mcpContHeadedNoVnc: 6080
|
|
351
|
-
}
|
|
149
|
+
this.runtime = options.runtime || null;
|
|
150
|
+
this.homeDir = options.homeDir || os.homedir();
|
|
151
|
+
this.root = path.join(this.homeDir, '.manyoyo', 'plugin', 'playwright');
|
|
152
|
+
this.currentDir = path.join(this.root, 'current');
|
|
153
|
+
this.runDir = path.join(this.root, 'run');
|
|
154
|
+
this.config = this.resolveConfig(asObject(options.globalConfig), asObject(options.runConfig));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
resolveConfig(globalConfig, runConfig) {
|
|
158
|
+
const merged = { ...globalConfig, ...runConfig };
|
|
159
|
+
const host = fingerprint.detectHostProfile();
|
|
160
|
+
const port = name => {
|
|
161
|
+
const value = Number(merged[name] || DEFAULT_PORTS[name]);
|
|
162
|
+
if (!Number.isInteger(value) || value <= 0 || value > 65535) {
|
|
163
|
+
throw new Error(`plugins.playwright.${name} 无效: ${merged[name]}`);
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
352
166
|
};
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
167
|
+
return {
|
|
168
|
+
port: port('port'),
|
|
169
|
+
vncPort: port('vncPort'),
|
|
170
|
+
novncPort: port('novncPort'),
|
|
171
|
+
locale: String(merged.locale || '').trim() || host.locale,
|
|
172
|
+
timezoneId: String(merged.timezoneId || '').trim() || host.timezoneId,
|
|
173
|
+
navigatorPlatform: String(merged.navigatorPlatform || '').trim(),
|
|
174
|
+
disableWebRTC: merged.disableWebRTC === true || String(merged.disableWebRTC).toLowerCase() === 'true',
|
|
175
|
+
devtoolsActivePortPath: String(merged.devtoolsActivePortPath || '').trim(),
|
|
176
|
+
extensionProdversion: String(merged.extensionProdversion || '').trim() || '132.0.0.0'
|
|
363
177
|
};
|
|
364
|
-
|
|
365
|
-
merged.runtime = String(merged.runtime || defaultConfig.runtime).trim().toLowerCase();
|
|
366
|
-
if (!VALID_RUNTIME.has(merged.runtime)) {
|
|
367
|
-
throw new Error(`playwright.runtime 无效: ${merged.runtime}`);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
merged.enabledScenes = asStringArray(
|
|
371
|
-
this.runConfig.enabledScenes,
|
|
372
|
-
asStringArray(this.globalConfig.enabledScenes, [...defaultConfig.enabledScenes])
|
|
373
|
-
);
|
|
374
|
-
merged.containerRuntime = this.resolveContainerRuntime(merged.containerRuntime);
|
|
375
|
-
merged.cliSessionScene = String(merged.cliSessionScene || defaultConfig.cliSessionScene).trim();
|
|
376
|
-
merged.navigatorPlatform = String(merged.navigatorPlatform || defaultConfig.navigatorPlatform).trim() || defaultConfig.navigatorPlatform;
|
|
377
|
-
merged.disableWebRTC = asBoolean(merged.disableWebRTC, defaultConfig.disableWebRTC);
|
|
378
|
-
|
|
379
|
-
if (merged.enabledScenes.length === 0) {
|
|
380
|
-
throw new Error('playwright.enabledScenes 不能为空');
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const invalidScene = merged.enabledScenes.find(scene => !SCENE_DEFS[scene]);
|
|
384
|
-
if (invalidScene) {
|
|
385
|
-
throw new Error(`playwright.enabledScenes 包含未知场景: ${invalidScene}`);
|
|
386
|
-
}
|
|
387
|
-
merged.devtoolsActivePortPath = String(merged.devtoolsActivePortPath || '').trim();
|
|
388
|
-
merged.devtoolsCdpTimeout = Number(merged.devtoolsCdpTimeout) || defaultConfig.devtoolsCdpTimeout;
|
|
389
|
-
|
|
390
|
-
if (merged.cliSessionScene && !isCliSessionScene(merged.cliSessionScene)) {
|
|
391
|
-
throw new Error(`playwright.cliSessionScene 无效: ${merged.cliSessionScene}`);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return merged;
|
|
395
178
|
}
|
|
396
179
|
|
|
397
|
-
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
return configured;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const forced = resolveForcedRuntime({ configured: this.rootGlobalConfig.containerRuntime });
|
|
404
|
-
if (forced) {
|
|
405
|
-
this.runtimeEnv = forced.env;
|
|
406
|
-
return forced.command;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const candidates = ['docker', 'podman'];
|
|
410
|
-
for (const cmd of candidates) {
|
|
411
|
-
if (this.ensureCommandAvailable(cmd)) {
|
|
412
|
-
return cmd;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return 'docker';
|
|
180
|
+
profile() {
|
|
181
|
+
const { locale, timezoneId, navigatorPlatform, disableWebRTC } = this.config;
|
|
182
|
+
return { locale, timezoneId, navigatorPlatform, disableWebRTC };
|
|
417
183
|
}
|
|
418
184
|
|
|
419
|
-
|
|
185
|
+
out(line = '') {
|
|
420
186
|
this.stdout.write(`${line}\n`);
|
|
421
187
|
}
|
|
422
188
|
|
|
423
|
-
|
|
189
|
+
err(line = '') {
|
|
424
190
|
this.stderr.write(`${line}\n`);
|
|
425
191
|
}
|
|
426
192
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
193
|
+
// ---------- 路径与状态 ----------
|
|
194
|
+
|
|
195
|
+
statePath() { return path.join(this.runDir, 'state.json'); }
|
|
196
|
+
tokenPath() { return path.join(this.runDir, 'token'); }
|
|
197
|
+
logPath() { return path.join(this.runDir, 'server.log'); }
|
|
198
|
+
hostConfigPath() { return path.join(this.root, 'host.json'); }
|
|
199
|
+
|
|
200
|
+
ensureDirs() {
|
|
201
|
+
for (const dir of [this.root, this.currentDir, this.runDir]) {
|
|
202
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
436
203
|
}
|
|
437
|
-
this.writeStdout('[tip] 如果希望容器内 manyoyo run 自动附着到当前 CLI 宿主场景,请在 ~/.manyoyo/manyoyo.json 中设置:');
|
|
438
|
-
this.writeStdout('{');
|
|
439
|
-
this.writeStdout(' "volumes": [');
|
|
440
|
-
this.writeStdout(' "~/.manyoyo/.cache/ms-playwright:/root/.cache/ms-playwright"');
|
|
441
|
-
this.writeStdout(' ],');
|
|
442
|
-
this.writeStdout(' "plugins": {');
|
|
443
|
-
this.writeStdout(' "playwright": {');
|
|
444
|
-
this.writeStdout(' "cliSessionScene": "cli-host-headed"');
|
|
445
|
-
this.writeStdout(' }');
|
|
446
|
-
this.writeStdout(' }');
|
|
447
|
-
this.writeStdout('}');
|
|
448
204
|
}
|
|
449
205
|
|
|
450
|
-
|
|
451
|
-
|
|
206
|
+
readState() {
|
|
207
|
+
try {
|
|
208
|
+
const state = JSON.parse(fs.readFileSync(this.statePath(), 'utf8'));
|
|
209
|
+
return state && MODES.includes(state.mode) ? state : { mode: 'default' };
|
|
210
|
+
} catch {
|
|
211
|
+
return { mode: 'default' };
|
|
212
|
+
}
|
|
452
213
|
}
|
|
453
214
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (platform === 'darwin') {
|
|
460
|
-
commands = [
|
|
461
|
-
['open', '-a', 'Google Chrome', url],
|
|
462
|
-
['open', url]
|
|
463
|
-
];
|
|
464
|
-
} else if (platform === 'win32') {
|
|
465
|
-
commands = [
|
|
466
|
-
['cmd', '/c', 'start', '', 'chrome', url]
|
|
467
|
-
];
|
|
468
|
-
} else {
|
|
469
|
-
commands = [
|
|
470
|
-
['google-chrome', url],
|
|
471
|
-
['google-chrome-stable', url],
|
|
472
|
-
['chromium', url],
|
|
473
|
-
['chromium-browser', url]
|
|
474
|
-
];
|
|
475
|
-
}
|
|
215
|
+
writeState(state) {
|
|
216
|
+
this.ensureDirs();
|
|
217
|
+
writeConfigFileSecure(this.statePath(), `${JSON.stringify(state, null, 4)}\n`);
|
|
218
|
+
}
|
|
476
219
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
220
|
+
// token 首次生成后持久化,重启服务不换;只有 down 才会让它失效
|
|
221
|
+
ensureToken() {
|
|
222
|
+
this.ensureDirs();
|
|
223
|
+
try {
|
|
224
|
+
const token = fs.readFileSync(this.tokenPath(), 'utf8').trim();
|
|
225
|
+
if (/^[0-9a-f]{64}$/.test(token)) {
|
|
226
|
+
return token;
|
|
481
227
|
}
|
|
228
|
+
} catch {
|
|
229
|
+
// 还没有,下面生成
|
|
482
230
|
}
|
|
483
|
-
|
|
231
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
232
|
+
writeConfigFileSecure(this.tokenPath(), `${token}\n`);
|
|
233
|
+
return token;
|
|
484
234
|
}
|
|
485
235
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
this.writeStdout('');
|
|
493
|
-
this.writeStdout('若用宿主机 Agent 操作宿主机浏览器,在宿主机执行:');
|
|
494
|
-
['claude', 'codex', 'gemini'].forEach(command => {
|
|
495
|
-
this.writeStdout(this.devHostAttachCommand(configPath, command));
|
|
496
|
-
});
|
|
236
|
+
readPersisted(name) {
|
|
237
|
+
try {
|
|
238
|
+
return fs.readFileSync(path.join(this.runDir, name), 'utf8').trim();
|
|
239
|
+
} catch {
|
|
240
|
+
return '';
|
|
241
|
+
}
|
|
497
242
|
}
|
|
498
243
|
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
out += chars[crypto.randomInt(0, chars.length)];
|
|
244
|
+
ensureVncPassword() {
|
|
245
|
+
const existing = this.readPersisted('vnc-password');
|
|
246
|
+
if (existing) {
|
|
247
|
+
return existing;
|
|
504
248
|
}
|
|
505
|
-
|
|
249
|
+
const password = randomAlnum(8);
|
|
250
|
+
writeConfigFileSecure(path.join(this.runDir, 'vnc-password'), `${password}\n`);
|
|
251
|
+
return password;
|
|
506
252
|
}
|
|
507
253
|
|
|
508
|
-
|
|
509
|
-
const isRuntimeCommand = args[0] === this.config.containerRuntime;
|
|
510
|
-
const result = spawnSync(args[0], args.slice(1), {
|
|
511
|
-
encoding: 'utf8',
|
|
512
|
-
env: isRuntimeCommand ? { ...(env || process.env), ...this.runtimeEnv } : (env || process.env),
|
|
513
|
-
stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
const completed = {
|
|
517
|
-
returncode: result.status === null ? 1 : result.status,
|
|
518
|
-
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
519
|
-
stderr: typeof result.stderr === 'string' ? result.stderr : ''
|
|
520
|
-
};
|
|
254
|
+
// ---------- 容器侧配置(挂载目录里的 config.json) ----------
|
|
521
255
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
256
|
+
containerConfigFor(state, runtimeCommand) {
|
|
257
|
+
const profile = this.profile();
|
|
258
|
+
const host = connectHostFor(runtimeCommand);
|
|
259
|
+
if (state.mode === 'chrome') {
|
|
260
|
+
return fingerprint.buildContainerConfig('cdp', {
|
|
261
|
+
...profile,
|
|
262
|
+
endpoint: `ws://${host}:${state.port}/${this.ensureToken()}`
|
|
263
|
+
});
|
|
528
264
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
error.stderr = completed.stderr;
|
|
535
|
-
throw error;
|
|
265
|
+
if (state.mode === 'headed' || state.mode === 'vnc') {
|
|
266
|
+
return fingerprint.buildContainerConfig('ws', {
|
|
267
|
+
...profile,
|
|
268
|
+
endpoint: `ws://${host}:${state.port}/${this.ensureToken()}`
|
|
269
|
+
});
|
|
536
270
|
}
|
|
537
|
-
|
|
538
|
-
return completed;
|
|
271
|
+
return fingerprint.buildContainerConfig('default', profile);
|
|
539
272
|
}
|
|
540
273
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
274
|
+
// 原子替换:目录挂载能看到 rename 之后的新文件
|
|
275
|
+
writeCurrentConfig(state, runtimeCommand) {
|
|
276
|
+
this.ensureDirs();
|
|
277
|
+
const config = `${JSON.stringify(this.containerConfigFor(state, runtimeCommand), null, 4)}\n`;
|
|
278
|
+
const initScript = fingerprint.buildInitScript(this.profile());
|
|
279
|
+
const configPath = path.join(this.currentDir, 'config.json');
|
|
280
|
+
const initPath = path.join(this.currentDir, 'stealth.init.js');
|
|
281
|
+
const same = (file, content) => {
|
|
282
|
+
try {
|
|
283
|
+
return fs.readFileSync(file, 'utf8') === content;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
if (!same(initPath, initScript)) {
|
|
289
|
+
writeConfigFileSecure(initPath, initScript);
|
|
545
290
|
}
|
|
546
|
-
if (
|
|
547
|
-
|
|
291
|
+
if (!same(configPath, config)) {
|
|
292
|
+
writeConfigFileSecure(configPath, config);
|
|
548
293
|
}
|
|
549
|
-
|
|
550
|
-
encoding: 'utf8',
|
|
551
|
-
stdio: ['ignore', 'ignore', 'ignore']
|
|
552
|
-
});
|
|
553
|
-
return result.status === 0;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
scenePort(sceneName) {
|
|
557
|
-
const def = SCENE_DEFS[sceneName];
|
|
558
|
-
return Number(this.config.ports[def.portKey]);
|
|
294
|
+
return configPath;
|
|
559
295
|
}
|
|
560
296
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
297
|
+
// 宿主机上的 Agent 用的配置(路径与端点都是宿主机视角)
|
|
298
|
+
writeHostConfig(state) {
|
|
299
|
+
const profile = this.profile();
|
|
300
|
+
const endpoint = `ws://127.0.0.1:${state.port}/${this.ensureToken()}`;
|
|
301
|
+
const initPath = path.join(this.root, 'host.init.js');
|
|
302
|
+
writeConfigFileSecure(initPath, fingerprint.buildInitScript(profile));
|
|
303
|
+
const config = state.mode === 'chrome'
|
|
304
|
+
? fingerprint.buildContainerConfig('cdp', { endpoint })
|
|
305
|
+
: fingerprint.buildContainerConfig('ws', { ...profile, endpoint });
|
|
306
|
+
config.outputDir = path.join(os.tmpdir(), '.playwright-cli');
|
|
307
|
+
if (config.browser.initScript) {
|
|
308
|
+
config.browser.initScript = [initPath];
|
|
309
|
+
}
|
|
310
|
+
writeConfigFileSecure(this.hostConfigPath(), `${JSON.stringify(config, null, 4)}\n`);
|
|
311
|
+
return this.hostConfigPath();
|
|
564
312
|
}
|
|
565
313
|
|
|
566
|
-
|
|
567
|
-
return
|
|
314
|
+
userDisplayPath(filePath) {
|
|
315
|
+
return filePath.startsWith(`${this.homeDir}${path.sep}`) ? `~${filePath.slice(this.homeDir.length)}` : filePath;
|
|
568
316
|
}
|
|
569
317
|
|
|
570
|
-
|
|
571
|
-
return path.join(this.config.runDir, `${sceneName}.endpoint.json`);
|
|
572
|
-
}
|
|
318
|
+
// ---------- 容器集成(CLI 与 Web 共用) ----------
|
|
573
319
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (
|
|
577
|
-
return
|
|
320
|
+
// 模式是否真的还活着(只看进程与端口,足够快;真实探测见 probe)
|
|
321
|
+
async modeAlive(state) {
|
|
322
|
+
if (state.mode === 'default') {
|
|
323
|
+
return true;
|
|
578
324
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
325
|
+
if (state.mode === 'vnc') {
|
|
326
|
+
// 发布端口由运行时转发,容器没了端口也可能还能连上,所以看容器状态
|
|
327
|
+
try {
|
|
328
|
+
const inspect = this.runtimeCall(['inspect', '--format', '{{.State.Running}}', VNC_CONTAINER]);
|
|
329
|
+
return inspect.status === 0 && inspect.stdout.trim() === 'true' && await portReady(state.port);
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
583
333
|
}
|
|
334
|
+
return pidIsOurs(state.pid) && await portReady(state.port);
|
|
584
335
|
}
|
|
585
336
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
337
|
+
// 返回 { env, volumes, extraArgs, warning };永远不抛错,不因 playwright 让 run 退出
|
|
338
|
+
async buildContainerIntegration({ runtimeCommand, envEntries = [], dryRun = false } = {}) {
|
|
339
|
+
let warning = '';
|
|
340
|
+
try {
|
|
341
|
+
// 预览命令(dryRun)不写任何文件、不改状态
|
|
342
|
+
if (!dryRun) {
|
|
343
|
+
this.ensureDirs();
|
|
344
|
+
}
|
|
345
|
+
let state = this.readState();
|
|
346
|
+
if (dryRun) {
|
|
347
|
+
state = { mode: 'default' };
|
|
348
|
+
} else if (!(await this.modeAlive(state))) {
|
|
349
|
+
warning = `Playwright ${state.mode} 模式已失效,容器内浏览器已回退到默认模式。恢复: manyoyo playwright up ${state.mode}`;
|
|
350
|
+
state = { mode: 'default' };
|
|
351
|
+
this.writeState(state);
|
|
352
|
+
}
|
|
353
|
+
if (!dryRun) {
|
|
354
|
+
this.writeCurrentConfig(state, runtimeCommand);
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return { env: [], volumes: [], extraArgs: [], warning: `Playwright 集成不可用: ${error.message || String(error)}` };
|
|
358
|
+
}
|
|
359
|
+
const noProxy = appendNoProxy(envEntryValue(envEntries, 'NO_PROXY') ?? envEntryValue(envEntries, 'no_proxy')
|
|
360
|
+
?? process.env.NO_PROXY ?? process.env.no_proxy);
|
|
361
|
+
return {
|
|
362
|
+
env: [
|
|
363
|
+
`PLAYWRIGHT_MCP_CONFIG=${fingerprint.CONTAINER_CONFIG_PATH}`,
|
|
364
|
+
'NO_UPDATE_NOTIFIER=1',
|
|
365
|
+
`NO_PROXY=${noProxy}`,
|
|
366
|
+
`no_proxy=${noProxy}`
|
|
367
|
+
],
|
|
368
|
+
volumes: [`${this.currentDir}:${fingerprint.CONTAINER_CONFIG_DIR}:ro`],
|
|
369
|
+
extraArgs: runtimeKind(runtimeCommand) === 'docker' ? ['--add-host', 'host.docker.internal:host-gateway'] : [],
|
|
370
|
+
warning
|
|
371
|
+
};
|
|
597
372
|
}
|
|
598
373
|
|
|
599
|
-
|
|
600
|
-
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
601
|
-
fs.writeFileSync(this.sceneCliAttachConfigPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
602
|
-
}
|
|
374
|
+
// ---------- 运行时与命令 ----------
|
|
603
375
|
|
|
604
|
-
|
|
605
|
-
|
|
376
|
+
resolveRuntime() {
|
|
377
|
+
if (!this.runtime) {
|
|
378
|
+
const { selectContainerRuntime } = require('../container-runtime');
|
|
379
|
+
this.runtime = selectContainerRuntime({ configured: this.rootGlobalConfig.containerRuntime });
|
|
380
|
+
}
|
|
381
|
+
return this.runtime;
|
|
606
382
|
}
|
|
607
383
|
|
|
608
|
-
|
|
384
|
+
runtimeCall(args, { capture = true } = {}) {
|
|
385
|
+
const runtime = this.resolveRuntime();
|
|
386
|
+
const result = spawnSync(runtime.command, args, {
|
|
387
|
+
encoding: 'utf8',
|
|
388
|
+
env: { ...process.env, ...runtime.env },
|
|
389
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
|
390
|
+
});
|
|
609
391
|
return {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
cdpHeaders: {
|
|
614
|
-
Host: `127.0.0.1:${endpoint.port}`
|
|
615
|
-
},
|
|
616
|
-
cdpTimeout: this.config.devtoolsCdpTimeout
|
|
617
|
-
}
|
|
392
|
+
status: result.error ? 1 : (result.status === null ? 1 : result.status),
|
|
393
|
+
stdout: result.stdout || '',
|
|
394
|
+
stderr: result.stderr || (result.error ? String(result.error.message) : '')
|
|
618
395
|
};
|
|
619
396
|
}
|
|
620
397
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
fs.writeFileSync(filePath, `${JSON.stringify(this.buildDevHostAttachConfig(endpoint, '127.0.0.1'), null, 4)}\n`, 'utf8');
|
|
625
|
-
return filePath;
|
|
398
|
+
corePath() {
|
|
399
|
+
const cliPackage = require.resolve('@playwright/cli/package.json');
|
|
400
|
+
return path.dirname(require.resolve('playwright-core/package.json', { paths: [path.dirname(cliPackage)] }));
|
|
626
401
|
}
|
|
627
402
|
|
|
628
|
-
|
|
629
|
-
|
|
403
|
+
coreCli() {
|
|
404
|
+
return path.join(this.corePath(), 'cli.js');
|
|
630
405
|
}
|
|
631
406
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (normalizedPath === normalizedHome) {
|
|
636
|
-
return '~';
|
|
637
|
-
}
|
|
638
|
-
if (normalizedPath.startsWith(`${normalizedHome}${path.sep}`)) {
|
|
639
|
-
return `~${normalizedPath.slice(normalizedHome.length)}`;
|
|
640
|
-
}
|
|
641
|
-
return filePath;
|
|
407
|
+
// 浏览器与探测都用 @playwright/cli 自带的 playwright-core,保证与容器内版本一致
|
|
408
|
+
installCommand(args) {
|
|
409
|
+
return `node ${shellValue(this.coreCli())} ${args}`;
|
|
642
410
|
}
|
|
643
411
|
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
if (
|
|
647
|
-
|
|
412
|
+
ensureBrowserInstalled() {
|
|
413
|
+
const result = spawnSync(process.execPath, [this.coreCli(), 'install', '--no-shell', 'chromium'], { stdio: 'inherit' });
|
|
414
|
+
if (result.status !== 0) {
|
|
415
|
+
throw new Error(`浏览器安装失败,请手动执行: ${this.installCommand('install --no-shell chromium')}`);
|
|
648
416
|
}
|
|
649
|
-
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
650
417
|
}
|
|
651
418
|
|
|
652
|
-
|
|
653
|
-
return [
|
|
654
|
-
`PLAYWRIGHT_MCP_CONFIG=${this.shellValue(this.userDisplayPath(configPath))}`
|
|
655
|
-
];
|
|
656
|
-
}
|
|
419
|
+
// ---------- 探测 ----------
|
|
657
420
|
|
|
658
|
-
|
|
659
|
-
return
|
|
421
|
+
probe(kind, endpoint, timeout = PROBE_TIMEOUT_MS) {
|
|
422
|
+
return new Promise(resolve => {
|
|
423
|
+
const child = spawn(process.execPath, [path.join(__dirname, 'playwright-probe.js')], {
|
|
424
|
+
env: {
|
|
425
|
+
...process.env,
|
|
426
|
+
MANYOYO_PROBE: JSON.stringify({ core: this.corePath(), kind, endpoint, timeout })
|
|
427
|
+
},
|
|
428
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
429
|
+
});
|
|
430
|
+
let output = '';
|
|
431
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeout + 3000);
|
|
432
|
+
child.stdout.on('data', chunk => { output += chunk; });
|
|
433
|
+
child.on('error', () => {
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
resolve({ ok: false, error: '无法启动探测进程' });
|
|
436
|
+
});
|
|
437
|
+
child.on('close', () => {
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
try {
|
|
440
|
+
resolve(JSON.parse(output.trim().split('\n').pop()));
|
|
441
|
+
} catch {
|
|
442
|
+
resolve({ ok: false, error: '探测超时或无输出' });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
});
|
|
660
446
|
}
|
|
661
447
|
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
if (text.startsWith('~/')) {
|
|
668
|
-
return path.join(this.config.homeDir, text.slice(2));
|
|
669
|
-
}
|
|
670
|
-
return text;
|
|
448
|
+
async probeState(state) {
|
|
449
|
+
const endpoint = `ws://127.0.0.1:${state.port}/${this.ensureToken()}`;
|
|
450
|
+
return state.mode === 'chrome'
|
|
451
|
+
? await this.probe('cdp', endpoint, CHROME_APPROVAL_TIMEOUT_MS)
|
|
452
|
+
: await this.probe('ws', endpoint);
|
|
671
453
|
}
|
|
672
454
|
|
|
673
|
-
|
|
674
|
-
const parts = String(value || '').split(':');
|
|
675
|
-
if (parts.length < 2) {
|
|
676
|
-
return null;
|
|
677
|
-
}
|
|
678
|
-
return {
|
|
679
|
-
hostPath: path.resolve(this.expandHomePath(parts[0])),
|
|
680
|
-
containerPath: parts[1]
|
|
681
|
-
};
|
|
682
|
-
}
|
|
455
|
+
// ---------- 进程管理 ----------
|
|
683
456
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
457
|
+
spawnDetached(command, args, logPath) {
|
|
458
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
459
|
+
const logFd = fs.openSync(logPath, 'a', 0o600);
|
|
460
|
+
const child = spawn(command, args, { detached: true, stdio: ['ignore', logFd, logFd] });
|
|
461
|
+
fs.closeSync(logFd);
|
|
462
|
+
child.unref();
|
|
463
|
+
return child;
|
|
691
464
|
}
|
|
692
465
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
466
|
+
async killGroup(pid, { verify = true } = {}) {
|
|
467
|
+
if (verify ? !pidIsOurs(pid) : !pidAlive(pid)) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
for (const signal of ['SIGTERM', 'SIGKILL']) {
|
|
471
|
+
try {
|
|
472
|
+
process.kill(-pid, signal);
|
|
473
|
+
} catch {
|
|
474
|
+
try {
|
|
475
|
+
process.kill(pid, signal);
|
|
476
|
+
} catch {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
for (let i = 0; i < 20; i += 1) {
|
|
481
|
+
if (!pidAlive(pid)) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// eslint-disable-next-line no-await-in-loop
|
|
485
|
+
await sleep(100);
|
|
486
|
+
}
|
|
697
487
|
}
|
|
698
|
-
return this.mergedRootVolumes().some(item => {
|
|
699
|
-
const parsed = this.parseVolumeEntry(item);
|
|
700
|
-
return parsed
|
|
701
|
-
&& parsed.hostPath === parsedRequired.hostPath
|
|
702
|
-
&& parsed.containerPath === parsedRequired.containerPath;
|
|
703
|
-
});
|
|
704
488
|
}
|
|
705
489
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
490
|
+
async waitForPort(port, child) {
|
|
491
|
+
const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
|
|
492
|
+
while (Date.now() < deadline) {
|
|
493
|
+
if (child && child.exitCode !== null) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
// eslint-disable-next-line no-await-in-loop
|
|
497
|
+
if (await portReady(port)) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
// eslint-disable-next-line no-await-in-loop
|
|
501
|
+
await sleep(300);
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
709
504
|
}
|
|
710
505
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome Canary', 'DevToolsActivePort'),
|
|
718
|
-
path.join(homeDir, 'Library', 'Application Support', 'Chromium', 'DevToolsActivePort'),
|
|
719
|
-
path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
|
|
720
|
-
path.join(homeDir, '.config', 'google-chrome', 'DevToolsActivePort'),
|
|
721
|
-
path.join(homeDir, '.config', 'chromium', 'DevToolsActivePort'),
|
|
722
|
-
path.join(homeDir, '.config', 'google-chrome-beta', 'DevToolsActivePort'),
|
|
723
|
-
path.join(homeDir, '.config', 'google-chrome-unstable', 'DevToolsActivePort'),
|
|
724
|
-
path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
|
|
725
|
-
path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'),
|
|
726
|
-
path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome Beta', 'User Data', 'DevToolsActivePort'),
|
|
727
|
-
path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome SxS', 'User Data', 'DevToolsActivePort'),
|
|
728
|
-
path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data', 'DevToolsActivePort'),
|
|
729
|
-
path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data', 'DevToolsActivePort')
|
|
730
|
-
]);
|
|
506
|
+
async stopCurrent(state) {
|
|
507
|
+
if (state.mode === 'vnc') {
|
|
508
|
+
this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
|
|
509
|
+
} else if (state.mode === 'headed' || state.mode === 'chrome') {
|
|
510
|
+
await this.killGroup(state.pid);
|
|
511
|
+
}
|
|
731
512
|
}
|
|
732
513
|
|
|
733
|
-
|
|
734
|
-
const lines = String(content || '')
|
|
735
|
-
.split(/\r?\n/)
|
|
736
|
-
.map(line => line.trim())
|
|
737
|
-
.filter(Boolean);
|
|
738
|
-
const port = Number(lines[0]);
|
|
739
|
-
const wsPath = lines[1] || '';
|
|
514
|
+
// ---------- 各模式启动 ----------
|
|
740
515
|
|
|
741
|
-
|
|
742
|
-
|
|
516
|
+
startFailure(mode, message, extra = '') {
|
|
517
|
+
this.err(`[up] ${mode} 失败: ${message}`);
|
|
518
|
+
if (extra) {
|
|
519
|
+
this.err(extra);
|
|
743
520
|
}
|
|
744
|
-
|
|
745
|
-
throw new Error(`${filePath} 内容无效: 第二行必须是 /devtools/browser/...`);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
return { port, wsPath };
|
|
521
|
+
return 1;
|
|
749
522
|
}
|
|
750
523
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
}
|
|
757
|
-
const content = fs.readFileSync(candidate, 'utf8');
|
|
758
|
-
return {
|
|
759
|
-
...this.parseDevToolsActivePort(content, candidate),
|
|
760
|
-
filePath: candidate
|
|
761
|
-
};
|
|
524
|
+
async portPreflight(mode, port) {
|
|
525
|
+
if (await portReady(port)) {
|
|
526
|
+
this.startFailure(mode, `端口 ${port} 已被占用。`,
|
|
527
|
+
'换一个端口: 在 ~/.manyoyo/manyoyo.json 设置 "plugins": { "playwright": { "port": 9335 } },或先停掉占用它的进程。');
|
|
528
|
+
return false;
|
|
762
529
|
}
|
|
763
|
-
|
|
764
|
-
throw new Error(`未找到 DevToolsActivePort,请先在 Chrome 启用 remote debugging,并挂载或配置 devtoolsActivePortPath。已检查: ${candidates.join(', ')}`);
|
|
530
|
+
return true;
|
|
765
531
|
}
|
|
766
532
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
533
|
+
async startHeaded(extensionPaths) {
|
|
534
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
535
|
+
return this.startFailure('headed', '当前没有图形界面(DISPLAY / WAYLAND_DISPLAY 为空)。', '没有桌面的机器请改用: manyoyo playwright up vnc');
|
|
536
|
+
}
|
|
537
|
+
const { port } = this.config;
|
|
538
|
+
if (!(await this.portPreflight('headed', port))) {
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
this.ensureBrowserInstalled();
|
|
543
|
+
} catch (error) {
|
|
544
|
+
return this.startFailure('headed', error.message);
|
|
545
|
+
}
|
|
546
|
+
const token = this.ensureToken();
|
|
547
|
+
const serverConfigPath = path.join(this.runDir, 'server.json');
|
|
548
|
+
// 优先开沙箱;系统禁止非特权用户命名空间(如 Ubuntu 23.10+ 的 AppArmor 限制)时退回无沙箱
|
|
549
|
+
let child = null;
|
|
550
|
+
for (const chromiumSandbox of [true, false]) {
|
|
551
|
+
writeConfigFileSecure(serverConfigPath, `${JSON.stringify(fingerprint.buildServerConfig({
|
|
552
|
+
host: headedListenHost(),
|
|
553
|
+
port,
|
|
554
|
+
wsPath: `/${token}`,
|
|
555
|
+
chromiumSandbox,
|
|
556
|
+
extensionArgs: extensions.buildExtensionLaunchArgs(extensionPaths),
|
|
557
|
+
...this.profile()
|
|
558
|
+
}), null, 4)}\n`);
|
|
559
|
+
fs.rmSync(this.logPath(), { force: true });
|
|
560
|
+
child = this.spawnDetached(process.execPath,
|
|
561
|
+
[path.join(__dirname, 'playwright-server.js'), this.corePath(), serverConfigPath], this.logPath());
|
|
562
|
+
if (await this.waitForPort(port, child)) {
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
await this.killGroup(child.pid, { verify: false });
|
|
566
|
+
if (!chromiumSandbox || !tailText(this.logPath(), 200).includes('No usable sandbox')) {
|
|
567
|
+
return this.startFailure('headed', '浏览器服务没有启动成功。',
|
|
568
|
+
`${tailText(this.logPath(), 20)}\n缺系统依赖时执行: sudo ${this.installCommand('install-deps chromium')}`);
|
|
569
|
+
}
|
|
570
|
+
this.out('[up] 系统不允许非特权用户命名空间,Chromium 沙箱不可用,改为无沙箱启动。');
|
|
571
|
+
}
|
|
572
|
+
return { mode: 'headed', port, pid: child.pid, listenHost: headedListenHost(), extensionPaths };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async startChrome() {
|
|
576
|
+
const { port } = this.config;
|
|
577
|
+
const candidates = this.devtoolsCandidates();
|
|
578
|
+
const enableHint = '请在 Chrome 地址栏进入 chrome://inspect/#remote-debugging,勾选“Allow remote debugging”(Chrome 没开就先打开它),然后重试。\n'
|
|
579
|
+
+ '(自定义用户目录时,用 plugins.playwright.devtoolsActivePortPath 指定 DevToolsActivePort 文件。)';
|
|
580
|
+
const target = findDevToolsActivePort(candidates);
|
|
581
|
+
if (!target) {
|
|
582
|
+
return this.startFailure('chrome', '没有找到开启了远程调试的 Chrome。', enableHint);
|
|
583
|
+
}
|
|
584
|
+
// Chrome 退出后会留下旧的 DevToolsActivePort,所以还要确认调试端口真的在监听
|
|
585
|
+
if (!(await portReady(target.port))) {
|
|
586
|
+
return this.startFailure('chrome', 'Chrome 没有在运行(只找到上次留下的调试端口记录)。', enableHint);
|
|
587
|
+
}
|
|
588
|
+
if (!(await this.portPreflight('chrome', port))) {
|
|
589
|
+
return 1;
|
|
590
|
+
}
|
|
591
|
+
fs.rmSync(this.logPath(), { force: true });
|
|
592
|
+
const relayConfigPath = path.join(this.runDir, 'relay.json');
|
|
593
|
+
writeConfigFileSecure(relayConfigPath, `${JSON.stringify({
|
|
594
|
+
host: defaultListenHost(), port, token: this.ensureToken(), candidates
|
|
595
|
+
}, null, 4)}\n`);
|
|
596
|
+
const child = this.spawnDetached(process.execPath,
|
|
597
|
+
[path.join(__dirname, 'playwright-relay.js'), relayConfigPath], this.logPath());
|
|
598
|
+
if (!(await this.waitForPort(port, child))) {
|
|
599
|
+
await this.killGroup(child.pid, { verify: false });
|
|
600
|
+
return this.startFailure('chrome', '中继没有启动成功。', tailText(this.logPath(), 20));
|
|
601
|
+
}
|
|
602
|
+
return { mode: 'chrome', port, pid: child.pid, listenHost: defaultListenHost() };
|
|
770
603
|
}
|
|
771
604
|
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
'
|
|
778
|
-
'
|
|
779
|
-
|
|
780
|
-
'
|
|
781
|
-
|
|
782
|
-
'
|
|
783
|
-
'
|
|
605
|
+
devtoolsCandidates() {
|
|
606
|
+
const h = this.homeDir;
|
|
607
|
+
const local = path.join(h, 'AppData', 'Local');
|
|
608
|
+
return [
|
|
609
|
+
this.config.devtoolsActivePortPath,
|
|
610
|
+
path.join(h, 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'),
|
|
611
|
+
path.join(h, 'Library', 'Application Support', 'Google', 'Chrome Canary', 'DevToolsActivePort'),
|
|
612
|
+
path.join(h, 'Library', 'Application Support', 'Chromium', 'DevToolsActivePort'),
|
|
613
|
+
path.join(h, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
|
|
614
|
+
path.join(h, '.config', 'google-chrome', 'DevToolsActivePort'),
|
|
615
|
+
path.join(h, '.config', 'google-chrome-beta', 'DevToolsActivePort'),
|
|
616
|
+
path.join(h, '.config', 'google-chrome-unstable', 'DevToolsActivePort'),
|
|
617
|
+
path.join(h, '.config', 'chromium', 'DevToolsActivePort'),
|
|
618
|
+
path.join(h, '.config', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
|
|
619
|
+
path.join(local, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'),
|
|
620
|
+
path.join(local, 'Google', 'Chrome Beta', 'User Data', 'DevToolsActivePort'),
|
|
621
|
+
path.join(local, 'Chromium', 'User Data', 'DevToolsActivePort'),
|
|
622
|
+
path.join(local, 'BraveSoftware', 'Brave-Browser', 'User Data', 'DevToolsActivePort')
|
|
623
|
+
].filter(Boolean);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
baseImageRef() {
|
|
627
|
+
const global = this.rootGlobalConfig;
|
|
628
|
+
const run = this.rootRunConfig;
|
|
629
|
+
const name = String(run.imageName || global.imageName || 'ghcr.io/xcanwin/manyoyo').trim();
|
|
630
|
+
const version = String(run.imageVersion || global.imageVersion || require('../../package.json').imageVersion).trim();
|
|
631
|
+
return { name, version, ref: `${name}:${version}` };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// vnc 容器直接用当前 manyoyo 镜像(已带 xvfb / x11vnc / noVNC),不需要额外构建
|
|
635
|
+
ensureBaseImage() {
|
|
636
|
+
const { ref } = this.baseImageRef();
|
|
637
|
+
if (this.runtimeCall(['image', 'inspect', '--format', '{{.Id}}', ref]).status !== 0) {
|
|
638
|
+
throw new Error(`镜像 ${ref} 不存在,请先运行一次 manyoyo run 拉取或构建镜像。`);
|
|
639
|
+
}
|
|
640
|
+
return ref;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async startVnc(extensionPaths) {
|
|
644
|
+
const { port, vncPort, novncPort } = this.config;
|
|
645
|
+
for (const item of [port, vncPort, novncPort]) {
|
|
646
|
+
if (await portReady(item)) {
|
|
647
|
+
return this.startFailure('vnc', `端口 ${item} 已被占用。`,
|
|
648
|
+
'在 ~/.manyoyo/manyoyo.json 的 plugins.playwright 里改 port / vncPort / novncPort,或先停掉占用它的进程。');
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
let image;
|
|
652
|
+
try {
|
|
653
|
+
image = this.ensureBaseImage();
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return this.startFailure('vnc', error.message);
|
|
656
|
+
}
|
|
657
|
+
const token = this.ensureToken();
|
|
658
|
+
const password = this.ensureVncPassword();
|
|
659
|
+
const mountDir = path.join(this.runDir, 'vnc');
|
|
660
|
+
fs.mkdirSync(mountDir, { recursive: true, mode: 0o700 });
|
|
661
|
+
const mounts = extensions.buildContainerExtensionMounts(extensionPaths);
|
|
662
|
+
for (const name of ['vnc-entry.sh', 'playwright-server.js']) {
|
|
663
|
+
const source = path.join(name === 'vnc-entry.sh' ? VNC_ASSET_DIR : __dirname, name);
|
|
664
|
+
writeConfigFileSecure(path.join(mountDir, name), fs.readFileSync(source, 'utf8'));
|
|
665
|
+
}
|
|
666
|
+
writeConfigFileSecure(path.join(mountDir, 'server.json'), `${JSON.stringify(fingerprint.buildServerConfig({
|
|
667
|
+
host: '0.0.0.0',
|
|
668
|
+
port,
|
|
669
|
+
wsPath: `/${token}`,
|
|
670
|
+
window: 'virtual',
|
|
671
|
+
chromiumSandbox: false,
|
|
672
|
+
extensionArgs: extensions.buildExtensionLaunchArgs(mounts.containerPaths),
|
|
673
|
+
...this.profile()
|
|
674
|
+
}), null, 4)}\n`);
|
|
675
|
+
const envFile = path.join(mountDir, 'env');
|
|
676
|
+
const noProxy = appendNoProxy(process.env.NO_PROXY ?? process.env.no_proxy);
|
|
677
|
+
writeConfigFileSecure(envFile, `VNC_PASSWORD=${password}\nNO_PROXY=${noProxy}\nno_proxy=${noProxy}\n`);
|
|
678
|
+
this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
|
|
679
|
+
const listenHost = defaultListenHost();
|
|
680
|
+
const args = [
|
|
681
|
+
'run', '-d', '--name', VNC_CONTAINER,
|
|
682
|
+
'--env-file', envFile,
|
|
683
|
+
'--publish', `${listenHost}:${port}:${port}`,
|
|
684
|
+
'--publish', `127.0.0.1:${vncPort}:5900`,
|
|
685
|
+
'--publish', `127.0.0.1:${novncPort}:6080`,
|
|
686
|
+
'--volume', `${mountDir}:${VNC_MOUNT_DIR}:ro`,
|
|
687
|
+
...mounts.volumes.flatMap(volume => ['--volume', volume]),
|
|
688
|
+
'--entrypoint', 'sh',
|
|
689
|
+
image, `${VNC_MOUNT_DIR}/vnc-entry.sh`
|
|
784
690
|
];
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
' } catch (_) {',
|
|
799
|
-
" const error = new Error('WebRTC is disabled');",
|
|
800
|
-
" error.name = 'NotAllowedError';",
|
|
801
|
-
' return error;',
|
|
802
|
-
' }',
|
|
803
|
-
' };',
|
|
804
|
-
" Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {",
|
|
805
|
-
' configurable: true,',
|
|
806
|
-
' writable: true,',
|
|
807
|
-
' value: async () => { throw errorFactory(); }',
|
|
808
|
-
' });',
|
|
809
|
-
' }',
|
|
810
|
-
' } catch (_) {}'
|
|
811
|
-
);
|
|
691
|
+
const run = this.runtimeCall(args);
|
|
692
|
+
if (run.status !== 0) {
|
|
693
|
+
return this.startFailure('vnc', '容器启动失败。', run.stderr.trim());
|
|
694
|
+
}
|
|
695
|
+
// 发布端口由运行时转发,容器没起来时 TCP 也能连上,所以以浏览器服务打印的 ready 为准
|
|
696
|
+
const isReady = () => /^ready$/m.test(this.runtimeCall(['logs', '--tail', '200', VNC_CONTAINER]).stdout);
|
|
697
|
+
let started = false;
|
|
698
|
+
for (let i = 0; i < 100 && !started; i += 1) {
|
|
699
|
+
started = isReady();
|
|
700
|
+
if (!started) {
|
|
701
|
+
// eslint-disable-next-line no-await-in-loop
|
|
702
|
+
await sleep(300);
|
|
703
|
+
}
|
|
812
704
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
705
|
+
if (!started) {
|
|
706
|
+
const logs = this.runtimeCall(['logs', '--tail', '30', VNC_CONTAINER]);
|
|
707
|
+
this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
|
|
708
|
+
return this.startFailure('vnc', '浏览器服务没有启动成功。', `${logs.stdout}${logs.stderr}`.trim());
|
|
709
|
+
}
|
|
710
|
+
return { mode: 'vnc', port, container: VNC_CONTAINER, listenHost, novncPort, vncPort, extensionPaths };
|
|
816
711
|
}
|
|
817
712
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const content = this.buildInitScriptContent();
|
|
821
|
-
fs.writeFileSync(filePath, content, 'utf8');
|
|
822
|
-
return filePath;
|
|
713
|
+
novncUrl() {
|
|
714
|
+
return `http://127.0.0.1:${this.config.novncPort}/vnc.html?autoconnect=1&resize=scale&password=${this.readPersisted('vnc-password')}`;
|
|
823
715
|
}
|
|
824
716
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
717
|
+
// ---------- 命令 ----------
|
|
718
|
+
|
|
719
|
+
printOverview() {
|
|
720
|
+
this.out('Playwright 浏览器模式(同一时间只有一个):');
|
|
721
|
+
for (const [name, desc] of MODE_HELP) {
|
|
722
|
+
this.out(` ${name.padEnd(8)}${desc}`);
|
|
828
723
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
724
|
+
this.out('');
|
|
725
|
+
this.out('用法: manyoyo playwright up <headed|chrome|vnc> 切换模式');
|
|
726
|
+
this.out(' manyoyo playwright down 回到默认模式');
|
|
727
|
+
this.out(' manyoyo playwright status 查看当前模式与真实可用性');
|
|
832
728
|
}
|
|
833
729
|
|
|
834
|
-
|
|
835
|
-
|
|
730
|
+
printUsage(state) {
|
|
731
|
+
this.out('');
|
|
732
|
+
this.out('容器内 Agent: 自动生效,直接 manyoyo run;已打开浏览器的会话先执行 playwright-cli close 再重新 open。');
|
|
733
|
+
if (state.mode === 'default') {
|
|
836
734
|
return;
|
|
837
735
|
}
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
736
|
+
const configPath = this.userDisplayPath(this.writeHostConfig(state));
|
|
737
|
+
this.out(`宿主机上的 Agent: PLAYWRIGHT_MCP_CONFIG=${shellValue(configPath)} claude`);
|
|
738
|
+
if (state.mode === 'vnc') {
|
|
739
|
+
this.out(`noVNC 观看: ${this.novncUrl()}`);
|
|
740
|
+
this.out(`原生 VNC: 127.0.0.1:${state.vncPort}(密码见 manyoyo playwright status)`);
|
|
741
|
+
}
|
|
742
|
+
if (state.mode === 'chrome') {
|
|
743
|
+
this.out('⚠️ 风险: 容器内 Agent 能操作你 Chrome 里已登录的所有网站。Chrome 每次新连接(每次 playwright-cli open、每次 status)都会弹出“要允许远程调试吗?”,请点“允许”。');
|
|
744
|
+
}
|
|
745
|
+
if (state.mode !== 'default' && !(process.platform === 'darwin' && state.mode !== 'headed')) {
|
|
746
|
+
this.out(`安全: 端口 ${state.port} 监听 0.0.0.0,仅靠 token 保护;建议用防火墙限制来源。`);
|
|
747
|
+
}
|
|
841
748
|
}
|
|
842
749
|
|
|
843
|
-
|
|
844
|
-
if (!
|
|
845
|
-
|
|
750
|
+
async up(mode, { extensionPaths = [], extensionNames = [] } = {}) {
|
|
751
|
+
if (!MODES.includes(mode)) {
|
|
752
|
+
this.printOverview();
|
|
753
|
+
if (mode) {
|
|
754
|
+
this.err('');
|
|
755
|
+
this.err(`未知模式: ${mode},可用: ${MODES.join(', ')}`);
|
|
756
|
+
}
|
|
757
|
+
return 1;
|
|
758
|
+
}
|
|
759
|
+
const extPaths = extensions.resolveExtensionInputs(this.root, { extensionPaths, extensionNames });
|
|
760
|
+
if (extPaths.length > 0 && mode === 'chrome') {
|
|
761
|
+
this.err('chrome 模式控制的是你自己的 Chrome,不支持 --ext-path / --ext-name;扩展只支持 headed、vnc。');
|
|
762
|
+
return 1;
|
|
763
|
+
}
|
|
764
|
+
this.ensureDirs();
|
|
765
|
+
await this.stopCurrent(this.readState());
|
|
766
|
+
this.writeState({ mode: 'default' });
|
|
767
|
+
|
|
768
|
+
const started = mode === 'headed' ? await this.startHeaded(extPaths)
|
|
769
|
+
: mode === 'chrome' ? await this.startChrome()
|
|
770
|
+
: await this.startVnc(extPaths);
|
|
771
|
+
if (typeof started === 'number') {
|
|
772
|
+
this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
|
|
773
|
+
return started;
|
|
774
|
+
}
|
|
775
|
+
const state = { ...started, startedAt: new Date().toISOString() };
|
|
776
|
+
this.writeState(state);
|
|
777
|
+
this.writeCurrentConfig(state, this.runtimeCommand());
|
|
778
|
+
|
|
779
|
+
if (mode === 'chrome') {
|
|
780
|
+
this.out('等待 Chrome 确认:请在 Chrome 弹出的“要允许远程调试吗?”里点“允许”(最多等 60 秒)…');
|
|
781
|
+
}
|
|
782
|
+
const result = await this.probeState(state);
|
|
783
|
+
if (!result.ok) {
|
|
784
|
+
if (mode === 'chrome') {
|
|
785
|
+
this.err('[up] chrome 失败: 连不上 Chrome 的调试接口。');
|
|
786
|
+
this.err('请确认已在 chrome://inspect/#remote-debugging 开启远程调试,并在 Chrome 弹出的“要允许远程调试吗?”里点“允许”(60 秒内),然后重试。');
|
|
787
|
+
} else {
|
|
788
|
+
this.err(`[up] ${mode} 失败: 浏览器服务已启动,但打不开页面(${result.error})。`);
|
|
789
|
+
this.err(tailText(this.logPath(), 10));
|
|
790
|
+
}
|
|
791
|
+
// 回滚:不让坏掉的模式留在配置里
|
|
792
|
+
await this.stopCurrent(state);
|
|
793
|
+
this.writeState({ mode: 'default' });
|
|
794
|
+
this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
|
|
795
|
+
this.err('已回到默认模式。');
|
|
796
|
+
return 1;
|
|
846
797
|
}
|
|
847
|
-
this.
|
|
798
|
+
this.out(`✅ ${mode} 模式已就绪(真实探测通过,${result.ms}ms)`);
|
|
799
|
+
this.printUsage(state);
|
|
800
|
+
return 0;
|
|
848
801
|
}
|
|
849
802
|
|
|
850
|
-
|
|
851
|
-
|
|
803
|
+
runtimeCommand() {
|
|
804
|
+
try {
|
|
805
|
+
return this.resolveRuntime().command;
|
|
806
|
+
} catch {
|
|
807
|
+
return 'docker';
|
|
808
|
+
}
|
|
852
809
|
}
|
|
853
810
|
|
|
854
|
-
|
|
855
|
-
|
|
811
|
+
async down() {
|
|
812
|
+
const state = this.readState();
|
|
813
|
+
await this.stopCurrent(state);
|
|
814
|
+
this.ensureDirs();
|
|
815
|
+
fs.rmSync(this.tokenPath(), { force: true });
|
|
816
|
+
fs.rmSync(this.hostConfigPath(), { force: true });
|
|
817
|
+
this.writeState({ mode: 'default' });
|
|
818
|
+
this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
|
|
819
|
+
this.out(state.mode === 'default'
|
|
820
|
+
? '当前已是默认模式(容器内虚拟屏里的有头浏览器)。'
|
|
821
|
+
: `已停止 ${state.mode} 模式,回到默认模式;已打开浏览器的容器会话先 playwright-cli close 再重新 open。`);
|
|
822
|
+
return 0;
|
|
856
823
|
}
|
|
857
824
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
if (
|
|
862
|
-
|
|
825
|
+
// 配置文件被删(或整个目录被删)时重建;返回是否重建过
|
|
826
|
+
ensureCurrentFiles(state) {
|
|
827
|
+
const missing = ['config.json', 'stealth.init.js'].some(name => !fs.existsSync(path.join(this.currentDir, name)));
|
|
828
|
+
if (missing) {
|
|
829
|
+
this.writeCurrentConfig(state, this.runtimeCommand());
|
|
863
830
|
}
|
|
864
|
-
return
|
|
831
|
+
return missing;
|
|
865
832
|
}
|
|
866
833
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
834
|
+
async status() {
|
|
835
|
+
let state = this.readState();
|
|
836
|
+
if (this.ensureCurrentFiles(state)) {
|
|
837
|
+
this.out('[status] 容器内浏览器的配置文件缺失,已重建;运行中的容器会自动恢复(整个 ~/.manyoyo/plugin/playwright 目录被删过的容器需要重建)。');
|
|
870
838
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
839
|
+
if (state.mode === 'default') {
|
|
840
|
+
this.out('当前模式: default(容器内虚拟屏里的有头浏览器,无需宿主机进程)');
|
|
841
|
+
this.out('想换模式: manyoyo playwright 查看全部模式');
|
|
842
|
+
return 0;
|
|
843
|
+
}
|
|
844
|
+
this.out(`当前模式: ${state.mode}(端口 ${state.port}${state.pid ? `,pid ${state.pid}` : ''})`);
|
|
845
|
+
const alive = await this.modeAlive(state);
|
|
846
|
+
const result = alive ? await this.probeState(state) : { ok: false, error: state.mode === 'vnc' ? 'vnc 容器没有在运行' : '浏览器服务进程已不在' };
|
|
847
|
+
if (result.ok) {
|
|
848
|
+
this.out(`真实探测: 通过(${result.ms}ms)`);
|
|
849
|
+
if (state.mode === 'vnc') {
|
|
850
|
+
this.out(`noVNC: ${this.novncUrl()}`);
|
|
881
851
|
}
|
|
852
|
+
return 0;
|
|
882
853
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
return
|
|
854
|
+
this.err(`真实探测: 失败(${result.error})`);
|
|
855
|
+
state = { mode: 'default' };
|
|
856
|
+
this.writeState(state);
|
|
857
|
+
this.writeCurrentConfig(state, this.runtimeCommand());
|
|
858
|
+
this.err('已把容器内浏览器回退到默认模式。恢复: manyoyo playwright up <headed|chrome|vnc>;排查: manyoyo playwright logs');
|
|
859
|
+
return 1;
|
|
889
860
|
}
|
|
890
861
|
|
|
891
|
-
|
|
892
|
-
|
|
862
|
+
logs() {
|
|
863
|
+
const state = this.readState();
|
|
864
|
+
if (state.mode === 'vnc') {
|
|
865
|
+
const logs = this.runtimeCall(['logs', '--tail', '80', VNC_CONTAINER]);
|
|
866
|
+
this.out(`${logs.stdout}${logs.stderr}`.trimEnd() || '[logs] 没有日志');
|
|
867
|
+
return logs.status === 0 ? 0 : 1;
|
|
868
|
+
}
|
|
869
|
+
this.out(tailText(this.logPath(), 80).trimEnd() || '[logs] 没有日志');
|
|
870
|
+
return 0;
|
|
893
871
|
}
|
|
894
872
|
|
|
895
|
-
|
|
896
|
-
|
|
873
|
+
// 想用 MCP 的 Agent:在容器内以 stdio 注册,读同一个 $PLAYWRIGHT_MCP_CONFIG,自动跟随当前模式
|
|
874
|
+
mcpAdd() {
|
|
875
|
+
this.out('# 在容器内执行(playwright-mcp 随当前模式自动切换,无需重新注册)');
|
|
876
|
+
this.out('claude mcp add -s user playwright -- playwright-mcp');
|
|
877
|
+
this.out('codex mcp add playwright -- playwright-mcp');
|
|
878
|
+
this.out('gemini mcp add -s user playwright playwright-mcp');
|
|
879
|
+
return 0;
|
|
897
880
|
}
|
|
898
881
|
|
|
899
|
-
|
|
900
|
-
if (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
fs.mkdirSync(this.cliBrowserCacheDirPath(), { recursive: true });
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
resolveTargets(sceneName = 'all') {
|
|
907
|
-
const requested = String(sceneName || 'all').trim();
|
|
908
|
-
const enabledSet = new Set(this.config.enabledScenes);
|
|
909
|
-
const runtime = this.config.runtime;
|
|
910
|
-
|
|
911
|
-
const isAllowedByRuntime = (scene) => {
|
|
912
|
-
const type = SCENE_DEFS[scene].type;
|
|
913
|
-
if (runtime === 'mixed') {
|
|
914
|
-
return true;
|
|
915
|
-
}
|
|
916
|
-
return runtime === type;
|
|
917
|
-
};
|
|
918
|
-
|
|
919
|
-
if (requested !== 'all') {
|
|
920
|
-
if (!SCENE_DEFS[requested]) {
|
|
921
|
-
throw new Error(`未知场景: ${requested}`);
|
|
922
|
-
}
|
|
923
|
-
if (!enabledSet.has(requested)) {
|
|
924
|
-
throw new Error(`场景未启用: ${requested}`);
|
|
925
|
-
}
|
|
926
|
-
if (!isAllowedByRuntime(requested)) {
|
|
927
|
-
throw new Error(`当前 runtime=${runtime},不允许场景: ${requested}`);
|
|
928
|
-
}
|
|
929
|
-
return [requested];
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
return SCENE_ORDER
|
|
933
|
-
.filter(scene => enabledSet.has(scene))
|
|
934
|
-
.filter(scene => isAllowedByRuntime(scene));
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
resolveExtensionPaths(extensionArgs = []) {
|
|
938
|
-
const inputs = asStringArray(extensionArgs, []);
|
|
939
|
-
const uniquePaths = [];
|
|
940
|
-
const seen = new Set();
|
|
941
|
-
|
|
942
|
-
for (const item of inputs) {
|
|
943
|
-
const absPath = path.resolve(item);
|
|
944
|
-
if (!fs.existsSync(absPath)) {
|
|
945
|
-
throw new Error(`扩展路径不存在: ${absPath}`);
|
|
946
|
-
}
|
|
947
|
-
const stat = fs.statSync(absPath);
|
|
948
|
-
if (!stat.isDirectory()) {
|
|
949
|
-
throw new Error(`扩展路径必须是目录: ${absPath}`);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
const manifestPath = path.join(absPath, 'manifest.json');
|
|
953
|
-
if (fs.existsSync(manifestPath)) {
|
|
954
|
-
if (!seen.has(absPath)) {
|
|
955
|
-
seen.add(absPath);
|
|
956
|
-
uniquePaths.push(absPath);
|
|
957
|
-
}
|
|
958
|
-
continue;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
const children = fs.readdirSync(absPath, { withFileTypes: true })
|
|
962
|
-
.filter(dirent => dirent.isDirectory())
|
|
963
|
-
.map(dirent => path.join(absPath, dirent.name))
|
|
964
|
-
.filter(child => fs.existsSync(path.join(child, 'manifest.json')));
|
|
965
|
-
|
|
966
|
-
if (children.length === 0) {
|
|
967
|
-
throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
for (const childPath of children) {
|
|
971
|
-
if (!seen.has(childPath)) {
|
|
972
|
-
seen.add(childPath);
|
|
973
|
-
uniquePaths.push(childPath);
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
return uniquePaths;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
resolveNamedExtensionPaths(extensionNames = []) {
|
|
982
|
-
const names = asStringArray(extensionNames, []);
|
|
983
|
-
const extensionRoot = path.resolve(this.extensionDirPath());
|
|
984
|
-
|
|
985
|
-
return names.map(name => {
|
|
986
|
-
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
|
|
987
|
-
throw new Error(`扩展名称无效: ${name}`);
|
|
988
|
-
}
|
|
989
|
-
return path.join(extensionRoot, name);
|
|
990
|
-
});
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
resolveExtensionInputs(options = {}) {
|
|
994
|
-
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
995
|
-
const namedPaths = this.resolveNamedExtensionPaths(options.extensionNames || []);
|
|
996
|
-
return this.resolveExtensionPaths([...extensionPaths, ...namedPaths]);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
buildExtensionLaunchArgs(extensionPaths) {
|
|
1000
|
-
const joined = extensionPaths.join(',');
|
|
1001
|
-
return [
|
|
1002
|
-
`--disable-extensions-except=${joined}`,
|
|
1003
|
-
`--load-extension=${joined}`
|
|
1004
|
-
];
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
sanitizeExtensionMountName(value) {
|
|
1008
|
-
const sanitized = String(value || '')
|
|
1009
|
-
.trim()
|
|
1010
|
-
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
1011
|
-
.replace(/-+/g, '-')
|
|
1012
|
-
.replace(/^-|-$/g, '');
|
|
1013
|
-
return sanitized || 'ext';
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
buildContainerExtensionMounts(extensionPaths = []) {
|
|
1017
|
-
const hostPaths = asStringArray(extensionPaths, []);
|
|
1018
|
-
const containerPaths = [];
|
|
1019
|
-
const volumeMounts = [];
|
|
1020
|
-
|
|
1021
|
-
hostPaths.forEach((hostPath, idx) => {
|
|
1022
|
-
const safeName = this.sanitizeExtensionMountName(path.basename(hostPath));
|
|
1023
|
-
const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
|
|
1024
|
-
containerPaths.push(containerPath);
|
|
1025
|
-
volumeMounts.push(`${hostPath}:${containerPath}:ro`);
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
return { containerPaths, volumeMounts };
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
baseLaunchArgs() {
|
|
1032
|
-
return [
|
|
1033
|
-
`--user-agent=${DEFAULT_FINGERPRINT_PROFILE.userAgent}`,
|
|
1034
|
-
`--lang=${DEFAULT_FINGERPRINT_PROFILE.locale}`,
|
|
1035
|
-
`--window-size=${DEFAULT_FINGERPRINT_PROFILE.width},${DEFAULT_FINGERPRINT_PROFILE.height}`,
|
|
1036
|
-
'--disable-blink-features=AutomationControlled',
|
|
1037
|
-
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
|
|
1038
|
-
];
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
buildMcpSceneConfig(sceneName, options = {}) {
|
|
1042
|
-
const def = SCENE_DEFS[sceneName];
|
|
1043
|
-
const port = this.scenePort(sceneName);
|
|
1044
|
-
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
1045
|
-
const initScript = asStringArray(options.initScript, []);
|
|
1046
|
-
const launchOptions = {
|
|
1047
|
-
channel: 'chromium',
|
|
1048
|
-
headless: def.headless,
|
|
1049
|
-
args: [...this.baseLaunchArgs()]
|
|
1050
|
-
};
|
|
1051
|
-
|
|
1052
|
-
if (extensionPaths.length > 0) {
|
|
1053
|
-
launchOptions.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
|
|
1054
|
-
}
|
|
1055
|
-
if (this.config.disableWebRTC) {
|
|
1056
|
-
launchOptions.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
const contextOptions = {
|
|
1060
|
-
userAgent: DEFAULT_FINGERPRINT_PROFILE.userAgent,
|
|
1061
|
-
locale: DEFAULT_FINGERPRINT_PROFILE.locale,
|
|
1062
|
-
timezoneId: DEFAULT_FINGERPRINT_PROFILE.timezoneId,
|
|
1063
|
-
extraHTTPHeaders: {
|
|
1064
|
-
'Accept-Language': DEFAULT_FINGERPRINT_PROFILE.acceptLanguage
|
|
1065
|
-
}
|
|
1066
|
-
};
|
|
1067
|
-
if (sceneName !== 'mcp-host-headed') {
|
|
1068
|
-
contextOptions.viewport = {
|
|
1069
|
-
width: DEFAULT_FINGERPRINT_PROFILE.width,
|
|
1070
|
-
height: DEFAULT_FINGERPRINT_PROFILE.height
|
|
1071
|
-
};
|
|
1072
|
-
contextOptions.screen = {
|
|
1073
|
-
width: DEFAULT_FINGERPRINT_PROFILE.width,
|
|
1074
|
-
height: DEFAULT_FINGERPRINT_PROFILE.height
|
|
1075
|
-
};
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
return {
|
|
1079
|
-
outputDir: '/tmp/.playwright-mcp',
|
|
1080
|
-
server: {
|
|
1081
|
-
host: def.listenHost,
|
|
1082
|
-
port,
|
|
1083
|
-
allowedHosts: [
|
|
1084
|
-
`localhost:${port}`,
|
|
1085
|
-
`127.0.0.1:${port}`,
|
|
1086
|
-
`host.docker.internal:${port}`,
|
|
1087
|
-
`host.containers.internal:${port}`
|
|
1088
|
-
]
|
|
1089
|
-
},
|
|
1090
|
-
browser: {
|
|
1091
|
-
chromiumSandbox: true,
|
|
1092
|
-
browserName: 'chromium',
|
|
1093
|
-
initScript,
|
|
1094
|
-
launchOptions,
|
|
1095
|
-
contextOptions
|
|
1096
|
-
}
|
|
1097
|
-
};
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
buildCliSceneConfig(sceneName, options = {}) {
|
|
1101
|
-
const def = SCENE_DEFS[sceneName];
|
|
1102
|
-
const extensionPaths = asStringArray(options.extensionPaths, []);
|
|
1103
|
-
const payload = {
|
|
1104
|
-
host: def.listenHost,
|
|
1105
|
-
port: this.scenePort(sceneName),
|
|
1106
|
-
wsPath: `/${sceneName}-${crypto.randomBytes(8).toString('hex')}`,
|
|
1107
|
-
headless: def.headless,
|
|
1108
|
-
channel: 'chromium',
|
|
1109
|
-
chromiumSandbox: true,
|
|
1110
|
-
args: [...this.baseLaunchArgs()]
|
|
1111
|
-
};
|
|
1112
|
-
|
|
1113
|
-
if (extensionPaths.length > 0) {
|
|
1114
|
-
payload.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
|
|
1115
|
-
}
|
|
1116
|
-
if (this.config.disableWebRTC) {
|
|
1117
|
-
payload.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
return payload;
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
buildSceneConfig(sceneName, options = {}) {
|
|
1124
|
-
if (isCliScene(sceneName)) {
|
|
1125
|
-
return this.buildCliSceneConfig(sceneName, options);
|
|
1126
|
-
}
|
|
1127
|
-
return this.buildMcpSceneConfig(sceneName, options);
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
ensureSceneConfig(sceneName, options = {}) {
|
|
1131
|
-
fs.mkdirSync(this.config.configDir, { recursive: true });
|
|
1132
|
-
if (isCliScene(sceneName)) {
|
|
1133
|
-
const payload = this.buildCliSceneConfig(sceneName, options);
|
|
1134
|
-
const filePath = this.sceneConfigPath(sceneName);
|
|
1135
|
-
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
1136
|
-
return filePath;
|
|
1137
|
-
}
|
|
1138
|
-
const initScriptPath = this.ensureSceneInitScript(sceneName);
|
|
1139
|
-
const configuredInitScript = asStringArray(options.initScript, []);
|
|
1140
|
-
const initScript = configuredInitScript.length > 0 ? configuredInitScript : [initScriptPath];
|
|
1141
|
-
const payload = this.buildSceneConfig(sceneName, {
|
|
1142
|
-
...options,
|
|
1143
|
-
initScript
|
|
1144
|
-
});
|
|
1145
|
-
const filePath = this.sceneConfigPath(sceneName);
|
|
1146
|
-
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
1147
|
-
return filePath;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
async portReady(port) {
|
|
1151
|
-
return await new Promise((resolve) => {
|
|
1152
|
-
let settled = false;
|
|
1153
|
-
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
1154
|
-
|
|
1155
|
-
const finish = (value) => {
|
|
1156
|
-
if (settled) {
|
|
1157
|
-
return;
|
|
1158
|
-
}
|
|
1159
|
-
settled = true;
|
|
1160
|
-
socket.destroy();
|
|
1161
|
-
resolve(value);
|
|
1162
|
-
};
|
|
1163
|
-
|
|
1164
|
-
socket.setTimeout(300);
|
|
1165
|
-
socket.once('connect', () => finish(true));
|
|
1166
|
-
socket.once('timeout', () => finish(false));
|
|
1167
|
-
socket.once('error', () => finish(false));
|
|
1168
|
-
});
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
async waitForPort(port) {
|
|
1172
|
-
for (let i = 0; i < 60; i += 1) {
|
|
1173
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1174
|
-
if (await this.portReady(port)) {
|
|
1175
|
-
return true;
|
|
1176
|
-
}
|
|
1177
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1178
|
-
await sleep(500);
|
|
1179
|
-
}
|
|
1180
|
-
return false;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
containerEnv(sceneName, cfgPath, options = {}) {
|
|
1184
|
-
const def = SCENE_DEFS[sceneName];
|
|
1185
|
-
const requireVncPassword = options.requireVncPassword === true;
|
|
1186
|
-
const env = {
|
|
1187
|
-
...process.env,
|
|
1188
|
-
PLAYWRIGHT_MCP_DOCKER_TAG: this.config.dockerTag,
|
|
1189
|
-
PLAYWRIGHT_MCP_PORT: String(this.scenePort(sceneName)),
|
|
1190
|
-
PLAYWRIGHT_MCP_CONFIG_PATH: cfgPath,
|
|
1191
|
-
PLAYWRIGHT_MCP_CONTAINER_NAME: def.containerName,
|
|
1192
|
-
PLAYWRIGHT_MCP_IMAGE: this.config.headedImage,
|
|
1193
|
-
PLAYWRIGHT_MCP_NOVNC_PORT: String(this.config.ports.mcpContHeadedNoVnc)
|
|
1194
|
-
};
|
|
1195
|
-
|
|
1196
|
-
if (sceneName === 'mcp-cont-headed') {
|
|
1197
|
-
const envKey = this.config.vncPasswordEnvKey;
|
|
1198
|
-
let password = process.env[envKey];
|
|
1199
|
-
if (!password) {
|
|
1200
|
-
password = this.randomAlnum(16);
|
|
1201
|
-
if (requireVncPassword) {
|
|
1202
|
-
this.writeStdout(`[up] mcp-cont-headed ${envKey} not set; generated random 16-char password: ${password}`);
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
env.VNC_PASSWORD = password;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
return env;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
containerComposePath(sceneName) {
|
|
1212
|
-
const def = SCENE_DEFS[sceneName];
|
|
1213
|
-
return path.join(this.config.composeDir, def.composeFile);
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
sceneComposeOverridePath(sceneName) {
|
|
1217
|
-
return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
ensureContainerComposeOverride(sceneName, volumeMounts = []) {
|
|
1221
|
-
const overridePath = this.sceneComposeOverridePath(sceneName);
|
|
1222
|
-
if (!Array.isArray(volumeMounts) || volumeMounts.length === 0) {
|
|
1223
|
-
fs.rmSync(overridePath, { force: true });
|
|
1224
|
-
return '';
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
1228
|
-
const lines = [
|
|
1229
|
-
'services:',
|
|
1230
|
-
' playwright:',
|
|
1231
|
-
' volumes:'
|
|
1232
|
-
];
|
|
1233
|
-
volumeMounts.forEach(item => {
|
|
1234
|
-
lines.push(` - ${JSON.stringify(String(item))}`);
|
|
1235
|
-
});
|
|
1236
|
-
fs.writeFileSync(overridePath, `${lines.join('\n')}\n`, 'utf8');
|
|
1237
|
-
return overridePath;
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
buildCliSessionIntegration(dockerCmd) {
|
|
1241
|
-
const sceneName = this.config.cliSessionScene;
|
|
1242
|
-
if (!sceneName) {
|
|
1243
|
-
return { envEntries: [], extraArgs: [], volumeEntries: [] };
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
if (isDevScene(sceneName)) {
|
|
1247
|
-
return this.buildDevHostCliSessionIntegration(dockerCmd, sceneName);
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
const endpoint = this.readSceneEndpoint(sceneName);
|
|
1251
|
-
if (!endpoint || !Number.isInteger(endpoint.port) || endpoint.port <= 0 || typeof endpoint.wsPath !== 'string' || !endpoint.wsPath) {
|
|
1252
|
-
return { envEntries: [], extraArgs: [], volumeEntries: [] };
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
|
|
1256
|
-
const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
|
1257
|
-
const remoteEndpoint = `ws://${connectHost}:${endpoint.port}${endpoint.wsPath}`;
|
|
1258
|
-
const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
|
|
1259
|
-
const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
|
|
1260
|
-
this.writeSceneCliAttachConfig(sceneName, {
|
|
1261
|
-
outputDir: '/tmp/.playwright-cli',
|
|
1262
|
-
browser: {
|
|
1263
|
-
// launch-server(非 shared)不预创建 context;remoteEndpoint 存在时
|
|
1264
|
-
// playwright-cli 默认 isolated=false 会走 contexts()[0] 并报
|
|
1265
|
-
// "unable to connect to a browser that does not have any contexts",
|
|
1266
|
-
// 因此容器内会话必须以 isolated 模式自建 context。
|
|
1267
|
-
remoteEndpoint,
|
|
1268
|
-
isolated: true
|
|
1269
|
-
}
|
|
1270
|
-
});
|
|
1271
|
-
const envEntries = [
|
|
1272
|
-
`PLAYWRIGHT_MCP_CONFIG=${containerConfigPath}`
|
|
1273
|
-
];
|
|
1274
|
-
const extraArgs = normalizedDockerCmd === 'docker'
|
|
1275
|
-
? ['--add-host', 'host.docker.internal:host-gateway']
|
|
1276
|
-
: [];
|
|
1277
|
-
const volumeEntries = ['--volume', `${hostConfigPath}:${containerConfigPath}:ro`];
|
|
1278
|
-
return { envEntries, extraArgs, volumeEntries };
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
buildDevHostCliSessionIntegration(dockerCmd, sceneName) {
|
|
1282
|
-
const endpoint = this.readDevToolsActivePort();
|
|
1283
|
-
const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
|
|
1284
|
-
const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
|
|
1285
|
-
const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
|
|
1286
|
-
const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
|
|
1287
|
-
|
|
1288
|
-
this.writeSceneCliAttachConfig(sceneName, this.buildDevHostAttachConfig(endpoint, connectHost));
|
|
1289
|
-
|
|
1290
|
-
const envEntries = [
|
|
1291
|
-
`PLAYWRIGHT_MCP_CONFIG=${containerConfigPath}`
|
|
1292
|
-
];
|
|
1293
|
-
const extraArgs = normalizedDockerCmd === 'docker'
|
|
1294
|
-
? ['--add-host', 'host.docker.internal:host-gateway']
|
|
1295
|
-
: [];
|
|
1296
|
-
const volumeEntries = ['--volume', `${hostConfigPath}:${containerConfigPath}:ro`];
|
|
1297
|
-
return { envEntries, extraArgs, volumeEntries };
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
async startContainer(sceneName, options = {}) {
|
|
1301
|
-
const runtime = this.config.containerRuntime;
|
|
1302
|
-
if (!this.ensureCommandAvailable(runtime)) {
|
|
1303
|
-
this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
|
|
1304
|
-
return 1;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
try {
|
|
1308
|
-
this.ensureContainerScenePrerequisites(sceneName);
|
|
1309
|
-
} catch (error) {
|
|
1310
|
-
return error.returncode || 1;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
const incomingExtensionPaths = asStringArray(options.extensionPaths, []);
|
|
1314
|
-
const hostInitScriptPath = this.sceneInitScriptPath(sceneName);
|
|
1315
|
-
const containerInitScriptPath = path.posix.join('/app/config', path.basename(hostInitScriptPath));
|
|
1316
|
-
let configOptions = {
|
|
1317
|
-
...options,
|
|
1318
|
-
extensionPaths: incomingExtensionPaths,
|
|
1319
|
-
initScript: [containerInitScriptPath]
|
|
1320
|
-
};
|
|
1321
|
-
const composeFiles = [this.containerComposePath(sceneName)];
|
|
1322
|
-
const volumeMounts = [`${hostInitScriptPath}:${containerInitScriptPath}:ro`];
|
|
1323
|
-
|
|
1324
|
-
if (incomingExtensionPaths.length > 0) {
|
|
1325
|
-
const mapped = this.buildContainerExtensionMounts(incomingExtensionPaths);
|
|
1326
|
-
volumeMounts.push(...mapped.volumeMounts);
|
|
1327
|
-
configOptions = {
|
|
1328
|
-
...options,
|
|
1329
|
-
extensionPaths: mapped.containerPaths,
|
|
1330
|
-
initScript: [containerInitScriptPath]
|
|
1331
|
-
};
|
|
1332
|
-
}
|
|
1333
|
-
const cfgPath = this.ensureSceneConfig(sceneName, configOptions);
|
|
1334
|
-
const overridePath = this.ensureContainerComposeOverride(sceneName, volumeMounts);
|
|
1335
|
-
if (overridePath) {
|
|
1336
|
-
composeFiles.push(overridePath);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
|
|
1340
|
-
const def = SCENE_DEFS[sceneName];
|
|
1341
|
-
|
|
1342
|
-
try {
|
|
1343
|
-
const args = [
|
|
1344
|
-
runtime,
|
|
1345
|
-
'compose',
|
|
1346
|
-
'-p',
|
|
1347
|
-
def.projectName
|
|
1348
|
-
];
|
|
1349
|
-
composeFiles.forEach(filePath => {
|
|
1350
|
-
args.push('-f', filePath);
|
|
1351
|
-
});
|
|
1352
|
-
args.push('up', '-d');
|
|
1353
|
-
this.runCmd(args, { env, check: true });
|
|
1354
|
-
} catch (error) {
|
|
1355
|
-
return error.returncode || 1;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
const port = this.scenePort(sceneName);
|
|
1359
|
-
if (await this.waitForPort(port)) {
|
|
1360
|
-
this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port}`);
|
|
1361
|
-
return 0;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
this.writeStderr(`[up] ${sceneName} did not become ready on 127.0.0.1:${port}`);
|
|
1365
|
-
return 1;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
stopContainer(sceneName) {
|
|
1369
|
-
const runtime = this.config.containerRuntime;
|
|
1370
|
-
if (!this.ensureCommandAvailable(runtime)) {
|
|
1371
|
-
this.writeStderr(`[down] ${sceneName} failed: ${runtime} command not found.`);
|
|
1372
|
-
return 1;
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
this.ensureContainerComposeOverride(sceneName, []);
|
|
1376
|
-
const cfgPath = this.sceneConfigPath(sceneName);
|
|
1377
|
-
const env = this.containerEnv(sceneName, cfgPath);
|
|
1378
|
-
const def = SCENE_DEFS[sceneName];
|
|
1379
|
-
|
|
1380
|
-
try {
|
|
1381
|
-
this.runCmd([
|
|
1382
|
-
runtime,
|
|
1383
|
-
'compose',
|
|
1384
|
-
'-p',
|
|
1385
|
-
def.projectName,
|
|
1386
|
-
'-f',
|
|
1387
|
-
this.containerComposePath(sceneName),
|
|
1388
|
-
'down'
|
|
1389
|
-
], { env, check: true });
|
|
1390
|
-
} catch (error) {
|
|
1391
|
-
return error.returncode || 1;
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
this.writeStdout(`[down] ${sceneName}`);
|
|
1395
|
-
return 0;
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
statusContainer(sceneName) {
|
|
1399
|
-
const runtime = this.config.containerRuntime;
|
|
1400
|
-
if (!this.ensureCommandAvailable(runtime)) {
|
|
1401
|
-
this.writeStderr(`[status] ${sceneName} failed: ${runtime} command not found.`);
|
|
1402
|
-
return 1;
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
const def = SCENE_DEFS[sceneName];
|
|
1406
|
-
const cp = this.runCmd([
|
|
1407
|
-
runtime,
|
|
1408
|
-
'ps',
|
|
1409
|
-
'--filter',
|
|
1410
|
-
`name=${def.containerName}`,
|
|
1411
|
-
'--format',
|
|
1412
|
-
'{{.Names}}'
|
|
1413
|
-
], { captureOutput: true, check: false });
|
|
1414
|
-
|
|
1415
|
-
const names = new Set(
|
|
1416
|
-
cp.stdout
|
|
1417
|
-
.split(/\r?\n/)
|
|
1418
|
-
.map(line => line.trim())
|
|
1419
|
-
.filter(Boolean)
|
|
1420
|
-
);
|
|
1421
|
-
|
|
1422
|
-
if (names.has(def.containerName)) {
|
|
1423
|
-
this.writeStdout(`[status] ${sceneName} running`);
|
|
1424
|
-
} else {
|
|
1425
|
-
this.writeStdout(`[status] ${sceneName} stopped`);
|
|
1426
|
-
}
|
|
1427
|
-
return 0;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
logsContainer(sceneName) {
|
|
1431
|
-
const runtime = this.config.containerRuntime;
|
|
1432
|
-
if (!this.ensureCommandAvailable(runtime)) {
|
|
1433
|
-
this.writeStderr(`[logs] ${sceneName} failed: ${runtime} command not found.`);
|
|
1434
|
-
return 1;
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
const def = SCENE_DEFS[sceneName];
|
|
1438
|
-
const cp = this.runCmd([
|
|
1439
|
-
runtime,
|
|
1440
|
-
'logs',
|
|
1441
|
-
'--tail',
|
|
1442
|
-
'80',
|
|
1443
|
-
def.containerName
|
|
1444
|
-
], { captureOutput: true, check: false });
|
|
1445
|
-
|
|
1446
|
-
const output = cp.stdout || cp.stderr;
|
|
1447
|
-
if (output.trim()) {
|
|
1448
|
-
this.writeStdout(output.trimEnd());
|
|
1449
|
-
} else {
|
|
1450
|
-
this.writeStdout(`[logs] ${sceneName} no logs`);
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
return cp.returncode === 0 ? 0 : 1;
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
hostLaunchCommand(sceneName, cfgPath) {
|
|
1457
|
-
if (isCliScene(sceneName)) {
|
|
1458
|
-
return {
|
|
1459
|
-
command: this.playwrightBinPath(sceneName),
|
|
1460
|
-
args: ['launch-server', '--browser', this.defaultBrowserName(sceneName), '--config', String(cfgPath)]
|
|
1461
|
-
};
|
|
1462
|
-
}
|
|
1463
|
-
return {
|
|
1464
|
-
command: this.localBinPath('playwright-mcp'),
|
|
1465
|
-
args: ['--config', String(cfgPath)]
|
|
1466
|
-
};
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
spawnHostProcess(command, args, logFd) {
|
|
1470
|
-
return spawn(command, args, {
|
|
1471
|
-
detached: true,
|
|
1472
|
-
stdio: ['ignore', logFd, logFd]
|
|
1473
|
-
});
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
stopHostStarter(pid) {
|
|
1477
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
1478
|
-
return;
|
|
1479
|
-
}
|
|
1480
|
-
try {
|
|
1481
|
-
process.kill(-pid, 'SIGTERM');
|
|
1482
|
-
return;
|
|
1483
|
-
} catch {
|
|
1484
|
-
// no-op
|
|
1485
|
-
}
|
|
1486
|
-
try {
|
|
1487
|
-
process.kill(pid, 'SIGTERM');
|
|
1488
|
-
} catch {
|
|
1489
|
-
// no-op
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
hostScenePids(sceneName) {
|
|
1494
|
-
const cfgPath = this.sceneConfigPath(sceneName);
|
|
1495
|
-
const pattern = isCliScene(sceneName)
|
|
1496
|
-
? `playwright.*launch-server.*--config ${cfgPath}`
|
|
1497
|
-
: `playwright-mcp.*--config ${cfgPath}`;
|
|
1498
|
-
const cp = this.runCmd(['pgrep', '-f', pattern], { captureOutput: true, check: false });
|
|
1499
|
-
|
|
1500
|
-
if (cp.returncode !== 0 || !cp.stdout.trim()) {
|
|
1501
|
-
return [];
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
const pids = [];
|
|
1505
|
-
for (const line of cp.stdout.split(/\r?\n/)) {
|
|
1506
|
-
const text = line.trim();
|
|
1507
|
-
if (/^\d+$/.test(text)) {
|
|
1508
|
-
pids.push(Number(text));
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
return pids;
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
async waitForHostPids(sceneName, fallbackPid) {
|
|
1515
|
-
for (let i = 0; i < 5; i += 1) {
|
|
1516
|
-
const pids = this.hostScenePids(sceneName);
|
|
1517
|
-
if (pids.length > 0) {
|
|
1518
|
-
return pids;
|
|
1519
|
-
}
|
|
1520
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1521
|
-
await sleep(100);
|
|
1522
|
-
}
|
|
1523
|
-
if (Number.isInteger(fallbackPid) && fallbackPid > 0) {
|
|
1524
|
-
return [fallbackPid];
|
|
1525
|
-
}
|
|
1526
|
-
return [];
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
async startHost(sceneName, options = {}) {
|
|
1530
|
-
try {
|
|
1531
|
-
this.ensureCliHostHeadedCacheDir(sceneName);
|
|
1532
|
-
this.ensureHostScenePrerequisites(sceneName);
|
|
1533
|
-
} catch (error) {
|
|
1534
|
-
this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
|
|
1535
|
-
return error.returncode || 1;
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
1539
|
-
const cfgPath = this.ensureSceneConfig(sceneName, options);
|
|
1540
|
-
const pidFile = this.scenePidFile(sceneName);
|
|
1541
|
-
const logFile = this.sceneLogFile(sceneName);
|
|
1542
|
-
const port = this.scenePort(sceneName);
|
|
1543
|
-
this.removeSceneEndpoint(sceneName);
|
|
1544
|
-
this.removeSceneCliAttachConfig(sceneName);
|
|
1545
|
-
|
|
1546
|
-
let managedPids = this.hostScenePids(sceneName);
|
|
1547
|
-
if (managedPids.length > 0 && (await this.portReady(port))) {
|
|
1548
|
-
this.writeStdout(`[up] ${sceneName} already running (pid(s) ${managedPids.join(' ')})`);
|
|
1549
|
-
return 0;
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
if (await this.portReady(port)) {
|
|
1553
|
-
this.writeStderr(`[up] ${sceneName} failed: port ${port} is already in use by another process.`);
|
|
1554
|
-
this.writeStderr('Stop the conflicting process first, then retry.');
|
|
1555
|
-
return 1;
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
fs.rmSync(pidFile, { force: true });
|
|
1559
|
-
const logFd = fs.openSync(logFile, 'a');
|
|
1560
|
-
let launchCommand = null;
|
|
1561
|
-
try {
|
|
1562
|
-
launchCommand = this.hostLaunchCommand(sceneName, cfgPath);
|
|
1563
|
-
} catch (error) {
|
|
1564
|
-
fs.closeSync(logFd);
|
|
1565
|
-
this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
|
|
1566
|
-
return 1;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
const starter = this.spawnHostProcess(launchCommand.command, launchCommand.args, logFd);
|
|
1570
|
-
fs.closeSync(logFd);
|
|
1571
|
-
if (typeof starter.unref === 'function') {
|
|
1572
|
-
starter.unref();
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
if (await this.waitForPort(port)) {
|
|
1576
|
-
if (isCliScene(sceneName)) {
|
|
1577
|
-
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
1578
|
-
this.writeSceneEndpoint(sceneName, {
|
|
1579
|
-
port,
|
|
1580
|
-
wsPath: String(cfg.wsPath || '')
|
|
1581
|
-
});
|
|
1582
|
-
}
|
|
1583
|
-
managedPids = await this.waitForHostPids(sceneName, starter.pid);
|
|
1584
|
-
if (managedPids.length > 0) {
|
|
1585
|
-
fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
|
|
1586
|
-
this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port} (pid(s) ${managedPids.join(' ')})`);
|
|
1587
|
-
this.remindCliSessionScene(sceneName);
|
|
1588
|
-
return 0;
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
this.writeStderr(`[up] ${sceneName} failed to start. tail ${logFile}:`);
|
|
1593
|
-
const tail = tailText(logFile, 30);
|
|
1594
|
-
if (tail) {
|
|
1595
|
-
this.writeStderr(tail);
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
if (starter.exitCode === null && !starter.killed) {
|
|
1599
|
-
this.stopHostStarter(starter.pid);
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
return 1;
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
async startDevHost(sceneName) {
|
|
1606
|
-
this.openChromeRemoteDebuggingPage();
|
|
1607
|
-
try {
|
|
1608
|
-
const endpoint = this.readDevToolsActivePort();
|
|
1609
|
-
if (!(await this.portReady(endpoint.port))) {
|
|
1610
|
-
throw new Error(`DevToolsActivePort 指向 127.0.0.1:${endpoint.port},但该端口不可连接;请确认 Chrome remote debugging 已启用,或重新打开 Chrome 后再试。`);
|
|
1611
|
-
}
|
|
1612
|
-
const cfgPath = this.writeDevHostAttachConfig(sceneName, endpoint);
|
|
1613
|
-
this.writeStdout(`[up] ${sceneName} ready via DevToolsActivePort (${endpoint.filePath}, 127.0.0.1:${endpoint.port})`);
|
|
1614
|
-
this.writeStdout(`[tip] 已尝试打开 ${this.remoteDebuggingPageUrl()},请确认 remote debugging 已启用。`);
|
|
1615
|
-
this.writeDevHostUsage(cfgPath);
|
|
1616
|
-
return 0;
|
|
1617
|
-
} catch (error) {
|
|
1618
|
-
this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
|
|
1619
|
-
return 1;
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
async stopHost(sceneName) {
|
|
1624
|
-
const pidFile = this.scenePidFile(sceneName);
|
|
1625
|
-
const port = this.scenePort(sceneName);
|
|
1626
|
-
const managedPids = this.hostScenePids(sceneName);
|
|
1627
|
-
this.removeSceneEndpoint(sceneName);
|
|
1628
|
-
this.removeSceneCliAttachConfig(sceneName);
|
|
1629
|
-
|
|
1630
|
-
for (const pid of managedPids) {
|
|
1631
|
-
try {
|
|
1632
|
-
process.kill(pid, 'SIGTERM');
|
|
1633
|
-
} catch {
|
|
1634
|
-
// no-op
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
if (managedPids.length > 0) {
|
|
1639
|
-
await sleep(300);
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
if (fs.existsSync(pidFile)) {
|
|
1643
|
-
const text = fs.readFileSync(pidFile, 'utf8').trim();
|
|
1644
|
-
if (/^\d+$/.test(text)) {
|
|
1645
|
-
try {
|
|
1646
|
-
process.kill(Number(text), 'SIGTERM');
|
|
1647
|
-
} catch {
|
|
1648
|
-
// no-op
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
fs.rmSync(pidFile, { force: true });
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
if (await this.portReady(port)) {
|
|
1655
|
-
this.writeStderr(`[down] ${sceneName} warning: port ${port} is still in use (possibly unmanaged process)`);
|
|
1656
|
-
return 1;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
this.writeStdout(`[down] ${sceneName}`);
|
|
1660
|
-
return 0;
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
stopDevHost(sceneName) {
|
|
1664
|
-
this.removeSceneEndpoint(sceneName);
|
|
1665
|
-
this.removeSceneCliAttachConfig(sceneName);
|
|
1666
|
-
this.removeDevHostAttachConfig(sceneName);
|
|
1667
|
-
this.writeStdout(`[down] ${sceneName} no managed process`);
|
|
1668
|
-
return 0;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
async statusHost(sceneName) {
|
|
1672
|
-
const pidFile = this.scenePidFile(sceneName);
|
|
1673
|
-
const port = this.scenePort(sceneName);
|
|
1674
|
-
const managedPids = this.hostScenePids(sceneName);
|
|
1675
|
-
|
|
1676
|
-
if (managedPids.length > 0 && (await this.portReady(port))) {
|
|
1677
|
-
this.writeStdout(`[status] ${sceneName} running (pid(s) ${managedPids.join(' ')})`);
|
|
1678
|
-
const pidfileValid = fs.existsSync(pidFile) && /^\d+$/.test(fs.readFileSync(pidFile, 'utf8').trim());
|
|
1679
|
-
if (!pidfileValid) {
|
|
1680
|
-
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
1681
|
-
fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
|
|
1682
|
-
}
|
|
1683
|
-
return 0;
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
if (managedPids.length > 0 && !(await this.portReady(port))) {
|
|
1687
|
-
this.writeStdout(`[status] ${sceneName} degraded (pid(s) ${managedPids.join(' ')}, port ${port} not reachable)`);
|
|
1688
|
-
return 0;
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
fs.rmSync(pidFile, { force: true });
|
|
1692
|
-
if (await this.portReady(port)) {
|
|
1693
|
-
this.writeStdout(`[status] ${sceneName} conflict (port ${port} in use by unmanaged process)`);
|
|
1694
|
-
} else {
|
|
1695
|
-
this.writeStdout(`[status] ${sceneName} stopped`);
|
|
1696
|
-
}
|
|
1697
|
-
return 0;
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
statusDevHost(sceneName) {
|
|
1701
|
-
try {
|
|
1702
|
-
const endpoint = this.readDevToolsActivePort();
|
|
1703
|
-
this.writeStdout(`[status] ${sceneName} configured (${endpoint.filePath}, 127.0.0.1:${endpoint.port})`);
|
|
1704
|
-
} catch {
|
|
1705
|
-
this.writeStdout(`[status] ${sceneName} not configured`);
|
|
1706
|
-
}
|
|
1707
|
-
return 0;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
async healthScene(sceneName) {
|
|
1711
|
-
if (isDevScene(sceneName)) {
|
|
1712
|
-
try {
|
|
1713
|
-
const endpoint = this.readDevToolsActivePort();
|
|
1714
|
-
this.writeStdout(`[health] ${sceneName} ok (DevToolsActivePort 127.0.0.1:${endpoint.port})`);
|
|
1715
|
-
return 0;
|
|
1716
|
-
} catch (error) {
|
|
1717
|
-
this.writeStdout(`[health] ${sceneName} fail (${error.message || String(error)})`);
|
|
1718
|
-
return 1;
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
const port = this.scenePort(sceneName);
|
|
1723
|
-
if (await this.portReady(port)) {
|
|
1724
|
-
this.writeStdout(`[health] ${sceneName} ok (127.0.0.1:${port})`);
|
|
1725
|
-
return 0;
|
|
1726
|
-
}
|
|
1727
|
-
this.writeStdout(`[health] ${sceneName} fail (127.0.0.1:${port})`);
|
|
1728
|
-
return 1;
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
logsHost(sceneName) {
|
|
1732
|
-
if (isDevScene(sceneName)) {
|
|
1733
|
-
this.writeStdout(`[logs] ${sceneName} no managed process`);
|
|
1734
|
-
return 0;
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
const logFile = this.sceneLogFile(sceneName);
|
|
1738
|
-
if (!fs.existsSync(logFile)) {
|
|
1739
|
-
this.writeStdout(`[logs] ${sceneName} no log file: ${logFile}`);
|
|
1740
|
-
return 0;
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
const tail = tailText(logFile, 80);
|
|
1744
|
-
if (tail) {
|
|
1745
|
-
this.writeStdout(tail);
|
|
1746
|
-
}
|
|
1747
|
-
return 0;
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
async downloadFile(url, output, retries = 3, timeoutMs = 60_000) {
|
|
1751
|
-
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
1752
|
-
if (!this.ensureCommandAvailable('curl')) {
|
|
1753
|
-
throw new Error('curl command not found');
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
let lastError = null;
|
|
1757
|
-
for (let i = 1; i <= retries; i += 1) {
|
|
1758
|
-
try {
|
|
1759
|
-
const result = this.runCmd([
|
|
1760
|
-
'curl',
|
|
1761
|
-
'--fail',
|
|
1762
|
-
'--location',
|
|
1763
|
-
'--silent',
|
|
1764
|
-
'--show-error',
|
|
1765
|
-
'--connect-timeout',
|
|
1766
|
-
String(timeoutSec),
|
|
1767
|
-
'--max-time',
|
|
1768
|
-
String(timeoutSec),
|
|
1769
|
-
'--output',
|
|
1770
|
-
output,
|
|
1771
|
-
url
|
|
1772
|
-
], { captureOutput: true, check: false });
|
|
1773
|
-
if (result.returncode !== 0) {
|
|
1774
|
-
throw new Error(result.stderr || `curl failed with exit code ${result.returncode}`);
|
|
1775
|
-
}
|
|
1776
|
-
return;
|
|
1777
|
-
} catch (error) {
|
|
1778
|
-
lastError = error;
|
|
1779
|
-
if (i < retries) {
|
|
1780
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1781
|
-
await sleep(1000);
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
extractZipBuffer(zipBuffer, outDir) {
|
|
1790
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
1791
|
-
const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
|
|
1792
|
-
fs.writeFileSync(tempZip, zipBuffer);
|
|
1793
|
-
|
|
1794
|
-
const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
|
|
1795
|
-
fs.rmSync(tempZip, { force: true });
|
|
1796
|
-
|
|
1797
|
-
if (result.error) {
|
|
1798
|
-
throw result.error;
|
|
1799
|
-
}
|
|
1800
|
-
if (result.status !== 0) {
|
|
1801
|
-
throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
extractCrx(crxFile, outDir) {
|
|
1806
|
-
const data = fs.readFileSync(crxFile);
|
|
1807
|
-
const offset = crxZipOffset(data);
|
|
1808
|
-
const zipBuffer = data.subarray(offset);
|
|
1809
|
-
|
|
1810
|
-
this.extractZipBuffer(zipBuffer, outDir);
|
|
1811
|
-
|
|
1812
|
-
const manifest = path.join(outDir, 'manifest.json');
|
|
1813
|
-
if (!fs.existsSync(manifest)) {
|
|
1814
|
-
throw new Error(`${crxFile} extracted but manifest.json missing`);
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
if (convertManifestV2ToV3(outDir)) {
|
|
1818
|
-
this.writeStdout(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
async downloadExtensions(options = {}) {
|
|
1823
|
-
if (!this.ensureCommandAvailable('unzip')) {
|
|
1824
|
-
this.writeStderr('[ext-download] failed: unzip command not found.');
|
|
1825
|
-
return 1;
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
const prodversion = String(options.prodversion || this.config.extensionProdversion || '132.0.0.0').trim();
|
|
1829
|
-
const extDir = path.resolve(this.extensionDirPath());
|
|
1830
|
-
const tmpDir = path.resolve(this.extensionTmpDirPath());
|
|
1831
|
-
|
|
1832
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1833
|
-
fs.mkdirSync(extDir, { recursive: true });
|
|
1834
|
-
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1835
|
-
|
|
1836
|
-
try {
|
|
1837
|
-
this.writeStdout(`[info] ext dir: ${extDir}`);
|
|
1838
|
-
this.writeStdout(`[info] tmp dir: ${tmpDir}`);
|
|
1839
|
-
|
|
1840
|
-
for (const [name, extId] of EXTENSIONS) {
|
|
1841
|
-
const url = buildCrxUrl(extId, prodversion);
|
|
1842
|
-
const crxFile = path.join(tmpDir, `${name}.crx`);
|
|
1843
|
-
const outDir = path.join(extDir, name);
|
|
1844
|
-
|
|
1845
|
-
this.writeStdout(`[download] ${name}`);
|
|
1846
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1847
|
-
await this.downloadFile(url, crxFile);
|
|
1848
|
-
|
|
1849
|
-
this.writeStdout(`[extract] ${name}`);
|
|
1850
|
-
fs.rmSync(outDir, { recursive: true, force: true });
|
|
1851
|
-
this.extractCrx(crxFile, outDir);
|
|
1852
|
-
}
|
|
1853
|
-
} finally {
|
|
1854
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1855
|
-
this.writeStdout(`[cleanup] removed ${tmpDir}`);
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
this.writeStdout(`[done] all extensions are ready: ${extDir}`);
|
|
1859
|
-
return 0;
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
detectCurrentIPv4() {
|
|
1863
|
-
const interfaces = os.networkInterfaces();
|
|
1864
|
-
for (const values of Object.values(interfaces)) {
|
|
1865
|
-
if (!Array.isArray(values)) {
|
|
1866
|
-
continue;
|
|
1867
|
-
}
|
|
1868
|
-
for (const item of values) {
|
|
1869
|
-
if (!item || item.internal) {
|
|
1870
|
-
continue;
|
|
1871
|
-
}
|
|
1872
|
-
if (item.family === 'IPv4') {
|
|
1873
|
-
return item.address;
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
return '';
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
resolveMcpAddHost(hostArg) {
|
|
1881
|
-
if (!hostArg) {
|
|
1882
|
-
return this.config.mcpDefaultHost;
|
|
1883
|
-
}
|
|
1884
|
-
const value = String(hostArg).trim();
|
|
1885
|
-
if (!value) {
|
|
1886
|
-
return '';
|
|
1887
|
-
}
|
|
1888
|
-
if (value === 'current-ip') {
|
|
1889
|
-
return this.detectCurrentIPv4();
|
|
1890
|
-
}
|
|
1891
|
-
return value;
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
printMcpAdd(hostArg) {
|
|
1895
|
-
const host = this.resolveMcpAddHost(hostArg);
|
|
1896
|
-
if (!host) {
|
|
1897
|
-
this.writeStderr('[mcp-add] failed: cannot determine host. Use --host <host> to set one explicitly.');
|
|
1898
|
-
return 1;
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
const scenes = this.resolveTargets('all').filter(sceneName => isMcpScene(sceneName));
|
|
1902
|
-
this.writeStdout('# 在容器中执行');
|
|
1903
|
-
for (const sceneName of scenes) {
|
|
1904
|
-
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
1905
|
-
this.writeStdout(`claude mcp add -t http -s user playwright-${sceneName} ${url}`);
|
|
1906
|
-
}
|
|
1907
|
-
this.writeStdout('');
|
|
1908
|
-
for (const sceneName of scenes) {
|
|
1909
|
-
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
1910
|
-
this.writeStdout(`codex mcp add playwright-${sceneName} --url ${url}`);
|
|
1911
|
-
}
|
|
1912
|
-
this.writeStdout('');
|
|
1913
|
-
for (const sceneName of scenes) {
|
|
1914
|
-
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
1915
|
-
this.writeStdout(`gemini mcp add -t http -s user playwright-${sceneName} ${url}`);
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
return 0;
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
printCliAdd() {
|
|
1922
|
-
const lines = [
|
|
1923
|
-
'# 在宿主机中执行',
|
|
1924
|
-
'PLAYWRIGHT_CLI_INSTALL_DIR="${TMPDIR:-/tmp}/manyoyo-playwright-cli-install-$$"',
|
|
1925
|
-
'mkdir -p "$PLAYWRIGHT_CLI_INSTALL_DIR/.playwright"',
|
|
1926
|
-
'cd "$PLAYWRIGHT_CLI_INSTALL_DIR"',
|
|
1927
|
-
`npm install -g @playwright/cli@${PLAYWRIGHT_CLI_VERSION}`,
|
|
1928
|
-
'playwright-cli install --skills',
|
|
1929
|
-
'PLAYWRIGHT_CLI_SKILL_SOURCE="$PLAYWRIGHT_CLI_INSTALL_DIR/.claude/skills/playwright-cli"',
|
|
1930
|
-
'for target in ~/.claude/skills/playwright-cli ~/.codex/skills/playwright-cli ~/.gemini/skills/playwright-cli; do',
|
|
1931
|
-
' mkdir -p "$target"',
|
|
1932
|
-
' cp -R "$PLAYWRIGHT_CLI_SKILL_SOURCE/." "$target/"',
|
|
1933
|
-
'done',
|
|
1934
|
-
'cd "$OLDPWD"',
|
|
1935
|
-
'rm -rf "$PLAYWRIGHT_CLI_INSTALL_DIR"'
|
|
1936
|
-
];
|
|
1937
|
-
this.writeStdout(lines.join('\n'));
|
|
1938
|
-
return 0;
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
printSummary() {
|
|
1942
|
-
const scenes = this.resolveTargets('all');
|
|
1943
|
-
this.writeStdout(`playwright\truntime=${this.config.runtime}\tscenes=${scenes.join(',')}`);
|
|
1944
|
-
return 0;
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
async runOnScene(action, sceneName, options = {}) {
|
|
1948
|
-
const def = SCENE_DEFS[sceneName];
|
|
1949
|
-
if (isDevScene(sceneName)) {
|
|
1950
|
-
if (action === 'up') {
|
|
1951
|
-
return await this.startDevHost(sceneName);
|
|
1952
|
-
}
|
|
1953
|
-
if (action === 'down') {
|
|
1954
|
-
return this.stopDevHost(sceneName);
|
|
1955
|
-
}
|
|
1956
|
-
if (action === 'status') {
|
|
1957
|
-
return this.statusDevHost(sceneName);
|
|
1958
|
-
}
|
|
1959
|
-
if (action === 'health') {
|
|
1960
|
-
return await this.healthScene(sceneName);
|
|
1961
|
-
}
|
|
1962
|
-
if (action === 'logs') {
|
|
1963
|
-
return this.logsHost(sceneName);
|
|
1964
|
-
}
|
|
882
|
+
async run({ action, mode = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
|
|
883
|
+
if (action === 'overview') {
|
|
884
|
+
this.printOverview();
|
|
885
|
+
return await this.status();
|
|
1965
886
|
}
|
|
1966
887
|
if (action === 'up') {
|
|
1967
|
-
return
|
|
1968
|
-
? await this.startContainer(sceneName, options)
|
|
1969
|
-
: await this.startHost(sceneName, options);
|
|
888
|
+
return await this.up(mode, { extensionPaths, extensionNames });
|
|
1970
889
|
}
|
|
1971
890
|
if (action === 'down') {
|
|
1972
|
-
return
|
|
1973
|
-
? this.stopContainer(sceneName)
|
|
1974
|
-
: await this.stopHost(sceneName);
|
|
891
|
+
return await this.down();
|
|
1975
892
|
}
|
|
1976
893
|
if (action === 'status') {
|
|
1977
|
-
return
|
|
1978
|
-
? this.statusContainer(sceneName)
|
|
1979
|
-
: await this.statusHost(sceneName);
|
|
1980
|
-
}
|
|
1981
|
-
if (action === 'health') {
|
|
1982
|
-
return await this.healthScene(sceneName);
|
|
894
|
+
return await this.status();
|
|
1983
895
|
}
|
|
1984
896
|
if (action === 'logs') {
|
|
1985
|
-
return
|
|
1986
|
-
? this.logsContainer(sceneName)
|
|
1987
|
-
: this.logsHost(sceneName);
|
|
897
|
+
return this.logs();
|
|
1988
898
|
}
|
|
1989
|
-
this.writeStderr(`unknown action: ${action}`);
|
|
1990
|
-
return 1;
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
async run({ action, scene = 'mcp-host-headless', host = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
|
|
1994
|
-
if (action === 'ls') {
|
|
1995
|
-
return this.printSummary();
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
899
|
if (action === 'mcp-add') {
|
|
1999
|
-
return this.
|
|
900
|
+
return this.mcpAdd();
|
|
2000
901
|
}
|
|
2001
|
-
|
|
2002
|
-
if (action === 'cli-add') {
|
|
2003
|
-
return this.printCliAdd();
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
902
|
if (action === 'ext-download') {
|
|
2007
|
-
return await
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
const targets = this.resolveTargets(scene);
|
|
2015
|
-
if (targets.length === 0) {
|
|
2016
|
-
this.writeStderr('没有可执行场景,请检查 runtime 与 enabledScenes 配置');
|
|
2017
|
-
return 1;
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
const resolvedExtensionPaths = action === 'up'
|
|
2021
|
-
? this.resolveExtensionInputs({ extensionPaths, extensionNames })
|
|
2022
|
-
: [];
|
|
2023
|
-
|
|
2024
|
-
let rc = 0;
|
|
2025
|
-
for (const sceneName of targets) {
|
|
2026
|
-
// eslint-disable-next-line no-await-in-loop
|
|
2027
|
-
const code = await this.runOnScene(action, sceneName, { extensionPaths: resolvedExtensionPaths });
|
|
2028
|
-
if (code !== 0) {
|
|
2029
|
-
rc = 1;
|
|
2030
|
-
}
|
|
903
|
+
return await extensions.downloadExtensions({
|
|
904
|
+
pluginRoot: this.root,
|
|
905
|
+
prodversion: prodversion || this.config.extensionProdversion,
|
|
906
|
+
log: line => this.out(line),
|
|
907
|
+
logError: line => this.err(line)
|
|
908
|
+
});
|
|
2031
909
|
}
|
|
2032
|
-
|
|
2033
|
-
return rc;
|
|
910
|
+
throw new Error(`未知 playwright 动作: ${action}`);
|
|
2034
911
|
}
|
|
2035
912
|
}
|
|
2036
913
|
|
|
2037
914
|
module.exports = {
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
915
|
+
MODES,
|
|
916
|
+
PlaywrightPlugin,
|
|
917
|
+
appendNoProxy,
|
|
918
|
+
connectHostFor,
|
|
919
|
+
defaultListenHost,
|
|
920
|
+
headedListenHost
|
|
2042
921
|
};
|