@mrrisega/dsh-remote 0.7.6-beta.9 → 0.7.7
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.
- package/clients/dsh-remote/dsh-bridge.mjs +300 -16
- package/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/clients/dsh-remote/test/channel-ref.test.mjs +250 -0
- package/clients/dsh-remote/test/device-locked.test.mjs +271 -0
- package/dsh-setup.mjs +97 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +101 -16
- package/packages/dsh-remote-web/lib/index.js +208 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +300 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +97 -3
- package/packages/dsh-remote-web/test/device-locked-panel.test.mjs +301 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// 设备超额锁定 —— 桌面插件面板侧回归(契约 docs/device-overquota-lock-contract.md §8)。
|
|
2
|
+
//
|
|
3
|
+
// 【为什么必须有】锁定不是"普通连接失败":设备行还在、手机端能看到这台电脑,只是中继按套餐
|
|
4
|
+
// 额度拒绝接入;bridge 不退出、30s 轮询等解锁。如果面板把它显示成「连接失败,请重试」,
|
|
5
|
+
// 用户只会反复点重试(契约明确提出"不能再出现无限重试暗示"),而真正的两条出路——
|
|
6
|
+
// **升级 Pro Max** 或 **手机端解绑**——根本没人告诉他。
|
|
7
|
+
//
|
|
8
|
+
// 本用例钉住:
|
|
9
|
+
// ① composeConnect:phase=locked / .bind-error.json code=device_locked → error + 固定文案
|
|
10
|
+
// (「本机已被锁定(当前套餐可同时使用 N 台)…本机会自动解锁」);
|
|
11
|
+
// ② 面板 UI(lib/client.js):红条带「🚀 升级 Pro Max」按钮,链接由 app_url 派生(不硬编码域名),
|
|
12
|
+
// 旧 device_limit_exceeded 文案已是新语义;
|
|
13
|
+
// ③ 插件半的设备绑定退避(bridge 退出码 5):10s→1min→5min→15min→封顶 30min,
|
|
14
|
+
// `.bind-error.json` 签名变化时复位;ensureConnection 退避期内不再拉起 bridge;
|
|
15
|
+
// ④ watcher(dsh-setup.mjs)识别退出码 5 并做同一套指数退避(bridge 与 watcher 常量必须一致)。
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import test from "node:test";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
__composeConnect as composeConnect,
|
|
26
|
+
__ensureConnection as ensureConnection,
|
|
27
|
+
__connectBook as connectBook,
|
|
28
|
+
__bindFailGate as bindFailGate,
|
|
29
|
+
__bindFailSignatureOf as bindFailSignatureOf,
|
|
30
|
+
__bindCapBackoffMs as bindCapBackoffMs,
|
|
31
|
+
__noteBindFailAttempt as noteBindFailAttempt,
|
|
32
|
+
__DEVICE_BIND_CAP_EXIT_CODE as DEVICE_BIND_CAP_EXIT_CODE,
|
|
33
|
+
__BIND_CAP_BACKOFF_MAX_MS as BIND_CAP_BACKOFF_MAX_MS,
|
|
34
|
+
} from "../lib/index.js";
|
|
35
|
+
|
|
36
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const ROOT = path.join(HERE, "..", "..", "..");
|
|
38
|
+
const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8").replace(/\r\n/g, "\n");
|
|
39
|
+
const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8").replace(/\r\n/g, "\n");
|
|
40
|
+
const SETUP_SRC = readFileSync(path.join(ROOT, "dsh-setup.mjs"), "utf8").replace(/\r\n/g, "\n");
|
|
41
|
+
const BRIDGE_SRC = readFileSync(path.join(ROOT, "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8").replace(/\r\n/g, "\n");
|
|
42
|
+
|
|
43
|
+
const DEVICE_ID = "dev-locked-0001";
|
|
44
|
+
|
|
45
|
+
/** 提取 `function name(...) { ... }` 的完整源码(按花括号配对,用于把纯函数单独求值)。 */
|
|
46
|
+
function extractFunction(src, name) {
|
|
47
|
+
const start = src.indexOf(`function ${name}(`);
|
|
48
|
+
assert.ok(start >= 0, `源码里找不到 function ${name}`);
|
|
49
|
+
const open = src.indexOf("{", start);
|
|
50
|
+
let depth = 0;
|
|
51
|
+
for (let i = open; i < src.length; i += 1) {
|
|
52
|
+
if (src[i] === "{") depth += 1;
|
|
53
|
+
else if (src[i] === "}") { depth -= 1; if (depth === 0) return src.slice(start, i + 1); }
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`function ${name} 花括号不配对`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ENV_KEYS = ["DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_PLATFORM", "DSH_REMOTE_TELEMETRY", "DSH_RELAY_DEFAULT_API", "DSH_RELAY_SELFHEAL_MS"];
|
|
59
|
+
function withTestEnv() {
|
|
60
|
+
const saved = {};
|
|
61
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
62
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 隔离:绝不碰真实 launchd/systemd/npx
|
|
63
|
+
process.env.DSH_RELAY_PLATFORM = "win32"; // 让 launchdStatus 直接返回「无此服务」(不 shell 出去问)
|
|
64
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
65
|
+
process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1";
|
|
66
|
+
delete process.env.DSH_RELAY_SELFHEAL_MS;
|
|
67
|
+
return () => {
|
|
68
|
+
for (const k of ENV_KEYS) {
|
|
69
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
70
|
+
else process.env[k] = saved[k];
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 假 relayDir:运行环境入口用「没有任何相对 import」的桩,避免半装判定干扰。 */
|
|
76
|
+
async function makeRelayDir(extra = {}) {
|
|
77
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-lock-panel-"));
|
|
78
|
+
const relayDir = path.join(root, "relay");
|
|
79
|
+
await mkdir(relayDir, { recursive: true });
|
|
80
|
+
await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub\n");
|
|
81
|
+
await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
82
|
+
phone: "13800000000", password: "pw", device_id: DEVICE_ID, api_url: "http://127.0.0.1:1",
|
|
83
|
+
}));
|
|
84
|
+
if (extra.state) await writeFile(path.join(relayDir, ".dsh-bridge-state.json"), JSON.stringify(extra.state));
|
|
85
|
+
if (extra.bindError) await writeFile(path.join(relayDir, ".bind-error.json"), JSON.stringify({ at: Date.now(), ...extra.bindError }));
|
|
86
|
+
return { root, relayDir };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const lockedState = {
|
|
90
|
+
device_id: DEVICE_ID,
|
|
91
|
+
phase: "locked",
|
|
92
|
+
account_bound_at: Date.now(),
|
|
93
|
+
locked: { message: "已超出当前套餐设备额度,升级 Pro Max 可多台设备同时使用", plan: "free", limit: 2, upgrade_to: "pro_max" },
|
|
94
|
+
last_error: null,
|
|
95
|
+
};
|
|
96
|
+
const lockedBindError = {
|
|
97
|
+
code: "device_locked",
|
|
98
|
+
message: "已超出当前套餐设备额度,升级 Pro Max 可多台设备同时使用",
|
|
99
|
+
device_id: DEVICE_ID,
|
|
100
|
+
plan: "free",
|
|
101
|
+
limit: 2,
|
|
102
|
+
upgrade_to: "pro_max",
|
|
103
|
+
};
|
|
104
|
+
const fakeLaunchd = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
|
|
105
|
+
const fakeManual = { watcher: [], bridge: [], allBridge: [] };
|
|
106
|
+
|
|
107
|
+
// ─────────────────────── ① composeConnect:锁定态 ───────────────────────
|
|
108
|
+
|
|
109
|
+
test("composeConnect:bridge phase=locked → error + 契约 §8.1 文案 + locked 详情", async () => {
|
|
110
|
+
const restore = withTestEnv();
|
|
111
|
+
const { root, relayDir } = await makeRelayDir({ state: lockedState, bindError: lockedBindError });
|
|
112
|
+
try {
|
|
113
|
+
const conn = await composeConnect(relayDir, {
|
|
114
|
+
launchd: fakeLaunchd, manual: { watcher: [], bridge: [], allBridge: [process.pid] },
|
|
115
|
+
});
|
|
116
|
+
assert.equal(conn.phase, "error", "锁定态对用户是「用不了」,必须是 error 阶段");
|
|
117
|
+
assert.equal(conn.online, false);
|
|
118
|
+
assert.equal(conn.error && conn.error.code, "device_locked", "错误码必须是 device_locked(面板据此出现升级按钮)");
|
|
119
|
+
assert.ok(conn.locked, "必须下发 locked 详情(plan/limit/upgradeTo)供面板渲染");
|
|
120
|
+
assert.equal(conn.locked.limit, 2);
|
|
121
|
+
assert.equal(conn.locked.plan, "free");
|
|
122
|
+
assert.equal(conn.locked.upgradeTo, "pro_max");
|
|
123
|
+
// 契约 §8.1 固定文案:N 台 + 升级 Pro Max + 手机端「管理电脑」解绑 + 自动解锁
|
|
124
|
+
assert.match(conn.detail, /本机已被锁定(当前套餐可同时使用 2 台)/);
|
|
125
|
+
assert.match(conn.detail, /升级 Pro Max 可多台设备同时使用/);
|
|
126
|
+
assert.match(conn.detail, /「管理电脑」解绑其他设备/);
|
|
127
|
+
assert.match(conn.detail, /自动解锁/);
|
|
128
|
+
assert.doesNotMatch(conn.detail, /无限重试|反复重试直到成功/, "不能出现无限重试的暗示");
|
|
129
|
+
} finally {
|
|
130
|
+
restore();
|
|
131
|
+
await rm(root, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("composeConnect:只有 .bind-error.json code=device_locked 也要识别(旧 bridge / 半升级现场)", async () => {
|
|
136
|
+
const restore = withTestEnv();
|
|
137
|
+
const { root, relayDir } = await makeRelayDir({ bindError: lockedBindError });
|
|
138
|
+
try {
|
|
139
|
+
const conn = await composeConnect(relayDir, {
|
|
140
|
+
launchd: fakeLaunchd, manual: { watcher: [], bridge: [], allBridge: [process.pid] },
|
|
141
|
+
});
|
|
142
|
+
assert.equal(conn.phase, "error");
|
|
143
|
+
assert.equal(conn.error.code, "device_locked");
|
|
144
|
+
assert.equal(conn.locked.limit, 2, "limit 从 bind-error.json 取(桥已富化该字段)");
|
|
145
|
+
assert.match(conn.detail, /自动解锁/);
|
|
146
|
+
} finally {
|
|
147
|
+
restore();
|
|
148
|
+
await rm(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("composeConnect:device_bind_cap → 硬上限文案,且不暗示无限重试", async () => {
|
|
153
|
+
const restore = withTestEnv();
|
|
154
|
+
const { root, relayDir } = await makeRelayDir({
|
|
155
|
+
bindError: { code: "device_bind_cap", message: "已绑定设备数已达上限(50 台)", device_id: DEVICE_ID },
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
const conn = await composeConnect(relayDir, { launchd: fakeLaunchd, manual: fakeManual });
|
|
159
|
+
assert.equal(conn.phase, "error");
|
|
160
|
+
assert.equal(conn.error.code, "device_bind_cap");
|
|
161
|
+
assert.match(conn.detail, /手机端「管理电脑」解绑/);
|
|
162
|
+
assert.match(conn.detail, /指数退避/, "要说明守护在退避重试(而不是每 10 秒空撞一次)");
|
|
163
|
+
assert.equal(conn.locked, null, "硬上限不是锁定态,不该出现升级按钮的 locked 详情");
|
|
164
|
+
} finally {
|
|
165
|
+
restore();
|
|
166
|
+
await rm(root, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ─────────────────────── ② 面板 UI 文案 / 按钮 ───────────────────────
|
|
171
|
+
|
|
172
|
+
test("面板红条:锁定态渲染「🚀 升级 Pro Max」按钮,链接由 app_url 派生(不硬编码域名)", () => {
|
|
173
|
+
assert.ok(CLIENT_SRC.includes("🚀 升级 Pro Max"), "契约 §8.2:红条必须有「🚀 升级 Pro Max」按钮");
|
|
174
|
+
assert.ok(CLIENT_SRC.includes("openLockedUpgrade"), "按钮必须有可点击的实现(不是纯文案)");
|
|
175
|
+
assert.ok(CLIENT_SRC.includes("upgradePromoUrlOf"), "链接必须走派生函数(app_url → /app/promo)");
|
|
176
|
+
assert.ok(CLIENT_SRC.includes("/app/promo"), "升级入口路径是 /app/promo");
|
|
177
|
+
// 派生函数本体:只认 http(s) 前缀 + /app 之前的部分,不写死生产域名
|
|
178
|
+
const fn = extractFunction(CLIENT_SRC, "upgradePromoUrlOf");
|
|
179
|
+
const derive = new Function(`${fn}\nreturn upgradePromoUrlOf;`)();
|
|
180
|
+
assert.equal(derive("https://example.com/app/?auth=abc"), "https://example.com/app/promo");
|
|
181
|
+
assert.equal(derive("https://example.com:13443/app"), "https://example.com:13443/app/promo");
|
|
182
|
+
assert.equal(derive(""), "", "拿不到 app_url 时返回空串,调用方回退到带登录态的升级通道");
|
|
183
|
+
assert.equal(derive("not-a-url"), "");
|
|
184
|
+
assert.doesNotMatch(fn, /risegao|n\.dsh|dsh\.remote|\.cn/, "派生函数里不得硬编码任何生产域名");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("面板文案:lockedPanelDetail 同时讲清「N 台 / 升级 / 解绑 / 自动解锁」", () => {
|
|
188
|
+
const fn = extractFunction(CLIENT_SRC, "lockedPanelDetail");
|
|
189
|
+
const detail = new Function(`${fn}\nreturn lockedPanelDetail;`)();
|
|
190
|
+
const text = detail(2);
|
|
191
|
+
assert.match(text, /本机已被锁定(当前套餐可同时使用 2 台)/);
|
|
192
|
+
assert.match(text, /升级 Pro Max 可多台设备同时使用/);
|
|
193
|
+
assert.match(text, /「管理电脑」解绑其他设备/);
|
|
194
|
+
assert.match(text, /自动解锁/);
|
|
195
|
+
assert.match(detail(null), /可同时使用 N 台/, "服务端没给额度时退回 N 占位(不编造数字)");
|
|
196
|
+
// 旧 device_limit_exceeded 文案已废弃(契约 §8.3):不能再出现"解绑旧设备才能登录"
|
|
197
|
+
assert.equal(CLIENT_SRC.includes("已达本套餐设备数上限。若是同一台电脑重装"), false,
|
|
198
|
+
"旧的「解绑旧设备才能登录」文案必须删掉(新语义是锁定 → 升级/解绑 → 自动解锁)");
|
|
199
|
+
assert.ok(CLIENT_SRC.includes('be.code === "device_bind_cap"') || CLIENT_SRC.includes('isBindCap'),
|
|
200
|
+
"后台服务卡要识别 device_bind_cap(退出码 5 的那类失败)");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ─────────────────────── ③ 插件半:设备绑定退避(退出码 5) ───────────────────────
|
|
204
|
+
|
|
205
|
+
test("插件半退避序列:10s→1min→5min→15min,之后封顶 30min;且常量与 bridge 一致", () => {
|
|
206
|
+
assert.equal(DEVICE_BIND_CAP_EXIT_CODE, 5, "退出码 5 是契约 §6.3 的专用码");
|
|
207
|
+
assert.equal(bindCapBackoffMs(1), 10_000);
|
|
208
|
+
assert.equal(bindCapBackoffMs(2), 60_000);
|
|
209
|
+
assert.equal(bindCapBackoffMs(3), 300_000);
|
|
210
|
+
assert.equal(bindCapBackoffMs(4), 900_000);
|
|
211
|
+
assert.equal(bindCapBackoffMs(5), 30 * 60 * 1000);
|
|
212
|
+
assert.equal(bindCapBackoffMs(99), BIND_CAP_BACKOFF_MAX_MS, "封顶 30 分钟,不能无限增长");
|
|
213
|
+
// bridge 与 watcher 的常量必须同源(否则 watcher 认不出退出码 5,又回 10s 空撞)
|
|
214
|
+
assert.match(BRIDGE_SRC, /export const DEVICE_BIND_CAP_EXIT_CODE = 5;/);
|
|
215
|
+
assert.match(SETUP_SRC, /const DEVICE_BIND_CAP_EXIT_CODE = 5;/);
|
|
216
|
+
assert.ok(/code === DEVICE_BIND_CAP_EXIT_CODE/.test(SETUP_SRC), "watcher 必须识别退出码 5");
|
|
217
|
+
assert.ok(/bindCapBackoffUntil = Date\.now\(\) \+ waitMs/.test(SETUP_SRC), "识别后要真的进入退避窗口");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("插件半退避闸门:签名变化复位、窗口内 blocked、窗口过可再试一次", async () => {
|
|
221
|
+
const restore = withTestEnv();
|
|
222
|
+
const { root, relayDir } = await makeRelayDir();
|
|
223
|
+
try {
|
|
224
|
+
const err = { code: "device_bind_cap", message: "已达上限", device_id: DEVICE_ID };
|
|
225
|
+
const sig1 = bindFailSignatureOf(err);
|
|
226
|
+
assert.equal(bindFailSignatureOf({ ...err, at: 111 }), sig1, "签名不含 at(bridge 每次重试都会刷 at)");
|
|
227
|
+
assert.notEqual(bindFailSignatureOf({ ...err, message: "别的原因" }), sig1, "结论变了才算变化");
|
|
228
|
+
|
|
229
|
+
const g0 = bindFailGate(relayDir, err);
|
|
230
|
+
assert.equal(g0.permanent, true);
|
|
231
|
+
assert.equal(g0.blocked, false, "首次观察到失败:允许一次重试");
|
|
232
|
+
const st = noteBindFailAttempt(relayDir, err);
|
|
233
|
+
assert.equal(st.step, 1);
|
|
234
|
+
const g1 = bindFailGate(relayDir, err);
|
|
235
|
+
assert.equal(g1.blocked, true, "记账后的第一档退避必须是 10s");
|
|
236
|
+
assert.ok(g1.remainingMs > 0 && g1.remainingMs <= 10_000);
|
|
237
|
+
|
|
238
|
+
// 步进序列(同一签名连续失败):10s→1min→5min→15min→30min
|
|
239
|
+
noteBindFailAttempt(relayDir, err);
|
|
240
|
+
noteBindFailAttempt(relayDir, err);
|
|
241
|
+
noteBindFailAttempt(relayDir, err);
|
|
242
|
+
noteBindFailAttempt(relayDir, err);
|
|
243
|
+
assert.equal(connectBook(relayDir).bindFail.step, 5);
|
|
244
|
+
assert.ok(connectBook(relayDir).bindFail.until - Date.now() <= 30 * 60 * 1000 + 50);
|
|
245
|
+
|
|
246
|
+
// 签名变化(用户解绑/换账号/提示被清掉)→ 立即复位
|
|
247
|
+
const gReset = bindFailGate(relayDir, { code: "device_locked", message: "x", device_id: DEVICE_ID });
|
|
248
|
+
assert.equal(gReset.permanent, false);
|
|
249
|
+
assert.equal(gReset.blocked, false);
|
|
250
|
+
assert.equal(gReset.step, 0);
|
|
251
|
+
// 非永久失败(网络抖动等)不参与该退避
|
|
252
|
+
assert.equal(bindFailGate(relayDir, { code: "api_unreachable", message: "x" }).permanent, false);
|
|
253
|
+
} finally {
|
|
254
|
+
restore();
|
|
255
|
+
await rm(root, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("ensureConnection:设备绑定硬上限退避期内不再拉起 bridge(避免把 watcher 退避重启掉)", async () => {
|
|
260
|
+
const restore = withTestEnv();
|
|
261
|
+
const { root, relayDir } = await makeRelayDir({
|
|
262
|
+
bindError: { code: "device_bind_cap", message: "已绑定设备数已达上限", device_id: DEVICE_ID },
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
const first = await ensureConnection(relayDir);
|
|
266
|
+
const book = connectBook(relayDir);
|
|
267
|
+
assert.equal(book.bindFail.step, 1, "第一次观察到永久失败 → 记一步退避后允许这一次动作");
|
|
268
|
+
// 退避窗口内的下一轮:必须直接拦下(action=bind-cap-backoff),不再动服务
|
|
269
|
+
const second = await ensureConnection(relayDir);
|
|
270
|
+
assert.equal(second && second.action, "bind-cap-backoff", `第二次必须是退避拦下,实际:${JSON.stringify(second)}`);
|
|
271
|
+
assert.ok(second.until > Date.now());
|
|
272
|
+
// 人工操作(面板「重试」= force)永远放行:安全阀不能把用户锁在门外
|
|
273
|
+
const forced = await ensureConnection(relayDir, { force: true });
|
|
274
|
+
assert.notEqual(forced && forced.action, "bind-cap-backoff", "force 必须绕过退避");
|
|
275
|
+
void first;
|
|
276
|
+
} finally {
|
|
277
|
+
restore();
|
|
278
|
+
await rm(root, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ─────────────────────── ④ watcher(dsh-setup.mjs)退避纯函数 ───────────────────────
|
|
283
|
+
|
|
284
|
+
test("dsh-setup.mjs watcher 的退避序列与签名复位(与插件半同一套语义)", () => {
|
|
285
|
+
const fnSrc = extractFunction(SETUP_SRC, "bindCapBackoffMs");
|
|
286
|
+
const consts = SETUP_SRC.slice(SETUP_SRC.indexOf("const DEVICE_BIND_CAP_EXIT_CODE"), SETUP_SRC.indexOf("function bindErrorSignature"));
|
|
287
|
+
const mod = new Function("path", "CONFIG_DIR", `${consts}\n${fnSrc}\nreturn { bindCapBackoffMs, DEVICE_BIND_CAP_EXIT_CODE, BIND_CAP_BACKOFF_MAX_MS };`)(
|
|
288
|
+
{ join: (...parts) => parts.join("/") },
|
|
289
|
+
"/tmp/fake-relay"
|
|
290
|
+
);
|
|
291
|
+
assert.equal(mod.DEVICE_BIND_CAP_EXIT_CODE, 5);
|
|
292
|
+
assert.deepEqual([1, 2, 3, 4, 5, 6].map(mod.bindCapBackoffMs), [10_000, 60_000, 300_000, 900_000, 30 * 60 * 1000, 30 * 60 * 1000]);
|
|
293
|
+
assert.equal(mod.bindCapBackoffMs(4), mod.BIND_CAP_BACKOFF_MAX_MS / 2, "15min 是封顶前最后一档");
|
|
294
|
+
// 退避提示里必须告诉用户「解绑后自动恢复」(否则用户以为要等 30 分钟且什么也做不了)
|
|
295
|
+
assert.match(SETUP_SRC, /解绑后自动恢复|解绑后会自动恢复/);
|
|
296
|
+
// 该退避只约束**自动**拉起:换账号时要清掉(新账号额度/硬上限可能完全不同)
|
|
297
|
+
assert.ok(/换账号 = 绑定前提已变/.test(SETUP_SRC), "换账号必须复位设备绑定退避");
|
|
298
|
+
assert.ok(/bridge 因设备绑定达到硬上限退出/.test(SETUP_SRC), "watcher 收到退出码 5 要有明确日志");
|
|
299
|
+
// 插件半必须记住"上次退避提示"的那行日志开关,否则 2.5s 轮询会把安装日志刷爆
|
|
300
|
+
assert.ok(/bindFailLogged/.test(INDEX_SRC));
|
|
301
|
+
});
|