@mrrisega/dsh-remote 0.7.2 → 0.7.3-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +214 -9
  2. package/clients/dsh-remote/e2ee-shim-script.js +98 -18
  3. package/clients/dsh-remote/mirror-diag-script.js +19 -0
  4. package/clients/dsh-remote/mobile-adapter.mjs +803 -16
  5. package/clients/dsh-remote/test/bridge-guard.test.mjs +169 -0
  6. package/clients/dsh-remote/test/e2ee-shim-selfheal.test.mjs +264 -0
  7. package/clients/dsh-remote/test/mirror-diag.test.mjs +75 -0
  8. package/clients/dsh-remote/test/mobile-adapter-compat.test.mjs +175 -0
  9. package/clients/dsh-remote/test/mobile-adapter-copy.test.mjs +56 -0
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
  11. package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
  12. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
  13. package/clients/dsh-remote/upstream-discovery.mjs +6 -1
  14. package/dsh-setup.mjs +225 -6
  15. package/package.json +1 -1
  16. package/packages/dsh-remote-web/lib/index.js +140 -3
  17. package/packages/dsh-remote-web/package.json +1 -1
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +803 -16
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
  23. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
  24. package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
  25. package/packages/dsh-remote-web/test/windows-compat.test.mjs +82 -12
@@ -0,0 +1,169 @@
1
+ /**
2
+ * C2 / C5 回归:bridge 的「互踢根治」与「心跳/退避」契约。
3
+ *
4
+ * 背景(2026-10-20 用户群故障诊断,报告 00 §6.2 / 04 §四):
5
+ * · R2:隧道 30 秒级断连,今天断开 6,561 次 / 322 台设备(活跃设备 90%),
6
+ * 75.2% 的隧道会话活不到 60s —— 心跳 15s + **丢 1 次 pong 就断** 是主因之一;
7
+ * · 互踢:中继在"同 deviceId 新连接注册"时 close(4000) 顶掉旧连接,而旧实现**收到 4000
8
+ * 仍然 2s 后重连** → 两个实例以 2~3.5s 周期乒乓(生产 dev-a50dced9cc19 327 次/10min);
9
+ * · 同机单实例保护缺失:孤儿 bridge / 升级后未退的旧 bridge 都能起第二份。
10
+ *
11
+ * 本文件锁三层:
12
+ * ① 纯函数策略(4000 长退避/退出、普通退避上限 10s + 抖动、心跳连续 2 次丢 pong);
13
+ * ② 单实例锁的判定纪律(死 pid/无关进程抢锁、真 bridge 阻塞、看不清时放行);
14
+ * ③ 端到端:真的起两个 bridge,第二个必须**启动即退出(退出码 4)**。
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import { spawn } from "node:child_process";
18
+ import http from "node:http";
19
+ import { mkdtemp, rm } from "node:fs/promises";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import test from "node:test";
23
+ import { setTimeout as delay } from "node:timers/promises";
24
+ import { fileURLToPath } from "node:url";
25
+ import { WebSocketServer } from "ws";
26
+
27
+ import {
28
+ DUPLICATE_INSTANCE_EXIT_CODE,
29
+ DUPLICATE_RECONNECT_BASE_MS,
30
+ HEARTBEAT_MAX_MISSED_PONGS,
31
+ MAX_RECONNECT_DELAY_MS,
32
+ RECONNECT_JITTER_MS,
33
+ RECONNECT_JITTER_MIN_BASE_MS,
34
+ TUNNEL_HEARTBEAT_DEFAULT_MS,
35
+ heartbeatStep,
36
+ instanceLockVerdict,
37
+ reconnectPlan,
38
+ } from "../dsh-bridge.mjs";
39
+
40
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
41
+ const BRIDGE = path.join(HERE, "..", "dsh-bridge.mjs");
42
+
43
+ // ─────────────────────── ① 心跳(C5)───────────────────────
44
+
45
+ test("C5 心跳:间隔从 15s 调到 20-25s,且连续丢 2 次 pong 才断", () => {
46
+ assert.ok(TUNNEL_HEARTBEAT_DEFAULT_MS >= 20_000 && TUNNEL_HEARTBEAT_DEFAULT_MS <= 25_000,
47
+ `心跳间隔必须在 20-25s(实际 ${TUNNEL_HEARTBEAT_DEFAULT_MS})`);
48
+ assert.equal(HEARTBEAT_MAX_MISSED_PONGS, 2, "必须容忍连续 2 次丢 pong(旧实现丢 1 次就断)");
49
+
50
+ // 有 pong:正常发下一拍
51
+ assert.deepEqual(heartbeatStep({ pongReceived: true, missedPongs: 0 }), { missedPongs: 0, terminate: false, ping: true });
52
+ // 第 1 次没等到 pong:**不能断**,继续探测(旧契约在这里就 terminate 了)
53
+ assert.deepEqual(heartbeatStep({ pongReceived: false, missedPongs: 0 }), { missedPongs: 1, terminate: false, ping: true });
54
+ // 第 2 次仍未 pong:判定隧道已死
55
+ assert.deepEqual(heartbeatStep({ pongReceived: false, missedPongs: 1 }), { missedPongs: 2, terminate: true, ping: false });
56
+ // 中间任何一次收到 pong,计数清零
57
+ assert.equal(heartbeatStep({ pongReceived: true, missedPongs: 1 }).missedPongs, 0);
58
+ });
59
+
60
+ // ─────────────────────── ② 重连退避(C2 + C5)───────────────────────
61
+
62
+ test("C2 互踢:close 4000 第一次长退避(30-60s),连续第 2 次直接退出", () => {
63
+ const first = reconnectPlan({ code: 4000, retry: 0, consecutive4000: 0, rand: () => 0.5 });
64
+ assert.equal(first.exit, false, "第一次被顶不能退出(可能是短暂升级重叠,长退避让对方站稳)");
65
+ assert.equal(first.consecutive4000, 1);
66
+ assert.ok(first.delayMs >= DUPLICATE_RECONNECT_BASE_MS, `第一次退避必须 ≥30s(实际 ${first.delayMs})`);
67
+ assert.ok(first.delayMs <= DUPLICATE_RECONNECT_BASE_MS * 2, `第一次退避上限 60s(实际 ${first.delayMs})`);
68
+
69
+ const second = reconnectPlan({ code: 4000, retry: 3, consecutive4000: first.consecutive4000, rand: () => 0.5 });
70
+ assert.equal(second.exit, true, "连续第 2 次 4000 → 必须退出(再重连只会继续互踢)");
71
+ assert.equal(second.delayMs, 0);
72
+
73
+ // 普通断线会清零计数:一次 4000 + 一次 1006 不构成"连续 2 次"
74
+ assert.equal(reconnectPlan({ code: 1006, retry: 1, consecutive4000: 1 }).consecutive4000, 0);
75
+ });
76
+
77
+ test("C5 普通退避:上限 30s→10s,且加 0-3s 抖动避免同步重连尖峰", () => {
78
+ // 第一档(2s)保持确定值:测试里「bridge 5 秒内必须重连」的硬期限不能被随机数撞破
79
+ const d0 = reconnectPlan({ code: 1006, retry: 0, rand: () => 0.99 });
80
+ assert.equal(d0.delayMs, 2_000);
81
+ assert.ok(MAX_RECONNECT_DELAY_MS <= 10_000, "上限必须降到 10s(旧上限 30s 让瞬时断网恢复过慢)");
82
+ // 高档位:base + 0~3s 抖动,且总时长不超过上限 + 抖动
83
+ for (const retry of [2, 3, 4, 9]) {
84
+ const lo = reconnectPlan({ code: 1006, retry, rand: () => 0 });
85
+ const hi = reconnectPlan({ code: 1006, retry, rand: () => 0.999 });
86
+ const base = Math.min(MAX_RECONNECT_DELAY_MS, 2_000 * 2 ** retry);
87
+ assert.equal(lo.delayMs, base, `retry=${retry} 无抖动时等于 base`);
88
+ assert.ok(hi.delayMs >= base && hi.delayMs < base + RECONNECT_JITTER_MS + 1, `retry=${retry} 抖动必须 0~3s(实际 ${hi.delayMs - base}ms)`);
89
+ assert.ok(base >= RECONNECT_JITTER_MIN_BASE_MS, "高档位才加抖动(首档保持确定性)");
90
+ }
91
+ // 档位封顶后再怎么重试也不涨
92
+ assert.equal(reconnectPlan({ code: 1006, retry: 20, rand: () => 0 }).delayMs, MAX_RECONNECT_DELAY_MS);
93
+ });
94
+
95
+ // ─────────────────────── ③ 单实例锁判定(C2)───────────────────────
96
+
97
+ test("C2 单实例锁判定:死 pid/无关进程抢锁,真 bridge 阻塞,看不清时放行(失败安全)", () => {
98
+ assert.equal(instanceLockVerdict({ selfPid: 10, pid: 0, alive: false }), "steal", "没有锁 → 抢");
99
+ assert.equal(instanceLockVerdict({ selfPid: 10, pid: 10, alive: true, isOurBridge: true }), "steal", "自己那把旧锁 → 抢");
100
+ assert.equal(instanceLockVerdict({ selfPid: 10, pid: 99, alive: false }), "steal", "pid 已死 → 陈旧锁可抢(绝不能永久挡住启动)");
101
+ assert.equal(instanceLockVerdict({ selfPid: 10, pid: 99, alive: true, isOurBridge: false }), "steal", "pid 被回收给无关进程 → 抢");
102
+ assert.equal(instanceLockVerdict({ selfPid: 10, pid: 99, alive: true, isOurBridge: true }), "blocked", "命令行确认是本 bridge → 已有实例,本实例退出");
103
+ assert.equal(instanceLockVerdict({ selfPid: 10, pid: 99, alive: true, isOurBridge: false, commandKnown: false }), "unknown",
104
+ "取不到命令行 → 不阻塞启动(多开由 4000 长退避/退出兜底),也不误删别人的锁");
105
+ });
106
+
107
+ // ─────────────────────── ④ 端到端:第二个实例启动即退出 ───────────────────────
108
+
109
+ test("C2 端到端:同一配置目录的第二个 bridge 启动即退出(退出码 4),不会去抢 deviceId", async () => {
110
+ let loginCount = 0;
111
+ const api = http.createServer((req, res) => {
112
+ const body = req.url === "/api/device-login"
113
+ ? { token: `token-${++loginCount}` }
114
+ : req.url === "/api/devices"
115
+ ? { device: { id: "dev-0000000000aa" } }
116
+ : { error: "not found" };
117
+ res.writeHead(req.url === "/api/devices" ? 201 : 200, { "content-type": "application/json" });
118
+ res.end(JSON.stringify(body));
119
+ });
120
+ await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
121
+
122
+ const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
123
+ await new Promise((resolve) => wss.once("listening", resolve));
124
+ const registered = new Promise((resolve) => {
125
+ wss.on("connection", (ws) => ws.once("message", () => resolve()));
126
+ });
127
+
128
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-bridge-guard-"));
129
+ const env = {
130
+ ...process.env,
131
+ DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
132
+ DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
133
+ DSH_BRIDGE_PHONE: "test-account",
134
+ DSH_BRIDGE_PASSWORD: "test-password",
135
+ DSH_BRIDGE_DEVICE_ID: "dev-0000000000aa",
136
+ DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json"),
137
+ DSH_WECHAT: "0", // 本用例只测隧道与锁;关掉微信通道避免无关轮询
138
+ DSH_BRIDGE_E2EE: "0" // 同理:不起 E2EE 初始化
139
+ };
140
+ let first = null;
141
+ try {
142
+ first = spawn(process.execPath, [BRIDGE], { env, stdio: ["ignore", "pipe", "pipe"] });
143
+ first.stdout.on("data", () => {});
144
+ first.stderr.on("data", () => {});
145
+ await Promise.race([
146
+ registered,
147
+ delay(8_000, null, { ref: false }).then(() => { throw new Error("第一个 bridge 未在 8 秒内注册隧道"); })
148
+ ]);
149
+
150
+ // 第二个实例:同一 DSH_BRIDGE_CONFIG(同 relayDir)→ 锁被第一个占着,必须启动即退出
151
+ const second = spawn(process.execPath, [BRIDGE], { env, stdio: ["ignore", "pipe", "pipe"] });
152
+ let errText = "";
153
+ second.stderr.on("data", (b) => { errText += b.toString(); });
154
+ const exitCode = await Promise.race([
155
+ new Promise((resolve) => second.on("exit", (code) => resolve(code))),
156
+ delay(10_000, null, { ref: false }).then(() => "timeout")
157
+ ]);
158
+ try { second.kill("SIGKILL"); } catch { /* 已退出 */ }
159
+ assert.equal(exitCode, DUPLICATE_INSTANCE_EXIT_CODE,
160
+ `第二个实例必须以退出码 ${DUPLICATE_INSTANCE_EXIT_CODE} 退出(实际 ${exitCode})——否则两个实例同 deviceId 互踢`);
161
+ assert.match(errText, /已有另一个 bridge/, `日志要说明为什么退出:${errText.slice(0, 500)}`);
162
+ assert.equal(loginCount, 1, "被挡下的第二个实例不得再去登录账号 API(登录接口按出口 IP 限流)");
163
+ } finally {
164
+ if (first) { try { first.kill("SIGKILL"); } catch { /* 已退出 */ } }
165
+ wss.close();
166
+ api.close();
167
+ await rm(tempDir, { recursive: true, force: true });
168
+ }
169
+ });
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * e2ee-shim-script.js —— **死会话自愈**回归(2026-10-20 群故障诊断 C3)。
4
+ *
5
+ * 现场(03-加载卡住与打不开.md §2.4 D1,P0):
6
+ * dev-08b73ba9de67 单设备一天 nginx **009(e2ee_session_locked 拒握手)8997 次**,
7
+ * 同时 4064 个 HTTP 200 —— 页面能打开,实时通道永远连不上。
8
+ * 根因是代码缺陷:桥端回 `x-dsh-e2ee-error: unknown_session` 时,shim 只清了
9
+ * **storage** 里的交接单,**从不把内存里的 `_ctx.sess` 置空**(全文只有 `_install()` 一处赋值)。
10
+ * 于是这一个页面的后续每一个 HTTP/WS 继续带死 sessId,WS 被 1008 无限拒、HTTP 全 502,
11
+ * 刷新也只是一次次把同一张死票装回来。
12
+ *
13
+ * 本文件锁死 C3 契约(用**真实抽取**的核心代码,不是测试里另抄一份):
14
+ * ① 收到 unknown_session → 内存会话被置空(下一次 WS 不再带 &e2ee=,HTTP 回落到明文透传);
15
+ * ② 连续 ≥3 次 WS 握手失败且有会话 → 清交接单 + 清内存会话(徽标文案见脚本接线断言);
16
+ * ③ 连续计数在握手成功后清零(偶发单次失败不得误清会话);
17
+ * ④ 「每页最多一次」受控 reload 的闸门真的只放行一次(防 reload 风暴)。
18
+ */
19
+ import assert from "node:assert/strict";
20
+ import test from "node:test";
21
+ import { readFileSync } from "node:fs";
22
+ import path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { extractShimCore, readShimScript, SHIM_EXPORTS } from "./lib-e2ee-shim.mjs";
25
+
26
+ const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
27
+ const SHIM_SCRIPT = path.join(THIS_DIR, "..", "e2ee-shim-script.js");
28
+
29
+ /**
30
+ * 抽取核心区间并额外暴露 C3 相关内部名字。
31
+ * 为什么不改 lib-e2ee-shim.mjs 的 SHIM_EXPORTS:那是其它 6 个用例文件共用的共享辅助,
32
+ * 本文件的导出清单只为自己服务,独立出来不会给既有用例带来任何风险。
33
+ */
34
+ const SELFHEAL_EXPORTS = [
35
+ ...SHIM_EXPORTS,
36
+ "seNoteE2eeError", "seE2eeErrorOf", "seForgetHandoverStores", "seStaleSessionHook", "seLastStaleCode",
37
+ "seNoteWsHandshakeFail", "seNoteWsOpened", "seWsFailHook", "seWsFailCount", "seWsFailStreak",
38
+ "SE_WS_FAIL_INVALIDATE", "seReloadOnceForDeadSession", "SE_RELOAD_GUARD_KEY",
39
+ ];
40
+ const coreSrc = extractShimCore() + "\nexport { " + SELFHEAL_EXPORTS.join(", ") + " };\n";
41
+ const shim = await import("data:text/javascript;base64," + Buffer.from(coreSrc, "utf8").toString("base64"));
42
+
43
+ const SESS_ID = "a".repeat(32);
44
+
45
+ // ── 测试替身:storage / location(函数体里按名字读全局,用法和浏览器一致) ────────────────
46
+ class FakeStore {
47
+ constructor() { this.m = new Map(); this.removes = []; }
48
+ getItem(k) { return this.m.has(k) ? this.m.get(k) : null; }
49
+ setItem(k, v) { this.m.set(k, String(v)); }
50
+ removeItem(k) { this.removes.push(k); this.m.delete(k); }
51
+ }
52
+ const HANDOVER_KEY = "dsh-e2ee-handover"; // DSH_E2EE_HANDOVER_KEY(shim 常量)
53
+
54
+ /** 造一张"本机记住"的交接单;在测 storage 清理时用。 */
55
+ function seedStores() {
56
+ const ss = new FakeStore();
57
+ const ls = new FakeStore();
58
+ ss.setItem(HANDOVER_KEY, "{\"v\":2}");
59
+ ls.setItem(HANDOVER_KEY, "{\"v\":2}");
60
+ return { ss, ls };
61
+ }
62
+ /** 只活在本「用例」里的全局替身:用完即撤,绝不泄漏到其它用例。 */
63
+ function withGlobals(fn) {
64
+ const saves = {
65
+ ss: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
66
+ ls: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
67
+ loc: Object.getOwnPropertyDescriptor(globalThis, "location"),
68
+ };
69
+ try { return fn(); } finally {
70
+ for (const [k, d] of [["sessionStorage", saves.ss], ["localStorage", saves.ls], ["location", saves.loc]]) {
71
+ try { if (d) Object.defineProperty(globalThis, k, d); else delete globalThis[k]; } catch { /* ignore */ }
72
+ }
73
+ }
74
+ }
75
+ function installStores(ss, ls) {
76
+ Object.defineProperty(globalThis, "sessionStorage", { value: ss, configurable: true, writable: true });
77
+ Object.defineProperty(globalThis, "localStorage", { value: ls, configurable: true, writable: true });
78
+ }
79
+
80
+ /** 假原生 WebSocket:只记录 URL,暴露 onopen/onerror/onclose 供用例手工触发。 */
81
+ class FakeNativeWS {
82
+ constructor(url, protocols) { this.url = String(url); this.protocols = protocols; this.sent = []; }
83
+ send(d) { this.sent.push(d); }
84
+ close() {}
85
+ }
86
+
87
+ /** 假桥端错误响应:只需要 headers.get('x-dsh-e2ee-error')。 */
88
+ function e2eeErrorResponse(code) {
89
+ return { status: 502, headers: { get: (n) => (String(n).toLowerCase() === "x-dsh-e2ee-error" ? code : null) } };
90
+ }
91
+
92
+ /** 直接挂一个数据 WS(绕过 URL 策略),用于驱动 SeE2eeWs 的握手失败回调。 */
93
+ function openDataWs(ctx) {
94
+ return new shim.SeE2eeWs(FakeNativeWS, "ws://relay.local/remote/dev-x/api/remote.mux", null,
95
+ { sess: ctx.sess, wsLabel: "w=deadbeef;e2ee=" + SESS_ID, ctx });
96
+ }
97
+
98
+ // ══════════════════════════════════════════════════════════════════════════════
99
+ // ① unknown_session → 内存会话必须置空(C3 主修:这是旧实现漏掉的那一步)
100
+ // ══════════════════════════════════════════════════════════════════════════════
101
+
102
+ test("★ unknown_session:内存 ctx.sess 被置空,且 storage 里的交接单被清掉", () => {
103
+ withGlobals(() => {
104
+ const { ss, ls } = seedStores();
105
+ installStores(ss, ls);
106
+ const ctx = { sess: { sessId: SESS_ID }, origin: "http://relay.local", deviceId: "" };
107
+ const code = shim.seNoteE2eeError(e2eeErrorResponse("unknown_session"), ctx);
108
+ assert.equal(code, "unknown_session");
109
+ assert.equal(ctx.sess, null,
110
+ "🔴 只清 storage 不清内存 = 本页后续每个 HTTP/WS 继续带死 sessId(dev-08b73ba9de67 一天 8997 次 009 的来源)");
111
+ assert.ok(ss.removes.includes(HANDOVER_KEY) && ls.removes.includes(HANDOVER_KEY),
112
+ "sessionStorage/localStorage 两份交接单都必须清(否则刷新又把死票装回来)");
113
+ });
114
+ });
115
+
116
+ test("★ unknown_session 之后:同一页面新建的 WS 不再带 &e2ee=(回落明文,不再无限被 1008 拒)", () => {
117
+ const ctx = { sess: { sessId: SESS_ID }, origin: "http://relay.local", deviceId: "dev-8e5fb209cdc8" };
118
+ const patched = shim.seMakeWsCtor(FakeNativeWS, ctx);
119
+ const before = patched("http://relay.local/api/remote.mux");
120
+ assert.ok(before instanceof shim.SeE2eeWs, "前提:有会话时 mux WS 必须被加密接管");
121
+ assert.match(before._inner.url, /e2ee=/, "前提:加密接管会追加 e2ee 参数");
122
+
123
+ // 桥端说不认识这个会话(本用例不依赖 storage:清存储是"下次进页面",清内存是"这一页")
124
+ assert.equal(shim.seNoteE2eeError(e2eeErrorResponse("unknown_session"), ctx), "unknown_session");
125
+ const after = patched("http://relay.local/api/remote.mux");
126
+ assert.ok(after instanceof FakeNativeWS, "会话清掉后必须回落原生 WS(明文)");
127
+ assert.equal(after.url.includes("e2ee="), false, "★ 下一次 WS 绝不能再带 dead sessId");
128
+ assert.equal(after.url, "http://relay.local/remote/dev-8e5fb209cdc8/api/remote.mux",
129
+ "明文回落仍要保留设备作用域改写(那是 2026-09-26 的另一条修复,不能回退)");
130
+ });
131
+
132
+ test("unknown_session 之后 HTTP 包装退化为原样透传(不再给死会话打信封)", async () => {
133
+ const ctx = { sess: { sessId: SESS_ID }, origin: "http://relay.local", deviceId: "" };
134
+ const seen = [];
135
+ const origFetch = (input, init) => { seen.push({ input: String(input), init }); return Promise.resolve({ ok: true, status: 200, headers: { get: () => null } }); };
136
+ shim.seNoteE2eeError(e2eeErrorResponse("unknown_session"), ctx);
137
+ const res = await shim.seFetchWrapper(origFetch, ctx, "http://relay.local/api/session/list", { method: "GET" });
138
+ assert.equal(res.status, 200);
139
+ assert.equal(seen.length, 1);
140
+ assert.equal(seen[0].init.method, "GET", "无会话 → 原样透传(旧行为:POST 信封打给死会话)");
141
+ });
142
+
143
+ // ══════════════════════════════════════════════════════════════════════════════
144
+ // ② 连续 3 次 WS 握手失败 → 清交接单 + 清内存会话
145
+ // ══════════════════════════════════════════════════════════════════════════════
146
+
147
+ test("★ 连续 3 次 WS 握手失败且有会话 → 清交接单 + 清内存会话(C3 第 2 条)", () => {
148
+ shim.seNoteWsOpened(); // 模块级连续计数是"一个页面"的语义,用例之间手工复位(等价于"上一次握手成功")
149
+ withGlobals(() => {
150
+ const { ss, ls } = seedStores();
151
+ installStores(ss, ls);
152
+ const ctx = { sess: { sessId: SESS_ID }, origin: "http://relay.local", deviceId: "" };
153
+ assert.equal(shim.SE_WS_FAIL_INVALIDATE, 3, "阈值常量必须是 3(测试按常量断言,不散落魔法数)");
154
+
155
+ const ws1 = openDataWs(ctx); ws1._inner.onerror({ type: "error" });
156
+ const ws2 = openDataWs(ctx); ws2._inner.onerror({ type: "error" });
157
+ assert.notEqual(ctx.sess, null, "失败 2 次还不能判死会话(偶发断网不该清掉加密)");
158
+ assert.equal(ss.removes.includes(HANDOVER_KEY), false, "同理 2 次时不得清交接单");
159
+
160
+ const ws3 = openDataWs(ctx); ws3._inner.onclose({ type: "close", code: 1008 });
161
+ assert.equal(ctx.sess, null, "★ 连续 3 次握手失败 = 桥端不认这张交接单 → 必须清掉本页内存会话");
162
+ assert.ok(ss.removes.includes(HANDOVER_KEY) && ls.removes.includes(HANDOVER_KEY),
163
+ "同时清两份交接单(本次不做受控 reload,见下一个用例的说明)");
164
+ });
165
+ });
166
+
167
+ test("连续失败计数在握手成功后清零:中途成功一次 → 再失败 2 次也不得清会话", () => {
168
+ shim.seNoteWsOpened();
169
+ withGlobals(() => {
170
+ const ctx = { sess: { sessId: SESS_ID }, origin: "http://relay.local", deviceId: "" };
171
+ const w1 = openDataWs(ctx); w1._inner.onerror({ type: "error" });
172
+ const w2 = openDataWs(ctx); w2._inner.onerror({ type: "error" });
173
+ // 第三次握手成功(官方自动重连连上了)→ 连续计数归零
174
+ const w3 = openDataWs(ctx); w3._inner.onopen({ type: "open" });
175
+ assert.equal(shim.seWsFailStreak, 0, "成功一次后连续失败计数必须清零");
176
+ const w4 = openDataWs(ctx); w4._inner.onerror({ type: "error" });
177
+ const w5 = openDataWs(ctx); w5._inner.onerror({ type: "error" });
178
+ assert.notEqual(ctx.sess, null, "清零后重新计数(2 次)→ 不得误判死会话");
179
+ });
180
+ });
181
+
182
+ test("已经清过会话之后继续失败:不再重复清、也不抛异常(幂等)", () => {
183
+ shim.seNoteWsOpened();
184
+ withGlobals(() => {
185
+ const ctx = { sess: { sessId: SESS_ID }, origin: "http://relay.local", deviceId: "" };
186
+ for (let i = 0; i < 3; i++) { const w = openDataWs(ctx); w._inner.onerror({ type: "error" }); }
187
+ assert.equal(ctx.sess, null);
188
+ const before = shim.seWsFailCount;
189
+ for (let i = 0; i < 2; i++) { const w = openDataWs(ctx); w._inner.onerror({ type: "error" }); }
190
+ assert.equal(ctx.sess, null);
191
+ assert.equal(shim.seWsFailCount, before + 2, "累计计数仍照常累加(徽标展示用)");
192
+ });
193
+ });
194
+
195
+ // ══════════════════════════════════════════════════════════════════════════════
196
+ // ③ 受控 reload:每标签页最多一次(防 reload 风暴)
197
+ // ══════════════════════════════════════════════════════════════════════════════
198
+
199
+ test("★ 受控 reload 闸门:第一次放行,第二次必须拒绝(每标签页最多一次)", () => {
200
+ withGlobals(() => {
201
+ const ss = new FakeStore();
202
+ installStores(ss, new FakeStore());
203
+ let reloads = 0;
204
+ Object.defineProperty(globalThis, "location", {
205
+ value: { reload() { reloads += 1; } }, configurable: true, writable: true,
206
+ });
207
+ assert.equal(shim.seReloadOnceForDeadSession(), true, "第一次(死票确定 + 刚刚清掉会话)应放行");
208
+ assert.equal(reloads, 1, "必须真的触发一次 reload");
209
+ assert.equal(shim.seReloadOnceForDeadSession(), false, "★ 第二次必须拒绝 —— 否则就是新的 reload 风暴(=499 来源)");
210
+ assert.equal(reloads, 1, "不得再 reload");
211
+ assert.ok(ss.getItem(shim.SE_RELOAD_GUARD_KEY), "闸门必须落 sessionStorage(刷新后仍在,才能跨页生效)");
212
+ });
213
+ });
214
+
215
+ test("受控 reload 在无 sessionStorage / 无 location 的环境里安全返回 false(不抛异常)", () => {
216
+ withGlobals(() => {
217
+ assert.equal(shim.seReloadOnceForDeadSession(), false, "node/受限环境没有 sessionStorage → 不得抛异常");
218
+ });
219
+ });
220
+
221
+ // ══════════════════════════════════════════════════════════════════════════════
222
+ // ④ 接线断言(DOM 层不在 node 可执行范围,但"回调有没有接上、文案对不对"必须锁死)
223
+ // ══════════════════════════════════════════════════════════════════════════════
224
+
225
+ test("★ 接线:HTTP 失效分支把 ctx 传给 seNoteE2eeError;WS 失败把 ctx 传进握手回调", () => {
226
+ const src = readShimScript();
227
+ assert.match(src, /if \(!seIsEnvelopeResponse\(res\)\) \{ seNoteE2eeError\(res, ctx\); return res; \}/,
228
+ "fetch 包装必须把 ctx 交给 seNoteE2eeError —— 否则内存会话永远清不掉(C3 主修)");
229
+ assert.match(src, /seNoteWsHandshakeFail\(self\._meta && self\._meta\.wsLabel, "error", self\._meta && self\._meta\.ctx\)/,
230
+ "WS error 回调必须带上 ctx");
231
+ assert.match(src, /seNoteWsHandshakeFail\(self\._meta && self\._meta\.wsLabel, \(ev && \(ev\.code \|\| ev\.reason\)\) \|\| "close", self\._meta && self\._meta\.ctx\)/,
232
+ "WS close 回调必须带上 ctx");
233
+ assert.match(src, /new SeE2eeWs\(NativeWS, target\.href, protocols, \{ sess: ctx\.sess, wsLabel: target\.wsLabel, ctx: ctx \}\)/,
234
+ "SeE2eeWs 必须拿到本页 ctx(否则清不到 _ctx.sess)");
235
+ });
236
+
237
+ test("★ 接线:徽标文案「加密会话已失效,需要重新解锁」出现在 WS 自愈分支", () => {
238
+ const src = readShimScript();
239
+ const i = src.indexOf("function _reportWsFail(");
240
+ assert.ok(i > 0, "应能找到 _reportWsFail");
241
+ const body = src.slice(i, i + 1400);
242
+ assert.match(body, /加密会话已失效,需要重新解锁/, "C3 验收口径的徽标文案必须原样出现");
243
+ assert.match(body, /dropped/, "必须区分「清了本页死会话」与「只是连不上」两种情形");
244
+ assert.match(body, /明文继续(功能可用,但不再加密)/, "降级后要如实说明“明文可用但不加密”");
245
+ });
246
+
247
+ test("★ 接线:受控 reload 只在 HTTP unknown_session(dropped) 分支发起,WS 分支不刷新", () => {
248
+ const src = readShimScript();
249
+ const staleAt = src.indexOf("function _reportStaleSession(");
250
+ const wsAt = src.indexOf("function _reportWsFail(");
251
+ assert.ok(staleAt > 0 && wsAt > staleAt);
252
+ const staleBody = src.slice(staleAt, wsAt);
253
+ assert.match(staleBody, /if \(dropped\) seReloadOnceForDeadSession\(\);/,
254
+ "HTTP 桥端主动答 unknown_session(死票确定)才做一次受控刷新");
255
+ const wsBody = src.slice(wsAt, wsAt + 1600);
256
+ assert.equal(wsBody.includes("seReloadOnceForDeadSession()"), false,
257
+ "WS 握手失败可能只是电脑端暂时离线 —— 那里清会话即可,绝不能自动刷新打断用户");
258
+ });
259
+
260
+ test("注入体纪律:脚本不含未转义 </script>,且 CORE 区间仍可抽取(本轮只加逻辑不动结构)", () => {
261
+ const raw = readFileSync(SHIM_SCRIPT, "utf8");
262
+ assert.equal(raw.includes("</script>"), false, "shim 脚本正文不得含 </script>(HTML 解析会提前闭合)");
263
+ assert.ok(extractShimCore().length > 1000, "CORE 区间可抽取");
264
+ });
@@ -80,3 +80,78 @@ test("★ 采集器源码自身:不含 </script>、不写 DOM、只在失败
80
80
  assert.match(src, /Failed to load plugins/, "必须识别首页启动失败文案");
81
81
  assert.match(src, /mirror_diag|DSH_MIRROR_DIAG|pagehide/, "必须有无痕静默路径");
82
82
  });
