// 浏览器端冒烟:在真实无头 Edge 里验证 // ① 分发能正常渲染(spine canvas 出现、无异常) // ② WE 模拟器确实生效(window.__weSim、面板、替身横幅) // ③ 属性下发链路真的打通(经模拟器改 preset → 背景与骨架跟着换) // ④ ?propsAt=dom 这条"load 之前下发"的竞态路径不会把配置丢掉 // ⑤ 单档分发(属性表里没有 preset/bgm)不白屏 // // 判定只用页面**真实可观察**的量:canvas 是否出现、body 背景、播放器 config。 // 不要用 window.__player —— 那是 tools/serve.mjs 的对拍驱动注入的探针,不是产品行为。 // // 用法:node tools/checks/smoke-sim.mts [base] // 前提:调试服在跑(node tools/dev.ts --no-build)。 // 不需要 `--with-sim`:模拟器由调试服自己从 build/scripts/wallpaper-engine.js 提供 // (它默认不进发布产物,见 ADR 0006 §1 与 dev.ts 的路由说明)。 import { withPage, sleep } from "../cdp.mjs"; const BASE = process.argv[2] ?? "http://127.0.0.1:5173"; let failures = 0; const check = (ok: unknown, label: string, detail?: unknown) => { if (!ok) failures++; console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`); }; const RELEASE = "collection-all"; /** 就绪判定:spine-container 里出现 canvas(= SpinePlayer 真的创建成功)。 */ async function waitCanvas(evaluate, tag) { const deadline = Date.now() + 40000; while (Date.now() < deadline) { const state = await evaluate(`(function(){ var c = document.getElementById("spine-container"); return { canvas: c ? c.querySelectorAll("canvas").length : 0, children: c ? c.children.length : -1 }; })()`); if (state && state.canvas > 0) return { ok: true, ...state }; await sleep(200); } return { ok: false, canvas: 0, error: `${tag} 超时` }; } function assertClean(tag, exceptions, consoleLines) { check(exceptions.length === 0, `${tag} 0 未捕获异常`, exceptions.slice(0, 2).join(" | ") || "无"); const noisy = consoleLines.filter((l) => /error|uncaught/i.test(l)); check(noisy.length === 0, `${tag} 0 控制台报错`, noisy.slice(0, 3).join(" | ") || "干净"); } // ── ① 正常路径 ── console.log("=== ① 正常路径:模拟器生效 + 属性下发 ==="); const normal = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => { await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` }); const ready = await waitCanvas(evaluate, "normal"); await sleep(600); const sim = await evaluate(`(function(){ var w = window.__weSim; if (!w) return null; return { isSimulated: w.__isSimulated, substituted: w.substituted.length, release: w.release, deliveries: w.deliveries.map(function(d){ return d.type; }), panel: !!document.getElementById("wesim"), panels: document.querySelectorAll("#wesim").length, edges: document.querySelectorAll("#wesim-edge").length, banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent : null, hasEngineGlobal: typeof window.wallpaperEngine, hasRegisterListener: typeof window.wallpaperRegisterListener }; })()`); const view = await evaluate(`(function(){ return { bg: document.body.style.backgroundImage, canvas: document.querySelectorAll("canvas").length, canvasSize: (function(){ var c = document.querySelector("canvas"); return c ? c.clientWidth + "x" + c.clientHeight : null; })() }; })()`); return { ready, sim, view, consoleLines, exceptions }; }); check(normal.ready.ok, "Spine 画布已创建(渲染就绪)", normal.ready.error ?? `canvas=${normal.ready.canvas}`); check(normal.sim?.isSimulated === true, "window.__weSim.__isSimulated = true"); check((normal.sim?.substituted ?? 0) > 0, "替身清单非空(做不到的 API 被显式标记)", `${normal.sim?.substituted} 项`); check(normal.sim?.panel === true, "模拟器面板已挂载(#wesim)"); // 回归断言:`--with-sim` 构建的分发自带驱动,调试服又会注入一次。 // 没有幂等闸的话模拟器会被 mount 两遍,页面上出现两个面板。 check(normal.sim?.panels === 1, "面板恰好一个(驱动幂等,没有被注入两次)", `${normal.sim?.panels} 个`); check(normal.sim?.edges === 1, "右边缘热区恰好一个", `${normal.sim?.edges} 个`); check(/模拟环境/.test(normal.sim?.banner ?? ""), "面板有常驻的「模拟环境」横幅", normal.sim?.banner?.trim()); check(normal.sim?.deliveries?.includes("applyUserProperties"), "已下发 applyUserProperties", normal.sim?.deliveries?.join(", ")); check(normal.sim?.deliveries?.includes("applyGeneralProperties"), "已下发 applyGeneralProperties", normal.sim?.deliveries?.join(", ")); check(/ava\.jpg/.test(normal.view.bg ?? ""), "默认预设(xilian)的背景已应用", normal.view.bg?.slice(-32)); // 严格还原 WE:这两个 API 在真实 WE 里都不存在,模拟器绝不能凭空提供 check(normal.sim?.hasEngineGlobal === "undefined", "未凭空暴露 window.wallpaperEngine(WE 没有这个全局)", normal.sim?.hasEngineGlobal); check(normal.sim?.hasRegisterListener === "undefined", "未暴露 window.wallpaperRegisterListener(WE 没有这个 API)", normal.sim?.hasRegisterListener); assertClean("正常路径", normal.exceptions, normal.consoleLines); // ── ② 属性下发真的改变了画面 ── console.log("\n=== ② 经模拟器改 preset → 背景与骨架跟着换 ==="); const switched = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => { await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` }); await waitCanvas(evaluate, "switch"); await sleep(600); const bgBefore = await evaluate("document.body.style.backgroundImage"); // setProperties 收的是**值**,不是 WE 的 { value } 信封(信封由模拟器自己套) await evaluate('window.__weSim.setProperties({ preset: "kv37" })'); await sleep(2000); const after = await evaluate(`(function(){ return { bg: document.body.style.backgroundImage, canvas: document.querySelectorAll("canvas").length, // 骨架换了就意味着播放器被重建过:容器里应当只有一张新 canvas children: document.getElementById("spine-container").children.length }; })()`); return { bgBefore, ...after, exceptions }; }); check(switched.bgBefore !== switched.bg, "背景图已更换", `${switched.bgBefore?.slice(-22)} → ${switched.bg?.slice(-22)}`); check(/kv37_xilian/.test(switched.bg ?? ""), "换成了 kv37 的背景", switched.bg?.slice(-30)); check(switched.canvas === 1, "换骨架后只剩一张 canvas(旧播放器已 dispose)", `canvas=${switched.canvas}`); check(switched.children === 1, "容器里没有残留节点(dispose 生效)", `children=${switched.children}`); check(switched.exceptions.length === 0, "切换过程 0 异常", switched.exceptions.slice(0, 2).join(" | ") || "无"); // ── ③ load 之前的属性下发 ── console.log("\n=== ③ ?__propsAt=dom:load 之前下发不丢配置 ==="); const race = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => { const props = encodeURIComponent(JSON.stringify({ preset: "kv37" })); await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/?__propsAt=dom&__props=${props}` }); const ready = await waitCanvas(evaluate, "race"); await sleep(800); const state = await evaluate(`(function(){ return { bg: document.body.style.backgroundImage, canvas: document.querySelectorAll("canvas").length, propsAt: window.__weSim ? window.__weSim.deliveries[0] && window.__weSim.deliveries[0].at : null }; })()`); return { ready, ...state, exceptions }; }); check(race.ready.ok, "播放器仍被创建(配置没被丢弃)", race.ready.error ?? "ok"); check(/kv37_xilian/.test(race.bg ?? ""), "load 前下发的 preset 已生效", race.bg?.slice(-30)); check(race.canvas === 1, "只有一张 canvas", `canvas=${race.canvas}`); check(race.exceptions.length === 0, "0 异常", race.exceptions.slice(0, 2).join(" | ") || "无"); // ── ④ 单档分发 ── console.log("\n=== ④ 单档分发(属性表里没有 preset/bgm) ==="); const single = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => { await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` }); const ready = await waitCanvas(evaluate, "single"); await sleep(800); const bg = await evaluate("document.body.style.backgroundImage"); const props = await evaluate("JSON.stringify(Object.keys(window.__weProperties))"); return { ready, bg, props, consoleLines, exceptions }; }); check(single.ready.ok, "单档分发渲染就绪(未白屏)", single.ready.error ?? "ok"); check(/kv37_xilian/.test(single.bg ?? ""), "单档分发背景已应用", single.bg?.slice(-30)); check(!/"preset"/.test(single.props ?? ""), "单档分发确实没有 preset 属性", single.props); check(!/"bgm"/.test(single.props ?? ""), "单档分发确实没有 bgm 属性", single.props); assertClean("单档分发", single.exceptions, single.consoleLines); console.log(failures === 0 ? "\n模拟器浏览器冒烟通过" : `\n模拟器浏览器冒烟失败:${failures} 处`); process.exit(failures === 0 ? 0 : 1);