#!/usr/bin/env node /** * useLiveRefetch(挂起/补刷/合并)+ 应用单例(URL 拼装、登出停流)的执行级验证。 * 用法:cd frontend && node scripts/verify-live-refetch.mjs * * 量具:esbuild 把真 TS + 真 vue 打成一个临时 bundle(vue 必须**打进同一份**, * 否则脚本里的 ref 与 bundle 里的 watch 分属两个 vue 实例、依赖收集是断的)。 */ import { build } from "esbuild"; import assert from "node:assert/strict"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import fs from "node:fs/promises"; import os from "node:os"; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, ".."); const modulePath = path.join(root, "src/composables/useLiveRefetch.ts").replaceAll("\\", "/"); // 单例(orderStream / stopOrderStream)按 Interfaces 住在 orderStreamInstance.ts —— 它不在 // useLiveRefetch.ts 的导出里,所以要显式再导一份进 bundle。 const instancePath = path.join(root, "src/utils/orderStreamInstance.ts").replaceAll("\\", "/"); // Auth 链会读 localStorage/sessionStorage:装壳(模块 import 期不读,保险起见先装) const makeStore = () => { const map = new Map(); return { getItem: k => (map.has(k) ? map.get(k) : null), setItem: (k, v) => map.set(k, String(v)), removeItem: k => map.delete(k), clear: () => map.clear() }; }; const localStore = makeStore(); globalThis.localStorage = localStore; globalThis.sessionStorage = makeStore(); // 单例那条腿要用真 `new EventSource(...)`:换成假件,把 URL 抓下来 const eventSources = []; globalThis.EventSource = class { constructor(url) { this.url = url; this.closed = false; this.listeners = new Map(); eventSources.push(this); } addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, []); this.listeners.get(type).push(fn); } close() { this.closed = true; } }; const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-live-refetch-")); const bundle = path.join(tmp, "bundle.mjs"); await build({ stdin: { contents: ` export * from ${JSON.stringify(modulePath)}; export * from ${JSON.stringify(instancePath)}; export { ref, nextTick } from "vue"; `, resolveDir: root, // 从 frontend/ 解析:vue 与 "@/..." 都从这里走 sourcefile: "verify-entry.mjs", loader: "js" }, outfile: bundle, bundle: true, format: "esm", platform: "neutral", target: "node22", logLevel: "error", alias: { "@": path.join(root, "src") }, define: { "import.meta.env.VITE_APP_BASE_API": JSON.stringify("/api/v1"), __VUE_OPTIONS_API__: "false", __VUE_PROD_DEVTOOLS__: "false", __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false" } }); const { useLiveRefetch, orderStream, stopOrderStream, ref, nextTick } = await import(pathToFileURL(bundle).href); const sleep = ms => new Promise(r => setTimeout(r, ms)); function fakeStream() { const subs = new Map(); const resyncs = new Set(); let unsubscribed = 0; return { subscribe(channel, fn) { if (!subs.has(channel)) subs.set(channel, new Set()); subs.get(channel).add(fn); return () => { subs.get(channel)?.delete(fn); unsubscribed += 1; }; }, onResync(fn) { resyncs.add(fn); return () => resyncs.delete(fn); }, stop() {}, isRunning: () => true, emit(channel) { for (const fn of subs.get(channel) || []) fn(); }, fireResync() { for (const fn of resyncs) fn(); }, get channels() { return [...subs.keys()].sort(); }, get unsubscribed() { return unsubscribed; } }; } let failed = 0; async function check(name, fn) { try { await fn(); console.log(" PASS " + name); } catch (e) { failed += 1; console.error(" FAIL " + name + "\n " + (e && e.message)); } } const OPTS = { debounceMs: 5 }; await check("订阅给定的三个通道", async () => { const stream = fakeStream(); let calls = 0; const dispose = useLiveRefetch(["lsy_order", "lsy_refund", "har_order"], () => { calls += 1; }, { ...OPTS, stream }); assert.deepEqual(stream.channels, ["har_order", "lsy_order", "lsy_refund"]); dispose(); }); await check("本通道的帧 → 合并窗口后刷一次;别的通道不触发", async () => { const stream = fakeStream(); let calls = 0; const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream }); stream.emit("lsy_refund"); await sleep(20); assert.equal(calls, 0, "没订阅的通道不许刷"); stream.emit("lsy_order"); assert.equal(calls, 0, "合并窗口内不许同步刷(同一次写入会来好几帧)"); await sleep(20); assert.equal(calls, 1); dispose(); }); await check("窗口内多帧(含重连补拉)只刷一次", async () => { const stream = fakeStream(); let calls = 0; const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream }); stream.emit("lsy_order"); stream.emit("lsy_order"); stream.fireResync(); await sleep(20); assert.equal(calls, 1, "一次写入的多帧 + 一次重连补拉,合并成一次重拉"); dispose(); }); await check("busy 期间挂起,回安全态立刻补刷一次", async () => { const stream = fakeStream(); const busy = ref(false); let calls = 0; const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy }); busy.value = true; await nextTick(); stream.emit("lsy_order"); stream.emit("lsy_order"); await sleep(20); assert.equal(calls, 0, "用户正在勾选/弹窗里操作,列表不许在脚下跳"); busy.value = false; await nextTick(); assert.equal(calls, 1, "回到安全态立刻补刷(不等下一个信号)"); await sleep(20); assert.equal(calls, 1, "补刷只做一次(pending 要清掉)"); // 补刷把 pending 消费掉之后,下一轮 busy 翻转(期间**没有**新信号)不许再刷一次。 // 没有这一段的话,「pending 永远不清」与「pending 用完即清」两种实现读数完全相同。 busy.value = true; await nextTick(); busy.value = false; await nextTick(); await sleep(20); assert.equal(calls, 1, "上一轮的 pending 已被消费:这一轮没有信号,一次都不该刷"); dispose(); }); await check("busy 翻转但期间没有信号 → 一次都不刷", async () => { const stream = fakeStream(); const busy = ref(false); let calls = 0; const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy }); busy.value = true; await nextTick(); busy.value = false; await nextTick(); await sleep(20); assert.equal(calls, 0, "没漏东西就白拉一次 = 每次关弹窗都打一次接口"); dispose(); }); await check("busy 期间的重连补拉也挂起", async () => { const stream = fakeStream(); const busy = ref(true); let calls = 0; const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy }); await nextTick(); stream.fireResync(); await sleep(20); assert.equal(calls, 0); busy.value = false; await nextTick(); assert.equal(calls, 1); dispose(); }); await check("refetch 抛错 / reject 都不冒泡,后续帧仍能刷", async () => { const stream = fakeStream(); let calls = 0; const dispose = useLiveRefetch( ["lsy_order"], () => { calls += 1; if (calls === 1) throw new Error("同步炸"); if (calls === 2) return Promise.reject(new Error("异步炸")); return Promise.resolve(); }, { ...OPTS, stream } ); stream.emit("lsy_order"); await sleep(20); stream.emit("lsy_order"); await sleep(20); stream.emit("lsy_order"); await sleep(20); assert.equal(calls, 3, "三次都该真的调下去(异常被吞掉,不影响下一次)"); dispose(); }); await check("dispose:退订全部通道、清掉未到期的合并、不再刷", async () => { const stream = fakeStream(); let calls = 0; const dispose = useLiveRefetch(["lsy_order", "lsy_refund"], () => { calls += 1; }, { ...OPTS, stream }); stream.emit("lsy_order"); dispose(); assert.equal(stream.unsubscribed, 2, "两个通道都要退订"); await sleep(20); assert.equal(calls, 0, "dispose 时未到期的合并必须被清掉(页面已经卸载了)"); stream.emit("lsy_order"); await sleep(20); assert.equal(calls, 0); }); await check("单例:URL 现取令牌拼装 + 登出停流", async () => { // Storage.get 会 JSON.parse,所以按 Storage.set 的写法存(带引号的 JSON 串) localStore.setItem("remember_me", "true"); localStore.setItem("access_token", JSON.stringify("tok-123")); assert.equal(orderStream(), orderStream(), "必须是同一个实例"); const off = orderStream().subscribe("lsy_order", () => {}); assert.equal(eventSources.length, 1); assert.equal(eventSources[0].url, "/api/v1/payment/stream/orders?token=tok-123"); stopOrderStream(); assert.equal(eventSources[0].closed, true, "登出停流必须真的关连接(token 已清,重连只会被 403 挡回)"); off(); }); console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`); if (failed > 0) process.exit(1);