| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- #!/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);
|