@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/lib/session.js ADDED
@@ -0,0 +1,592 @@
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.MinitoolE2ESession = void 0;
37
+ exports.readDistDir = readDistDir;
38
+ /**
39
+ * 一次 open→操作→close 的生命周期,持有 Chromium + 宿主页 + 静态服务。
40
+ * 所有对模拟器的调用都经 `page.evaluate` 打到宿主页的 `window.__MINITOOL_E2E__`,
41
+ * Node 侧不持有任何浏览器内对象。方法分两组(工具名也按这个分):通用的任何形态都成立,
42
+ * 游戏专属的全部经 `window.__GAME_TEST__`,只有 game_2d / game_3d 模板才挂那个桥。
43
+ */
44
+ const fs = __importStar(require("node:fs"));
45
+ const path = __importStar(require("node:path"));
46
+ const browser_1 = require("./browser");
47
+ const host_server_1 = require("./host-server");
48
+ const upload_1 = require("./upload");
49
+ /** 按扩展名判定是否走 base64 中转(文本文件直传,省一次编解码) */
50
+ const BINARY_EXTENSIONS = new Set([
51
+ ".png",
52
+ ".jpg",
53
+ ".jpeg",
54
+ ".webp",
55
+ ".gif",
56
+ ".ico",
57
+ ".bmp",
58
+ ".woff",
59
+ ".woff2",
60
+ ".ttf",
61
+ ".otf",
62
+ ".eot",
63
+ ".mp3",
64
+ ".wav",
65
+ ".ogg",
66
+ ".m4a",
67
+ ".mp4",
68
+ ".webm",
69
+ ".glb",
70
+ ".gltf",
71
+ ".bin",
72
+ ".wasm",
73
+ ".zip",
74
+ ]);
75
+ function notImplemented(capability, message, blockedBy) {
76
+ // 打一行日志:沙箱里能据此看到模型确实调了这个还没实现的能力,
77
+ // 从而判断该优先做哪一个(比凭猜测排期靠谱)。
78
+ console.warn(`[minitool-e2e] 通用 e2e 能力 ${capability} 尚未实现,已如实返回 not_implemented`);
79
+ return { status: "not_implemented", capability, message, ...(blockedBy ? { blockedBy } : {}) };
80
+ }
81
+ class MinitoolE2ESession {
82
+ browser;
83
+ page;
84
+ host;
85
+ viewport;
86
+ constructor(browser, page, host, viewport) {
87
+ this.browser = browser;
88
+ this.page = page;
89
+ this.host = host;
90
+ this.viewport = viewport;
91
+ }
92
+ static async create(opts = {}) {
93
+ const viewport = opts.viewport ?? { width: 390, height: 844 };
94
+ const host = await (0, host_server_1.startHostServer)({ viewport });
95
+ let browser;
96
+ try {
97
+ browser = await (0, browser_1.launchChromium)(opts);
98
+ const context = await browser.newContext({
99
+ // 画布留出边距,避免模拟器容器贴边导致 devicePixelRatio 换算出现半像素
100
+ viewport: { width: viewport.width + 40, height: viewport.height + 56 },
101
+ deviceScaleFactor: 2,
102
+ });
103
+ const page = await context.newPage();
104
+ await page.goto(`${host.baseUrl}/`, { waitUntil: "load" });
105
+ await page.waitForFunction(() => globalThis.__MINITOOL_E2E_READY__ === true, undefined, { timeout: opts.readyTimeoutMs ?? 30_000 });
106
+ return new MinitoolE2ESession(browser, page, host, viewport);
107
+ }
108
+ catch (err) {
109
+ await browser?.close().catch(() => undefined);
110
+ await host.close().catch(() => undefined);
111
+ throw err;
112
+ }
113
+ }
114
+ /**
115
+ * 把产物目录读进内存交给模拟器挂载。刻意**不等游戏桥**(那段轮询在 `gameAttach()`):
116
+ * 等桥只能轮询到超时,非游戏产物必然白等满 10s,而多数验证只需「加载→截图→读日志」。
117
+ */
118
+ async open(input) {
119
+ const files = readDistDir(input.distDir);
120
+ if (Object.keys(files).length === 0) {
121
+ throw new Error(`[minitool-e2e] 产物目录为空:${input.distDir}`);
122
+ }
123
+ const entry = input.entry ?? "index.html";
124
+ if (!(entry in files)) {
125
+ throw new Error(`[minitool-e2e] 产物目录缺少入口 ${entry}:${input.distDir}(实有 ${Object.keys(files).slice(0, 10).join(", ")})`);
126
+ }
127
+ const { width, height } = input.viewport ?? this.viewport;
128
+ const viewport = { width, height };
129
+ await this.page.setViewportSize({ width: width + 40, height: height + 56 });
130
+ const result = await this.page.evaluate(async ({ files, entry, query, viewport }) => {
131
+ const bridge = globalThis.__MINITOOL_E2E__;
132
+ const simulator = bridge.simulator;
133
+ // 内外视口一起更新,避免截图仍使用旧容器而产生大块留白。
134
+ const preview = document.getElementById("preview");
135
+ preview.style.width = `${viewport.width}px`;
136
+ preview.style.height = `${viewport.height}px`;
137
+ simulator.setViewport(viewport);
138
+ bridge.viewport = viewport;
139
+ // base64 → Uint8Array 必须在页面内还原:结构化克隆过不去 Buffer
140
+ const decoded = {};
141
+ for (const [name, content] of Object.entries(files)) {
142
+ if (typeof content === "string") {
143
+ decoded[name] = content;
144
+ continue;
145
+ }
146
+ const raw = atob(content.base64);
147
+ const bytes = new Uint8Array(raw.length);
148
+ for (let i = 0; i < raw.length; i++)
149
+ bytes[i] = raw.charCodeAt(i);
150
+ decoded[name] = bytes;
151
+ }
152
+ const loaded = await simulator.loadFiles(decoded, { entry, query });
153
+ return { entry: loaded?.entry ?? entry, fileCount: Object.keys(decoded).length };
154
+ }, {
155
+ files,
156
+ entry,
157
+ query: { playtest: "1", ...(input.query ?? {}) },
158
+ viewport,
159
+ });
160
+ this.viewport = viewport;
161
+ return { ...result, viewport: { ...viewport } };
162
+ }
163
+ /**
164
+ * 等游戏语义桥就绪并判定它到底有没有。
165
+ *
166
+ * 必须轮询而非立刻判定:`loadFiles` resolve 只代表虚拟入口已挂载、iframe 已开始导航,
167
+ * **不代表 guest 的 JS 跑完了**(游戏模板要等 Pixi 初始化 + WebGL 首帧才挂上桥)。
168
+ * 反过来,**非游戏产物必然等满 timeoutMs 才返回 false** —— 无法预先判断产物形态,
169
+ * 这正是把它从 `open()` 拆出来的理由。
170
+ */
171
+ async gameAttach(timeoutMs = 10_000) {
172
+ const startedAt = Date.now();
173
+ const bridgeAvailable = await this.page.evaluate(async (deadlineMs) => {
174
+ const simulator = globalThis.__MINITOOL_E2E__.simulator;
175
+ const deadline = Date.now() + deadlineMs;
176
+ for (;;) {
177
+ const ok = await simulator.gamePlaytest.isAvailable().catch(() => false);
178
+ if (ok)
179
+ return true;
180
+ if (Date.now() >= deadline)
181
+ return false;
182
+ await new Promise((resolve) => setTimeout(resolve, 100));
183
+ }
184
+ }, timeoutMs);
185
+ return { bridgeAvailable, waitedMs: Date.now() - startedAt };
186
+ }
187
+ // ── 游戏专属:以下全部经 __GAME_TEST__ 桥,非游戏产物会 reject ──────────────────
188
+ /** 读游戏 manifest(目标、可用动作、里程碑、胜负判据)。非游戏产物会 reject。 */
189
+ manifest() {
190
+ return this.invokeBridge("manifest");
191
+ }
192
+ /** 读当前语义状态快照。 */
193
+ snapshot() {
194
+ return this.invokeBridge("snapshot");
195
+ }
196
+ /**
197
+ * 执行一个 manifest 声明的动作。走**语义桥**(模板注册的动作回调),不是真实指针事件。
198
+ *
199
+ * ⚠️ 真实 pointer 路径必须先渲染一帧:试玩模式下模板会 `app.stop()`,而 Pixi v8 的命中测试
200
+ * 依赖只在 `render()` 内部赋值的 `renderer.lastObjectRendered`。从未渲染过时
201
+ * `EventBoundary.mapEvent` 会**静默 return** —— 点击看似发生了却什么都不触发。
202
+ * 所以派发坐标事件前必须先 `step(16)`。该路径未实现,见 performPointerAction()。
203
+ */
204
+ act(action) {
205
+ return this.invokeBridge("act", [action]);
206
+ }
207
+ /** 推进确定性时钟。同时也是「先渲染一帧」的手段(见 act 注释)。 */
208
+ step(ms) {
209
+ return this.invokeBridge("step", [ms]);
210
+ }
211
+ /** 重开,可带确定性随机种子。 */
212
+ reset(seed) {
213
+ return this.invokeBridge("reset", seed === undefined ? [] : [seed]);
214
+ }
215
+ /** 取页面侧攒下的事件(console / guestError / CSP / 失败请求),取完即清。 */
216
+ async logs(level) {
217
+ return this.page.evaluate((lvl) => globalThis.__MINITOOL_E2E__.drainEvents(lvl), level);
218
+ }
219
+ /** 截当前帧。截的是模拟器容器,不是整个浏览器窗口。 */
220
+ async screenshot() {
221
+ const element = await this.page.$("#preview");
222
+ // scale:"css" 让截图与 dom rect / interact 坐标同为 1:1 逻辑 CSS px(否则 deviceScaleFactor=2 会放大一倍)
223
+ const buffer = await (element ?? this.page).screenshot({ type: "png", scale: "css" });
224
+ const result = {
225
+ data: Buffer.from(buffer).toString("base64"),
226
+ mimeType: "image/png",
227
+ description: "Current miniapp-simulator frame",
228
+ };
229
+ // 已配置上传(宿主侧的 e2e 截图上传代理)时先把 PNG 进桶,再带 URL 返回给模型。
230
+ // 上传失败降级为纯 base64:截图判读不因上传故障中断,符合 e2e 的 fail-open 口径。
231
+ const uploadBase = process.env.MINITOOL_E2E_UPLOAD_BASE?.trim();
232
+ const taskId = process.env.MINITOOL_E2E_TASK_ID?.trim();
233
+ if (uploadBase && taskId) {
234
+ try {
235
+ const uploaded = await (0, upload_1.uploadScreenshot)(result.data, {
236
+ base: uploadBase,
237
+ taskId,
238
+ token: process.env.MINITOOL_E2E_UPLOAD_TOKEN?.trim() || undefined,
239
+ });
240
+ result.url = uploaded.url;
241
+ result.fileKey = uploaded.fileKey;
242
+ result.description = `Current miniapp-simulator frame (saved to ${uploaded.url})`;
243
+ }
244
+ catch (err) {
245
+ console.warn(`[minitool-e2e] 截图上传失败,降级为纯 base64:${err instanceof Error ? err.message : String(err)}`);
246
+ }
247
+ }
248
+ return result;
249
+ }
250
+ // ── e2e 通用能力(实现待补,签名已定) ──────────────────────────────────────────
251
+ //
252
+ // 与上面那批 game_* 的区别:**不经 `__GAME_TEST__` 桥**,所以任何产物都成立,
253
+ // 不只游戏。将来非游戏形态(spa_vue / fullstack_node)的验证主要靠这几个。
254
+ /**
255
+ * 读小工具发出的网络请求与响应。数据源已探针实测过,但**不是**模拟器的 `network` 事件 ——
256
+ * 那个事件声明了、转发了,却**没有任何地方 emit**,且 payload 里没有 body。
257
+ * 真正能拿到完整请求/响应的是 **bridge 协议日志**:guest 走 `xhs.miniTool.request` 时
258
+ * bridge 会把 invoke/callback 打进 console,用 `msg.args()` + `jsonValue()` 反序列化
259
+ * (console 文本里只是 "Object",拿不到)。
260
+ */
261
+ network(_query = {}) {
262
+ return Promise.resolve(notImplemented("e2e_network", "网络请求观测尚未实现,返回结果不可作为判定依据。", [
263
+ "需要在宿主页侧常驻收集 bridge 协议日志(page.on('console') + msg.args() 反序列化)",
264
+ "同源相对路径的 fetch 由 Service Worker 从内存返回,不经网络层,需另走 SW 侧统计",
265
+ ]));
266
+ }
267
+ /**
268
+ * 读当前渲染出的 DOM 摘要:白屏/是否渲染出内容不靠截图也能判。元素描述与模拟器
269
+ * `inspector/selector.ts` 的 ElementSelection 同 schema(label/selector/tag/class/text/rect)。
270
+ */
271
+ async dom(query = {}) {
272
+ const frame = await this.guestFrame();
273
+ const domSummary = await frame.evaluate(({ selector, maxNodes }) => {
274
+ const doc = document;
275
+ const max = maxNodes ?? 50;
276
+ const escapeIdent = (v) => {
277
+ const css = window.CSS;
278
+ if (css && typeof css.escape === "function")
279
+ return css.escape(v);
280
+ return v.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
281
+ };
282
+ const isUniqueId = (el) => {
283
+ if (!el.id)
284
+ return false;
285
+ try {
286
+ return doc.querySelectorAll(`#${escapeIdent(el.id)}`).length === 1;
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ };
292
+ const computeSelector = (node) => {
293
+ const parts = [];
294
+ let el = node;
295
+ while (el && el.nodeType === 1 && el !== doc.documentElement) {
296
+ if (isUniqueId(el)) {
297
+ parts.unshift(`#${escapeIdent(el.id)}`);
298
+ break;
299
+ }
300
+ let part = el.tagName.toLowerCase();
301
+ const parent = el.parentElement;
302
+ if (parent) {
303
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
304
+ if (sameTag.length > 1)
305
+ part += `:nth-of-type(${sameTag.indexOf(el) + 1})`;
306
+ }
307
+ parts.unshift(part);
308
+ el = el.parentElement;
309
+ }
310
+ return parts.join(" > ");
311
+ };
312
+ const makeLabel = (el) => {
313
+ const tag = el.tagName.toLowerCase();
314
+ const identity = el.id
315
+ ? `#${el.id}`
316
+ : el.classList.length > 0
317
+ ? `.${el.classList.item(0)}`
318
+ : "";
319
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 18);
320
+ return text ? `${tag}${identity} · ${text}` : `${tag}${identity}`;
321
+ };
322
+ const describe = (el) => {
323
+ const r = el.getBoundingClientRect();
324
+ const attributes = {};
325
+ for (const attr of Array.from(el.attributes))
326
+ attributes[attr.name] = attr.value;
327
+ return {
328
+ label: makeLabel(el),
329
+ selector: computeSelector(el),
330
+ tagName: el.tagName.toLowerCase(),
331
+ id: el.id || null,
332
+ classList: Array.from(el.classList),
333
+ text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 200),
334
+ attributes,
335
+ rect: { x: r.left, y: r.top, width: r.width, height: r.height },
336
+ };
337
+ };
338
+ const matches = selector ? Array.from(doc.querySelectorAll(selector)) : [];
339
+ const source = selector ? matches : Array.from(doc.body?.querySelectorAll("*") ?? []);
340
+ const sampled = source.slice(0, max).map(describe);
341
+ return {
342
+ viewport: { width: window.innerWidth, height: window.innerHeight },
343
+ url: location.href,
344
+ title: doc.title,
345
+ selector: selector ?? null,
346
+ matchCount: selector ? matches.length : null,
347
+ body: {
348
+ textLength: doc.body?.innerText.length ?? 0,
349
+ elementCount: doc.body?.querySelectorAll("*").length ?? 0,
350
+ },
351
+ sampledNodes: sampled,
352
+ truncated: source.length > max,
353
+ };
354
+ }, { selector: query.selector, maxNodes: query.maxNodes });
355
+ const sceneTree = await this.readSceneTree();
356
+ return { ...domSummary, sceneTree };
357
+ }
358
+ /**
359
+ * 通用交互适配:手势和 reload 只调用模拟器公开能力,文本输入才直接操作 guest DOM。
360
+ * 坐标空间 = 应用逻辑 CSS px(由 simulator 的 GestureHostBus 统一换算)。
361
+ */
362
+ async interact(input) {
363
+ if (input.action === "reload") {
364
+ const result = await this.page.evaluate(async () => {
365
+ const simulator = globalThis.__MINITOOL_E2E__?.simulator;
366
+ if (!simulator?.reload)
367
+ throw new Error("[minitool-e2e] 模拟器 reload 能力未就绪");
368
+ await simulator.reload();
369
+ return { action: "reload" };
370
+ });
371
+ return { ...result, via: "simulator" };
372
+ }
373
+ const gestureActions = new Set(["tap", "doubleTap", "longPress", "scroll", "swipe"]);
374
+ if (gestureActions.has(input.action)) {
375
+ if (input.action === "scroll" &&
376
+ !((Number.isFinite(input.deltaX) && input.deltaX !== 0) ||
377
+ (Number.isFinite(input.deltaY) && input.deltaY !== 0))) {
378
+ throw new Error("[minitool-e2e] scroll 需要非零的 deltaX 或 deltaY");
379
+ }
380
+ if (input.action === "swipe" &&
381
+ (typeof input.toX !== "number" || typeof input.toY !== "number")) {
382
+ throw new Error("[minitool-e2e] swipe 需要终点 toX 与 toY");
383
+ }
384
+ let point = { x: input.x, y: input.y };
385
+ if (input.selector) {
386
+ const frame = await this.guestFrame();
387
+ point = await frame.evaluate((selector) => {
388
+ const target = document.querySelector(selector);
389
+ if (!target)
390
+ throw new Error(`[minitool-e2e] selector「${selector}」未命中任何元素`);
391
+ const rect = target.getBoundingClientRect();
392
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
393
+ }, input.selector);
394
+ }
395
+ else if (input.action === "scroll" &&
396
+ typeof input.x === "undefined" &&
397
+ typeof input.y === "undefined") {
398
+ // 保持旧协议兼容:scroll 只带 deltaX/deltaY 时,以 guest viewport 中心作为 wheel 落点。
399
+ // GestureHostBus 需要坐标,但调用方不应该为了滚动被迫感知模拟器坐标系。
400
+ const frame = await this.guestFrame();
401
+ point = await frame.evaluate(() => ({
402
+ x: Math.max(0, window.innerWidth / 2),
403
+ y: Math.max(0, window.innerHeight / 2),
404
+ }));
405
+ }
406
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
407
+ throw new Error("[minitool-e2e] 手势需要 selector,或同时给 x 与 y(应用逻辑 CSS px)");
408
+ }
409
+ const result = await this.page.evaluate(async (gesture) => {
410
+ const simulator = globalThis.__MINITOOL_E2E__?.simulator;
411
+ const dispatch = simulator?.gesture?.dispatch;
412
+ if (typeof dispatch !== "function")
413
+ throw new Error("[minitool-e2e] 模拟器 gesture.dispatch 能力未就绪");
414
+ return dispatch.call(simulator.gesture, gesture);
415
+ }, {
416
+ action: input.action,
417
+ x: point.x,
418
+ y: point.y,
419
+ toX: input.toX,
420
+ toY: input.toY,
421
+ deltaX: input.deltaX,
422
+ deltaY: input.deltaY,
423
+ duration: input.duration,
424
+ });
425
+ return { ...result, via: "simulator.gesture" };
426
+ }
427
+ // 只有文本输入仍需直接操作 guest DOM(模拟器无对应语义能力);tap/doubleTap/longPress/scroll/swipe
428
+ // 全部走上面的 simulator.gesture.dispatch,不在 e2e 侧合成指针事件。
429
+ if (input.action !== "input") {
430
+ throw new Error(`[minitool-e2e] interact:未知 action「${input.action}」`);
431
+ }
432
+ const frame = await this.guestFrame();
433
+ if (!input.selector)
434
+ throw new Error("[minitool-e2e] interact:input 需要 selector 指向可输入元素");
435
+ if (typeof input.text !== "string")
436
+ throw new Error("[minitool-e2e] interact:input 需要 text");
437
+ return frame.evaluate(({ selector, text }) => {
438
+ const target = document.querySelector(selector);
439
+ if (!target)
440
+ throw new Error(`[minitool-e2e] interact:selector「${selector}」未命中任何元素`);
441
+ target.focus?.();
442
+ const el = target;
443
+ const proto = el.tagName === "TEXTAREA"
444
+ ? window.HTMLTextAreaElement.prototype
445
+ : window.HTMLInputElement.prototype;
446
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
447
+ if (setter)
448
+ setter.call(el, text);
449
+ else
450
+ el.value = text;
451
+ el.dispatchEvent(new Event("input", { bubbles: true }));
452
+ el.dispatchEvent(new Event("change", { bubbles: true }));
453
+ return { action: "input", selector, text };
454
+ }, { selector: input.selector, text: input.text });
455
+ }
456
+ /**
457
+ * 运行时健康汇总:把 logs() 里攒下的事件按口径分类,返回显式计数 + 少量采样,不发空集合。
458
+ */
459
+ async health() {
460
+ const events = (await this.logs());
461
+ const counts = {
462
+ guestError: 0,
463
+ error: 0,
464
+ consoleError: 0,
465
+ consoleWarn: 0,
466
+ navigation: 0,
467
+ renderDone: 0,
468
+ };
469
+ const samples = {
470
+ guestError: [],
471
+ error: [],
472
+ consoleError: [],
473
+ };
474
+ const pick = (d) => {
475
+ if (typeof d === "string")
476
+ return d;
477
+ if (d && typeof d === "object") {
478
+ const o = d;
479
+ for (const k of ["message", "error", "reason", "detail"]) {
480
+ if (typeof o[k] === "string")
481
+ return o[k];
482
+ }
483
+ return "字段:" + Object.keys(o).slice(0, 6).join(",");
484
+ }
485
+ return null;
486
+ };
487
+ const push = (bucket, d, cap = 5) => {
488
+ const m = pick(d);
489
+ if (m && samples[bucket].length < cap)
490
+ samples[bucket].push(m);
491
+ };
492
+ for (const ev of events) {
493
+ switch (ev.name) {
494
+ case "guestError":
495
+ counts.guestError++;
496
+ push("guestError", ev.detail);
497
+ break;
498
+ case "error":
499
+ counts.error++;
500
+ push("error", ev.detail);
501
+ break;
502
+ case "console": {
503
+ const level = ev.detail?.level;
504
+ if (level === "error") {
505
+ counts.consoleError++;
506
+ push("consoleError", ev.detail);
507
+ }
508
+ else if (level === "warn") {
509
+ counts.consoleWarn++;
510
+ }
511
+ break;
512
+ }
513
+ case "navigation":
514
+ counts.navigation++;
515
+ break;
516
+ case "renderDone":
517
+ counts.renderDone++;
518
+ break;
519
+ }
520
+ }
521
+ const errorTotal = counts.guestError + counts.error + counts.consoleError;
522
+ return {
523
+ status: errorTotal > 0 ? "degraded" : "ok",
524
+ checkedEvents: events.length,
525
+ counts,
526
+ topErrors: samples,
527
+ };
528
+ }
529
+ /**
530
+ * 真实指针动作,未实现(前提见下面 throw 的文案)。探针已验明的坐标换算式:
531
+ * `canvasBox.origin + appCoord * (canvasBox.size / app.screen.size)`,其中
532
+ * `selectByRef` 返回的 `viewportRect` 已是宿主屏幕坐标 —— 但它有副作用(会打开选取层并盖
533
+ * 一层 z-index 极高的拦截元素),所以要等模拟器补一个无副作用的 `describeByRef`。
534
+ */
535
+ performPointerAction(_action) {
536
+ return Promise.reject(new Error("[minitool-e2e] 真实指针动作尚未实现(当前 act 走语义桥)。" +
537
+ "依赖项:miniapp-simulator 需提供无副作用的 describeByRef;" +
538
+ "且派发坐标事件前必须先 step(16) 渲染一帧,否则 Pixi 命中测试静默失效。"));
539
+ }
540
+ /** 定位 guest 应用 iframe(主 frame 之外的唯一子 frame)。 */
541
+ async guestFrame() {
542
+ const frames = this.page.frames();
543
+ const guest = frames.find((f) => f !== this.page.mainFrame());
544
+ if (!guest)
545
+ throw new Error("[minitool-e2e] 应用 iframe 尚未挂载(请先 e2e_open)");
546
+ return guest;
547
+ }
548
+ /**
549
+ * 读场景树(canvas / Pixi / Three 的 Scene Provider)。无副作用、只读;
550
+ * 产物没注册 provider 时返回空快照(DOM 形态就是如此)。
551
+ */
552
+ async readSceneTree() {
553
+ return this.page.evaluate(async () => {
554
+ const sim = globalThis.__MINITOOL_E2E__?.simulator;
555
+ try {
556
+ const tree = await sim?.inspector?.getSceneTree?.();
557
+ return tree ?? { version: 1, providers: [] };
558
+ }
559
+ catch {
560
+ return { version: 1, providers: [] };
561
+ }
562
+ });
563
+ }
564
+ async close() {
565
+ await this.browser.close().catch(() => undefined);
566
+ await this.host.close().catch(() => undefined);
567
+ return { closed: true };
568
+ }
569
+ invokeBridge(method, args = []) {
570
+ return this.page.evaluate(({ method, args }) => globalThis.__MINITOOL_E2E__.simulator.gamePlaytest[method](...args), { method, args });
571
+ }
572
+ }
573
+ exports.MinitoolE2ESession = MinitoolE2ESession;
574
+ /** 递归读产物目录,文本直读、二进制转 base64 标记。 */
575
+ function readDistDir(dir, prefix = "") {
576
+ if (!fs.existsSync(dir)) {
577
+ throw new Error(`[minitool-e2e] 产物目录不存在:${dir}`);
578
+ }
579
+ const out = {};
580
+ for (const name of fs.readdirSync(dir)) {
581
+ const file = path.join(dir, name);
582
+ if (fs.statSync(file).isDirectory()) {
583
+ Object.assign(out, readDistDir(file, `${prefix}${name}/`));
584
+ continue;
585
+ }
586
+ const ext = path.extname(name).toLowerCase();
587
+ out[`${prefix}${name}`] = BINARY_EXTENSIONS.has(ext)
588
+ ? { __binary: true, base64: fs.readFileSync(file).toString("base64") }
589
+ : fs.readFileSync(file, "utf8");
590
+ }
591
+ return out;
592
+ }
package/lib/upload.js ADDED
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_UPLOAD_TIMEOUT_MS = void 0;
4
+ exports.uploadScreenshot = uploadScreenshot;
5
+ /**
6
+ * 上传请求的硬超时。
7
+ *
8
+ * 没有它,宿主上传代理无响应 / 连接半开 / 服务端持续不返回时 `fetch` 会一直 pending,
9
+ * `session.screenshot()` 就永远不返回 —— 调用方那句「上传失败降级为纯 base64」也就永远不生效,
10
+ * e2e 判读会被一个卡死的上传拖死。
11
+ *
12
+ * 取值远高于实测耗时(SIT 真上传 ~0.5s),只兜「卡死」不兜「慢」。
13
+ */
14
+ exports.DEFAULT_UPLOAD_TIMEOUT_MS = 20_000;
15
+ async function uploadScreenshot(base64, config) {
16
+ const endpoint = `${config.base.replace(/\/+$/, "")}/e2e/screenshot`;
17
+ const res = await fetch(endpoint, {
18
+ method: "POST",
19
+ headers: {
20
+ "content-type": "application/json",
21
+ ...(config.token ? { "x-e2e-upload-token": config.token } : {}),
22
+ },
23
+ body: JSON.stringify({ taskId: config.taskId, base64 }),
24
+ // 必须有限超时:宿主不响应时否则会一直 pending,把 screenshot() 挂死(见 DEFAULT_UPLOAD_TIMEOUT_MS)。
25
+ // signal 会一直挂在响应上,所以后面 res.json() 卡住同样会被中断。
26
+ signal: AbortSignal.timeout(config.timeoutMs ?? exports.DEFAULT_UPLOAD_TIMEOUT_MS),
27
+ });
28
+ if (!res.ok) {
29
+ throw new Error(`[minitool-e2e] 截图上传失败:HTTP ${res.status}`);
30
+ }
31
+ // 宿主信封 `{ code, data, message/msg }`,成功 code === 0、数据在 data 里。
32
+ const body = (await res.json());
33
+ if (typeof body.code === "number" && body.code !== 0) {
34
+ throw new Error(`[minitool-e2e] 截图上传被拒:${body.message || body.msg || `code=${body.code}`}`);
35
+ }
36
+ const url = body.data?.url;
37
+ if (typeof url !== "string" || !url) {
38
+ throw new Error(`[minitool-e2e] 截图上传未拿到 URL:${JSON.stringify(body)}`);
39
+ }
40
+ return { url, fileKey: typeof body.data?.fileKey === "string" ? body.data.fileKey : "" };
41
+ }
@@ -0,0 +1,44 @@
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
+ * 上传请求必须**有限超时**。
8
+ *
9
+ * 宿主上传代理无响应 / 连接半开时,没有超时的 `fetch` 会一直 pending,`session.screenshot()`
10
+ * 就永远不返回 —— 调用方那句「上传失败降级为纯 base64」也就永远不生效,一次卡死的上传能把
11
+ * e2e 判读整个拖死。这里用一个只收请求、永不响应的本地服务端把这条兜底钉住。
12
+ */
13
+ const strict_1 = __importDefault(require("node:assert/strict"));
14
+ const node_http_1 = __importDefault(require("node:http"));
15
+ const node_test_1 = __importDefault(require("node:test"));
16
+ const upload_1 = require("./upload");
17
+ /** 只建立连接、永不回包的服务端(模拟半开 / 无响应的宿主上传代理)。 */
18
+ async function startSilentServer() {
19
+ const server = node_http_1.default.createServer(() => {
20
+ // 故意不 res.end():让请求悬在那里
21
+ });
22
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
23
+ const address = server.address();
24
+ const port = typeof address === "object" && address ? address.port : 0;
25
+ return {
26
+ base: `http://127.0.0.1:${port}`,
27
+ close: () => new Promise((resolve) => {
28
+ server.closeAllConnections?.();
29
+ server.close(() => resolve());
30
+ }),
31
+ };
32
+ }
33
+ (0, node_test_1.default)("上传:宿主不响应时必须按 timeoutMs 失败,而不是一直挂着", async () => {
34
+ const { base, close } = await startSilentServer();
35
+ try {
36
+ const startedAt = Date.now();
37
+ await strict_1.default.rejects(() => (0, upload_1.uploadScreenshot)("iVBORw0KGgo=", { base, taskId: "t-upload-timeout", timeoutMs: 200 }), /abort|timeout/i);
38
+ // 真正的判据:是「到点失败」而不是「恰好很快失败」
39
+ strict_1.default.ok(Date.now() - startedAt >= 200, "早于 timeoutMs 就失败了,用例没有测到超时路径");
40
+ }
41
+ finally {
42
+ await close();
43
+ }
44
+ });