lsy-keepalive-harness.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. /**
  2. * keep-alive 组件名匹配的执行级验证台。
  3. *
  4. * 【要证的一句话】`<keep-alive :include>` 匹配的是**组件名**,而 `cachedViews` 里放的是
  5. * **路由名**(=`sys_menu.route_name`)⇒ 两者不逐字相同时,那页的 `keep_alive=true`
  6. * **静默失效**(vue 不报错,只是不缓存)。乐收赢 `collection/` 下三个页面正是这个状态。
  7. *
  8. * 【为什么不能只看源码】「名字现在对上了」与「现在真的会缓存」是两句不同的话。
  9. * 前者是文本读数,后者是运行时行为。本台测**后者**:编译**真**的 SFC(`@vue/compiler-sfc`)、
  10. * 用 `frontend/node_modules` 里那份**真** `vue` 挂进**真** `<keep-alive>`,
  11. * 数 `setup()` 被调用了几次 —— 缓存命中时再激活**不会**重跑 `setup`。
  12. *
  13. * 【两向读数一次跑完】`include` 给两个值:
  14. * ① 该页面真实的组件名 ⇒ 期望 setups=1(命中缓存)
  15. * ② 一个必然不匹配的串 ⇒ 期望 setups=2(未缓存)
  16. * ② 是 ① 的正对照:没有它,setups=1 也可能只是「计数器根本没在工作」。
  17. *
  18. * 【不做 DOM】用 `createRenderer` 接一套哑节点操作,不引 jsdom。
  19. * 【模板不渲染】(`inlineTemplate: false`)⇒ 本台证明的是**缓存语义**,不是页面长什么样。
  20. * 【不设 process.exitCode】与同目录各台一致 ⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
  21. *
  22. * 用法:node lsy-keepalive-harness.mjs <index.vue 路径> [标签]
  23. */
  24. import fs from "node:fs";
  25. import { createRequire } from "node:module";
  26. const FE = "D:/project2/payment-platform/frontend/";
  27. const require = createRequire(FE + "package.json");
  28. const vue = require("vue");
  29. const { parse, compileScript } = require("@vue/compiler-sfc");
  30. const SRC = process.argv[2];
  31. const LABEL = process.argv[3] || SRC;
  32. /** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自同目录 fe-update-harness.mjs)。 */
  33. function compileSfc(file) {
  34. const src = fs.readFileSync(file, "utf8");
  35. const { descriptor, errors } = parse(src, { filename: file });
  36. if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
  37. let code = compileScript(descriptor, { id: "kaprobe", inlineTemplate: false }).content;
  38. const braceImport = (spec) =>
  39. new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
  40. const asStub = (slot) => (_m, n) =>
  41. `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`;
  42. code = code
  43. // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
  44. // ⇒ 正则必须带 `g`。
  45. .replace(braceImport("vue"), asStub("vue"))
  46. // 业务接口与 UI 库都只打桩(模板不渲染,它们不会有真行为)。
  47. // ★ 下面那句兜底断言是**故意**留的:漏了哪个 import 会当场炸,而不是悄悄跑出假绿。
  48. .replace(braceImport("@/api/module_payment/collection"), asStub("api"))
  49. .replace(braceImport("@/api/module_payment/onboarding"), asStub("api"))
  50. .replace(braceImport("element-plus"), asStub("ep"))
  51. // 默认导入(`import QRCode from "qrcode"` / 子组件)—— 正则与上面那族**不同形**,
  52. // ★ 漏掉它时上面那句兜底断言会当场炸(实测:cashier 页就是靠它发现的)。
  53. .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`)
  54. .replace(
  55. /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
  56. (_m, n) => `const ${n} = globalThis.__U.ep;`
  57. );
  58. if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
  59. const ts = require("typescript");
  60. code = ts.transpileModule(code, {
  61. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  62. fileName: "index.ts",
  63. }).outputText;
  64. code = code.replace(/export\s+default/, "const __sfc =");
  65. if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
  66. code += "\nglobalThis.__U.sfc = __sfc;";
  67. return code;
  68. }
  69. // ---------- 桩 ----------
  70. // 页面 `onMounted` 会打这几个接口;模板不渲染,所以只要它别抛就行。
  71. const okPage = () => ({ data: { code: 0, data: { list: [], total: 0 } } });
  72. const apiStub = new Proxy(
  73. {},
  74. { get: (_t, name) => (name === "then" ? undefined : async () => okPage()) }
  75. );
  76. // UI 库:任何名字拿到都是「可调用、可继续取属性、调用返回 Promise」的东西
  77. // (`ElMessageBox.confirm(...)` 要能 await)。
  78. const callable = new Proxy(async () => undefined, {
  79. get: (_t, k) => (k === "then" ? undefined : callable),
  80. apply: () => Promise.resolve(),
  81. });
  82. const epStub = new Proxy({}, { get: () => callable });
  83. // 无 DOM 台子的垫片:有的页面 `onMounted` 里会碰 `window`(如 `window.setInterval`)。
  84. // ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当成缺陷去改页面。
  85. if (typeof globalThis.window === "undefined") {
  86. globalThis.window = globalThis;
  87. }
  88. globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null };
  89. new Function(compileSfc(SRC))();
  90. const Real = globalThis.__U.sfc;
  91. const REAL_NAME = Real && Real.name;
  92. if (!REAL_NAME) throw new Error("编译出来的组件没有 name —— defineOptions 没被保留?");
  93. // ---------- 无 DOM 渲染器 ----------
  94. const node = () => ({ children: [] });
  95. const noop = () => {};
  96. const { createRenderer, h, KeepAlive, ref, nextTick } = vue;
  97. const renderer = createRenderer({
  98. createElement: node,
  99. createText: node,
  100. createComment: node,
  101. setText: noop,
  102. setElementText: noop,
  103. insert: noop,
  104. remove: noop,
  105. parentNode: () => null,
  106. nextSibling: () => null,
  107. patchProp: noop,
  108. });
  109. /** 数 setup 跑了几次 —— 缓存命中后再激活**不会**重跑 setup。 */
  110. async function countSetups(includeName) {
  111. let setups = 0;
  112. const Wrapped = {
  113. ...Real,
  114. render: () => null,
  115. setup(props, ctx) {
  116. setups++;
  117. return Real.setup ? Real.setup(props, ctx) : undefined;
  118. },
  119. };
  120. const Other = { name: "KaprobeOther", render: () => null };
  121. // ★ 必须是 ref:普通变量不会被追踪,`__switch` 改完不会触发重渲染,
  122. // 于是「没缓存」与「根本没切页」在读数上分不开。
  123. const cur = ref("target");
  124. const Host = {
  125. setup() {
  126. globalThis.__switch = (v) => {
  127. cur.value = v;
  128. };
  129. return () => h(KeepAlive, { include: [includeName] }, () =>
  130. cur.value === "target" ? h(Wrapped) : h(Other)
  131. );
  132. },
  133. };
  134. renderer.createApp(Host).mount(node());
  135. await nextTick();
  136. globalThis.__switch("other");
  137. await nextTick();
  138. globalThis.__switch("target");
  139. await nextTick();
  140. return setups;
  141. }
  142. // ★ 第 4 个参数可把「命中」那一轮喂成**别的**名字 —— 用来跑变异:
  143. // 把页面里的 name 改成旧值,而 include 仍喂活库那个名字,此时**必须**读出不缓存。
  144. // 不给就是自比(include 取本页真名)。
  145. const INCLUDE = process.argv[4] || REAL_NAME;
  146. const hit = await countSetups(INCLUDE);
  147. const miss = await countSetups("__kaprobe_never_matches__");
  148. const verdict = hit === 1 && miss === 2 ? "PASS" : "FAIL";
  149. console.log(`组件名=${REAL_NAME} 文件=${LABEL} include=${INCLUDE}`);
  150. console.log(` include=[${INCLUDE}] setups=${hit} 期望 1(命中缓存)`);
  151. console.log(` include=[必然不匹配的串] setups=${miss} 期望 2(未缓存,正对照)`);
  152. console.log(`SUMMARY ${verdict} hit=${hit} miss=${miss} name=${REAL_NAME}`);