From 399f99edd780f7c790b3dacea3005f94cf1d1cc7 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sat, 19 Sep 2026 19:13:57 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E9=9B=B6=E4=BE=9D=E8=B5=96=E5=AF=B9?= =?UTF-8?q?=E6=8B=8D=E6=B5=8B=E8=AF=95=E5=8F=B0=EF=BC=88=E5=8F=AF=E5=9C=A8?= =?UTF-8?q?=E4=B8=8D=E6=94=B9=20dist/=20=E7=9A=84=E5=89=8D=E6=8F=90?= =?UTF-8?q?=E4=B8=8B=E5=A4=8D=E7=8E=B0=20WE=20=E8=A1=8C=E4=B8=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/serve.mjs 本地静态服务 + 测试驱动注入(模拟 WE 下发用户属性、冻结动画相位、藏 canvas 取参考帧) tools/cdp.mjs 最小 CDP 客户端(真实时间等待;--screenshot 的虚拟时间会在骨骼加载完成前就耗尽) tools/shot.mjs 截图/取状态,就绪判据可传入 tools/diff.mjs ffmpeg 差分:PSNR / SSIM / 平均亮度差 tools/capture.mjs 对拍矩阵:壁纸 × 分辨率 × (正常, 藏 canvas) 实测:xilian 1920x1080 spine 层贡献 mean|diff|=26.1/255,kv37 为 10.2/255, 同配置两次截图噪声底 0.68/255。 --- .gitattributes | 1 + .gitignore | 6 ++ tools/capture.mjs | 70 ++++++++++++++++++ tools/cdp.mjs | 126 ++++++++++++++++++++++++++++++++ tools/diff.mjs | 49 +++++++++++++ tools/serve.mjs | 181 ++++++++++++++++++++++++++++++++++++++++++++++ tools/shot.mjs | 66 +++++++++++++++++ 7 files changed, 499 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 tools/capture.mjs create mode 100644 tools/cdp.mjs create mode 100644 tools/diff.mjs create mode 100644 tools/serve.mjs create mode 100644 tools/shot.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fa1385d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3c2908 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# 对拍与开发产物的输出目录(可随时重新生成,不入库) +tools/shots/ +tools/.cache/ + +# 杂项 +*.log diff --git a/tools/capture.mjs b/tools/capture.mjs new file mode 100644 index 0000000..f7e09a2 --- /dev/null +++ b/tools/capture.mjs @@ -0,0 +1,70 @@ +// 对拍矩阵:每套壁纸 × 每种分辨率 各出两张 PNG —— 正常渲染 + 藏起 canvas 的"只有背景"参考帧, +// 然后给出"spine 层占了多少像素"的数值。 +// 用法:node tools/capture.mjs [--presets xilian,kv37] [--sizes 3440x1440,1920x1080,1080x1920] +// [--base http://127.0.0.1:8181] [--propsAt load|dom] [--no-ref] + +import { spawn } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +const argv = process.argv.slice(2); +const tag = argv[0]; +if (!tag || tag.startsWith("--")) { + console.error("usage: node tools/capture.mjs [--presets a,b] [--sizes WxH,...] [--base url] [--propsAt dom] [--no-ref]"); + process.exit(2); +} +const flag = (name, dflt) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 ? argv[i + 1] : dflt; +}; +const presets = flag("presets", "xilian,kv37").split(",").filter(Boolean); +const sizes = flag("sizes", "3440x1440,1920x1080,1080x1920").split(",").filter(Boolean); +const base = flag("base", "http://127.0.0.1:8181"); +const propsAt = flag("propsAt", "load"); +const wantRef = !argv.includes("--no-ref"); +const wait = flag("wait", "25000"); + +const outDir = resolve("tools/shots", tag); +await mkdir(outDir, { recursive: true }); + +function run(cmd, args) { + return new Promise((resolve) => { + const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + p.stdout.on("data", (d) => (out += d)); + p.stderr.on("data", (d) => (out += d)); + p.on("close", (code) => resolve({ code, out })); + }); +} + +const url = (extra) => `${base}/index.html?${extra}`; +const props = (preset) => `__props=${encodeURIComponent(JSON.stringify({ preset }))}`; + +const rows = []; +for (const preset of presets) { + for (const size of sizes) { + const [w, h] = size.split("x"); + const main = resolve(outDir, `${preset}-${size}.png`); + const q = `${props(preset)}&__freeze=1${propsAt === "dom" ? "&__propsAt=dom" : ""}`; + const a = await run(process.execPath, ["tools/shot.mjs", main, url(q), w, h, wait]); + if (a.code !== 0) { + console.log(`FAIL ${preset} ${size}: ${a.out.trim().split("\n").pop()}`); + continue; + } + let refLine = ""; + if (wantRef) { + const ref = resolve(outDir, `${preset}-${size}.ref.png`); + const b = await run(process.execPath, ["tools/shot.mjs", ref, url(q + "&__hide=canvas"), w, h, wait]); + if (b.code === 0) { + const d = await run(process.execPath, ["tools/diff.mjs", ref, main]); + refLine = d.out.trim(); + } else { + refLine = "ref FAILED"; + } + } + rows.push(`${preset.padEnd(7)} ${size.padEnd(11)} spine 层贡献: ${refLine}`); + console.log(rows[rows.length - 1]); + } +} + +console.log(`\n输出目录: tools/shots/${tag}/`); diff --git a/tools/cdp.mjs b/tools/cdp.mjs new file mode 100644 index 0000000..11cfc07 --- /dev/null +++ b/tools/cdp.mjs @@ -0,0 +1,126 @@ +// 最小 CDP 客户端:用真实时间等待页面就绪,再截图 / 取状态。 +// 为什么不用 msedge --screenshot:那个走的是"虚拟时间",而贴图解码与 WebGL 初始化吃的是真实 CPU 时间, +// 虚拟时间预算会在骨骼真正加载完之前就耗尽,截出来的是还没画内容的帧(这个坑已经踩过一次)。 + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +const EDGE = [ + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", +].find((p) => existsSync(p)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export async function withPage(opts, fn) { + const { width = 1920, height = 1080, profile = "tools/.cache/edge-cdp", extraArgs = [] } = opts; + if (!EDGE) throw new Error("找不到 msedge.exe"); + await mkdir(resolve(profile), { recursive: true }); + + const child = spawn( + EDGE, + [ + "--headless=new", + "--remote-debugging-port=0", + `--user-data-dir=${resolve(profile)}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--hide-scrollbars", + "--enable-unsafe-swiftshader", + "--autoplay-policy=no-user-gesture-required", + "--mute-audio", + `--window-size=${width},${height}`, + ...extraArgs, + "about:blank", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let stderr = ""; + const wsUrl = await new Promise((res, rej) => { + const timer = setTimeout(() => rej(new Error("等待 DevTools 端点超时")), 20000); + child.stderr.on("data", (d) => { + stderr += d; + const m = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (m) { + clearTimeout(timer); + res(m[1]); + } + }); + child.on("close", () => { + clearTimeout(timer); + rej(new Error("Edge 提前退出:\n" + stderr.slice(-800))); + }); + }); + + const port = new URL(wsUrl).port; + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = list.find((t) => t.type === "page"); + if (!page) throw new Error("没有 page target"); + + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = (e) => rej(new Error("CDP WebSocket 连接失败: " + (e?.message ?? ""))); + }); + + let nextId = 1; + const pending = new Map(); + const consoleLines = []; + const exceptions = []; + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? rej(new Error(`${msg.error.message} (${JSON.stringify(msg.error.data ?? "")})`)) : res(msg.result); + return; + } + if (msg.method === "Runtime.consoleAPICalled") { + const text = (msg.params.args ?? []).map((a) => a.value ?? a.description ?? a.type).join(" "); + consoleLines.push(`${msg.params.type}: ${text}`); + } + if (msg.method === "Runtime.exceptionThrown") { + const d = msg.params.exceptionDetails; + exceptions.push(d.exception?.description ?? d.text); + } + if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") { + consoleLines.push(`log-error: ${msg.params.entry.text}`); + } + }; + + const send = (method, params = {}) => + new Promise((res, rej) => { + const id = nextId++; + pending.set(id, { res, rej }); + ws.send(JSON.stringify({ id, method, params })); + }); + + const evaluate = async (expression) => { + const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "evaluate 失败"); + return r.result?.value; + }; + + try { + await send("Page.enable"); + await send("Runtime.enable"); + await send("Log.enable"); + await send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile: false, + }); + return await fn({ send, evaluate, consoleLines, exceptions, stderr: () => stderr }); + } finally { + try { ws.close(); } catch {} + child.kill(); + await sleep(120); + } +} + +export { sleep }; diff --git a/tools/diff.mjs b/tools/diff.mjs new file mode 100644 index 0000000..052063c --- /dev/null +++ b/tools/diff.mjs @@ -0,0 +1,49 @@ +// 两张 PNG 的数值对拍:PSNR / SSIM / 平均亮度差(0-255)。 +// 用法:node tools/diff.mjs [diff-out.png] +// 判读:平均差 < 1 视为肉眼无差异;1-3 为细微;> 8 通常意味着构图/内容变了。 + +import { spawn } from "node:child_process"; + +const [a, b, outPng] = process.argv.slice(2); +if (!a || !b) { + console.error("usage: node tools/diff.mjs [diff-out.png]"); + process.exit(2); +} + +function ff(args) { + return new Promise((resolve) => { + const p = spawn("ffmpeg", ["-hide_banner", "-loglevel", "info", ...args], { stdio: ["ignore", "pipe", "pipe"] }); + let err = ""; + p.stderr.on("data", (d) => (err += d)); + p.stdout.on("data", () => {}); + p.on("close", () => resolve(err)); + }); +} + +const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]); +const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]); +const yavg = await ff([ + "-i", a, "-i", b, + "-lavfi", "blend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG", + "-f", "null", "-", +]); + +const num = (text, re) => { + const m = text.match(re); + return m ? Number(m[1]) : NaN; +}; + +const yavgs = [...yavg.matchAll(/YAVG=([\d.]+)/g)].map((m) => Number(m[1])); + +const report = { + psnr_avg: /average:inf/.test(psnr) ? Infinity : num(psnr, /average:([\d.]+)/), + ssim_all: num(ssim, /All:([\d.]+)/), + mean_abs_diff: yavgs.length ? yavgs[yavgs.length - 1] : NaN, +}; + +console.log(`psnr=${report.psnr_avg} dB ssim=${report.ssim_all} mean|diff|=${report.mean_abs_diff}/255`); + +if (outPng) { + await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]); + console.log(`diff image -> ${outPng}`); +} diff --git a/tools/serve.mjs b/tools/serve.mjs new file mode 100644 index 0000000..24ead24 --- /dev/null +++ b/tools/serve.mjs @@ -0,0 +1,181 @@ +// 零依赖静态服务器:把 dist/ 以正确的 MIME 暴露出来,供无头 Edge 与人工本地预览使用。 +// 用法:node tools/serve.mjs [port] [--root ] +// +// 它同时承担"测试台"的角色,且完全不改动 dist/: +// ?nojs=1 剥掉所有 `; + +async function html(body, query) { + let out = body; + if (query.get("nojs") === "1") out = out.replace(/]*><\/script>/g, ""); + else if (query.has("__props") || query.has("__freeze")) { + out = out.replace(/(]*type="module"[^>]*>)/, DRIVER(query) + "$1"); + } + return out; +} + +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://127.0.0.1"); + let rel = decodeURIComponent(url.pathname); + if (rel.endsWith("/")) rel += "index.html"; + const abs = normalize(join(root, rel)); + if (abs !== root && !abs.startsWith(root + sep)) { + res.writeHead(403).end("forbidden"); + return; + } + const info = await stat(abs).catch(() => null); + if (!info || !info.isFile()) { + res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404 ${rel}`); + console.log(`404 ${rel}`); + return; + } + if (extname(abs).toLowerCase() === ".html") { + const body = await html(await readFile(abs, "utf8"), url.searchParams); + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(body), + "cache-control": "no-store", + }); + res.end(body); + return; + } + res.writeHead(200, { + "content-type": MIME[extname(abs).toLowerCase()] ?? "application/octet-stream", + "content-length": info.size, + "cache-control": "no-store", + }); + createReadStream(abs).pipe(res); + } catch (err) { + res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(err)); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`serving ${root}`); + console.log(` http://127.0.0.1:${port}/`); +}); diff --git a/tools/shot.mjs b/tools/shot.mjs new file mode 100644 index 0000000..a15d974 --- /dev/null +++ b/tools/shot.mjs @@ -0,0 +1,66 @@ +// 用 CDP 截图 / 取状态(真实时间等待,见 cdp.mjs 顶部注释)。 +// 用法:node tools/shot.mjs [waitMs] [options] +// --wait "" 就绪判据,默认为"骨骼已加载或已报错" +// --eval "" 截图前执行的表达式(结果会打印出来) +// --settle 就绪后额外等待,确保至少画过一帧(默认 500) +// --dom 不截图,打印 document.title 里的测试状态 +// --extra "" 追加 Edge 旗标 + +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { withPage, sleep } from "./cdp.mjs"; + +const argv = process.argv.slice(2); +const [out, url, w, h, waitMs = "30000"] = argv; +if (!out || !url || !w || !h) { + console.error('usage: node tools/shot.mjs [waitMs] [--wait js] [--eval js] [--settle ms] [--dom]'); + process.exit(2); +} +const opt = (name, dflt) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 ? argv[i + 1] : dflt; +}; +const DEFAULT_WAIT = '(window.__test ? !!(window.__test.successAt || window.__test.error) : document.readyState === "complete")'; +const waitFor = opt("wait", DEFAULT_WAIT); +const settle = Number(opt("settle", "500")); +const domMode = argv.includes("--dom"); +const extraArgs = []; +if (opt("extra")) extraArgs.push(opt("extra")); + +const result = await withPage({ width: Number(w), height: Number(h), extraArgs }, async ({ send, evaluate, consoleLines, exceptions }) => { + await send("Page.navigate", { url }); + + const deadline = Date.now() + Number(waitMs); + let ready = false; + while (Date.now() < deadline) { + if (await evaluate(waitFor)) { ready = true; break; } + await sleep(150); + } + + const state = await evaluate('(function(){ try { return JSON.parse((document.title||"").replace(/^TESTSTATE /,"")) } catch(e){ return null } })()'); + await sleep(settle); + + if (domMode) { + const evalOut = opt("eval") ? await evaluate(opt("eval")) : undefined; + return { ready, state, evalOut, consoleLines, exceptions }; + } + + if (opt("eval")) await evaluate(opt("eval")); + const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }); + await writeFile(resolve(out), Buffer.from(shot.data, "base64")); + return { ready, state, consoleLines, exceptions }; +}); + +const tag = result.ready ? "ready" : "TIMEOUT"; +if (domMode) { + console.log(`state=${tag}`); + if (result.evalOut !== undefined) console.log(`eval=${JSON.stringify(result.evalOut)}`); + console.log(JSON.stringify(result.state, null, 2)); +} else { + console.log(`${out} ${tag}`); + if (result.state) console.log(` state: ${JSON.stringify(result.state)}`); +} +const noisy = result.consoleLines.filter((l) => /error|warn|uncaught|fail/i.test(l)); +if (noisy.length) console.log(" console: " + noisy.slice(0, 6).join(" | ")); +if (result.exceptions.length) console.log(" exceptions: " + result.exceptions.slice(0, 4).join(" | ")); +if (!result.ready) process.exit(1);