@skillbuilder/minitool-e2e 0.0.0-beta-20261027053041051

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.md ADDED
@@ -0,0 +1,61 @@
1
+ # @skillbuilder/minitool-e2e
2
+
3
+ 小工具**端到端验证(e2e)**能力:在沙箱容器内起 Chromium + `miniapp-simulator`,真实装载出码产物并操作它,让出码 Agent 能自己验证「这东西到底跑不跑得起来」。
4
+
5
+ > 命名:能力层一律叫 **e2e**,游戏「试玩」只是它当前唯一落地的**模式**。所以包名、端口名、环境变量都不带 play;只有真正游戏专属的东西(`game_*` 工具、`game-playtest` 子代理、产物侧 `__GAME_TEST__` 桥)保留 playtest 字样。
6
+
7
+ ## 为什么单独一个包
8
+
9
+ 出码链路里 e2e 是**可选自检**能力,但它的依赖(Chromium、playwright-core)又重又平台敏感。单独成包把这些依赖关在一侧:出码 Agent 只认工具名,将来要拆成独立服务、换远端浏览器集群,或给别的 Agent 复用,都不必动出码侧。
10
+
11
+ ## 两种接入形态
12
+
13
+ 同一套会话实现(`session.ts`),两个门面:
14
+
15
+ ### 1. 进程内端口(当前出码链路走这条)
16
+
17
+ 补上 `codegen-agent` 里 `CodegenAgentContext.e2e`(`CodegenE2EPort`)这个**一直没人提供**的可选端口 —— 在此之前,`game-playtest-agent.ts` 里那 9 个 `game_*` 工具是死代码。
18
+
19
+ ```js
20
+ const { port, reason } = await createMinitoolE2EPortIfAvailable()
21
+ if (!port) console.warn(`[e2e] 能力不可用,降级跳过:${reason}`)
22
+ initCodegenAgent({ ...localPorts.context, e2e: port })
23
+ ```
24
+
25
+ 不可用时返回 `undefined` 而不是抛错:自检挂了绝不能让用户的出码任务失败。
26
+
27
+ ### 2. 独立 MCP server(能力隔离 / 后续拆分)
28
+
29
+ ```bash
30
+ MINITOOL_E2E_DIST_DIR=/data/widget-workspace/<user>/<project>/dist npx minitool-e2e-mcp
31
+ ```
32
+
33
+ stdio + JSON-RPC,暴露 9 个 tool:`game_open` / `game_manifest` / `game_snapshot` / `game_act` / `game_step` / `game_reset` / `game_logs` / `game_screenshot` / `game_close`。
34
+
35
+ 一个进程只服务**一个**会话。并发试玩靠起多个进程 —— 沙箱是 1 核 2GiB,单进程多浏览器只会互相拖死。
36
+
37
+ ## 运行前置
38
+
39
+ | 前置 | 说明 |
40
+ |---|---|
41
+ | 发行版 Chromium | 用 `MINITOOL_E2E_CHROMIUM_PATH` 指路径。**不能用 playwright 自带的**:那是 glibc 构建,Alpine/musl 上跑不起来,所以依赖只取 `playwright-core`(不含下载器) |
42
+ | 已构建的 miniapp-simulator | 需要 `lib/` **和** `dist/sw/` 都在。它有两套独立构建脚本,只跑 `build:npm` 会缺 SW、只跑 `build:sw` 会缺 runtime,两种缺失都会在 `resolveSimulatorAssets()` 显式报错 |
43
+
44
+ ## 几个不显然的实现约束
45
+
46
+ 写在代码注释里,这里只列索引:
47
+
48
+ - **SW 必须挂在根路径**(`host-server.ts`)。SW 最大作用域由脚本自身路径决定;挂在 `/miniapp-sw.js` 时作用域为 `/`,天然覆盖模拟器的虚拟前缀 `/miniapp-simulator/`,**不需要** `Service-Worker-Allowed` 响应头。
49
+ - **`loadFiles` resolve ≠ 桥就绪**(`session.ts`)。它只代表虚拟入口挂载完成、iframe 开始导航;游戏模板要等 Pixi 初始化 + 首帧之后才挂 `__GAME_TEST__`。所以 `open()` 里有界轮询等桥,非游戏产物会等满超时后如实返回 `bridgeAvailable=false`。
50
+ - **点击前必须先渲染一帧**(`session.ts` 的 `act` 注释)。试玩模式下模板会 `app.stop()`,而 Pixi v8 的命中测试依赖 `renderer.lastObjectRendered`,该字段只在 `render()` 内赋值。从未渲染过时 `EventBoundary.mapEvent` 会**静默 return** —— 点击看似发生了却什么都不触发。真实指针路径(`performPointerAction`)尚未实现,当前 `act` 走模板自己的语义桥。
51
+ - **模拟器资产是运行时解析,不做构建期拷贝**(`assets.ts`)。SW 与 runtime 之间有硬协议版本校验,拷贝会把「拷的那一刻的版本」冻进本包;运行时解析永远拿同一次 install 的同一份。另外模拟器的 `exports` 只声明了 `.` 和 `./remote`,`require.resolve` 两条路都被堵死,所以沿 `node_modules` 逐级上溯找包目录。
52
+
53
+ ## 本地冒烟
54
+
55
+ ```bash
56
+ MINITOOL_E2E_DIST_DIR=/path/to/dist \
57
+ MINITOOL_E2E_CHROMIUM_PATH="/path/to/chromium" \
58
+ node scripts/smoke.mjs
59
+ ```
60
+
61
+ 跑完整闭环:`open → manifest → snapshot → step → act → snapshot → logs → screenshot → close`,并把截图落到 `/tmp/minitool-e2e-smoke/frame.png`。不是单测,CI 不跑。
package/lib/assets.js ADDED
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveSimulatorAssets = resolveSimulatorAssets;
37
+ /**
38
+ * 运行时从 node_modules 解析模拟器产物,不做构建期拷贝:SW 与 runtime 之间有**硬协议版本校验**
39
+ * 且出自两套独立构建脚本,拷贝会把「拷的那一刻的版本」冻进本包,两侧各自升级即错配。
40
+ *
41
+ * ⚠️ 因此要求消费方已**完整**构建过 miniapp-simulator(只跑 build:npm 会缺 SW、只跑 build:sw
42
+ * 会缺 runtime)—— 两种缺失都在下面显式报错,而不是等到浏览器里白屏。
43
+ */
44
+ const fs = __importStar(require("node:fs"));
45
+ const path = __importStar(require("node:path"));
46
+ /** 模拟器包内相对路径,与其 package.json 的 files 字段对应 */
47
+ const LIB_ENTRY = "lib/index.js";
48
+ const SW_FILE = "dist/sw/miniapp-simulator-service-worker.js";
49
+ const SIMULATOR_PACKAGE = "@skillbuilder/miniapp-simulator";
50
+ /**
51
+ * ★ 不能用 `require.resolve`:模拟器的 `exports` 只声明了 `.`(且只给 `import` 条件)与
52
+ * `./remote`,两条路都被堵死 —— 子路径报 ERR_PACKAGE_PATH_NOT_EXPORTED,裸包名在 CJS 下
53
+ * 因缺 `require` 条件失败。而我们要的 `dist/sw/` 压根没出现在 exports 里。
54
+ * 所以沿 node_modules 逐级上溯,绕开 exports 解析。
55
+ */
56
+ function resolvePackageRoot() {
57
+ const segments = SIMULATOR_PACKAGE.split("/");
58
+ let dir = __dirname;
59
+ for (;;) {
60
+ const candidate = path.join(dir, "node_modules", ...segments);
61
+ if (fs.existsSync(path.join(candidate, "package.json"))) {
62
+ return fs.realpathSync(candidate);
63
+ }
64
+ const parent = path.dirname(dir);
65
+ if (parent === dir)
66
+ break;
67
+ dir = parent;
68
+ }
69
+ throw new Error(`未能在任何上层 node_modules 中找到 ${SIMULATOR_PACKAGE}(起点 ${__dirname})`);
70
+ }
71
+ function resolveSimulatorAssets() {
72
+ let packageRoot;
73
+ try {
74
+ packageRoot = resolvePackageRoot();
75
+ }
76
+ catch (err) {
77
+ throw new Error("[minitool-e2e] 无法解析 @skillbuilder/miniapp-simulator。" +
78
+ "请确认依赖已安装,且该包已构建出 lib/(pnpm --filter @skillbuilder/miniapp-simulator build)。" +
79
+ `原始错误:${err instanceof Error ? err.message : String(err)}`);
80
+ }
81
+ const libDir = path.join(packageRoot, "lib");
82
+ const serviceWorkerFile = path.join(packageRoot, SW_FILE);
83
+ const missing = [];
84
+ if (!fs.existsSync(path.join(libDir, "index.js")))
85
+ missing.push(LIB_ENTRY);
86
+ if (!fs.existsSync(serviceWorkerFile))
87
+ missing.push(SW_FILE);
88
+ if (missing.length > 0) {
89
+ throw new Error(`[minitool-e2e] miniapp-simulator 产物不完整,缺少:${missing.join(", ")}(包根 ${packageRoot})。` +
90
+ "注意它有两套独立构建脚本,只跑 build:npm 会缺 SW、只跑 build:sw 会缺 runtime;" +
91
+ "请跑完整的 pnpm --filter @skillbuilder/miniapp-simulator build。");
92
+ }
93
+ return { packageRoot, libDir, serviceWorkerFile };
94
+ }
package/lib/browser.js ADDED
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveChromiumPath = resolveChromiumPath;
4
+ exports.launchChromium = launchChromium;
5
+ exports.probeChromium = probeChromium;
6
+ /**
7
+ * Chromium 启动,面向沙箱容器(Alpine/musl、无 GPU、1 核 2GiB、非 root)。启动参数的由来:
8
+ * - **不用 playwright 自带的浏览器下载**:它的预编译 Chromium 是 glibc 构建,musl 上跑不起来。
9
+ * 镜像里装发行版 chromium 靠 executablePath 指过去,所以依赖只取 `playwright-core`。
10
+ * - `--use-gl=swiftshader --enable-unsafe-swiftshader`:无 GPU,WebGL 必须软渲染否则 Pixi
11
+ * 初始化失败;新版 Chromium 把 swiftshader 归为 unsafe,要第二个开关。
12
+ * - `--disable-dev-shm-usage`:容器 /dev/shm 只有 64MB,不关会随机崩标签页。
13
+ * - `--no-sandbox`:容器内已是受限环境,再套一层 namespace 沙箱通常起不来。
14
+ */
15
+ const playwright_core_1 = require("playwright-core");
16
+ const CONTAINER_ARGS = [
17
+ "--no-sandbox",
18
+ "--disable-dev-shm-usage",
19
+ "--disable-gpu-sandbox",
20
+ "--use-gl=swiftshader",
21
+ "--enable-unsafe-swiftshader",
22
+ // 容器内无音频设备,不关会在日志里刷一堆 ALSA 报错
23
+ "--mute-audio",
24
+ // 关掉与试玩无关的后台任务,省 CPU(1 核环境下很明显)
25
+ "--disable-background-timer-throttling",
26
+ "--disable-backgrounding-occluded-windows",
27
+ "--disable-renderer-backgrounding",
28
+ "--disable-features=TranslateUI,MediaRouter",
29
+ ];
30
+ function resolveChromiumPath(explicit) {
31
+ return explicit ?? process.env.MINITOOL_E2E_CHROMIUM_PATH ?? undefined;
32
+ }
33
+ async function launchChromium(opts = {}) {
34
+ const executablePath = resolveChromiumPath(opts.executablePath);
35
+ try {
36
+ return await playwright_core_1.chromium.launch({
37
+ headless: opts.headless ?? true,
38
+ executablePath,
39
+ args: [...CONTAINER_ARGS, ...(opts.extraArgs ?? [])],
40
+ timeout: opts.launchTimeoutMs ?? 60_000,
41
+ });
42
+ }
43
+ catch (err) {
44
+ throw new Error("[minitool-e2e] Chromium 启动失败。容器内需预装发行版 chromium 并通过 " +
45
+ "MINITOOL_E2E_CHROMIUM_PATH 指定路径(playwright 自带的 glibc 构建在 Alpine/musl 上不可用)。" +
46
+ `当前 executablePath=${executablePath ?? "(未指定,交由 playwright 自查)"}。` +
47
+ `原始错误:${err instanceof Error ? err.message : String(err)}`);
48
+ }
49
+ }
50
+ /** 探测 chromium 是否可用;用于降级决策(不可用时不注册试玩工具面,而不是让出码失败)。 */
51
+ async function probeChromium(opts = {}) {
52
+ let browser;
53
+ try {
54
+ browser = await launchChromium(opts);
55
+ return { available: true };
56
+ }
57
+ catch (err) {
58
+ return { available: false, reason: err instanceof Error ? err.message : String(err) };
59
+ }
60
+ finally {
61
+ await browser?.close().catch(() => undefined);
62
+ }
63
+ }
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MINITOOL_E2E_MCP_SERVER_NAME = void 0;
37
+ exports.createMinitoolE2EPort = createMinitoolE2EPort;
38
+ exports.createMinitoolE2EPortIfAvailable = createMinitoolE2EPortIfAvailable;
39
+ /**
40
+ * codegen-agent 里 `CodegenAgentContext.e2e` 那个可选端口的实现。
41
+ *
42
+ * 本包被加载两次:主进程 require 本文件(只拼配置 + 生成 prompt,不碰 chromium),
43
+ * MCPManager 另 spawn `lib/mcp/stdio-entry.js`(真工具与 chromium 都在那)。prompt 走前者的
44
+ * 同步调用而非 MCP 的 `prompts/list`:后者会把一段静态文字变成一次可能失败的 IO。
45
+ *
46
+ * 不 import codegen-agent 的类型(会成循环依赖,且它是 optionalDependency)——
47
+ * 靠 TS 结构化类型,契约漂移由 bootstrap 那一处的类型检查兜住。
48
+ */
49
+ const fs = __importStar(require("node:fs"));
50
+ const path = __importStar(require("node:path"));
51
+ const browser_1 = require("./browser");
52
+ const contract_1 = require("./contract");
53
+ const gate_1 = require("./gate");
54
+ var contract_2 = require("./contract");
55
+ Object.defineProperty(exports, "MINITOOL_E2E_MCP_SERVER_NAME", { enumerable: true, get: function () { return contract_2.MINITOOL_E2E_MCP_SERVER_NAME; } });
56
+ function mcpEntryPath() {
57
+ return path.join(__dirname, "mcp", "stdio-entry.js");
58
+ }
59
+ /**
60
+ * 拼 MCP 子进程 env。三个不能想当然的点:
61
+ * 1. 不能用 `minitool-e2e-mcp` 这个 bin:安装时 `lib/` 还没构建,软链建不出来。
62
+ * 2. 不能用 `require.resolve` 拼子路径:package.json 的 exports 堵死了,只能用 `__dirname`。
63
+ * 3. **`env` 一旦传了,子进程就完全拿不到父进程环境变量**(agentbase/src/mcp.ts:323 只补回
64
+ * PATH)⇒ chromium 路径 / HOME / TMPDIR 必须显式列进来,漏一个就是「连上了但浏览器起不来」。
65
+ */
66
+ function buildBaseEnv(opts) {
67
+ const env = {};
68
+ const chromiumPath = (0, browser_1.resolveChromiumPath)(opts.executablePath);
69
+ if (chromiumPath)
70
+ env.MINITOOL_E2E_CHROMIUM_PATH = chromiumPath;
71
+ // HOME/TMPDIR:playwright 要往 tmp 写 user-data-dir,容器里 HOME 也必须可写。
72
+ // LANG/LC_ALL:影响 CJK 字形选择,缺了截图里可能是豆腐块。
73
+ for (const key of ["HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"]) {
74
+ const value = process.env[key];
75
+ if (value)
76
+ env[key] = value;
77
+ }
78
+ // 截图上传回宿主。OSS AK/SK 不进容器,这里只带低价值 token。
79
+ if (opts.uploadBase)
80
+ env.MINITOOL_E2E_UPLOAD_BASE = opts.uploadBase;
81
+ if (opts.uploadToken)
82
+ env.MINITOOL_E2E_UPLOAD_TOKEN = opts.uploadToken;
83
+ return env;
84
+ }
85
+ /** 建端口。MCP 入口缺失会抛错,正常调用方是下面的 `IfAvailable` 版本。 */
86
+ function createMinitoolE2EPort(opts = {}) {
87
+ const entry = mcpEntryPath();
88
+ if (!fs.existsSync(entry)) {
89
+ throw new Error(`MCP 入口缺失(本包未构建完整?):${entry}`);
90
+ }
91
+ const baseEnv = buildBaseEnv(opts);
92
+ const resolveDistDir = opts.resolveDistDir ?? ((dir) => path.join(dir, "dist"));
93
+ return {
94
+ resolveRun({ workspaceDir, appKind, hasGameBridge, gate, taskId }) {
95
+ const normalized = (0, gate_1.normalizeE2EGate)(gate);
96
+ if (!(0, gate_1.isE2EEnabledForKind)(normalized, appKind)) {
97
+ // 「下发了快照但结构不对」与「压根没下发」都归一化成 undefined,但归因完全不同:
98
+ // 前者是控制面 bug,后者只是老版本控制面还没带这个字段。只有这里同时握着两个值。
99
+ const malformed = gate !== undefined && gate !== null && normalized === undefined;
100
+ return {
101
+ allowed: false,
102
+ reason: malformed
103
+ ? `e2e gate malformed (closed): ${JSON.stringify(gate)}`
104
+ : (0, gate_1.describeE2EGate)(normalized),
105
+ };
106
+ }
107
+ return {
108
+ allowed: true,
109
+ plan: {
110
+ allowedToolNames: (0, contract_1.e2eAllowedToolNames)(hasGameBridge),
111
+ // distDir 必须在这里算好写进 prompt:MCP 子进程的 cwd 不是任务工作区,推不出产物在哪。
112
+ instruction: (0, contract_1.buildE2EInstruction)({
113
+ distDir: resolveDistDir(workspaceDir),
114
+ hasGameBridge,
115
+ }),
116
+ extraTurns: (0, contract_1.e2eExtraTurns)(hasGameBridge),
117
+ mcpServers: {
118
+ [contract_1.MINITOOL_E2E_MCP_SERVER_NAME]: {
119
+ type: "stdio",
120
+ // 不用 "node":不依赖 PATH 上有 node,也保证与宿主同一个版本
121
+ command: process.execPath,
122
+ args: [entry],
123
+ env: { ...baseEnv, MINITOOL_E2E_TASK_ID: taskId },
124
+ },
125
+ },
126
+ },
127
+ };
128
+ },
129
+ };
130
+ }
131
+ /**
132
+ * 两道判据缺一不可:chromium 能真起停一次、MCP 入口文件存在。
133
+ * 刻意**不抛错** —— e2e 不可用绝不能让用户的出码任务失败,宿主拿到 undefined 就当没这能力,
134
+ * 任务照跑、只是没有验证环节。镜像里本包没构建全就是以这种方式暴露的。
135
+ */
136
+ async function createMinitoolE2EPortIfAvailable(opts = {}) {
137
+ const probe = await (0, browser_1.probeChromium)(opts);
138
+ if (!probe.available)
139
+ return { port: undefined, reason: probe.reason };
140
+ try {
141
+ return { port: createMinitoolE2EPort(opts) };
142
+ }
143
+ catch (err) {
144
+ return { port: undefined, reason: err instanceof Error ? err.message : String(err) };
145
+ }
146
+ }
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * e2e 的对外契约:工具名、MCP 前缀名、prompt、额外回合数。
4
+ *
5
+ * 工具名以**字符串**形态存在两处(`mcp/server.ts` 的 registerTool、下面 prompt 里点名的名字),
6
+ * 漏改一处 tsc 一个字都不报,模型会去调一个不存在的工具、拿到 `Tool not found`,然后自己编
7
+ * 一套「能力未注入」的解释(线上踩过)。两处都从本文件同一个数组取,改名字只能改这里。
8
+ *
9
+ * 本文件**不 import 任何运行时模块**:它在 codegen-agent 主进程里被同步调用,必须零副作用。
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.MINITOOL_E2E_MCP_SERVER_NAME = exports.E2E_TOOL_NAMES = exports.E2E_GAME_TOOL_NAMES = exports.E2E_GENERIC_TOOL_NAMES = void 0;
13
+ exports.toMcpToolName = toMcpToolName;
14
+ exports.e2eExtraTurns = e2eExtraTurns;
15
+ exports.e2eAllowedToolNames = e2eAllowedToolNames;
16
+ exports.buildE2EInstruction = buildE2EInstruction;
17
+ /** 任何产物形态都成立。后 4 个签名已定、实现待补。 */
18
+ exports.E2E_GENERIC_TOOL_NAMES = [
19
+ "e2e_open",
20
+ "e2e_logs",
21
+ "e2e_screenshot",
22
+ "e2e_close",
23
+ "e2e_network",
24
+ "e2e_dom",
25
+ "e2e_interact",
26
+ "e2e_health",
27
+ ];
28
+ /**
29
+ * 全部经产物侧 `window.__GAME_TEST__` 桥,只有 game_2d / game_3d 模板挂它。
30
+ * `attach` 单独成一个工具是因为等桥就绪只能轮询到超时,非游戏产物必然白等满 10s ——
31
+ * 只让真要用游戏语义的调用方付这个时间。
32
+ */
33
+ exports.E2E_GAME_TOOL_NAMES = [
34
+ "e2e_game_attach",
35
+ "e2e_game_manifest",
36
+ "e2e_game_snapshot",
37
+ "e2e_game_act",
38
+ "e2e_game_step",
39
+ "e2e_game_reset",
40
+ ];
41
+ exports.E2E_TOOL_NAMES = [...exports.E2E_GENERIC_TOOL_NAMES, ...exports.E2E_GAME_TOOL_NAMES];
42
+ /** 工具名会是 `mcp__minitool-e2e__e2e_*`(agentbase/src/mcp.ts:466 的命名规则)。 */
43
+ exports.MINITOOL_E2E_MCP_SERVER_NAME = "minitool-e2e";
44
+ /** prompt 里必须写这个带前缀的名字 —— 写裸名模型就调不到。 */
45
+ function toMcpToolName(name) {
46
+ return `mcp__${exports.MINITOOL_E2E_MCP_SERVER_NAME}__${name}`;
47
+ }
48
+ /**
49
+ * 额外回合数,按有无游戏桥分别给(无桥约 4 次调用,有桥约 15 次)。
50
+ * 必须补:这些调用全落在出码 Agent 自己的 maxTurns 预算里,不补的话
51
+ * 「写完码正要去验」的任务会直接撞 maxTurns 收场。
52
+ */
53
+ function e2eExtraTurns(hasGameBridge) {
54
+ return hasGameBridge ? 16 : 10;
55
+ }
56
+ /** 本轮该放行给模型的工具名(已带 MCP 前缀)。无游戏桥时只给通用 8 个。 */
57
+ function e2eAllowedToolNames(hasGameBridge) {
58
+ const names = hasGameBridge ? exports.E2E_TOOL_NAMES : exports.E2E_GENERIC_TOOL_NAMES;
59
+ return names.map((name) => toMcpToolName(name));
60
+ }
61
+ /**
62
+ * @param distDir 产物绝对路径,**必传** —— MCP 子进程的 cwd 不是任务工作区
63
+ * (`MCPConfig` 也没有 cwd 字段),只能写进 prompt 让模型照抄。
64
+ * @param hasGameBridge false 时**整段不提游戏工具**:点名一个没挂载的工具,模型会去调、
65
+ * 拿到 `Tool xxx not found`,然后自己编一套解释。
66
+ */
67
+ function buildE2EInstruction(opts) {
68
+ const tool = toMcpToolName;
69
+ const withGame = opts.hasGameBridge;
70
+ const groupIntro = withGame
71
+ ? `工具分两组,名字就是边界:
72
+ - \`e2e_*\` 通用能力,任何产物都能用;
73
+ - \`e2e_game_*\` 依赖产物侧的游戏测试桥,只有 game_2d / game_3d 形态才有;
74
+ - description 里带 "NOT IMPLEMENTED" 的工具尚未实现,**其返回值不可作为任何判断依据**。`
75
+ : `本形态只有通用 \`e2e_*\` 一组工具(产物是单页 HTML,没有游戏测试桥)。
76
+ description 里带 "NOT IMPLEMENTED" 的工具尚未实现,**其返回值不可作为任何判断依据**。`;
77
+ const gameSection = withGame
78
+ ? `
79
+ 游戏形态(game_2d / game_3d)在第 2 步之前追加:
80
+ 1. ${tool("e2e_game_attach")} 等游戏测试桥就绪;\`bridgeAvailable=false\` 就停在这里判 BLOCKED,别再调其它 \`e2e_game_*\`。
81
+ 2. 读 ${tool("e2e_game_manifest")} 与初始 ${tool("e2e_game_snapshot")},理解目标、动作与胜负规则。
82
+ 画布的结构读 ${tool("e2e_dom")} 的 \`sceneTree\`:\`snapshot\` 是运行时状态(score/phase/target),
83
+ \`sceneTree\` 是静态场景结构 + 源码定位,两者互补。
84
+ - 场景节点字段:\`ref\`(唯一定位符,形如 \`<providerId>:<节点>\`)、\`label\`(中文名)、
85
+ \`visible\`/\`selectable\`(是否可命中)、\`source.file\` + \`source.definitionKey\`(源码定位)。
86
+ - 多个画布 = \`sceneTree\` 里多个 \`provider\`,各自有 \`providerId\` + \`engine\`,靠 providerId 区分归属。
87
+ 3. ${tool("e2e_game_act")} + ${tool("e2e_game_step")} 走通至少一条核心玩法路径,并在关键节点读 snapshot;不要只执行一次动作就收。
88
+ 4. 覆盖 manifest 声明的主要动作与至少两个里程碑;验证 ${tool("e2e_game_reset")} 与固定 seed 可复现。
89
+ `
90
+ : "";
91
+ // 只有通用组时,模型唯一能拿到的证据就是首帧截图 ⇒ 判据必须写得更具体,否则它截个开屏图就宣布通过。
92
+ const passRule = withGame
93
+ ? "PASS 才可以结束交付。只看到静态首帧、无法观察状态、或主动作没有真正改变状态时**不得**判 PASS。"
94
+ : `PASS 才可以结束交付。**仅凭首帧截图不构成 PASS** —— 必须用 ${tool("e2e_dom")} 确认本轮需求点真实存在、用 ${tool("e2e_health")} 确认无未捕获异常/console 报错;交互类需求还必须用 ${tool("e2e_interact")} 真点过后观察到状态变化。做不到就判 BLOCKED,不要含糊放过。`;
95
+ return `## 端到端验证(e2e)门禁
96
+
97
+ 你自己就有端到端验证能力,**不要委派子代理**。最新源码 build 成功后必须真实验证过才能交付。
98
+
99
+ ${groupIntro}
100
+
101
+ 通用流程:
102
+ 1. ${tool("e2e_open")}(必须传 distDir="${opts.distDir}")加载产物,返回体里有 viewport(坐标锚)。
103
+ 2. ${tool("e2e_screenshot")} 看首帧;${tool("e2e_dom")} 确认不是白屏、本轮需求点对应的元素真实存在。canvas / 游戏形态的画布内容 DOM 里看不到,要看 ${tool("e2e_dom")} 返回里的 \`sceneTree\`。
104
+ 3. ${tool("e2e_health")} 看有无未捕获异常 / console 报错;需要原始事件时再用 ${tool("e2e_logs")}。
105
+ 4. 交互类需求用 ${tool("e2e_interact")} 真点一下:优先 selector(取元素中心);canvas / 合成 UI 这类 selector 指不到时才用裸 x/y —— 逻辑 CSS px,与截图 / dom 的 rect 同一把尺子。
106
+ 5. 收完证据调 ${tool("e2e_close")} 释放浏览器 —— 不关会一直占着沙箱内存。
107
+ ${gameSection}
108
+ 结论必须落到 PASS / FAIL / BLOCKED:
109
+ - ${passRule}
110
+ - FAIL:按最小复现修源码,重新 build,再验一次。
111
+ - BLOCKED:保留产物与已有证据,明确报告是基础设施还是测试桥阻塞,不得声称验证已通过。`;
112
+ }
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * 工具名的两处字符串副本(`mcp/server.ts` 的 registerTool、`contract.ts` 的 E2E_TOOL_NAMES)
8
+ * 必须一致 —— 漏改一处 tsc 一个字都不报,模型会照 prompt 去调一个不存在的工具。只能靠断言挡。
9
+ *
10
+ * 刻意走一次真 MCP 握手 + listTools,而不是读私有 `_registeredTools`:前者是模型看到的那份
11
+ * 清单,后者是实现细节。listTools 不触碰任何 handler,所以不起浏览器也不起子进程。
12
+ */
13
+ const strict_1 = __importDefault(require("node:assert/strict"));
14
+ const node_test_1 = require("node:test");
15
+ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
16
+ const inMemory_js_1 = require("@modelcontextprotocol/sdk/inMemory.js");
17
+ const contract_1 = require("./contract");
18
+ const server_1 = require("./mcp/server");
19
+ const session_1 = require("./session");
20
+ const sorted = (names) => [...names].sort();
21
+ (0, node_test_1.test)("scroll 拒绝无位移调用,有位移才派发模拟器手势", async () => {
22
+ const dispatched = [];
23
+ const session = Object.create(session_1.MinitoolE2ESession.prototype);
24
+ session.page = {
25
+ evaluate: async (_fn, gesture) => {
26
+ dispatched.push(gesture);
27
+ return { ok: true };
28
+ },
29
+ };
30
+ for (const delta of [{}, { deltaX: 0, deltaY: 0 }, { deltaX: Number.NaN }]) {
31
+ await strict_1.default.rejects(session.interact({ action: "scroll", x: 20, y: 20, ...delta }), /非零/);
32
+ }
33
+ strict_1.default.equal(dispatched.length, 0);
34
+ await session.interact({ action: "scroll", x: 20, y: 20, deltaY: 100 });
35
+ strict_1.default.equal(dispatched.length, 1);
36
+ });
37
+ /** 走一次真握手拿模型实际看到的工具清单。 */
38
+ async function listRegisteredTools() {
39
+ const [clientTransport, serverTransport] = inMemory_js_1.InMemoryTransport.createLinkedPair();
40
+ const server = (0, server_1.createMinitoolE2EMcpServer)();
41
+ const client = new index_js_1.Client({ name: "contract-test", version: "0.0.1" });
42
+ try {
43
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
44
+ const { tools } = await client.listTools();
45
+ return tools.map((tool) => ({ name: tool.name, description: tool.description }));
46
+ }
47
+ finally {
48
+ await client.close().catch(() => undefined);
49
+ await server.close().catch(() => undefined);
50
+ }
51
+ }
52
+ (0, node_test_1.test)("MCP server 注册的工具名与 E2E_TOOL_NAMES 精确一致", async () => {
53
+ const tools = await listRegisteredTools();
54
+ strict_1.default.deepEqual(sorted(tools.map((t) => t.name)), sorted(contract_1.E2E_TOOL_NAMES), "registerTool 与 contract 常量表不一致:新增/改名工具时两处都要动");
55
+ });
56
+ (0, node_test_1.test)("resolveRun 放行的名单 = MCP 注册的工具(带前缀)", async () => {
57
+ // 这条是新增的:出码侧按 allowedToolNames 做精确过滤,名单里多一个不存在的工具是浪费,
58
+ // 少一个则那个工具会被静默滤掉、模型压根看不到(正是「工具没集成进去」的形状之一)。
59
+ const registered = (await listRegisteredTools()).map((t) => t.name);
60
+ const registeredWithPrefix = registered.map((name) => (0, contract_1.toMcpToolName)(name));
61
+ strict_1.default.deepEqual(sorted((0, contract_1.e2eAllowedToolNames)(true)), sorted(registeredWithPrefix), "hasGameBridge=true 应放行全部 14 个");
62
+ strict_1.default.deepEqual(sorted((0, contract_1.e2eAllowedToolNames)(false)), sorted(contract_1.E2E_GENERIC_TOOL_NAMES.map(contract_1.toMcpToolName)), "hasGameBridge=false 应只放行通用 8 个");
63
+ // 无桥时一个游戏工具都不许放行 —— 否则模型会去调、拿到一堆「桥不可用」并误判成基础设施故障
64
+ for (const gameName of contract_1.E2E_GAME_TOOL_NAMES) {
65
+ strict_1.default.ok(!(0, contract_1.e2eAllowedToolNames)(false).includes((0, contract_1.toMcpToolName)(gameName)), `hasGameBridge=false 却放行了游戏工具 ${gameName}`);
66
+ }
67
+ });
68
+ (0, node_test_1.test)("两组名字的前缀边界成立:通用不带 game,游戏专属一律 e2e_game_", () => {
69
+ for (const name of contract_1.E2E_GENERIC_TOOL_NAMES) {
70
+ strict_1.default.ok(name.startsWith("e2e_"), name);
71
+ strict_1.default.ok(!name.startsWith("e2e_game_"), `${name} 不该落在通用组`);
72
+ }
73
+ for (const name of contract_1.E2E_GAME_TOOL_NAMES) {
74
+ strict_1.default.ok(name.startsWith("e2e_game_"), `${name} 缺 e2e_game_ 前缀`);
75
+ }
76
+ strict_1.default.equal(new Set(contract_1.E2E_TOOL_NAMES).size, contract_1.E2E_TOOL_NAMES.length, "工具名有重复");
77
+ });
78
+ (0, node_test_1.test)("prompt 里点名的工具都真实存在,且一律带 MCP 前缀", () => {
79
+ // prompt 里会用 `e2e_*` / `e2e_game_*` 指代整组能力,那不是工具名,先摘掉
80
+ const dropGroupWildcards = (text) => text.replace(/e2e(_game)?_\*/g, "");
81
+ const mcpNames = new Set(contract_1.E2E_TOOL_NAMES.map(contract_1.toMcpToolName));
82
+ const mcpPattern = /mcp__[a-z0-9-]+__e2e_[a-z_]+/g;
83
+ for (const hasGameBridge of [true, false]) {
84
+ const prompt = dropGroupWildcards((0, contract_1.buildE2EInstruction)({ distDir: "/w/dist", hasGameBridge }));
85
+ for (const cited of prompt.match(mcpPattern) ?? []) {
86
+ strict_1.default.ok(mcpNames.has(cited), `prompt 点了不存在的 MCP 工具 ${cited}`);
87
+ }
88
+ // 不能残留任何裸名:裸名模型调不到(它看到的都是带前缀的)
89
+ const leftover = prompt.replace(mcpPattern, "").match(/e2e_[a-z_]+/g);
90
+ strict_1.default.equal(leftover, null, `prompt 里残留了没加前缀的裸工具名(${leftover?.join(", ")}),模型会调不到`);
91
+ }
92
+ });
93
+ (0, node_test_1.test)("hasGameBridge=false 的 prompt 一个游戏工具都不许点名", () => {
94
+ // prompt 点名一个没放行的工具,模型会去调、拿到 `Tool xxx not found`,然后自己编一套
95
+ // 「能力未注入」的解释 —— 这正是线上那次误判的形状,所以这条断言必须存在。
96
+ const generic = (0, contract_1.buildE2EInstruction)({ distDir: "/w/dist", hasGameBridge: false });
97
+ for (const gameName of contract_1.E2E_GAME_TOOL_NAMES) {
98
+ strict_1.default.ok(!generic.includes(gameName), `无桥形态的 prompt 里点了游戏工具 ${gameName}`);
99
+ }
100
+ // 通用组的 4 个已实现工具必须都点到,否则模型不知道该怎么走流程
101
+ for (const name of ["e2e_open", "e2e_screenshot", "e2e_logs", "e2e_close"]) {
102
+ strict_1.default.ok(generic.includes((0, contract_1.toMcpToolName)(name)), `无桥形态的 prompt 漏了 ${name}`);
103
+ }
104
+ // 反向对照:有桥时必须点到游戏工具,否则这个参数等于没生效
105
+ const withGame = (0, contract_1.buildE2EInstruction)({ distDir: "/w/dist", hasGameBridge: true });
106
+ strict_1.default.ok(withGame.includes((0, contract_1.toMcpToolName)("e2e_game_attach")), "有桥形态却没点名游戏工具");
107
+ });
108
+ (0, node_test_1.test)("prompt 必须把 distDir 原样写进去(MCP 侧推不出产物在哪)", () => {
109
+ // MCPConfig 没有 cwd 字段,子进程的 cwd 不是任务工作区 ⇒ 产物绝对路径只能靠 prompt 传递。
110
+ // 漏了这一句,模型调 e2e_open 会打到一个错误的目录,报出指错方向的错。
111
+ const distDir = "/data/widget-workspace/wp-abc/dist";
112
+ strict_1.default.ok((0, contract_1.buildE2EInstruction)({ distDir, hasGameBridge: false }).includes(distDir));
113
+ strict_1.default.ok((0, contract_1.buildE2EInstruction)({ distDir, hasGameBridge: true }).includes(distDir));
114
+ });
115
+ (0, node_test_1.test)("未实现的通用能力(仅 network),description 首句就声明结果不可用作证据", async () => {
116
+ // 返回 `requests: []` 会被模型读成「查过了,没问题」并据此判 PASS,比没有这个工具糟得多。
117
+ const stubs = new Set(["e2e_network"]);
118
+ for (const tool of await listRegisteredTools()) {
119
+ const declaresStub = (tool.description ?? "").startsWith("NOT IMPLEMENTED");
120
+ strict_1.default.equal(declaresStub, stubs.has(tool.name),
121
+ // 反向也要断:已实现的工具挂上这个前缀会让模型白白不敢用
122
+ `${tool.name} 的 NOT IMPLEMENTED 声明与实现状态不符`);
123
+ }
124
+ });
125
+ (0, node_test_1.test)("有桥的回合预算明显高于无桥(两组开销差一个量级)", () => {
126
+ // 不是断绝对数值(那会让调参必须改测试),只断这个关系不许被写反 ——
127
+ // 写反了游戏任务会在「写完码正要去验」时撞 maxTurns 收场。
128
+ strict_1.default.ok((0, contract_1.e2eExtraTurns)(true) > (0, contract_1.e2eExtraTurns)(false));
129
+ strict_1.default.ok((0, contract_1.e2eExtraTurns)(false) > 0);
130
+ });