83
+
84
+ // ══════════════════════════════════════════════════════════════════════════════
85
+ // C4/C6(2026-10-20 诊断):把适配层的「自动重试 / compat-official / scrim」计数带进上报
86
+ //
87
+ // 理由:服务端只有 nginx + mirror_diag 两层时,分不清"失败后是自愈了还是永久坏了"。
88
+ // 适配层把这几个计数挂在 window.__dshMaRetryStats / __dshMaDiag 上(只读),
89
+ // 采集器在**有**这些全局时把它们作为可选字段 ui 带回去;没有时整体缺省(不改变既有 payload 形状)。
90
+ // ══════════════════════════════════════════════════════════════════════════════
91
+
92
+ import vm from "node:vm";
93
+
94
+ /** 在 vm 里跑真实采集器脚本,返回 { payloads, fire, runTimers }。 */
95
+ function runDiagScript(winExtra = {}) {
96
+ const SRC = fs.readFileSync(DIAG_SCRIPT_PATH, "utf8");
97
+ const listeners = new Map();
98
+ const fetches = [];
99
+ const timers = [];
100
+ const win = {
101
+ location: { href: "https://n.risegao.cn:13443/remote/dev-x/", origin: "https://n.risegao.cn:13443", pathname: "/remote/dev-x/" },
102
+ navigator: { userAgent: "iPhone Safari (mirror-diag test)" },
103
+ localStorage: { getItem: (k) => (k === "dsh_token" ? "tk-test" : null) },
104
+ addEventListener: (type, fn) => { const arr = listeners.get(type) || []; arr.push(fn); listeners.set(type, arr); },
105
+ MutationObserver: undefined,
106
+ PerformanceObserver: undefined,
107
+ ...winExtra,
108
+ };
109
+ const doc = { cookie: "", body: { innerText: "" }, documentElement: {} };
110
+ const ctx = vm.createContext({
111
+ window: win, document: doc, W: win, D: doc,
112
+ navigator: win.navigator, localStorage: win.localStorage,
113
+ fetch: (url, init) => { fetches.push({ url: String(url), init }); return Promise.resolve(); },
114
+ setTimeout: (fn) => { timers.push(fn); return timers.length; },
115
+ clearTimeout: () => {},
116
+ URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error,
117
+ });
118
+ ctx.globalThis = ctx;
119
+ vm.runInContext(SRC, ctx, { timeout: 5000 });
120
+ return {
121
+ win,
122
+ payloads: () => fetches.map((f) => { try { return JSON.parse(f.init.body); } catch { return null; } }).filter(Boolean),
123
+ /** 触发采集器登记的 window 事件(资源 error 在捕获阶段被接住)。 */
124
+ fire(type, ev) { for (const fn of listeners.get(type) || []) fn(ev); },
125
+ /** 走完所有已登记的 setTimeout(flush 延迟 2500ms)。 */
126
+ runTimers() { const q = timers.splice(0); for (const fn of q) fn(); },
127
+ };
128
+ }
129
+
130
+ test("★ C4/C6:适配层在场时,上报里带可选字段 ui(retry / compat / scrim)", () => {
131
+ const env = runDiagScript({
132
+ __dshMaRetryStats: { page: 2, res: 1, lastKind: "page:boot_failed", at: 1 },
133
+ __dshMaDiag: { compatOfficial: { on: true }, scrim: { activations: 3, invariantHits: 1 } },
134
+ });
135
+ // 一条真实会触发上报的失败:/plugins/ 聚合包加载失败
136
+ env.fire("error", { target: { src: "https://n.risegao.cn:13443/plugins/??@deepseek-ai/x.js", tagName: "SCRIPT" } });
137
+ env.runTimers();
138
+ const [payload] = env.payloads();
139
+ assert.ok(payload, "有失败资源时必须上报");
140
+ assert.equal(payload.items.length, 1, "既有 items 契约不变");
141
+ assert.equal(payload.items[0].t, "res");
142
+ assert.deepEqual(payload.ui.retry, { page: 2, res: 1, kind: "page:boot_failed" },
143
+ "★ 自动重试次数必须带回去 —— 否则服务端分不清「自愈了」和「永久坏了」");
144
+ assert.equal(payload.ui.compat, 1, "compat-official 降级也要能被服务端看见(UI 插件兼容问题的规模)");
145
+ assert.deepEqual(payload.ui.scrim, { on: 3, fix: 1 }, "scrim 下闸/不变量摘除计数(「又点不动了」的现场证据)");
146
+ });
147
+
148
+ test("适配层不在场(旧版本/宽屏/未注入)时:payload 形状不变、不出现 ui 字段", () => {
149
+ const env = runDiagScript();
150
+ env.fire("error", { target: { src: "https://n.risegao.cn:13443/assets/index.js", tagName: "SCRIPT" } });
151
+ env.runTimers();
152
+ const [payload] = env.payloads();
153
+ assert.ok(payload, "有失败资源时必须上报");
154
+ assert.equal("ui" in payload, false, "没有适配层诊断时不得新增字段(老服务端/老页面兼容)");
155
+ assert.deepEqual(Object.keys(payload).sort(), ["at", "boot_failed", "items", "kind", "page", "ua", "v"],
156
+ "既有 payload 字段一个不多一个不少");
157
+ });