verify-order-stream.mjs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. #!/usr/bin/env node
  2. /**
  3. * 订单实时流(src/utils/orderStream.ts)的执行级验证。
  4. * 用法:cd frontend && node scripts/verify-order-stream.mjs
  5. *
  6. * 断言失败打印 FAIL 并以非 0 退出;每条 PASS 都打名字(读数要看得见)。
  7. */
  8. import { build } from "esbuild";
  9. import assert from "node:assert/strict";
  10. import path from "node:path";
  11. import { fileURLToPath, pathToFileURL } from "node:url";
  12. import fs from "node:fs/promises";
  13. import os from "node:os";
  14. const here = path.dirname(fileURLToPath(import.meta.url));
  15. const root = path.resolve(here, "..");
  16. const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-order-stream-"));
  17. const bundle = path.join(tmp, "orderStream.mjs");
  18. await build({
  19. entryPoints: [path.join(root, "src/utils/orderStream.ts")],
  20. outfile: bundle,
  21. bundle: true,
  22. format: "esm",
  23. platform: "neutral",
  24. logLevel: "error"
  25. });
  26. const { createOrderStream } = await import(pathToFileURL(bundle).href);
  27. // ─────────────────────────── 假件 ───────────────────────────
  28. class FakeEventSource {
  29. constructor(url, at) {
  30. this.url = url;
  31. this.at = at; // 建连时刻(假时钟读数)
  32. this.closed = false;
  33. this.listeners = new Map();
  34. }
  35. addEventListener(type, fn) {
  36. if (!this.listeners.has(type)) this.listeners.set(type, []);
  37. this.listeners.get(type).push(fn);
  38. }
  39. close() {
  40. this.closed = true;
  41. }
  42. fire(type, payload = {}) {
  43. for (const fn of this.listeners.get(type) || []) fn(payload);
  44. }
  45. }
  46. function fakeClock() {
  47. let t = 0;
  48. const jobs = [];
  49. let nextId = 1;
  50. return {
  51. now: () => t,
  52. setTimer: (fn, ms) => {
  53. const id = nextId++;
  54. jobs.push({ id, at: t + ms, fn });
  55. return id;
  56. },
  57. clearTimer: id => {
  58. const i = jobs.findIndex(j => j.id === id);
  59. if (i >= 0) jobs.splice(i, 1);
  60. },
  61. advance(ms) {
  62. const end = t + ms;
  63. for (;;) {
  64. const due = jobs.filter(j => j.at <= end).sort((a, b) => a.at - b.at)[0];
  65. if (!due) break;
  66. jobs.splice(jobs.indexOf(due), 1);
  67. t = due.at;
  68. due.fn();
  69. }
  70. t = end;
  71. }
  72. };
  73. }
  74. function harness() {
  75. const clock = fakeClock();
  76. const built = [];
  77. const state = { urlCalls: 0 };
  78. const stream = createOrderStream({
  79. url: () => {
  80. state.urlCalls += 1;
  81. return `http://x/api/v1/payment/stream/orders?token=T${state.urlCalls}`;
  82. },
  83. createEventSource: url => {
  84. const es = new FakeEventSource(url, clock.now());
  85. built.push(es);
  86. return es;
  87. },
  88. now: clock.now,
  89. setTimer: clock.setTimer,
  90. clearTimer: clock.clearTimer,
  91. backoffBaseMs: 1000,
  92. backoffMaxMs: 30000,
  93. watchdogMs: 50000
  94. });
  95. return { clock, built, state, stream };
  96. }
  97. let failed = 0;
  98. function check(name, fn) {
  99. try {
  100. fn();
  101. console.log(" PASS " + name);
  102. } catch (e) {
  103. failed += 1;
  104. console.error(" FAIL " + name + "\n " + (e && e.message));
  105. }
  106. }
  107. // ─────────────────────────── 断言 ───────────────────────────
  108. check("subscribe 建一条连接,URL 来自 url()", () => {
  109. const { built, stream } = harness();
  110. const off = stream.subscribe("lsy_order", () => {});
  111. assert.equal(built.length, 1);
  112. assert.equal(built[0].url, "http://x/api/v1/payment/stream/orders?token=T1");
  113. off();
  114. });
  115. check("changed 帧只触发该通道的订阅者", () => {
  116. const { built, stream } = harness();
  117. let order = 0;
  118. let refund = 0;
  119. const off1 = stream.subscribe("lsy_order", () => { order += 1; });
  120. const off2 = stream.subscribe("lsy_refund", () => { refund += 1; });
  121. built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });
  122. assert.equal(order, 1);
  123. assert.equal(refund, 0, "别的通道的帧不许触发本通道");
  124. built[0].fire("changed", { data: JSON.stringify({ channel: "har_order" }) });
  125. assert.equal(order, 1);
  126. assert.equal(refund, 0, "没人订阅的通道:静默丢弃");
  127. off1();
  128. off2();
  129. });
  130. check("坏帧不抛异常、不影响后续帧", () => {
  131. const { built, stream } = harness();
  132. let n = 0;
  133. const off = stream.subscribe("lsy_order", () => { n += 1; });
  134. built[0].fire("changed", { data: "{oops" });
  135. built[0].fire("changed", {});
  136. built[0].fire("changed", { data: JSON.stringify({}) });
  137. built[0].fire("changed", { data: JSON.stringify({ channel: "lsy_order" }) });
  138. assert.equal(n, 1, "只有最后那条合法帧该生效");
  139. off();
  140. });
  141. check("首次 open 不补拉;重连后的 open 补拉一次", () => {
  142. const { clock, built, stream } = harness();
  143. let resync = 0;
  144. const offR = stream.onResync(() => { resync += 1; });
  145. const off = stream.subscribe("lsy_order", () => {});
  146. built[0].fire("open", {});
  147. assert.equal(resync, 0, "首次连接时页面自己正在加载,不该再多拉一次");
  148. built[0].fire("error", {});
  149. clock.advance(1000);
  150. assert.equal(built.length, 2);
  151. built[1].fire("open", {});
  152. assert.equal(resync, 1, "断线期间漏掉的帧必须靠重连补拉兜回来");
  153. assert.equal(built[0].closed, true, "旧连接要显式 close(浏览器自带重连与我们的退避不能两条腿跑)");
  154. off();
  155. offR();
  156. });
  157. check("退避 1s→2s→4s→…→30s 封顶", () => {
  158. const { clock, built, stream } = harness();
  159. const off = stream.subscribe("lsy_order", () => {});
  160. let expected = 1;
  161. for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
  162. built[built.length - 1].fire("error", {});
  163. clock.advance(delay - 1);
  164. assert.equal(built.length, expected, `${delay}ms 还没到就重连了`);
  165. clock.advance(1);
  166. assert.equal(built.length, expected + 1, `${delay}ms 到了还没重连`);
  167. expected += 1;
  168. }
  169. off();
  170. });
  171. check("成功连接后退避重置回 1s(连续断线不把惩罚累积上去)", () => {
  172. const { clock, built, stream } = harness();
  173. const off = stream.subscribe("lsy_order", () => {});
  174. built[0].fire("error", {}); // 第一次断线:1s
  175. clock.advance(1000);
  176. assert.equal(built.length, 2);
  177. built[1].fire("error", {}); // 还没连上过:2s
  178. clock.advance(1999);
  179. assert.equal(built.length, 2, "还没到 2000ms 就重连了");
  180. clock.advance(1);
  181. assert.equal(built.length, 3);
  182. built[2].fire("open", {}); // 连上了 —— 下一次断线必须回到 1s
  183. built[2].fire("error", {});
  184. clock.advance(999);
  185. assert.equal(built.length, 3, "999ms 就重连了(退避被重置过头?)");
  186. clock.advance(1);
  187. assert.equal(built.length, 4,
  188. "成功连接后退避没重置:仍在按上一轮的 4s 计,页面会在毫无必要的情况下越等越久");
  189. off();
  190. });
  191. check("每次重连都重新调 url()(令牌换新)", () => {
  192. const { clock, built, state, stream } = harness();
  193. const off = stream.subscribe("lsy_order", () => {});
  194. built[0].fire("open", {});
  195. assert.equal(state.urlCalls, 1);
  196. built[0].fire("error", {});
  197. clock.advance(1000);
  198. assert.equal(state.urlCalls, 2, "重连必须重新拼 URL —— 旧令牌可能已经失效");
  199. assert.notEqual(built[0].url, built[1].url);
  200. off();
  201. });
  202. check("50s 没有任何帧(ping 也算)→ 弃掉重建", () => {
  203. const { clock, built, stream } = harness();
  204. const off = stream.subscribe("lsy_order", () => {});
  205. built[0].fire("open", {});
  206. clock.advance(20000);
  207. built[0].fire("ping", {}); // 服务端 20s 一个 ping:看门狗被喂饱
  208. clock.advance(49999);
  209. assert.equal(built[0].closed, false, "ping 之后不到 50s 不该重建");
  210. clock.advance(1);
  211. assert.equal(built[0].closed, true, "50s 没有帧 = 半死连接(TCP 还在、对端不推了),必须重建");
  212. clock.advance(1000);
  213. assert.equal(built.length, 2);
  214. off();
  215. });
  216. check("stop() 关连接、清定时器,此后不再重连", () => {
  217. const { clock, built, stream } = harness();
  218. const off = stream.subscribe("lsy_order", () => {});
  219. built[0].fire("open", {});
  220. stream.stop();
  221. assert.equal(built[0].closed, true);
  222. clock.advance(600000);
  223. assert.equal(built.length, 1, "stop 之后不许再有任何新连接(登出后 token 已清,重连只会被 403 挡回)");
  224. off();
  225. });
  226. check("最后一个订阅者退订 → 连接关闭", () => {
  227. const { built, stream } = harness();
  228. const off1 = stream.subscribe("lsy_order", () => {});
  229. const off2 = stream.subscribe("lsy_refund", () => {});
  230. off1();
  231. assert.equal(built[0].closed, false, "还有订阅者时不许断");
  232. off2();
  233. assert.equal(built[0].closed, true);
  234. });
  235. check("三个通道名的线上契约(与后端 OrderEventPublisher 逐字一致)", () => {
  236. const CHANNELS = ["lsy_order", "lsy_refund", "har_order"];
  237. const { built, stream } = harness();
  238. const hits = { lsy_order: 0, lsy_refund: 0, har_order: 0 };
  239. const offs = CHANNELS.map(c => stream.subscribe(c, () => { hits[c] += 1; }));
  240. for (const c of CHANNELS) {
  241. built[0].fire("changed", { data: JSON.stringify({ channel: c }) });
  242. }
  243. assert.deepEqual(hits, { lsy_order: 1, lsy_refund: 1, har_order: 1 });
  244. offs.forEach(off => off());
  245. });
  246. console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
  247. if (failed > 0) process.exit(1);