Files
SpineWallpaper/tools/checks/verify-static-preview.mts
T
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

158 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 静态预览验收:证明 WE 模拟面板**不依赖调试服**,且是**右边缘滑出**的。
//
// 为什么要有这个测试:
// ① 面板曾经只在调试服注入时才存在。用户要求 GitHub Pages 预览也能弹出它——
// 静态托管没有调试服,所以必须由 `--with-sim` 把驱动**注入 index.html**。
// ② Pages 把站点放在 `/<repo>/` **子路径**下。驱动若用根绝对路径 `/scripts/…`,
// 在子路径下必然 404。所以这个测试刻意把分发挂在 `/repo/` 前缀后面服务,
// 而不是挂在根上——挂根上测不出这个错。
//
// 用法:node tools/checks/verify-static-preview.mts [分发目录名]
// 前提:已经 `pnpm build --with-sim`。
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { withPage, sleep } from "../cdp.mjs";
const DIR = process.argv[2] ?? "collection-all";
const PORT = Number(process.env.STATIC_PORT ?? 8195);
const ROOT = resolve("dist/releases");
/** 模拟 GitHub Pages 的 /<repo>/ 子路径。**不能**是空串,否则测不出根绝对路径的错。 */
const PREFIX = "/repo";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
};
const notFound: string[] = [];
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
if (!pathname.startsWith(`${PREFIX}/`)) {
notFound.push(pathname);
res.writeHead(404).end("outside prefix");
return;
}
let rel = pathname.slice(PREFIX.length + 1);
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = join(ROOT, normalize(rel));
if (!file.startsWith(ROOT)) {
res.writeHead(403).end("escape");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
notFound.push(pathname);
res.writeHead(404).end("not found");
return;
}
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream", "content-length": info.size });
createReadStream(file).pipe(res);
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}${PREFIX}/${DIR}/`;
console.log(`静态托管(模拟 Pages 子路径):${BASE}\n`);
const W = 1920;
const H = 1080;
const EDGE_X = W - 8; // 16px 热区之内
const AWAY_X = 200; // 远离右边缘
const result = await withPage({ width: W, height: H, profile: "tools/.cache/edge-static" }, async ({ send, evaluate, consoleLines, exceptions }) => {
const move = async (x, y) => {
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
await sleep(320); // > 面板 .18s 过渡
};
const open = () => evaluate(`document.getElementById("wesim").dataset.open`);
await send("Page.navigate", { url: BASE });
await sleep(3500);
const initial = await evaluate(`(function(){
var p = document.getElementById("wesim");
var e = document.getElementById("wesim-edge");
return {
sim: !!window.__weSim,
isSimulated: window.__weSim ? window.__weSim.__isSimulated : null,
driverOnce: window.__weSimDriver === true,
panel: !!p, edge: !!e,
open: p ? p.dataset.open : null,
banner: p && p.querySelector(".banner") ? p.querySelector(".banner").textContent : null,
canvas: document.querySelectorAll("canvas").length,
hasToggle: !!document.querySelector("#wesim .toggle")
};
})()`);
// ── 交互:右边缘滑出 ──
await move(AWAY_X, H / 2);
const closedInitially = await open();
await move(EDGE_X, H / 2);
const openedByEdge = await open();
await move(AWAY_X, H / 2);
const closedByLeave = await open();
await move(EDGE_X, H / 2);
const reopened = await open();
// 取消收起:此刻真实指针就停在右边缘热区里。没有 armed 规则的话,
// 面板滑走会立刻让边缘命中再次触发,表现为"取消关不掉"。
// (官方面板没有 ×,底部是确认/取消,所以这里点「取消」。)
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(400);
const afterClose = await open();
// 离开边缘再回来 → 重新武装
await move(AWAY_X, H / 2);
await move(EDGE_X, H / 2);
const afterRearm = await open();
return { initial, closedInitially, openedByEdge, closedByLeave, reopened, afterClose, afterRearm, consoleLines, exceptions };
});
const r = result;
console.log("=== ① 静态托管下模拟器可用(无调试服)===");
check(r.initial.sim === true, "window.__weSim 存在(驱动已随产物注入)");
check(r.initial.isSimulated === true, "window.__weSim.__isSimulated = true");
check(r.initial.driverOnce === true, "驱动幂等标记已置位");
check(r.initial.panel === true, "#wesim 面板已挂载");
check(r.initial.edge === true, "#wesim-edge 右边缘热区已挂载");
check(r.initial.hasToggle === false, "没有常驻开关按钮(打开方式只有右边缘)");
check(/模拟环境/.test(r.initial.banner ?? ""), "面板有「模拟环境」横幅", r.initial.banner?.trim());
check(r.initial.canvas > 0, "壁纸本身也渲染了", `canvas=${r.initial.canvas}`);
console.log("\n=== ② 右边缘滑出交互 ===");
check(r.closedInitially === "0", "初始收起", `data-open=${r.closedInitially}`);
check(r.openedByEdge === "1", "指针移到右边缘 → 滑出", `data-open=${r.openedByEdge}`);
check(r.closedByLeave === "0", "指针离开 → 自动收回", `data-open=${r.closedByLeave}`);
check(r.reopened === "1", "再次移到右边缘 → 再次滑出", `data-open=${r.reopened}`);
check(r.afterClose === "0", "「取消」收起后不会被边缘热区立刻弹回", `data-open=${r.afterClose}`);
check(r.afterRearm === "1", "离开边缘后再回来 → 重新可触发", `data-open=${r.afterRearm}`);
console.log("\n=== ③ 子路径下没有 404(相对路径正确)===");
check(notFound.length === 0, "静态服务器 0 个 404", notFound.slice(0, 3).join(" | ") || "无");
const noisy = r.consoleLines.filter((l) => /error|uncaught/i.test(l));
check(r.exceptions.length === 0, "0 未捕获异常", r.exceptions.slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
server.close();
console.log(failures === 0 ? "\n静态预览:通过(面板不依赖调试服,且随右边缘滑出)" : `\n静态预览:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);