// 热更新验收:真的改一个源文件,看浏览器是不是按预期更新。 // // 断言两件不同的事,不能混为一谈: // · 改 CSS → **只换样式表**,页面不重载(Spine 播放器与面板状态都保住) // · 改 runtime → **整页刷新** // // 用"页面上的标记还在不在"来区分这两者:整页刷新会把 window 上的标记抹掉, // 只换样式表则不会。只看"页面变了没有"是分不出这两种情况的。 // // 用法:node tools/checks/verify-hot-reload.mts [base] // 前提:调试服在跑,且是**新版**(带热更新)的 tools/dev.ts。 import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { withPage, sleep } from "../cdp.mjs"; const BASE = process.argv[2] ?? "http://127.0.0.1:5173"; const RELEASE = "collection-all"; const CSS_FILE = "src/styles/index.css"; const TS_FILE = "src/runtime/viewport-fitter.ts"; const cssOriginal = readFileSync(CSS_FILE, "utf8"); const tsOriginal = readFileSync(TS_FILE, "utf8"); let failures = 0; const check = (ok: unknown, label: string, detail?: unknown) => { if (!ok) failures++; console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`); }; /** 等到条件成立,超时返回 false。 */ async function waitFor(evaluate, expression, ms, tag) { const deadline = Date.now() + ms; while (Date.now() < deadline) { if (await evaluate(expression).catch(() => false)) return true; await sleep(150); } console.log(` (${tag} 等待超时)`); return false; } try { await withPage({ width: 1280, height: 900, profile: "tools/.cache/edge-hotreload" }, async ({ send, evaluate }) => { await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` }); await sleep(3500); console.log("=== ① 注入与连接 ==="); const injected = await evaluate(`!!document.querySelector('script[src*="/__dev/events"]') || document.documentElement.innerHTML.includes("/__dev/events")`); check(injected === true, "页面里注入了热更新客户端"); const esState = await evaluate(`(function(){ // EventSource 没有暴露实例,用一条同地址的连接探一下服务端在不在 return fetch("/__dev/events", { method: "GET" }).then(function (r) { return r.headers.get("content-type") || ""; }).catch(function () { return "ERR"; }); })()`); check(/text\/event-stream/.test(esState ?? ""), "SSE 端点返回 text/event-stream", esState); console.log("\n=== ② 改 CSS:只换样式表,不整页重载 ==="); await evaluate(`window.__hotReloadMarker = "css";`); // 页面有两条样式表(spine-player.css / index.css),要盯的是**被改的那一条**, // 盯第一条会把"换了别的样式表"当成成功。 const indexHref = `(function(){ var links = document.querySelectorAll('link[rel="stylesheet"]'); for (var i = 0; i < links.length; i++) if (links[i].href.indexOf("index.css") >= 0) return links[i].href; return null; })()`; const beforeHref = await evaluate(indexHref); appendFileSync(CSS_FILE, `\n/* hot-reload probe ${Date.now()} */\n`); const swapped = await waitFor(evaluate, `${indexHref} !== ${JSON.stringify(beforeHref)}`, 25000, "样式表换新"); check(swapped, "被改的样式表 href 换成了带 ?t= 的新地址", await evaluate(indexHref)); const markerAfterCss = await evaluate(`window.__hotReloadMarker`); check(markerAfterCss === "css", "页面**没有**整页重载(window 上的标记还在)", String(markerAfterCss)); const cssText = await evaluate(`fetch(${indexHref}).then(function(r){ return r.text(); })`); check(/hot-reload probe/.test(cssText ?? ""), "换上的样式表确实是新内容"); console.log("\n=== ③ 改 runtime:整页刷新 ==="); writeFileSync(CSS_FILE, cssOriginal); // 先把 CSS 还原,免得下面这一轮被判成 css-only await sleep(2500); await evaluate(`window.__hotReloadMarker = "ts";`); const markerBefore = await evaluate(`window.__hotReloadMarker`); check(markerBefore === "ts", "刷新前标记已就位", String(markerBefore)); appendFileSync(TS_FILE, `\n// hot-reload probe ${Date.now()}\n`); const reloaded = await waitFor(evaluate, `window.__hotReloadMarker === undefined`, 30000, "整页刷新"); check(reloaded, "页面整页刷新了(标记被抹掉)"); check((await evaluate(`!!document.getElementById("wesim")`)) === true, "刷新后模拟器面板重新挂上"); }); } finally { // 无论成败都要还原源码,否则测试会把探针注释留在仓库里 writeFileSync(CSS_FILE, cssOriginal); writeFileSync(TS_FILE, tsOriginal); await sleep(2500); console.log("\n源文件已还原"); } console.log(failures === 0 ? "热更新:通过" : `热更新:失败 ${failures} 处`); process.exit(failures === 0 ? 0 : 1);