| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- /**
- * keep-alive 组件名匹配的执行级验证台。
- *
- * 【要证的一句话】`<keep-alive :include>` 匹配的是**组件名**,而 `cachedViews` 里放的是
- * **路由名**(=`sys_menu.route_name`)⇒ 两者不逐字相同时,那页的 `keep_alive=true`
- * **静默失效**(vue 不报错,只是不缓存)。乐收赢 `collection/` 下三个页面正是这个状态。
- *
- * 【为什么不能只看源码】「名字现在对上了」与「现在真的会缓存」是两句不同的话。
- * 前者是文本读数,后者是运行时行为。本台测**后者**:编译**真**的 SFC(`@vue/compiler-sfc`)、
- * 用 `frontend/node_modules` 里那份**真** `vue` 挂进**真** `<keep-alive>`,
- * 数 `setup()` 被调用了几次 —— 缓存命中时再激活**不会**重跑 `setup`。
- *
- * 【两向读数一次跑完】`include` 给两个值:
- * ① 该页面真实的组件名 ⇒ 期望 setups=1(命中缓存)
- * ② 一个必然不匹配的串 ⇒ 期望 setups=2(未缓存)
- * ② 是 ① 的正对照:没有它,setups=1 也可能只是「计数器根本没在工作」。
- *
- * 【不做 DOM】用 `createRenderer` 接一套哑节点操作,不引 jsdom。
- * 【模板不渲染】(`inlineTemplate: false`)⇒ 本台证明的是**缓存语义**,不是页面长什么样。
- * 【不设 process.exitCode】与同目录各台一致 ⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
- *
- * 用法:node lsy-keepalive-harness.mjs <index.vue 路径> [标签]
- */
- import fs from "node:fs";
- import { createRequire } from "node:module";
- const FE = "D:/project2/payment-platform/frontend/";
- const require = createRequire(FE + "package.json");
- const vue = require("vue");
- const { parse, compileScript } = require("@vue/compiler-sfc");
- const SRC = process.argv[2];
- const LABEL = process.argv[3] || SRC;
- /** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自同目录 fe-update-harness.mjs)。 */
- function compileSfc(file) {
- const src = fs.readFileSync(file, "utf8");
- const { descriptor, errors } = parse(src, { filename: file });
- if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
- let code = compileScript(descriptor, { id: "kaprobe", inlineTemplate: false }).content;
- const braceImport = (spec) =>
- new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
- const asStub = (slot) => (_m, n) =>
- `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`;
- code = code
- // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
- // ⇒ 正则必须带 `g`。
- .replace(braceImport("vue"), asStub("vue"))
- // 业务接口与 UI 库都只打桩(模板不渲染,它们不会有真行为)。
- // ★ 下面那句兜底断言是**故意**留的:漏了哪个 import 会当场炸,而不是悄悄跑出假绿。
- .replace(braceImport("@/api/module_payment/collection"), asStub("api"))
- .replace(braceImport("@/api/module_payment/onboarding"), asStub("api"))
- .replace(braceImport("element-plus"), asStub("ep"))
- // 默认导入(`import QRCode from "qrcode"` / 子组件)—— 正则与上面那族**不同形**,
- // ★ 漏掉它时上面那句兜底断言会当场炸(实测:cashier 页就是靠它发现的)。
- .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`)
- .replace(
- /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
- (_m, n) => `const ${n} = globalThis.__U.ep;`
- );
- if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
- const ts = require("typescript");
- code = ts.transpileModule(code, {
- compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
- fileName: "index.ts",
- }).outputText;
- code = code.replace(/export\s+default/, "const __sfc =");
- if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
- code += "\nglobalThis.__U.sfc = __sfc;";
- return code;
- }
- // ---------- 桩 ----------
- // 页面 `onMounted` 会打这几个接口;模板不渲染,所以只要它别抛就行。
- const okPage = () => ({ data: { code: 0, data: { list: [], total: 0 } } });
- const apiStub = new Proxy(
- {},
- { get: (_t, name) => (name === "then" ? undefined : async () => okPage()) }
- );
- // UI 库:任何名字拿到都是「可调用、可继续取属性、调用返回 Promise」的东西
- // (`ElMessageBox.confirm(...)` 要能 await)。
- const callable = new Proxy(async () => undefined, {
- get: (_t, k) => (k === "then" ? undefined : callable),
- apply: () => Promise.resolve(),
- });
- const epStub = new Proxy({}, { get: () => callable });
- // 无 DOM 台子的垫片:有的页面 `onMounted` 里会碰 `window`(如 `window.setInterval`)。
- // ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当成缺陷去改页面。
- if (typeof globalThis.window === "undefined") {
- globalThis.window = globalThis;
- }
- globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null };
- new Function(compileSfc(SRC))();
- const Real = globalThis.__U.sfc;
- const REAL_NAME = Real && Real.name;
- if (!REAL_NAME) throw new Error("编译出来的组件没有 name —— defineOptions 没被保留?");
- // ---------- 无 DOM 渲染器 ----------
- const node = () => ({ children: [] });
- const noop = () => {};
- const { createRenderer, h, KeepAlive, ref, nextTick } = vue;
- const renderer = createRenderer({
- createElement: node,
- createText: node,
- createComment: node,
- setText: noop,
- setElementText: noop,
- insert: noop,
- remove: noop,
- parentNode: () => null,
- nextSibling: () => null,
- patchProp: noop,
- });
- /** 数 setup 跑了几次 —— 缓存命中后再激活**不会**重跑 setup。 */
- async function countSetups(includeName) {
- let setups = 0;
- const Wrapped = {
- ...Real,
- render: () => null,
- setup(props, ctx) {
- setups++;
- return Real.setup ? Real.setup(props, ctx) : undefined;
- },
- };
- const Other = { name: "KaprobeOther", render: () => null };
- // ★ 必须是 ref:普通变量不会被追踪,`__switch` 改完不会触发重渲染,
- // 于是「没缓存」与「根本没切页」在读数上分不开。
- const cur = ref("target");
- const Host = {
- setup() {
- globalThis.__switch = (v) => {
- cur.value = v;
- };
- return () => h(KeepAlive, { include: [includeName] }, () =>
- cur.value === "target" ? h(Wrapped) : h(Other)
- );
- },
- };
- renderer.createApp(Host).mount(node());
- await nextTick();
- globalThis.__switch("other");
- await nextTick();
- globalThis.__switch("target");
- await nextTick();
- return setups;
- }
- // ★ 第 4 个参数可把「命中」那一轮喂成**别的**名字 —— 用来跑变异:
- // 把页面里的 name 改成旧值,而 include 仍喂活库那个名字,此时**必须**读出不缓存。
- // 不给就是自比(include 取本页真名)。
- const INCLUDE = process.argv[4] || REAL_NAME;
- const hit = await countSetups(INCLUDE);
- const miss = await countSetups("__kaprobe_never_matches__");
- const verdict = hit === 1 && miss === 2 ? "PASS" : "FAIL";
- console.log(`组件名=${REAL_NAME} 文件=${LABEL} include=${INCLUDE}`);
- console.log(` include=[${INCLUDE}] setups=${hit} 期望 1(命中缓存)`);
- console.log(` include=[必然不匹配的串] setups=${miss} 期望 2(未缓存,正对照)`);
- console.log(`SUMMARY ${verdict} hit=${hit} miss=${miss} name=${REAL_NAME}`);
|