|
@@ -0,0 +1,300 @@
|
|
|
|
|
+#!/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);
|