verify-live-refetch.mjs 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. #!/usr/bin/env node
  2. /**
  3. * useLiveRefetch(挂起/补刷/合并)+ 应用单例(URL 拼装、登出停流)的执行级验证。
  4. * 用法:cd frontend && node scripts/verify-live-refetch.mjs
  5. *
  6. * 量具:esbuild 把真 TS + 真 vue 打成一个临时 bundle(vue 必须**打进同一份**,
  7. * 否则脚本里的 ref 与 bundle 里的 watch 分属两个 vue 实例、依赖收集是断的)。
  8. */
  9. import { build } from "esbuild";
  10. import assert from "node:assert/strict";
  11. import path from "node:path";
  12. import { fileURLToPath, pathToFileURL } from "node:url";
  13. import fs from "node:fs/promises";
  14. import os from "node:os";
  15. const here = path.dirname(fileURLToPath(import.meta.url));
  16. const root = path.resolve(here, "..");
  17. const modulePath = path.join(root, "src/composables/useLiveRefetch.ts").replaceAll("\\", "/");
  18. // 单例(orderStream / stopOrderStream)按 Interfaces 住在 orderStreamInstance.ts —— 它不在
  19. // useLiveRefetch.ts 的导出里,所以要显式再导一份进 bundle。
  20. const instancePath = path.join(root, "src/utils/orderStreamInstance.ts").replaceAll("\\", "/");
  21. // Auth 链会读 localStorage/sessionStorage:装壳(模块 import 期不读,保险起见先装)
  22. const makeStore = () => {
  23. const map = new Map();
  24. return {
  25. getItem: k => (map.has(k) ? map.get(k) : null),
  26. setItem: (k, v) => map.set(k, String(v)),
  27. removeItem: k => map.delete(k),
  28. clear: () => map.clear()
  29. };
  30. };
  31. const localStore = makeStore();
  32. globalThis.localStorage = localStore;
  33. globalThis.sessionStorage = makeStore();
  34. // 单例那条腿要用真 `new EventSource(...)`:换成假件,把 URL 抓下来
  35. const eventSources = [];
  36. globalThis.EventSource = class {
  37. constructor(url) {
  38. this.url = url;
  39. this.closed = false;
  40. this.listeners = new Map();
  41. eventSources.push(this);
  42. }
  43. addEventListener(type, fn) {
  44. if (!this.listeners.has(type)) this.listeners.set(type, []);
  45. this.listeners.get(type).push(fn);
  46. }
  47. close() {
  48. this.closed = true;
  49. }
  50. };
  51. const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-live-refetch-"));
  52. const bundle = path.join(tmp, "bundle.mjs");
  53. await build({
  54. stdin: {
  55. contents: `
  56. export * from ${JSON.stringify(modulePath)};
  57. export * from ${JSON.stringify(instancePath)};
  58. export { ref, nextTick } from "vue";
  59. `,
  60. resolveDir: root, // 从 frontend/ 解析:vue 与 "@/..." 都从这里走
  61. sourcefile: "verify-entry.mjs",
  62. loader: "js"
  63. },
  64. outfile: bundle,
  65. bundle: true,
  66. format: "esm",
  67. platform: "neutral",
  68. target: "node22",
  69. logLevel: "error",
  70. alias: { "@": path.join(root, "src") },
  71. define: {
  72. "import.meta.env.VITE_APP_BASE_API": JSON.stringify("/api/v1"),
  73. __VUE_OPTIONS_API__: "false",
  74. __VUE_PROD_DEVTOOLS__: "false",
  75. __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false"
  76. }
  77. });
  78. const { useLiveRefetch, orderStream, stopOrderStream, ref, nextTick } = await import(pathToFileURL(bundle).href);
  79. const sleep = ms => new Promise(r => setTimeout(r, ms));
  80. function fakeStream() {
  81. const subs = new Map();
  82. const resyncs = new Set();
  83. let unsubscribed = 0;
  84. return {
  85. subscribe(channel, fn) {
  86. if (!subs.has(channel)) subs.set(channel, new Set());
  87. subs.get(channel).add(fn);
  88. return () => {
  89. subs.get(channel)?.delete(fn);
  90. unsubscribed += 1;
  91. };
  92. },
  93. onResync(fn) {
  94. resyncs.add(fn);
  95. return () => resyncs.delete(fn);
  96. },
  97. stop() {},
  98. isRunning: () => true,
  99. emit(channel) {
  100. for (const fn of subs.get(channel) || []) fn();
  101. },
  102. fireResync() {
  103. for (const fn of resyncs) fn();
  104. },
  105. get channels() {
  106. return [...subs.keys()].sort();
  107. },
  108. get unsubscribed() {
  109. return unsubscribed;
  110. }
  111. };
  112. }
  113. let failed = 0;
  114. async function check(name, fn) {
  115. try {
  116. await fn();
  117. console.log(" PASS " + name);
  118. } catch (e) {
  119. failed += 1;
  120. console.error(" FAIL " + name + "\n " + (e && e.message));
  121. }
  122. }
  123. const OPTS = { debounceMs: 5 };
  124. await check("订阅给定的三个通道", async () => {
  125. const stream = fakeStream();
  126. let calls = 0;
  127. const dispose = useLiveRefetch(["lsy_order", "lsy_refund", "har_order"], () => { calls += 1; }, { ...OPTS, stream });
  128. assert.deepEqual(stream.channels, ["har_order", "lsy_order", "lsy_refund"]);
  129. dispose();
  130. });
  131. await check("本通道的帧 → 合并窗口后刷一次;别的通道不触发", async () => {
  132. const stream = fakeStream();
  133. let calls = 0;
  134. const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });
  135. stream.emit("lsy_refund");
  136. await sleep(20);
  137. assert.equal(calls, 0, "没订阅的通道不许刷");
  138. stream.emit("lsy_order");
  139. assert.equal(calls, 0, "合并窗口内不许同步刷(同一次写入会来好几帧)");
  140. await sleep(20);
  141. assert.equal(calls, 1);
  142. dispose();
  143. });
  144. await check("窗口内多帧(含重连补拉)只刷一次", async () => {
  145. const stream = fakeStream();
  146. let calls = 0;
  147. const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });
  148. stream.emit("lsy_order");
  149. stream.emit("lsy_order");
  150. stream.fireResync();
  151. await sleep(20);
  152. assert.equal(calls, 1, "一次写入的多帧 + 一次重连补拉,合并成一次重拉");
  153. dispose();
  154. });
  155. await check("busy 期间挂起,回安全态立刻补刷一次", async () => {
  156. const stream = fakeStream();
  157. const busy = ref(false);
  158. let calls = 0;
  159. const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
  160. busy.value = true;
  161. await nextTick();
  162. stream.emit("lsy_order");
  163. stream.emit("lsy_order");
  164. await sleep(20);
  165. assert.equal(calls, 0, "用户正在勾选/弹窗里操作,列表不许在脚下跳");
  166. busy.value = false;
  167. await nextTick();
  168. assert.equal(calls, 1, "回到安全态立刻补刷(不等下一个信号)");
  169. await sleep(20);
  170. assert.equal(calls, 1, "补刷只做一次(pending 要清掉)");
  171. // 补刷把 pending 消费掉之后,下一轮 busy 翻转(期间**没有**新信号)不许再刷一次。
  172. // 没有这一段的话,「pending 永远不清」与「pending 用完即清」两种实现读数完全相同。
  173. busy.value = true;
  174. await nextTick();
  175. busy.value = false;
  176. await nextTick();
  177. await sleep(20);
  178. assert.equal(calls, 1, "上一轮的 pending 已被消费:这一轮没有信号,一次都不该刷");
  179. dispose();
  180. });
  181. await check("合并窗口内进入 busy:窗口到期不许刷,回安全态补刷一次", async () => {
  182. const stream = fakeStream();
  183. const busy = ref(false);
  184. let calls = 0;
  185. const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
  186. stream.emit("lsy_order"); // 信号先到:窗口开始计时(此刻 busy = false)
  187. busy.value = true; // 用户在窗口还没走完时开始操作
  188. await nextTick();
  189. await sleep(20); // 窗口到期
  190. assert.equal(calls, 0, "已经起计时的窗口必须随 busy 一起撤掉:列表不许在用户操作期间跳");
  191. busy.value = false;
  192. await nextTick();
  193. assert.equal(calls, 1, "那一帧没刷成,回安全态要补上(不能丢)");
  194. await sleep(20);
  195. assert.equal(calls, 1, "补刷只做一次");
  196. dispose();
  197. });
  198. await check("busy 翻转但期间没有信号 → 一次都不刷", async () => {
  199. const stream = fakeStream();
  200. const busy = ref(false);
  201. let calls = 0;
  202. const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
  203. busy.value = true;
  204. await nextTick();
  205. busy.value = false;
  206. await nextTick();
  207. await sleep(20);
  208. assert.equal(calls, 0, "没漏东西就白拉一次 = 每次关弹窗都打一次接口");
  209. dispose();
  210. });
  211. await check("busy 期间的重连补拉也挂起", async () => {
  212. const stream = fakeStream();
  213. const busy = ref(true);
  214. let calls = 0;
  215. const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
  216. await nextTick();
  217. stream.fireResync();
  218. await sleep(20);
  219. assert.equal(calls, 0);
  220. busy.value = false;
  221. await nextTick();
  222. assert.equal(calls, 1);
  223. dispose();
  224. });
  225. await check("refetch 抛错 / reject 都不冒泡,后续帧仍能刷", async () => {
  226. const stream = fakeStream();
  227. let calls = 0;
  228. const dispose = useLiveRefetch(
  229. ["lsy_order"],
  230. () => {
  231. calls += 1;
  232. if (calls === 1) throw new Error("同步炸");
  233. if (calls === 2) return Promise.reject(new Error("异步炸"));
  234. return Promise.resolve();
  235. },
  236. { ...OPTS, stream }
  237. );
  238. stream.emit("lsy_order");
  239. await sleep(20);
  240. stream.emit("lsy_order");
  241. await sleep(20);
  242. stream.emit("lsy_order");
  243. await sleep(20);
  244. assert.equal(calls, 3, "三次都该真的调下去(异常被吞掉,不影响下一次)");
  245. dispose();
  246. });
  247. await check("dispose:退订全部通道、清掉未到期的合并、不再刷", async () => {
  248. const stream = fakeStream();
  249. let calls = 0;
  250. const dispose = useLiveRefetch(["lsy_order", "lsy_refund"], () => { calls += 1; }, { ...OPTS, stream });
  251. stream.emit("lsy_order");
  252. dispose();
  253. assert.equal(stream.unsubscribed, 2, "两个通道都要退订");
  254. await sleep(20);
  255. assert.equal(calls, 0, "dispose 时未到期的合并必须被清掉(页面已经卸载了)");
  256. stream.emit("lsy_order");
  257. await sleep(20);
  258. assert.equal(calls, 0);
  259. });
  260. await check("单例:URL 现取令牌拼装 + 登出停流", async () => {
  261. // Storage.get 会 JSON.parse,所以按 Storage.set 的写法存(带引号的 JSON 串)
  262. localStore.setItem("remember_me", "true");
  263. localStore.setItem("access_token", JSON.stringify("tok-123"));
  264. assert.equal(orderStream(), orderStream(), "必须是同一个实例");
  265. const off = orderStream().subscribe("lsy_order", () => {});
  266. assert.equal(eventSources.length, 1);
  267. assert.equal(eventSources[0].url, "/api/v1/payment/stream/orders?token=tok-123");
  268. stopOrderStream();
  269. assert.equal(eventSources[0].closed, true, "登出停流必须真的关连接(token 已清,重连只会被 403 挡回)");
  270. off();
  271. });
  272. console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
  273. if (failed > 0) process.exit(1);