|
|
@@ -0,0 +1,170 @@
|
|
|
+/**
|
|
|
+ * 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}`);
|