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