|
|
@@ -0,0 +1,289 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+/**
|
|
|
+ * 订单实时流(src/utils/orderStream.ts)的执行级验证。
|
|
|
+ * 用法:cd frontend && node scripts/verify-order-stream.mjs
|
|
|
+ *
|
|
|
+ * 断言失败打印 FAIL 并以非 0 退出;每条 PASS 都打名字(读数要看得见)。
|
|
|
+ */
|
|
|
+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 tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-order-stream-"));
|
|
|
+const bundle = path.join(tmp, "orderStream.mjs");
|
|
|
+
|
|
|
+await build({
|
|
|
+ entryPoints: [path.join(root, "src/utils/orderStream.ts")],
|
|
|
+ outfile: bundle,
|
|
|
+ bundle: true,
|
|
|
+ format: "esm",
|
|
|
+ platform: "neutral",
|
|
|
+ logLevel: "error"
|
|
|
+});
|
|
|
+
|
|
|
+const { createOrderStream } = await import(pathToFileURL(bundle).href);
|
|
|
+
|
|
|
+// ─────────────────────────── 假件 ───────────────────────────
|
|
|
+
|
|
|
+class FakeEventSource {
|
|
|
+ constructor(url, at) {
|
|
|
+ this.url = url;
|
|
|
+ this.at = at; // 建连时刻(假时钟读数)
|
|
|
+ this.closed = false;
|
|
|
+ this.listeners = new Map();
|
|
|
+ }
|
|
|
+ addEventListener(type, fn) {
|
|
|
+ if (!this.listeners.has(type)) this.listeners.set(type, []);
|
|
|
+ this.listeners.get(type).push(fn);
|
|
|
+ }
|
|
|
+ close() {
|
|
|
+ this.closed = true;
|
|
|
+ }
|
|
|
+ fire(type, payload = {}) {
|
|
|
+ for (const fn of this.listeners.get(type) || []) fn(payload);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function fakeClock() {
|
|
|
+ let t = 0;
|
|
|
+ const jobs = [];
|
|
|
+ let nextId = 1;
|
|
|
+ return {
|
|
|
+ now: () => t,
|
|
|
+ setTimer: (fn, ms) => {
|
|
|
+ const id = nextId++;
|
|
|
+ jobs.push({ id, at: t + ms, fn });
|
|
|
+ return id;
|
|
|
+ },
|
|
|
+ clearTimer: id => {
|
|
|
+ const i = jobs.findIndex(j => j.id === id);
|
|
|
+ if (i >= 0) jobs.splice(i, 1);
|
|
|
+ },
|
|
|
+ advance(ms) {
|
|
|
+ const end = t + ms;
|
|
|
+ for (;;) {
|
|
|
+ const due = jobs.filter(j => j.at <= end).sort((a, b) => a.at - b.at)[0];
|
|
|
+ if (!due) break;
|
|
|
+ jobs.splice(jobs.indexOf(due), 1);
|
|
|
+ t = due.at;
|
|
|
+ due.fn();
|
|
|
+ }
|
|
|
+ t = end;
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function harness() {
|
|
|
+ const clock = fakeClock();
|
|
|
+ const built = [];
|
|
|
+ const state = { urlCalls: 0 };
|
|
|
+ const stream = createOrderStream({
|
|
|
+ url: () => {
|
|
|
+ state.urlCalls += 1;
|
|
|
+ return `http://x/api/v1/payment/stream/orders?token=T${state.urlCalls}`;
|
|
|
+ },
|
|
|
+ createEventSource: url => {
|
|
|
+ const es = new FakeEventSource(url, clock.now());
|
|
|
+ built.push(es);
|
|
|
+ return es;
|
|
|
+ },
|
|
|
+ now: clock.now,
|
|
|
+ setTimer: clock.setTimer,
|
|
|
+ clearTimer: clock.clearTimer,
|
|
|
+ backoffBaseMs: 1000,
|
|
|
+ backoffMaxMs: 30000,
|
|
|
+ watchdogMs: 50000
|
|
|
+ });
|
|
|
+ return { clock, built, state, stream };
|
|
|
+}
|
|
|
+
|
|
|
+let failed = 0;
|
|
|
+function check(name, fn) {
|
|
|
+ try {
|
|
|
+ fn();
|
|
|
+ console.log(" PASS " + name);
|
|
|
+ } catch (e) {
|
|
|
+ failed += 1;
|
|
|
+ console.error(" FAIL " + name + "\n " + (e && e.message));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─────────────────────────── 断言 ───────────────────────────
|
|
|
+
|
|
|
+check("subscribe 建一条连接,URL 来自 url()", () => {
|
|
|
+ const { built, stream } = harness();
|
|
|
+ const off = stream.subscribe("lsy_order", () => {});
|
|
|
+ assert.equal(built.length, 1);
|
|
|
+ assert.equal(built[0].url, "http://x/api/v1/payment/stream/orders?token=T1");
|
|
|
+ off();
|
|
|
+});
|
|
|
+
|
|
|
+check("changed 帧只触发该通道的订阅者", () => {
|
|
|
+ const { built, stream } = harness();
|
|
|
+ let order = 0;
|
|
|
+ let refund = 0;
|
|
|
+ const off1 = stream.subscribe("lsy_order", () => { order += 1; });
|
|
|
+ const off2 = stream.subscribe("lsy_refund", () => { refund += 1; });
|
|
|
+
|
|
|
+ built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });
|
|
|
+ assert.equal(order, 1);
|
|
|
+ assert.equal(refund, 0, "别的通道的帧不许触发本通道");
|
|
|
+
|
|
|
+ built[0].fire("changed", { data: JSON.stringify({ channel: "har_order" }) });
|
|
|
+ assert.equal(order, 1);
|
|
|
+ assert.equal(refund, 0, "没人订阅的通道:静默丢弃");
|
|
|
+
|
|
|
+ off1();
|
|
|
+ off2();
|
|
|
+});
|
|
|
+
|
|
|
+check("坏帧不抛异常、不影响后续帧", () => {
|
|
|
+ const { built, stream } = harness();
|
|
|
+ let n = 0;
|
|
|
+ const off = stream.subscribe("lsy_order", () => { n += 1; });
|
|
|
+
|
|
|
+ built[0].fire("changed", { data: "{oops" });
|
|
|
+ built[0].fire("changed", {});
|
|
|
+ built[0].fire("changed", { data: JSON.stringify({}) });
|
|
|
+ built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });
|
|
|
+
|
|
|
+ assert.equal(n, 1, "只有最后那条合法帧该生效");
|
|
|
+ off();
|
|
|
+});
|
|
|
+
|
|
|
+check("首次 open 不补拉;重连后的 open 补拉一次", () => {
|
|
|
+ const { clock, built, stream } = harness();
|
|
|
+ let resync = 0;
|
|
|
+ const offR = stream.onResync(() => { resync += 1; });
|
|
|
+ const off = stream.subscribe("lsy_order", () => {});
|
|
|
+
|
|
|
+ built[0].fire("open", {});
|
|
|
+ assert.equal(resync, 0, "首次连接时页面自己正在加载,不该再多拉一次");
|
|
|
+
|
|
|
+ built[0].fire("error", {});
|
|
|
+ clock.advance(1000);
|
|
|
+ assert.equal(built.length, 2);
|
|
|
+ built[1].fire("open", {});
|
|
|
+ assert.equal(resync, 1, "断线期间漏掉的帧必须靠重连补拉兜回来");
|
|
|
+ assert.equal(built[0].closed, true, "旧连接要显式 close(浏览器自带重连与我们的退避不能两条腿跑)");
|
|
|
+
|
|
|
+ off();
|
|
|
+ offR();
|
|
|
+});
|
|
|
+
|
|
|
+check("退避 1s→2s→4s→…→30s 封顶", () => {
|
|
|
+ const { clock, built, stream } = harness();
|
|
|
+ const off = stream.subscribe("lsy_order", () => {});
|
|
|
+
|
|
|
+ let expected = 1;
|
|
|
+ for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
|
|
|
+ built[built.length - 1].fire("error", {});
|
|
|
+ clock.advance(delay - 1);
|
|
|
+ assert.equal(built.length, expected, `${delay}ms 还没到就重连了`);
|
|
|
+ clock.advance(1);
|
|
|
+ assert.equal(built.length, expected + 1, `${delay}ms 到了还没重连`);
|
|
|
+ expected += 1;
|
|
|
+ }
|
|
|
+ off();
|
|
|
+});
|
|
|
+
|
|
|
+check("成功连接后退避重置回 1s(连续断线不把惩罚累积上去)", () => {
|
|
|
+ const { clock, built, stream } = harness();
|
|
|
+ const off = stream.subscribe("lsy_order", () => {});
|
|
|
+
|
|
|
+ built[0].fire("error", {}); // 第一次断线:1s
|
|
|
+ clock.advance(1000);
|
|
|
+ assert.equal(built.length, 2);
|
|
|
+
|
|
|
+ built[1].fire("error", {}); // 还没连上过:2s
|
|
|
+ clock.advance(1999);
|
|
|
+ assert.equal(built.length, 2, "还没到 2000ms 就重连了");
|
|
|
+ clock.advance(1);
|
|
|
+ assert.equal(built.length, 3);
|
|
|
+
|
|
|
+ built[2].fire("open", {}); // 连上了 —— 下一次断线必须回到 1s
|
|
|
+ built[2].fire("error", {});
|
|
|
+ clock.advance(999);
|
|
|
+ assert.equal(built.length, 3, "999ms 就重连了(退避被重置过头?)");
|
|
|
+ clock.advance(1);
|
|
|
+ assert.equal(built.length, 4,
|
|
|
+ "成功连接后退避没重置:仍在按上一轮的 4s 计,页面会在毫无必要的情况下越等越久");
|
|
|
+
|
|
|
+ off();
|
|
|
+});
|
|
|
+
|
|
|
+check("每次重连都重新调 url()(令牌换新)", () => {
|
|
|
+ const { clock, built, state, stream } = harness();
|
|
|
+ const off = stream.subscribe("lsy_order", () => {});
|
|
|
+ built[0].fire("open", {});
|
|
|
+ assert.equal(state.urlCalls, 1);
|
|
|
+
|
|
|
+ built[0].fire("error", {});
|
|
|
+ clock.advance(1000);
|
|
|
+ assert.equal(state.urlCalls, 2, "重连必须重新拼 URL —— 旧令牌可能已经失效");
|
|
|
+ assert.notEqual(built[0].url, built[1].url);
|
|
|
+ off();
|
|
|
+});
|
|
|
+
|
|
|
+check("50s 没有任何帧(ping 也算)→ 弃掉重建", () => {
|
|
|
+ const { clock, built, stream } = harness();
|
|
|
+ const off = stream.subscribe("lsy_order", () => {});
|
|
|
+ built[0].fire("open", {});
|
|
|
+
|
|
|
+ clock.advance(20000);
|
|
|
+ built[0].fire("ping", {}); // 服务端 20s 一个 ping:看门狗被喂饱
|
|
|
+
|
|
|
+ clock.advance(49999);
|
|
|
+ assert.equal(built[0].closed, false, "ping 之后不到 50s 不该重建");
|
|
|
+ clock.advance(1);
|
|
|
+ assert.equal(built[0].closed, true, "50s 没有帧 = 半死连接(TCP 还在、对端不推了),必须重建");
|
|
|
+ clock.advance(1000);
|
|
|
+ assert.equal(built.length, 2);
|
|
|
+ off();
|
|
|
+});
|
|
|
+
|
|
|
+check("stop() 关连接、清定时器,此后不再重连", () => {
|
|
|
+ const { clock, built, stream } = harness();
|
|
|
+ const off = stream.subscribe("lsy_order", () => {});
|
|
|
+ built[0].fire("open", {});
|
|
|
+
|
|
|
+ stream.stop();
|
|
|
+
|
|
|
+ assert.equal(built[0].closed, true);
|
|
|
+ clock.advance(600000);
|
|
|
+ assert.equal(built.length, 1, "stop 之后不许再有任何新连接(登出后 token 已清,重连只会被 403 挡回)");
|
|
|
+ off();
|
|
|
+});
|
|
|
+
|
|
|
+check("最后一个订阅者退订 → 连接关闭", () => {
|
|
|
+ const { built, stream } = harness();
|
|
|
+ const off1 = stream.subscribe("lsy_order", () => {});
|
|
|
+ const off2 = stream.subscribe("lsy_refund", () => {});
|
|
|
+
|
|
|
+ off1();
|
|
|
+ assert.equal(built[0].closed, false, "还有订阅者时不许断");
|
|
|
+ off2();
|
|
|
+ assert.equal(built[0].closed, true);
|
|
|
+});
|
|
|
+
|
|
|
+check("三个通道名的线上契约(与后端 OrderEventPublisher 逐字一致)", () => {
|
|
|
+ const CHANNELS = ["lsy_order", "lsy_refund", "har_order"];
|
|
|
+ const { built, stream } = harness();
|
|
|
+ const hits = { lsy_order: 0, lsy_refund: 0, har_order: 0 };
|
|
|
+ const offs = CHANNELS.map(c => stream.subscribe(c, () => { hits[c] += 1; }));
|
|
|
+
|
|
|
+ for (const c of CHANNELS) {
|
|
|
+ built[0].fire("changed", { data: JSON.stringify({ channel: c }) });
|
|
|
+ }
|
|
|
+
|
|
|
+ assert.deepEqual(hits, { lsy_order: 1, lsy_refund: 1, har_order: 1 });
|
|
|
+ offs.forEach(off => off());
|
|
|
+});
|
|
|
+
|
|
|
+console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
|
|
|
+if (failed > 0) process.exit(1);
|