@xcanwin/manyoyo 5.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/manyoyo.js +118 -6
- package/config.example.json +26 -0
- package/lib/services/index.js +63 -0
- package/lib/services/playwright-assets/compose-headed.yaml +19 -0
- package/lib/services/playwright-assets/compose-headless.yaml +11 -0
- package/lib/services/playwright-assets/headed.Dockerfile +15 -0
- package/lib/services/playwright-assets/init-headed.sh +17 -0
- package/lib/services/playwright.js +792 -0
- package/package.json +1 -1
package/bin/manyoyo.js
CHANGED
|
@@ -14,6 +14,7 @@ const { buildContainerRunArgs, buildContainerRunCommand } = require('../lib/cont
|
|
|
14
14
|
const { initAgentConfigs } = require('../lib/init-config');
|
|
15
15
|
const { buildImage } = require('../lib/image-build');
|
|
16
16
|
const { resolveAgentResumeArg, buildAgentResumeCommand } = require('../lib/agent-resume');
|
|
17
|
+
const { runPluginCommand } = require('../lib/services');
|
|
17
18
|
const { version: BIN_VERSION, imageVersion: IMAGE_VERSION_DEFAULT } = require('../package.json');
|
|
18
19
|
const IMAGE_VERSION_BASE = String(IMAGE_VERSION_DEFAULT || '1.0.0').split('-')[0];
|
|
19
20
|
const IMAGE_VERSION_HELP_EXAMPLE = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
|
|
@@ -236,6 +237,7 @@ function sanitizeSensitiveData(obj) {
|
|
|
236
237
|
* @property {Object.<string, string|number|boolean>} [env] - 环境变量映射
|
|
237
238
|
* @property {string[]} [envFile] - 环境文件数组
|
|
238
239
|
* @property {string[]} [volumes] - 挂载卷数组
|
|
240
|
+
* @property {Object.<string, Object>} [plugins] - 可选插件配置映射(如 plugins.playwright)
|
|
239
241
|
* @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
|
|
240
242
|
* @property {string} [yolo] - YOLO 模式
|
|
241
243
|
* @property {string} [containerMode] - 容器模式
|
|
@@ -698,11 +700,33 @@ function updateManyoyo() {
|
|
|
698
700
|
|
|
699
701
|
function getContList() {
|
|
700
702
|
try {
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
703
|
+
const output = dockerExecArgs([
|
|
704
|
+
'ps', '-a', '--size',
|
|
705
|
+
'--format', '{{.Names}}\t{{.Status}}\t{{.Size}}\t{{.ID}}\t{{.Image}}\t{{.Ports}}\t{{.Networks}}\t{{.Mounts}}'
|
|
706
|
+
], { stdio: 'pipe' });
|
|
707
|
+
|
|
708
|
+
const rows = output
|
|
709
|
+
.split('\n')
|
|
710
|
+
.map(line => line.trim())
|
|
711
|
+
.filter(Boolean)
|
|
712
|
+
.filter(line => {
|
|
713
|
+
const cols = line.split('\t');
|
|
714
|
+
const name = cols[0] || '';
|
|
715
|
+
const image = cols[4] || '';
|
|
716
|
+
// include manyoyo runtime containers (image match)
|
|
717
|
+
// and plugin containers (both legacy manyoyo-* and new my-* prefixes)
|
|
718
|
+
return image.includes('manyoyo') || name.startsWith('manyoyo-') || name.startsWith('my-');
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
console.log('NO.\tNAMES\tSTATUS\tSIZE\tCONTAINER ID\tIMAGE\tPORTS\tNETWORKS\tMOUNTS');
|
|
722
|
+
if (rows.length > 0) {
|
|
723
|
+
const numberedRows = rows.map((line, index) => {
|
|
724
|
+
return `${index + 1}.\t${line}`;
|
|
725
|
+
});
|
|
726
|
+
console.log(numberedRows.join('\n'));
|
|
727
|
+
}
|
|
704
728
|
} catch (e) {
|
|
705
|
-
console.log(e.stdout || '');
|
|
729
|
+
console.log((e && e.stdout) || '');
|
|
706
730
|
}
|
|
707
731
|
}
|
|
708
732
|
|
|
@@ -843,6 +867,49 @@ async function setupCommander() {
|
|
|
843
867
|
selectedAction = action;
|
|
844
868
|
selectedOptions = options;
|
|
845
869
|
};
|
|
870
|
+
const selectPluginAction = (params = {}, options = {}) => {
|
|
871
|
+
selectAction('plugin', {
|
|
872
|
+
...options,
|
|
873
|
+
pluginAction: params.action || 'ls',
|
|
874
|
+
pluginName: params.pluginName || 'playwright',
|
|
875
|
+
pluginScene: params.scene || 'host-headless',
|
|
876
|
+
pluginHost: params.host || ''
|
|
877
|
+
});
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const registerPlaywrightAliasCommands = (command) => {
|
|
881
|
+
command.command('ls')
|
|
882
|
+
.description('列出 playwright 启用场景')
|
|
883
|
+
.option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
|
|
884
|
+
.action(options => selectPluginAction({
|
|
885
|
+
action: 'ls',
|
|
886
|
+
pluginName: 'playwright',
|
|
887
|
+
scene: 'all'
|
|
888
|
+
}, options));
|
|
889
|
+
|
|
890
|
+
const actions = ['up', 'down', 'status', 'health', 'logs'];
|
|
891
|
+
actions.forEach(action => {
|
|
892
|
+
command.command(`${action} [scene]`)
|
|
893
|
+
.description(`${action} playwright 场景,scene 默认 host-headless`)
|
|
894
|
+
.option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
|
|
895
|
+
.action((scene, options) => selectPluginAction({
|
|
896
|
+
action,
|
|
897
|
+
pluginName: 'playwright',
|
|
898
|
+
scene: scene || 'host-headless'
|
|
899
|
+
}, options));
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
command.command('mcp-add')
|
|
903
|
+
.description('输出 playwright 的 MCP 接入命令')
|
|
904
|
+
.option('--host <host>', 'MCP URL 使用的主机名或IP (默认 host.docker.internal)')
|
|
905
|
+
.option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
|
|
906
|
+
.action(options => selectPluginAction({
|
|
907
|
+
action: 'mcp-add',
|
|
908
|
+
pluginName: 'playwright',
|
|
909
|
+
scene: 'all',
|
|
910
|
+
host: options.host || ''
|
|
911
|
+
}, options));
|
|
912
|
+
};
|
|
846
913
|
|
|
847
914
|
program
|
|
848
915
|
.name(MANYOYO_NAME)
|
|
@@ -870,6 +937,8 @@ async function setupCommander() {
|
|
|
870
937
|
${MANYOYO_NAME} run -x "echo 123" 指定命令执行
|
|
871
938
|
${MANYOYO_NAME} serve 3000 -u admin -P 123456 启动带登录认证的网页服务
|
|
872
939
|
${MANYOYO_NAME} serve 0.0.0.0:3000 监听全部网卡,便于局域网访问
|
|
940
|
+
${MANYOYO_NAME} playwright up host-headless 启动 playwright 默认场景(推荐)
|
|
941
|
+
${MANYOYO_NAME} plugin playwright up host-headless 通过 plugin 命名空间启动
|
|
873
942
|
${MANYOYO_NAME} run -n test -q tip -q cmd 多次使用静默选项
|
|
874
943
|
`);
|
|
875
944
|
|
|
@@ -910,6 +979,21 @@ async function setupCommander() {
|
|
|
910
979
|
});
|
|
911
980
|
});
|
|
912
981
|
|
|
982
|
+
const playwrightCommand = program.command('playwright').description('管理 playwright 插件服务(推荐)');
|
|
983
|
+
registerPlaywrightAliasCommands(playwrightCommand);
|
|
984
|
+
|
|
985
|
+
const pluginCommand = program.command('plugin').description('管理 manyoyo 插件');
|
|
986
|
+
pluginCommand.command('ls')
|
|
987
|
+
.description('列出可用插件与启用场景')
|
|
988
|
+
.option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
|
|
989
|
+
.action(options => selectPluginAction({
|
|
990
|
+
action: 'ls',
|
|
991
|
+
pluginName: 'playwright',
|
|
992
|
+
scene: 'all'
|
|
993
|
+
}, options));
|
|
994
|
+
const pluginPlaywrightCommand = pluginCommand.command('playwright').description('管理 playwright 插件服务');
|
|
995
|
+
registerPlaywrightAliasCommands(pluginPlaywrightCommand);
|
|
996
|
+
|
|
913
997
|
const configCommand = program.command('config').description('查看解析后的配置或命令');
|
|
914
998
|
const configShowCommand = configCommand.command('show').description('显示最终生效配置并退出');
|
|
915
999
|
applyRunStyleOptions(configShowCommand, { includeRmOnExit: false, includeServePreview: true });
|
|
@@ -982,7 +1066,7 @@ async function setupCommander() {
|
|
|
982
1066
|
const isShowCommandMode = selectedAction === 'config-command';
|
|
983
1067
|
const isServerMode = options.server !== undefined;
|
|
984
1068
|
|
|
985
|
-
const noDockerActions = new Set(['init', 'update', 'install', 'config-show']);
|
|
1069
|
+
const noDockerActions = new Set(['init', 'update', 'install', 'config-show', 'plugin']);
|
|
986
1070
|
if (!noDockerActions.has(selectedAction)) {
|
|
987
1071
|
ensureDocker();
|
|
988
1072
|
}
|
|
@@ -1003,6 +1087,21 @@ async function setupCommander() {
|
|
|
1003
1087
|
process.exit(0);
|
|
1004
1088
|
}
|
|
1005
1089
|
|
|
1090
|
+
if (selectedAction === 'plugin') {
|
|
1091
|
+
const runConfig = options.run ? loadRunConfig(options.run, config) : {};
|
|
1092
|
+
return {
|
|
1093
|
+
isPluginMode: true,
|
|
1094
|
+
pluginRequest: {
|
|
1095
|
+
action: options.pluginAction,
|
|
1096
|
+
pluginName: options.pluginName,
|
|
1097
|
+
scene: options.pluginScene || 'host-headless',
|
|
1098
|
+
host: options.pluginHost || ''
|
|
1099
|
+
},
|
|
1100
|
+
pluginGlobalConfig: config,
|
|
1101
|
+
pluginRunConfig: runConfig
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1006
1105
|
// Load run config if specified
|
|
1007
1106
|
const runConfig = options.run ? loadRunConfig(options.run, config) : {};
|
|
1008
1107
|
|
|
@@ -1162,7 +1261,8 @@ async function setupCommander() {
|
|
|
1162
1261
|
isBuildMode,
|
|
1163
1262
|
isRemoveMode,
|
|
1164
1263
|
isShowCommandMode,
|
|
1165
|
-
isServerMode
|
|
1264
|
+
isServerMode,
|
|
1265
|
+
isPluginMode: false
|
|
1166
1266
|
};
|
|
1167
1267
|
}
|
|
1168
1268
|
|
|
@@ -1495,6 +1595,18 @@ async function main() {
|
|
|
1495
1595
|
try {
|
|
1496
1596
|
// 1. Setup commander and parse arguments
|
|
1497
1597
|
const modeState = await setupCommander();
|
|
1598
|
+
|
|
1599
|
+
if (modeState.isPluginMode) {
|
|
1600
|
+
const exitCode = await runPluginCommand(modeState.pluginRequest, {
|
|
1601
|
+
globalConfig: modeState.pluginGlobalConfig,
|
|
1602
|
+
runConfig: modeState.pluginRunConfig,
|
|
1603
|
+
projectRoot: path.join(__dirname, '..'),
|
|
1604
|
+
stdout: process.stdout,
|
|
1605
|
+
stderr: process.stderr
|
|
1606
|
+
});
|
|
1607
|
+
process.exit(exitCode);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1498
1610
|
const runtime = createRuntimeContext(modeState);
|
|
1499
1611
|
|
|
1500
1612
|
// 2. Start web server mode
|
package/config.example.json
CHANGED
|
@@ -25,6 +25,27 @@
|
|
|
25
25
|
"imageBuildArgs": [],
|
|
26
26
|
"quiet": ["tip", "cmd"],
|
|
27
27
|
|
|
28
|
+
// 可选插件(manyoyo playwright / manyoyo plugin playwright)
|
|
29
|
+
"plugins": {
|
|
30
|
+
"playwright": {
|
|
31
|
+
// mixed: 支持容器+宿主机;container: 仅容器;host: 仅宿主机
|
|
32
|
+
"runtime": "mixed",
|
|
33
|
+
// 启用场景(可按需裁剪)
|
|
34
|
+
"enabledScenes": ["cont-headless", "cont-headed", "host-headless", "host-headed"],
|
|
35
|
+
// mcp-add 默认 host(可改为 localhost / 127.0.0.1)
|
|
36
|
+
"mcpDefaultHost": "host.docker.internal",
|
|
37
|
+
// cont-headed 场景读取的密码环境变量名(默认 VNC_PASSWORD)
|
|
38
|
+
"vncPasswordEnvKey": "VNC_PASSWORD",
|
|
39
|
+
"ports": {
|
|
40
|
+
"contHeadless": 8931,
|
|
41
|
+
"contHeaded": 8932,
|
|
42
|
+
"hostHeadless": 8933,
|
|
43
|
+
"hostHeaded": 8934,
|
|
44
|
+
"contHeadedNoVnc": 6080
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
28
49
|
// 运行配置集合:通过 -r <name> 读取 runs.<name>
|
|
29
50
|
"runs": {
|
|
30
51
|
"claude": {
|
|
@@ -33,6 +54,11 @@
|
|
|
33
54
|
"shell": "claude",
|
|
34
55
|
"env": {
|
|
35
56
|
"ANTHROPIC_MODEL": "claude-sonnet-4-5"
|
|
57
|
+
},
|
|
58
|
+
"plugins": {
|
|
59
|
+
"playwright": {
|
|
60
|
+
"runtime": "container"
|
|
61
|
+
}
|
|
36
62
|
}
|
|
37
63
|
}
|
|
38
64
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PlaywrightPlugin } = require('./playwright');
|
|
4
|
+
|
|
5
|
+
const AVAILABLE_PLUGINS = ['playwright'];
|
|
6
|
+
|
|
7
|
+
function asObject(value) {
|
|
8
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPluginConfigs(pluginName, globalConfig, runConfig) {
|
|
15
|
+
const globalPlugins = asObject(asObject(globalConfig).plugins);
|
|
16
|
+
const runPlugins = asObject(asObject(runConfig).plugins);
|
|
17
|
+
return {
|
|
18
|
+
globalPluginConfig: asObject(globalPlugins[pluginName]),
|
|
19
|
+
runPluginConfig: asObject(runPlugins[pluginName])
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createPlugin(pluginName, options = {}) {
|
|
24
|
+
if (pluginName !== 'playwright') {
|
|
25
|
+
throw new Error(`未知插件: ${pluginName}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cfg = getPluginConfigs(pluginName, options.globalConfig, options.runConfig);
|
|
29
|
+
return new PlaywrightPlugin({
|
|
30
|
+
projectRoot: options.projectRoot,
|
|
31
|
+
stdout: options.stdout,
|
|
32
|
+
stderr: options.stderr,
|
|
33
|
+
globalConfig: cfg.globalPluginConfig,
|
|
34
|
+
runConfig: cfg.runPluginConfig
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runPluginCommand(request, options = {}) {
|
|
39
|
+
const action = String(request && request.action || '').trim();
|
|
40
|
+
|
|
41
|
+
if (action === 'ls') {
|
|
42
|
+
const plugin = createPlugin('playwright', options);
|
|
43
|
+
return await plugin.run({ action: 'ls' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const pluginName = String(request && request.pluginName || '').trim();
|
|
47
|
+
if (!pluginName) {
|
|
48
|
+
throw new Error('plugin 名称不能为空');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const plugin = createPlugin(pluginName, options);
|
|
52
|
+
return await plugin.run({
|
|
53
|
+
action,
|
|
54
|
+
scene: request.scene,
|
|
55
|
+
host: request.host
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
AVAILABLE_PLUGINS,
|
|
61
|
+
createPlugin,
|
|
62
|
+
runPluginCommand
|
|
63
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
services:
|
|
2
|
+
playwright:
|
|
3
|
+
build:
|
|
4
|
+
context: .
|
|
5
|
+
dockerfile: headed.Dockerfile
|
|
6
|
+
args:
|
|
7
|
+
PLAYWRIGHT_MCP_BASE_IMAGE: mcr.microsoft.com/playwright/mcp:${PLAYWRIGHT_MCP_DOCKER_TAG:-latest}
|
|
8
|
+
image: ${PLAYWRIGHT_MCP_IMAGE:-localhost/xcanwin/manyoyo-playwright-headed}
|
|
9
|
+
container_name: ${PLAYWRIGHT_MCP_CONTAINER_NAME:-my-playwright-cont-headed}
|
|
10
|
+
init: true
|
|
11
|
+
stdin_open: true
|
|
12
|
+
ports:
|
|
13
|
+
- "127.0.0.1:${PLAYWRIGHT_MCP_PORT:-8932}:${PLAYWRIGHT_MCP_PORT:-8932}"
|
|
14
|
+
- "127.0.0.1:${PLAYWRIGHT_MCP_NOVNC_PORT:-6080}:6080"
|
|
15
|
+
environment:
|
|
16
|
+
VNC_PASSWORD: "${VNC_PASSWORD:?VNC_PASSWORD is required}"
|
|
17
|
+
volumes:
|
|
18
|
+
- ${PLAYWRIGHT_MCP_CONFIG_PATH:?PLAYWRIGHT_MCP_CONFIG_PATH is required}:/app/config/playwright.json:ro
|
|
19
|
+
command: ["node", "cli.js", "--config", "/app/config/playwright.json"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
services:
|
|
2
|
+
playwright:
|
|
3
|
+
image: mcr.microsoft.com/playwright/mcp:${PLAYWRIGHT_MCP_DOCKER_TAG:-latest}
|
|
4
|
+
container_name: ${PLAYWRIGHT_MCP_CONTAINER_NAME:-my-playwright-cont-headless}
|
|
5
|
+
init: true
|
|
6
|
+
stdin_open: true
|
|
7
|
+
ports:
|
|
8
|
+
- "127.0.0.1:${PLAYWRIGHT_MCP_PORT:-8931}:${PLAYWRIGHT_MCP_PORT:-8931}"
|
|
9
|
+
volumes:
|
|
10
|
+
- ${PLAYWRIGHT_MCP_CONFIG_PATH:?PLAYWRIGHT_MCP_CONFIG_PATH is required}:/app/config/playwright.json:ro
|
|
11
|
+
command: ["--config", "/app/config/playwright.json"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ARG PLAYWRIGHT_MCP_BASE_IMAGE=mcr.microsoft.com/playwright/mcp:latest
|
|
2
|
+
FROM ${PLAYWRIGHT_MCP_BASE_IMAGE}
|
|
3
|
+
|
|
4
|
+
USER root
|
|
5
|
+
RUN apt-get update && \
|
|
6
|
+
apt-get install -y --no-install-recommends \
|
|
7
|
+
ca-certificates xvfb x11vnc novnc websockify fluxbox && \
|
|
8
|
+
update-ca-certificates && \
|
|
9
|
+
apt-get clean && \
|
|
10
|
+
rm -rf /tmp/* /var/tmp/* /var/log/apt /var/log/*.log /var/lib/apt/lists/*
|
|
11
|
+
|
|
12
|
+
COPY init-headed.sh /usr/local/bin/init-headed.sh
|
|
13
|
+
RUN chmod +x /usr/local/bin/init-headed.sh
|
|
14
|
+
|
|
15
|
+
ENTRYPOINT ["/usr/local/bin/init-headed.sh"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
export DISPLAY=:99
|
|
5
|
+
|
|
6
|
+
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
|
7
|
+
fluxbox >/tmp/fluxbox.log 2>&1 &
|
|
8
|
+
|
|
9
|
+
if [[ -z "${VNC_PASSWORD:-}" ]]; then
|
|
10
|
+
echo "VNC_PASSWORD is required."
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
x11vnc -display :99 -forever -shared -rfbport 5900 -localhost -passwd "$VNC_PASSWORD" >/tmp/x11vnc.log 2>&1 &
|
|
15
|
+
websockify --web=/usr/share/novnc/ 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
|
|
16
|
+
|
|
17
|
+
exec "$@"
|
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawn, spawnSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const SCENE_ORDER = ['cont-headless', 'cont-headed', 'host-headless', 'host-headed'];
|
|
10
|
+
|
|
11
|
+
const SCENE_DEFS = {
|
|
12
|
+
'cont-headless': {
|
|
13
|
+
type: 'container',
|
|
14
|
+
configFile: 'container-headless.json',
|
|
15
|
+
composeFile: 'compose-headless.yaml',
|
|
16
|
+
projectName: 'my-playwright-cont-headless',
|
|
17
|
+
containerName: 'my-playwright-cont-headless',
|
|
18
|
+
portKey: 'contHeadless',
|
|
19
|
+
headless: true,
|
|
20
|
+
listenHost: '0.0.0.0'
|
|
21
|
+
},
|
|
22
|
+
'cont-headed': {
|
|
23
|
+
type: 'container',
|
|
24
|
+
configFile: 'container-headed.json',
|
|
25
|
+
composeFile: 'compose-headed.yaml',
|
|
26
|
+
projectName: 'my-playwright-cont-headed',
|
|
27
|
+
containerName: 'my-playwright-cont-headed',
|
|
28
|
+
portKey: 'contHeaded',
|
|
29
|
+
headless: false,
|
|
30
|
+
listenHost: '0.0.0.0'
|
|
31
|
+
},
|
|
32
|
+
'host-headless': {
|
|
33
|
+
type: 'host',
|
|
34
|
+
configFile: 'host-headless.json',
|
|
35
|
+
portKey: 'hostHeadless',
|
|
36
|
+
headless: true,
|
|
37
|
+
listenHost: '127.0.0.1'
|
|
38
|
+
},
|
|
39
|
+
'host-headed': {
|
|
40
|
+
type: 'host',
|
|
41
|
+
configFile: 'host-headed.json',
|
|
42
|
+
portKey: 'hostHeaded',
|
|
43
|
+
headless: false,
|
|
44
|
+
listenHost: '127.0.0.1'
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
|
|
49
|
+
const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
|
|
50
|
+
|
|
51
|
+
function sleep(ms) {
|
|
52
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function tailText(filePath, lineCount) {
|
|
56
|
+
if (!fs.existsSync(filePath)) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
60
|
+
return lines.slice(-lineCount).join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function asObject(value) {
|
|
64
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function asStringArray(value, fallback) {
|
|
71
|
+
if (!Array.isArray(value)) {
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
return value
|
|
75
|
+
.map(item => String(item || '').trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class PlaywrightPlugin {
|
|
80
|
+
constructor(options = {}) {
|
|
81
|
+
this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
|
|
82
|
+
this.stdout = options.stdout || process.stdout;
|
|
83
|
+
this.stderr = options.stderr || process.stderr;
|
|
84
|
+
this.globalConfig = asObject(options.globalConfig);
|
|
85
|
+
this.runConfig = asObject(options.runConfig);
|
|
86
|
+
this.config = this.resolveConfig();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
resolveConfig() {
|
|
90
|
+
const homeDir = os.homedir();
|
|
91
|
+
const defaultConfig = {
|
|
92
|
+
runtime: 'mixed',
|
|
93
|
+
enabledScenes: [...SCENE_ORDER],
|
|
94
|
+
hostListen: '127.0.0.1',
|
|
95
|
+
mcpDefaultHost: 'host.docker.internal',
|
|
96
|
+
dockerTag: process.env.PLAYWRIGHT_MCP_DOCKER_TAG || 'latest',
|
|
97
|
+
npmVersion: process.env.PLAYWRIGHT_MCP_NPM_VERSION || 'latest',
|
|
98
|
+
containerRuntime: 'podman',
|
|
99
|
+
vncPasswordEnvKey: 'VNC_PASSWORD',
|
|
100
|
+
headedImage: 'localhost/xcanwin/manyoyo-playwright-headed',
|
|
101
|
+
configDir: path.join(homeDir, '.manyoyo', 'services', 'playwright', 'config'),
|
|
102
|
+
runDir: path.join(homeDir, '.manyoyo', 'services', 'playwright', 'run'),
|
|
103
|
+
composeDir: path.join(__dirname, 'playwright-assets'),
|
|
104
|
+
ports: {
|
|
105
|
+
contHeadless: 8931,
|
|
106
|
+
contHeaded: 8932,
|
|
107
|
+
hostHeadless: 8933,
|
|
108
|
+
hostHeaded: 8934,
|
|
109
|
+
contHeadedNoVnc: 6080
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const merged = {
|
|
114
|
+
...defaultConfig,
|
|
115
|
+
...this.globalConfig,
|
|
116
|
+
...this.runConfig,
|
|
117
|
+
ports: {
|
|
118
|
+
...defaultConfig.ports,
|
|
119
|
+
...asObject(this.globalConfig.ports),
|
|
120
|
+
...asObject(this.runConfig.ports)
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
merged.runtime = String(merged.runtime || defaultConfig.runtime).trim().toLowerCase();
|
|
125
|
+
if (!VALID_RUNTIME.has(merged.runtime)) {
|
|
126
|
+
throw new Error(`playwright.runtime 无效: ${merged.runtime}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
merged.enabledScenes = asStringArray(
|
|
130
|
+
this.runConfig.enabledScenes,
|
|
131
|
+
asStringArray(this.globalConfig.enabledScenes, [...defaultConfig.enabledScenes])
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (merged.enabledScenes.length === 0) {
|
|
135
|
+
throw new Error('playwright.enabledScenes 不能为空');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const invalidScene = merged.enabledScenes.find(scene => !SCENE_DEFS[scene]);
|
|
139
|
+
if (invalidScene) {
|
|
140
|
+
throw new Error(`playwright.enabledScenes 包含未知场景: ${invalidScene}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return merged;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
writeStdout(line = '') {
|
|
147
|
+
this.stdout.write(`${line}\n`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
writeStderr(line = '') {
|
|
151
|
+
this.stderr.write(`${line}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
runCmd(args, { env = null, captureOutput = false, check = true } = {}) {
|
|
155
|
+
const result = spawnSync(args[0], args.slice(1), {
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
env: env || process.env,
|
|
158
|
+
stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const completed = {
|
|
162
|
+
returncode: result.status === null ? 1 : result.status,
|
|
163
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
164
|
+
stderr: typeof result.stderr === 'string' ? result.stderr : ''
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (result.error) {
|
|
168
|
+
if (check) {
|
|
169
|
+
throw result.error;
|
|
170
|
+
}
|
|
171
|
+
completed.returncode = 1;
|
|
172
|
+
return completed;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (check && completed.returncode !== 0) {
|
|
176
|
+
const error = new Error(`command failed with exit code ${completed.returncode}`);
|
|
177
|
+
error.returncode = completed.returncode;
|
|
178
|
+
error.stdout = completed.stdout;
|
|
179
|
+
error.stderr = completed.stderr;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return completed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
ensureCommandAvailable(command) {
|
|
187
|
+
const check = this.runCmd([command, '--version'], { captureOutput: true, check: false });
|
|
188
|
+
return check.returncode === 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
scenePort(sceneName) {
|
|
192
|
+
const def = SCENE_DEFS[sceneName];
|
|
193
|
+
return Number(this.config.ports[def.portKey]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
sceneConfigPath(sceneName) {
|
|
197
|
+
const def = SCENE_DEFS[sceneName];
|
|
198
|
+
return path.join(this.config.configDir, def.configFile);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
scenePidFile(sceneName) {
|
|
202
|
+
return path.join(this.config.runDir, `${sceneName}.pid`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
sceneLogFile(sceneName) {
|
|
206
|
+
return path.join(this.config.runDir, `${sceneName}.log`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
resolveTargets(sceneName = 'all') {
|
|
210
|
+
const requested = String(sceneName || 'all').trim();
|
|
211
|
+
const enabledSet = new Set(this.config.enabledScenes);
|
|
212
|
+
const runtime = this.config.runtime;
|
|
213
|
+
|
|
214
|
+
const isAllowedByRuntime = (scene) => {
|
|
215
|
+
const type = SCENE_DEFS[scene].type;
|
|
216
|
+
if (runtime === 'mixed') {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
return runtime === type;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (requested !== 'all') {
|
|
223
|
+
if (!SCENE_DEFS[requested]) {
|
|
224
|
+
throw new Error(`未知场景: ${requested}`);
|
|
225
|
+
}
|
|
226
|
+
if (!enabledSet.has(requested)) {
|
|
227
|
+
throw new Error(`场景未启用: ${requested}`);
|
|
228
|
+
}
|
|
229
|
+
if (!isAllowedByRuntime(requested)) {
|
|
230
|
+
throw new Error(`当前 runtime=${runtime},不允许场景: ${requested}`);
|
|
231
|
+
}
|
|
232
|
+
return [requested];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return SCENE_ORDER
|
|
236
|
+
.filter(scene => enabledSet.has(scene))
|
|
237
|
+
.filter(scene => isAllowedByRuntime(scene));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
buildSceneConfig(sceneName) {
|
|
241
|
+
const def = SCENE_DEFS[sceneName];
|
|
242
|
+
const port = this.scenePort(sceneName);
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
server: {
|
|
246
|
+
host: def.listenHost,
|
|
247
|
+
port,
|
|
248
|
+
allowedHosts: [
|
|
249
|
+
`localhost:${port}`,
|
|
250
|
+
`127.0.0.1:${port}`,
|
|
251
|
+
`host.docker.internal:${port}`,
|
|
252
|
+
`host.containers.internal:${port}`
|
|
253
|
+
]
|
|
254
|
+
},
|
|
255
|
+
browser: {
|
|
256
|
+
chromiumSandbox: true,
|
|
257
|
+
browserName: 'chromium',
|
|
258
|
+
launchOptions: {
|
|
259
|
+
channel: 'chromium',
|
|
260
|
+
headless: def.headless
|
|
261
|
+
},
|
|
262
|
+
contextOptions: {
|
|
263
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36'
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
ensureSceneConfig(sceneName) {
|
|
270
|
+
fs.mkdirSync(this.config.configDir, { recursive: true });
|
|
271
|
+
const payload = this.buildSceneConfig(sceneName);
|
|
272
|
+
const filePath = this.sceneConfigPath(sceneName);
|
|
273
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
|
|
274
|
+
return filePath;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async portReady(port) {
|
|
278
|
+
return await new Promise((resolve) => {
|
|
279
|
+
let settled = false;
|
|
280
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
281
|
+
|
|
282
|
+
const finish = (value) => {
|
|
283
|
+
if (settled) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
settled = true;
|
|
287
|
+
socket.destroy();
|
|
288
|
+
resolve(value);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
socket.setTimeout(300);
|
|
292
|
+
socket.once('connect', () => finish(true));
|
|
293
|
+
socket.once('timeout', () => finish(false));
|
|
294
|
+
socket.once('error', () => finish(false));
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async waitForPort(port) {
|
|
299
|
+
for (let i = 0; i < 30; i += 1) {
|
|
300
|
+
// eslint-disable-next-line no-await-in-loop
|
|
301
|
+
if (await this.portReady(port)) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
// eslint-disable-next-line no-await-in-loop
|
|
305
|
+
await sleep(500);
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
containerEnv(sceneName, cfgPath, options = {}) {
|
|
311
|
+
const def = SCENE_DEFS[sceneName];
|
|
312
|
+
const requireVncPassword = options.requireVncPassword === true;
|
|
313
|
+
const env = {
|
|
314
|
+
...process.env,
|
|
315
|
+
PLAYWRIGHT_MCP_DOCKER_TAG: this.config.dockerTag,
|
|
316
|
+
PLAYWRIGHT_MCP_PORT: String(this.scenePort(sceneName)),
|
|
317
|
+
PLAYWRIGHT_MCP_CONFIG_PATH: cfgPath,
|
|
318
|
+
PLAYWRIGHT_MCP_CONTAINER_NAME: def.containerName,
|
|
319
|
+
PLAYWRIGHT_MCP_IMAGE: this.config.headedImage,
|
|
320
|
+
PLAYWRIGHT_MCP_NOVNC_PORT: String(this.config.ports.contHeadedNoVnc)
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
if (sceneName === 'cont-headed') {
|
|
324
|
+
const envKey = this.config.vncPasswordEnvKey;
|
|
325
|
+
const password = process.env[envKey];
|
|
326
|
+
if (!password && requireVncPassword) {
|
|
327
|
+
throw new Error(`${envKey} is required for cont-headed`);
|
|
328
|
+
}
|
|
329
|
+
// podman-compose resolves ${VNC_PASSWORD:?..} even for `down`.
|
|
330
|
+
// Keep `up` strict, but use a non-empty placeholder for non-up actions.
|
|
331
|
+
env.VNC_PASSWORD = password || '__MANYOYO_PLACEHOLDER__';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return env;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
containerComposePath(sceneName) {
|
|
338
|
+
const def = SCENE_DEFS[sceneName];
|
|
339
|
+
return path.join(this.config.composeDir, def.composeFile);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async startContainer(sceneName) {
|
|
343
|
+
const runtime = this.config.containerRuntime;
|
|
344
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
345
|
+
this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const cfgPath = this.ensureSceneConfig(sceneName);
|
|
350
|
+
const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
|
|
351
|
+
const def = SCENE_DEFS[sceneName];
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
this.runCmd([
|
|
355
|
+
runtime,
|
|
356
|
+
'compose',
|
|
357
|
+
'-p',
|
|
358
|
+
def.projectName,
|
|
359
|
+
'-f',
|
|
360
|
+
this.containerComposePath(sceneName),
|
|
361
|
+
'up',
|
|
362
|
+
'-d'
|
|
363
|
+
], { env, check: true });
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return error.returncode || 1;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const port = this.scenePort(sceneName);
|
|
369
|
+
if (await this.waitForPort(port)) {
|
|
370
|
+
this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port}`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this.writeStderr(`[up] ${sceneName} did not become ready on 127.0.0.1:${port}`);
|
|
375
|
+
return 1;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
stopContainer(sceneName) {
|
|
379
|
+
const runtime = this.config.containerRuntime;
|
|
380
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
381
|
+
this.writeStderr(`[down] ${sceneName} failed: ${runtime} command not found.`);
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const cfgPath = this.sceneConfigPath(sceneName);
|
|
386
|
+
const env = this.containerEnv(sceneName, cfgPath);
|
|
387
|
+
const def = SCENE_DEFS[sceneName];
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
this.runCmd([
|
|
391
|
+
runtime,
|
|
392
|
+
'compose',
|
|
393
|
+
'-p',
|
|
394
|
+
def.projectName,
|
|
395
|
+
'-f',
|
|
396
|
+
this.containerComposePath(sceneName),
|
|
397
|
+
'down'
|
|
398
|
+
], { env, check: true });
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return error.returncode || 1;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.writeStdout(`[down] ${sceneName}`);
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
statusContainer(sceneName) {
|
|
408
|
+
const runtime = this.config.containerRuntime;
|
|
409
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
410
|
+
this.writeStderr(`[status] ${sceneName} failed: ${runtime} command not found.`);
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const def = SCENE_DEFS[sceneName];
|
|
415
|
+
const cp = this.runCmd([
|
|
416
|
+
runtime,
|
|
417
|
+
'ps',
|
|
418
|
+
'--filter',
|
|
419
|
+
`name=${def.containerName}`,
|
|
420
|
+
'--format',
|
|
421
|
+
'{{.Names}}'
|
|
422
|
+
], { captureOutput: true, check: false });
|
|
423
|
+
|
|
424
|
+
const names = new Set(
|
|
425
|
+
cp.stdout
|
|
426
|
+
.split(/\r?\n/)
|
|
427
|
+
.map(line => line.trim())
|
|
428
|
+
.filter(Boolean)
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
if (names.has(def.containerName)) {
|
|
432
|
+
this.writeStdout(`[status] ${sceneName} running`);
|
|
433
|
+
} else {
|
|
434
|
+
this.writeStdout(`[status] ${sceneName} stopped`);
|
|
435
|
+
}
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
logsContainer(sceneName) {
|
|
440
|
+
const runtime = this.config.containerRuntime;
|
|
441
|
+
if (!this.ensureCommandAvailable(runtime)) {
|
|
442
|
+
this.writeStderr(`[logs] ${sceneName} failed: ${runtime} command not found.`);
|
|
443
|
+
return 1;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const def = SCENE_DEFS[sceneName];
|
|
447
|
+
const cp = this.runCmd([
|
|
448
|
+
runtime,
|
|
449
|
+
'logs',
|
|
450
|
+
'--tail',
|
|
451
|
+
'80',
|
|
452
|
+
def.containerName
|
|
453
|
+
], { captureOutput: true, check: false });
|
|
454
|
+
|
|
455
|
+
const output = cp.stdout || cp.stderr;
|
|
456
|
+
if (output.trim()) {
|
|
457
|
+
this.writeStdout(output.trimEnd());
|
|
458
|
+
} else {
|
|
459
|
+
this.writeStdout(`[logs] ${sceneName} no logs`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return cp.returncode === 0 ? 0 : 1;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
spawnHostProcess(cfgPath, logFd) {
|
|
466
|
+
return spawn('npx', [`@playwright/mcp@${this.config.npmVersion}`, '--config', String(cfgPath)], {
|
|
467
|
+
detached: true,
|
|
468
|
+
stdio: ['ignore', logFd, logFd]
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
stopHostStarter(pid) {
|
|
473
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
process.kill(-pid, 'SIGTERM');
|
|
478
|
+
return;
|
|
479
|
+
} catch {
|
|
480
|
+
// no-op
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
process.kill(pid, 'SIGTERM');
|
|
484
|
+
} catch {
|
|
485
|
+
// no-op
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
hostScenePids(sceneName) {
|
|
490
|
+
const cfgPath = this.sceneConfigPath(sceneName);
|
|
491
|
+
const pattern = `playwright-mcp.*--config ${cfgPath}`;
|
|
492
|
+
const cp = this.runCmd(['pgrep', '-f', pattern], { captureOutput: true, check: false });
|
|
493
|
+
|
|
494
|
+
if (cp.returncode !== 0 || !cp.stdout.trim()) {
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const pids = [];
|
|
499
|
+
for (const line of cp.stdout.split(/\r?\n/)) {
|
|
500
|
+
const text = line.trim();
|
|
501
|
+
if (/^\d+$/.test(text)) {
|
|
502
|
+
pids.push(Number(text));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return pids;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async waitForHostPids(sceneName, fallbackPid) {
|
|
509
|
+
for (let i = 0; i < 5; i += 1) {
|
|
510
|
+
const pids = this.hostScenePids(sceneName);
|
|
511
|
+
if (pids.length > 0) {
|
|
512
|
+
return pids;
|
|
513
|
+
}
|
|
514
|
+
// eslint-disable-next-line no-await-in-loop
|
|
515
|
+
await sleep(100);
|
|
516
|
+
}
|
|
517
|
+
if (Number.isInteger(fallbackPid) && fallbackPid > 0) {
|
|
518
|
+
return [fallbackPid];
|
|
519
|
+
}
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async startHost(sceneName) {
|
|
524
|
+
if (!this.ensureCommandAvailable('npx')) {
|
|
525
|
+
this.writeStderr(`[up] ${sceneName} failed: npx command not found.`);
|
|
526
|
+
return 1;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
fs.mkdirSync(this.config.runDir, { recursive: true });
|
|
530
|
+
const cfgPath = this.ensureSceneConfig(sceneName);
|
|
531
|
+
const pidFile = this.scenePidFile(sceneName);
|
|
532
|
+
const logFile = this.sceneLogFile(sceneName);
|
|
533
|
+
const port = this.scenePort(sceneName);
|
|
534
|
+
|
|
535
|
+
let managedPids = this.hostScenePids(sceneName);
|
|
536
|
+
if (managedPids.length > 0 && (await this.portReady(port))) {
|
|
537
|
+
this.writeStdout(`[up] ${sceneName} already running (pid(s) ${managedPids.join(' ')})`);
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (await this.portReady(port)) {
|
|
542
|
+
this.writeStderr(`[up] ${sceneName} failed: port ${port} is already in use by another process.`);
|
|
543
|
+
this.writeStderr('Stop the conflicting process first, then retry.');
|
|
544
|
+
return 1;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
fs.rmSync(pidFile, { force: true });
|
|
548
|
+
const logFd = fs.openSync(logFile, 'a');
|
|
549
|
+
const starter = this.spawnHostProcess(cfgPath, logFd);
|
|
550
|
+
fs.closeSync(logFd);
|
|
551
|
+
if (typeof starter.unref === 'function') {
|
|
552
|
+
starter.unref();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (await this.waitForPort(port)) {
|
|
556
|
+
managedPids = await this.waitForHostPids(sceneName, starter.pid);
|
|
557
|
+
if (managedPids.length > 0) {
|
|
558
|
+
fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
|
|
559
|
+
this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port} (pid(s) ${managedPids.join(' ')})`);
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.writeStderr(`[up] ${sceneName} failed to start. tail ${logFile}:`);
|
|
565
|
+
const tail = tailText(logFile, 30);
|
|
566
|
+
if (tail) {
|
|
567
|
+
this.writeStderr(tail);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (starter.exitCode === null && !starter.killed) {
|
|
571
|
+
this.stopHostStarter(starter.pid);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return 1;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async stopHost(sceneName) {
|
|
578
|
+
const pidFile = this.scenePidFile(sceneName);
|
|
579
|
+
const port = this.scenePort(sceneName);
|
|
580
|
+
const managedPids = this.hostScenePids(sceneName);
|
|
581
|
+
|
|
582
|
+
for (const pid of managedPids) {
|
|
583
|
+
try {
|
|
584
|
+
process.kill(pid, 'SIGTERM');
|
|
585
|
+
} catch {
|
|
586
|
+
// no-op
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (managedPids.length > 0) {
|
|
591
|
+
await sleep(300);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (fs.existsSync(pidFile)) {
|
|
595
|
+
const text = fs.readFileSync(pidFile, 'utf8').trim();
|
|
596
|
+
if (/^\d+$/.test(text)) {
|
|
597
|
+
try {
|
|
598
|
+
process.kill(Number(text), 'SIGTERM');
|
|
599
|
+
} catch {
|
|
600
|
+
// no-op
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
fs.rmSync(pidFile, { force: true });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (await this.portReady(port)) {
|
|
607
|
+
this.writeStderr(`[down] ${sceneName} warning: port ${port} is still in use (possibly unmanaged process)`);
|
|
608
|
+
return 1;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.writeStdout(`[down] ${sceneName}`);
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async statusHost(sceneName) {
|
|
616
|
+
const pidFile = this.scenePidFile(sceneName);
|
|
617
|
+
const port = this.scenePort(sceneName);
|
|
618
|
+
const managedPids = this.hostScenePids(sceneName);
|
|
619
|
+
|
|
620
|
+
if (managedPids.length > 0 && (await this.portReady(port))) {
|
|
621
|
+
this.writeStdout(`[status] ${sceneName} running (pid(s) ${managedPids.join(' ')})`);
|
|
622
|
+
const pidfileValid = fs.existsSync(pidFile) && /^\d+$/.test(fs.readFileSync(pidFile, 'utf8').trim());
|
|
623
|
+
if (!pidfileValid) {
|
|
624
|
+
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
625
|
+
fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
|
|
626
|
+
}
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (managedPids.length > 0 && !(await this.portReady(port))) {
|
|
631
|
+
this.writeStdout(`[status] ${sceneName} degraded (pid(s) ${managedPids.join(' ')}, port ${port} not reachable)`);
|
|
632
|
+
return 0;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
fs.rmSync(pidFile, { force: true });
|
|
636
|
+
if (await this.portReady(port)) {
|
|
637
|
+
this.writeStdout(`[status] ${sceneName} conflict (port ${port} in use by unmanaged process)`);
|
|
638
|
+
} else {
|
|
639
|
+
this.writeStdout(`[status] ${sceneName} stopped`);
|
|
640
|
+
}
|
|
641
|
+
return 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async healthScene(sceneName) {
|
|
645
|
+
const port = this.scenePort(sceneName);
|
|
646
|
+
if (await this.portReady(port)) {
|
|
647
|
+
this.writeStdout(`[health] ${sceneName} ok (127.0.0.1:${port})`);
|
|
648
|
+
return 0;
|
|
649
|
+
}
|
|
650
|
+
this.writeStdout(`[health] ${sceneName} fail (127.0.0.1:${port})`);
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
logsHost(sceneName) {
|
|
655
|
+
const logFile = this.sceneLogFile(sceneName);
|
|
656
|
+
if (!fs.existsSync(logFile)) {
|
|
657
|
+
this.writeStdout(`[logs] ${sceneName} no log file: ${logFile}`);
|
|
658
|
+
return 0;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const tail = tailText(logFile, 80);
|
|
662
|
+
if (tail) {
|
|
663
|
+
this.writeStdout(tail);
|
|
664
|
+
}
|
|
665
|
+
return 0;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
detectCurrentIPv4() {
|
|
669
|
+
const interfaces = os.networkInterfaces();
|
|
670
|
+
for (const values of Object.values(interfaces)) {
|
|
671
|
+
if (!Array.isArray(values)) {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
for (const item of values) {
|
|
675
|
+
if (!item || item.internal) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (item.family === 'IPv4') {
|
|
679
|
+
return item.address;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return '';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
resolveMcpAddHost(hostArg) {
|
|
687
|
+
if (!hostArg) {
|
|
688
|
+
return this.config.mcpDefaultHost;
|
|
689
|
+
}
|
|
690
|
+
const value = String(hostArg).trim();
|
|
691
|
+
if (!value) {
|
|
692
|
+
return '';
|
|
693
|
+
}
|
|
694
|
+
if (value === 'current-ip') {
|
|
695
|
+
return this.detectCurrentIPv4();
|
|
696
|
+
}
|
|
697
|
+
return value;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
printMcpAdd(hostArg) {
|
|
701
|
+
const host = this.resolveMcpAddHost(hostArg);
|
|
702
|
+
if (!host) {
|
|
703
|
+
this.writeStderr('[mcp-add] failed: cannot determine host. Use --host <host> to set one explicitly.');
|
|
704
|
+
return 1;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const scenes = this.resolveTargets('all');
|
|
708
|
+
for (const sceneName of scenes) {
|
|
709
|
+
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
710
|
+
this.writeStdout(`claude mcp add --transport http playwright-${sceneName} ${url}`);
|
|
711
|
+
}
|
|
712
|
+
this.writeStdout('');
|
|
713
|
+
for (const sceneName of scenes) {
|
|
714
|
+
const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
|
|
715
|
+
this.writeStdout(`codex mcp add playwright-${sceneName} --url ${url}`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return 0;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
printSummary() {
|
|
722
|
+
const scenes = this.resolveTargets('all');
|
|
723
|
+
this.writeStdout(`playwright\truntime=${this.config.runtime}\tscenes=${scenes.join(',')}`);
|
|
724
|
+
return 0;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async runOnScene(action, sceneName) {
|
|
728
|
+
const def = SCENE_DEFS[sceneName];
|
|
729
|
+
if (action === 'up') {
|
|
730
|
+
return def.type === 'container'
|
|
731
|
+
? await this.startContainer(sceneName)
|
|
732
|
+
: await this.startHost(sceneName);
|
|
733
|
+
}
|
|
734
|
+
if (action === 'down') {
|
|
735
|
+
return def.type === 'container'
|
|
736
|
+
? this.stopContainer(sceneName)
|
|
737
|
+
: await this.stopHost(sceneName);
|
|
738
|
+
}
|
|
739
|
+
if (action === 'status') {
|
|
740
|
+
return def.type === 'container'
|
|
741
|
+
? this.statusContainer(sceneName)
|
|
742
|
+
: await this.statusHost(sceneName);
|
|
743
|
+
}
|
|
744
|
+
if (action === 'health') {
|
|
745
|
+
return await this.healthScene(sceneName);
|
|
746
|
+
}
|
|
747
|
+
if (action === 'logs') {
|
|
748
|
+
return def.type === 'container'
|
|
749
|
+
? this.logsContainer(sceneName)
|
|
750
|
+
: this.logsHost(sceneName);
|
|
751
|
+
}
|
|
752
|
+
this.writeStderr(`unknown action: ${action}`);
|
|
753
|
+
return 1;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async run({ action, scene = 'host-headless', host = '' }) {
|
|
757
|
+
if (action === 'ls') {
|
|
758
|
+
return this.printSummary();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (action === 'mcp-add') {
|
|
762
|
+
return this.printMcpAdd(host);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (!VALID_ACTIONS.has(action)) {
|
|
766
|
+
throw new Error(`未知 plugin 动作: ${action}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const targets = this.resolveTargets(scene);
|
|
770
|
+
if (targets.length === 0) {
|
|
771
|
+
this.writeStderr('没有可执行场景,请检查 runtime 与 enabledScenes 配置');
|
|
772
|
+
return 1;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
let rc = 0;
|
|
776
|
+
for (const sceneName of targets) {
|
|
777
|
+
// eslint-disable-next-line no-await-in-loop
|
|
778
|
+
const code = await this.runOnScene(action, sceneName);
|
|
779
|
+
if (code !== 0) {
|
|
780
|
+
rc = 1;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return rc;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
module.exports = {
|
|
789
|
+
SCENE_ORDER,
|
|
790
|
+
SCENE_DEFS,
|
|
791
|
+
PlaywrightPlugin
|
|
792
|
+
};
|