// export 改写的**单元测试**:直接调用 tools/lib/bundle.ts 里真正在用的那个函数。 // // 六个坑,每个都对应一次真实事故,所以每个都有一条断言: // ① 字符串替换里的 `$1exports.` 被解析成捕获组 "1e" → "exports." 整段消失 // ② 两条规则互相截胡(`export function` 先被吃成 `exports.function`,function 留在原地) // ③ V8 把声明关键字吞掉 → `exports.foo(a) {` // ④ 嵌套可选组让捕获位置漂移 → `exports.function resolveViewport(...)` // ⑤ `export default {`(对象字面量)没有匹配到任何规则 → `exports.default {` // ⑥ **改写毁掉本地绑定**:`export const X = 1` 直接变成 `exports.X = 1`, // 于是同模块里别处引用 `X` 变成 ReferenceError。真事故:`frameForAspect` 的 // 默认参数 `referenceAspect = REFERENCE_ASPECT` 直到渲染第一帧才求值—— // 加载、尺寸、资源全部正常,只有画面是白的。所以现在一律保留声明原文, // 导出推迟到模块体末尾。下面有专门一条断言盯着"声明必须还在"。 import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { transformModuleForTest } from "../lib/bundle.ts"; function body(code: string) { // 去掉包装体,只留模块体,方便断言 const m = /^__weModules\["__test__"\] = \(function \(\) \{\n const exports = \{\};\n([\s\S]*)\n return exports;\n\}\)\(\);\n$/.exec(code); if (!m) throw new Error("包装体结构变了,测试需要同步更新"); return (m[1] ?? "") .split("\n") .map((l) => (l.startsWith(" ") ? l.slice(2) : l)) .join("\n"); } function parses(code) { const child = spawnSync( process.execPath, ["--experimental-vm-modules", "--no-warnings", "-e", 'new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));'], { input: code, encoding: "utf8" }, ); return child.status === 0 ? null : (child.stderr || "").split("\n").find((l) => /SyntaxError|ReferenceError/.test(l)) ?? "解析失败"; } const cases: [string, string][] = [ // [输入, 期望输出(精确,含空格)] // 声明原文必须**逐字保留**,导出在末尾统一赋值——见文件头 ⑥。 ["export default class C {}", "class C {}\nexports.default = C;"], ["export default Presets;", "exports.default = Presets;"], ["export default {", "exports.default = {"], ["export default function f(a) {}", "function f(a) {}\nexports.default = f;"], ["export default async function f(a) {}", "async function f(a) {}\nexports.default = f;"], ["export const x = 1;", "const x = 1;\nexports.x = x;"], ["export let y = 2;", "let y = 2;\nexports.y = y;"], ["export function resolveViewport(v) {}", "function resolveViewport(v) {}\nexports.resolveViewport = resolveViewport;"], ["export async function load(u) {}", "async function load(u) {}\nexports.load = load;"], ["export class PresetController {}", "class PresetController {}\nexports.PresetController = PresetController;"], // 含 "exports" 的属性访问绝不能被误改 ["class A {\n exports2 = 1;\n}", "class A {\n exports2 = 1;\n}"], ["obj.exports = 1;\nobj.exports.foo = 2;", "obj.exports = 1;\nobj.exports.foo = 2;"], ["export const a = 1;\nexport const b = 2;\n", "const a = 1;\nconst b = 2;\n\nexports.a = a;\nexports.b = b;"], ['export const defaultPresetId = "kv37";', 'const defaultPresetId = "kv37";\nexports.defaultPresetId = defaultPresetId;'], ]; let failed = 0; const report = (ok: unknown, label: string, detail?: unknown) => { if (!ok) failed++; console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? `\n ${detail}` : ""}`); }; console.log("=== 单元断言(调真实实现) ==="); for (const [input, expected] of cases) { const actual = body(transformModuleForTest(input)); report( actual === expected, JSON.stringify(input), actual === expected ? undefined : `得到 ${JSON.stringify(actual)}\n 期望 ${JSON.stringify(expected)}`, ); } // ⑥ 的专用断言:声明必须原样活着。这条比"导出键对得上"更根本—— // 导出键对了、本地绑定没了,症状是运行到某一帧才炸,最难查。 console.log("\n=== 本地绑定必须活着(⑥) ==="); const bindingCases: [string, string][] = [ ["export const X = 16 / 9;\nfunction f(a = X) { return a; }", "X"], ["export function f() {}\nfunction g() { return f(); }", "f"], ["export class C {}\nnew C();", "C"], ]; for (const [input, name] of bindingCases) { const actual = body(transformModuleForTest(input)); // 声明本身要在(`const X =` / `function f(` / `class C `),不能只剩 exports.X = X const declared = new RegExp(`(?:^|\\n)(?:const|let|var|async function|function|class) ${name}\\b`).test(actual); report(declared, `${JSON.stringify(input)} 里 ${name} 的声明还在`, declared ? undefined : `实际 ${JSON.stringify(actual)}`); } // 声明与赋值都在的话,真跑一遍也不能抛 ReferenceError console.log("\n=== 改写后可执行(不是只可解析) ==="); const runnable = [ "export const REFERENCE_ASPECT = 16 / 9;\nexport function frameForAspect(a = REFERENCE_ASPECT) { return a; }", "export function helper() { return 42; }\nexport const value = helper();", "export class Base {}\nexport const instance = new Base();", ]; for (const input of runnable) { const code = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(input)}\n`; const child = spawnSync(process.execPath, ["--input-type=module", "--no-warnings", "-e", code], { encoding: "utf8" }); const ok = child.status === 0; report(ok, `${JSON.stringify(input.slice(0, 46))}… 执行不抛错`, ok ? undefined : (child.stderr || "").split("\n").slice(0, 3).join(" | ")); } console.log("\n=== 真实产物:改写后必须可解析 ==="); const files = [ "build/scripts/index.js", "build/scripts/viewport-fitter.js", "build/scripts/preset-controller.js", "build/scripts/wallpaper-engine.js", "dist/releases/single-hsr-kv37/scripts/presets.js", // 生成的 preset.js 里是 `export default {` 对象字面量——最容易漏的一类 "dist/releases/single-hsr-kv37/preset.js", ]; for (const file of files) { let source; try { source = readFileSync(file, "utf8"); } catch { report(false, `${file} 不存在`); continue; } const wrapped = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(source)}\n`; const problem = parses(wrapped); report(problem === null, `${file} 改写后可解析`, problem ?? undefined); } // 关键:改写后 exports 的键必须与源码里的默认导出/具名导出对得上 console.log("\n=== 语义断言:导出的键不能丢 ==="); const semantic: [string, string[]][] = [ ["export default { id: 1 };", ["default"]], ["export default Presets;", ["default"]], ["export default class C {}", ["default"]], ["export default function f() {}", ["default"]], ["export const a = 1;\nexport function b() {}", ["a", "b"]], ]; for (const [input, keys] of semantic) { const actual = body(transformModuleForTest(input)); const missing = keys.filter((k) => !new RegExp(`^exports\\.${k}\\b`, "m").test(actual)); report(missing.length === 0, `${JSON.stringify(input)} 导出 ${JSON.stringify(keys)}`, missing.length ? `缺 ${JSON.stringify(missing)},实际 ${JSON.stringify(actual)}` : undefined); } console.log(failed === 0 ? "\nexport 改写:全部通过" : `\nexport 改写:${failed} 处失败`); process.exit(failed === 0 ? 0 : 1);