test: 零依赖对拍测试台(可在不改 dist/ 的前提下复现 WE 行为)
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。
This commit is contained in:
1 parent
d8c7e492f7
commit
399f99edd7
7 files changed
+499
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
* -text
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 对拍与开发产物的输出目录(可随时重新生成,不入库)
|
||||||
|
tools/shots/
|
||||||
|
tools/.cache/
|
||||||
|
|
||||||
|
# 杂项
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 对拍矩阵:每套壁纸 × 每种分辨率 各出两张 PNG —— 正常渲染 + 藏起 canvas 的"只有背景"参考帧,
|
||||||
|
// 然后给出"spine 层占了多少像素"的数值。
|
||||||
|
// 用法:node tools/capture.mjs <tag> [--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 <tag> [--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}/`);
|
||||||
+126
@@ -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 };
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 两张 PNG 的数值对拍:PSNR / SSIM / 平均亮度差(0-255)。
|
||||||
|
// 用法:node tools/diff.mjs <a.png> <b.png> [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 <a.png> <b.png> [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}`);
|
||||||
|
}
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
// 零依赖静态服务器:把 dist/ 以正确的 MIME 暴露出来,供无头 Edge 与人工本地预览使用。
|
||||||
|
// 用法:node tools/serve.mjs [port] [--root <dir>]
|
||||||
|
//
|
||||||
|
// 它同时承担"测试台"的角色,且完全不改动 dist/:
|
||||||
|
// ?nojs=1 剥掉所有 <script>,得到"只有 CSS 背景、没有 canvas"的对照组
|
||||||
|
// ?__props={...} 模拟 Wallpaper Engine 下发用户属性,如 {"preset":"kv37","audio_volume":0.2}
|
||||||
|
// ?__propsAt=load 默认在 window.load 之后下发(正常路径)
|
||||||
|
// =dom 则在 DOMContentLoaded 时下发,复现"WE 在 load 之前就推属性"的时序
|
||||||
|
// ?__freeze=1 骨骼加载完成后暂停播放并把动画相位归零,使截图逐像素可复现
|
||||||
|
// ?__hide=canvas 渲染正常但把 canvas 藏起来,得到"只有背景图"的同款参考帧
|
||||||
|
//
|
||||||
|
// 驱动脚本会把状态写进 document.title,配合 shot.mjs --dom 即可断言。
|
||||||
|
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const portArg = args.find((a) => /^\d+$/.test(a));
|
||||||
|
const rootFlag = args.indexOf("--root");
|
||||||
|
const root = resolve(rootFlag >= 0 ? args[rootFlag + 1] : "dist");
|
||||||
|
const port = Number(portArg ?? 8181);
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".atlas": "text/plain; charset=utf-8",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".flac": "audio/flac",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".opus": "audio/ogg",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".webm": "video/webm",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注入到 <script type="module" src="scripts/index.js"> 之前:
|
||||||
|
// 这是唯一能"在 spine-player.js 已加载、而 index.js 尚未执行"的插入点。
|
||||||
|
const DRIVER = (q) => `<script>
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var props = {};
|
||||||
|
try { props = JSON.parse(q.get("__props") || "{}"); } catch (e) { props = {}; }
|
||||||
|
var freeze = q.get("__freeze") === "1";
|
||||||
|
var propsAt = q.get("__propsAt") || "load";
|
||||||
|
var state = { patched: false, presets: Object.keys(props), propsAt: null, successAt: null, frames: null, error: null };
|
||||||
|
window.__test = state;
|
||||||
|
|
||||||
|
if (!window.spine || !window.spine.SpinePlayer) {
|
||||||
|
state.error = "spine.SpinePlayer missing";
|
||||||
|
} else {
|
||||||
|
// 注意:spine-player.js 用 __export 把导出定义成 getter-only,
|
||||||
|
// 直接给 spine.SpinePlayer 赋值在非严格模式下会静默失败,所以只能补 prototype。
|
||||||
|
var proto = window.spine.SpinePlayer.prototype;
|
||||||
|
var origValidate = proto.validateConfig;
|
||||||
|
proto.validateConfig = function (config) {
|
||||||
|
var prevSuccess = config.success;
|
||||||
|
var prevError = config.error;
|
||||||
|
config.success = function (p) {
|
||||||
|
window.__player = p;
|
||||||
|
state.successAt = Math.round(performance.now());
|
||||||
|
if (freeze) freezePlayer(p);
|
||||||
|
if (prevSuccess) prevSuccess(p);
|
||||||
|
};
|
||||||
|
config.error = function (p, msg) { state.error = String(msg); if (prevError) prevError(p, msg); };
|
||||||
|
state.patched = true;
|
||||||
|
return origValidate.call(this, config);
|
||||||
|
};
|
||||||
|
var origInitialize = proto.initialize;
|
||||||
|
proto.initialize = function () {
|
||||||
|
window.__player = this;
|
||||||
|
return origInitialize.apply(this, arguments);
|
||||||
|
};
|
||||||
|
var origDraw = proto.drawFrame;
|
||||||
|
proto.drawFrame = function () {
|
||||||
|
state.drawCalls = (state.drawCalls || 0) + 1;
|
||||||
|
return origDraw.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezePlayer(p) {
|
||||||
|
try {
|
||||||
|
p.pause();
|
||||||
|
var cur = p.animationState && p.animationState.getCurrent(0);
|
||||||
|
if (cur) cur.trackTime = 0;
|
||||||
|
if (p.skeleton) p.skeleton.updateWorldTransform(2);
|
||||||
|
state.info = {
|
||||||
|
animations: p.skeleton ? p.skeleton.data.animations.map(function (a) { return a.name; }) : [],
|
||||||
|
skin: p.skeleton ? p.skeleton.data.skins.map(function (s) { return s.name; }) : [],
|
||||||
|
playerError: p.error ? String(p.error) : null,
|
||||||
|
};
|
||||||
|
if (q.get("__hide") === "canvas" && p.canvas) p.canvas.style.visibility = "hidden";
|
||||||
|
} catch (e) { state.error = "freeze: " + e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushProps() {
|
||||||
|
if (!Object.keys(props).length) return;
|
||||||
|
var listener = window.wallpaperPropertyListener;
|
||||||
|
if (!listener || !listener.applyUserProperties) { state.error = "wallpaperPropertyListener missing"; return; }
|
||||||
|
var payload = {};
|
||||||
|
Object.keys(props).forEach(function (k) { payload[k] = { value: props[k] }; });
|
||||||
|
listener.applyUserProperties(payload);
|
||||||
|
state.propsAt = Math.round(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propsAt === "dom") {
|
||||||
|
document.addEventListener("DOMContentLoaded", pushProps);
|
||||||
|
} else {
|
||||||
|
window.addEventListener("load", function () { setTimeout(pushProps, 0); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticks = 0;
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
ticks++;
|
||||||
|
if (window.__player) state.frames = window.__player.time ? Math.round(window.__player.time.frames) : state.frames;
|
||||||
|
document.title = "TESTSTATE " + JSON.stringify(state);
|
||||||
|
if (ticks > 40) clearInterval(timer);
|
||||||
|
}, 250);
|
||||||
|
})();
|
||||||
|
</script>`;
|
||||||
|
|
||||||
|
async function html(body, query) {
|
||||||
|
let out = body;
|
||||||
|
if (query.get("nojs") === "1") out = out.replace(/<script\b[^>]*><\/script>/g, "");
|
||||||
|
else if (query.has("__props") || query.has("__freeze")) {
|
||||||
|
out = out.replace(/(<script\b[^>]*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}/`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 用 CDP 截图 / 取状态(真实时间等待,见 cdp.mjs 顶部注释)。
|
||||||
|
// 用法:node tools/shot.mjs <out.png|-> <url> <W> <H> [waitMs] [options]
|
||||||
|
// --wait "<js 表达式>" 就绪判据,默认为"骨骼已加载或已报错"
|
||||||
|
// --eval "<js>" 截图前执行的表达式(结果会打印出来)
|
||||||
|
// --settle <ms> 就绪后额外等待,确保至少画过一帧(默认 500)
|
||||||
|
// --dom 不截图,打印 document.title 里的测试状态
|
||||||
|
// --extra "<edge 旗标>" 追加 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 <out.png|-> <url> <W> <H> [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);
|
||||||
Reference in new issue
Block a user