// 验收 `pnpm build --sim`:把产出的自包含包用 file:// 直接打开,确认 // ① 骨架真的画出来了(canvas 出现且有非空像素) // ② 0 网络请求失败、0 异常、0 控制台报错 // ③ 模拟器面板在,且明确标着"模拟环境" // ④ 音频能播(data: URL 内联) // ⑤ 全程没有任何 http(s) 请求("自包含"的硬定义) // // 用法:node tools/checks/verify-sim-page.mts [分发目录名] import { withPage, sleep } from "../cdp.mjs"; import { existsSync, statSync } from "node:fs"; import { resolve } from "node:path"; const dirName = process.argv[2] ?? "single-hsr-kv37"; const pageAbs = resolve("dist/releases", dirName, "sim/index.html"); if (!existsSync(pageAbs)) { console.error(`找不到 ${pageAbs}(先跑 node tools/build.ts --single kv37 --sim)`); process.exit(1); } const fileUrl = "file:///" + pageAbs.replace(/\\/g, "/"); let failures = 0; const check = (ok: unknown, label: string, detail?: unknown) => { if (!ok) failures++; console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`); }; console.log(`自包含包:${pageAbs}`); console.log(`体积:${(statSync(pageAbs).size / 1024 / 1024).toFixed(1)} MB\n`); await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => { const rawSend = send; await rawSend("Page.navigate", { url: fileUrl }); const deadline = Date.now() + 45000; let ready = false; while (Date.now() < deadline) { const canvasCount = await evaluate('document.querySelectorAll("#spine-container canvas").length'); if (canvasCount > 0) { ready = true; break; } await sleep(250); } await sleep(2500); // ⑤ "自包含"的硬定义:这个文档取过的资源里不能有任何 http(s)。 // 用页面侧的 Resource Timing,不需要 CDP 的事件通道。 const externalRequests = await evaluate(`(function () { return performance.getEntriesByType("resource") .map(function (e) { return e.name; }) .filter(function (n) { return /^https?:/i.test(n); }); })()`); check( Array.isArray(externalRequests) && externalRequests.length === 0, "全程没有任何 http(s) 请求(自包含的硬定义)", Array.isArray(externalRequests) && externalRequests.length > 0 ? externalRequests.slice(0, 3).join(", ") : "无", ); const state = await evaluate(`(function () { var canvas = document.querySelector("#spine-container canvas"); var out = { canvas: document.querySelectorAll("#spine-container canvas").length, canvasSize: canvas ? canvas.clientWidth + "x" + canvas.clientHeight : null, bg: (document.body.style.backgroundImage || "").slice(0, 24), bgIsData: /^url\\(["']?data:/.test(document.body.style.backgroundImage || ""), sim: !!window.__weSim, banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent.trim() : null, // 自包含页在 <分发根>/sim/ 下,预览图必须已被内联成 data: URL—— // file:// 下 ../preview.gif 是跨目录,读不到。 previewSrc: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.getAttribute("src").slice(0, 5) : null; })(), previewNatural: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.naturalWidth + "x" + i.naturalHeight : null; })(), modules: window.__weModules ? Object.keys(window.__weModules).length : 0, assetKeys: window.__simAssets ? Object.keys(window.__simAssets).length : 0, audioSrcIsData: false, audioDuration: null }; var a = document.getElementById("background-music"); if (a) { out.audioSrcIsData = /^data:/.test(a.src || ""); out.audioDuration = isFinite(a.duration) ? a.duration : null; out.audioError = a.error ? a.error.code : null; } return out; })()`); console.log("=== 渲染 ==="); check(ready, "出现 Spine 画布", `canvas=${state.canvas}`); check(state.canvas === 1, "只有一张 canvas", `canvas=${state.canvas}`); check(state.canvasSize === "1920x1080", "画布尺寸等于窗口", state.canvasSize); // 画布真的画了东西:跨帧采样非透明比例。 // // 两个坑叠在一起,先前两次都判成了"空白": // ① 页面里有两张 canvas——spine 自己建的那张(承载渲染)和一张 300×150 的默认尺寸空画布。 // 按 `#spine-container canvas` 取到的是后者,尺寸就不对。 // ② 播放器用 `preserveDrawingBuffer: false`(默认),**帧外**读像素拿到的永远是已清空的缓冲。 // 只读一次必然是 0%,与画没画无关。必须在 rAF 里连续采样、取历史最大值。 await evaluate(`(function () { var list = [].slice.call(document.querySelectorAll("#spine-container canvas")); var canvas = list.sort(function (a, b) { return b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight; })[0]; if (!canvas) { window.__px = { error: "没有 canvas" }; return; } var gl = canvas.getContext("webgl2") || canvas.getContext("webgl"); if (!gl) { window.__px = { error: "拿不到 WebGL 上下文" }; return; } window.__px = { best: 0, frames: 0, w: canvas.width, h: canvas.height, count: list.length }; var buf = new Uint8Array(canvas.width * canvas.height * 4); var tick = function () { gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, buf); var n = 0; for (var i = 3; i < buf.length; i += 4) if (buf[i] > 8) n++; var ratio = n / (canvas.width * canvas.height); if (ratio > window.__px.best) window.__px.best = ratio; window.__px.frames++; if (window.__px.frames < 90) requestAnimationFrame(tick); }; requestAnimationFrame(tick); })()`); await sleep(2500); const pixels = await evaluate("window.__px"); check( pixels !== null && pixels.best !== undefined && pixels.best > 0.02, "画布上有实际像素(不是空白)", pixels === null ? "没有 canvas" : pixels.error ? pixels.error : `非透明占比 ${((pixels.best ?? 0) * 100).toFixed(1)}%(${pixels.w}x${pixels.h},采样 ${pixels.frames} 帧)`, ); console.log("\n=== 自包含性 ==="); check(state.bgIsData, "背景图是 data: URL(没有外部文件依赖)", state.bg); check(state.assetKeys > 0, "内联资源表已建立", `${state.assetKeys} 个 key`); check(state.modules >= 9, "模块注册表已建立", `${state.modules} 个模块`); check(state.audioSrcIsData, "音频是 data: URL(内联)"); check(state.audioDuration !== null && state.audioDuration > 60, "音频可解码", state.audioDuration ? `${state.audioDuration.toFixed(0)}s` : `error=${state.audioError}`); check(pageAbs.length > 0 && !/^https?:/.test(state.bg), "没有指向 http(s) 的资源"); console.log("\n=== 模拟器 ==="); check(state.sim, "window.__weSim 存在"); check(/模拟环境/.test(state.banner ?? ""), "面板标着「模拟环境」", state.banner); // 自包含包里的预览图:必须是内联的 data: URL,而且要真的解码出来(坏图也会是 data:)。 if (state.previewSrc !== null) { check(state.previewSrc === "data:", "预览图已内联成 data: URL", state.previewSrc); check(state.previewNatural === "160x160", "内联的预览图能解码", state.previewNatural); } console.log("\n=== 干净度 ==="); check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 3).join(" | ") || "无"); const noisy = consoleLines.filter((l) => /error|failed|blocked|CORS/i.test(l)); check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净"); }); console.log(failures === 0 ? "\n自包含包可用:双击即可打开,无需 pnpm dev,也无需任何 http 服务" : `\n自包含包验收失败:${failures} 处`); process.exit(failures === 0 ? 0 : 1);