@mrrisega/dsh-remote 0.7.6 → 0.7.9-beta.1
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 +332 -19
- 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/clients/dsh-remote/test/wechat-channel.test.mjs +452 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +568 -29
- package/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/dsh-setup.mjs +297 -51
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +119 -16
- package/packages/dsh-remote-web/lib/index.js +555 -63
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +332 -19
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +297 -51
- package/packages/dsh-remote-web/test/device-locked-panel.test.mjs +301 -0
- package/packages/dsh-remote-web/test/setup-win-scan.test.mjs +455 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +50 -0
- package/packages/dsh-remote-web/test/windows-process-discovery.test.mjs +427 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 渠道归因码(channel referral)全链路回归。
|
|
2
|
+
//
|
|
3
|
+
// 契约:dsh-relay-enterprise/docs/channel-referral-contract.md §1 / §5 ——
|
|
4
|
+
// `npx @mrrisega/dsh-remote --ref 368`
|
|
5
|
+
// → 安装器把 `--ref`(3~6 位数字)写进 ~/.dsh-remote/.dsh-config.json 的 `ref_code`
|
|
6
|
+
// → bridge `POST /api/devices` 带 `ref_code`;
|
|
7
|
+
// → 插件 `POST /api/install-report` 带 `ref_code`,面板注册 `POST /api/register` 带 `channel_code`。
|
|
8
|
+
// 非法值一律静默忽略(归因是附加信息,绝不能因此让安装失败)。
|
|
9
|
+
//
|
|
10
|
+
// 三层锁(哪一层单独测都不够):
|
|
11
|
+
// A. 解析/合并纯函数:非法值忽略、CLI > 环境变量、升级不覆盖已有值;
|
|
12
|
+
// B. 真起 bridge 进程 + 127.0.0.1 假账号 API:逐字断言 `/api/devices` 的 body;
|
|
13
|
+
// C. 真跑安装器子进程(隔离 HOME / DSH_RELAY_DIR):断言磁盘上的 ref_code 行为。
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ 所有测试进程只绑 127.0.0.1(仓库测试铁律:不许绑 0.0.0.0)。
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { execFile, spawn } from "node:child_process";
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } 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
|
+
import { applyRefCodeToConfig, normalizeRefCode, resolveRefCode } from "../src/channel-ref.mjs";
|
|
27
|
+
|
|
28
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const BRIDGE = path.join(HERE, "..", "dsh-bridge.mjs");
|
|
30
|
+
const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
|
|
31
|
+
|
|
32
|
+
// ───────────────────────── A. 纯函数(安装器/桥共用同一份规则) ─────────────────────────
|
|
33
|
+
|
|
34
|
+
test("ref 解析:3~12 位字母或数字才合法,其余一律空串(绝不抛错)", () => {
|
|
35
|
+
const valid = [
|
|
36
|
+
["368", "368"],
|
|
37
|
+
[" 368 ", "368"], // 前后空白容忍(argv 里偶尔带)
|
|
38
|
+
["0368", "0368"], // 保留前导零:码是字符串不是数字
|
|
39
|
+
["123456789012", "123456789012"], // 12 位上限
|
|
40
|
+
["bili", "bili"], // ★字母短词(更隐蔽,业主 2026-10 口径)
|
|
41
|
+
["BILI", "bili"], // 大小写不敏感 → 归一化小写
|
|
42
|
+
[" Bili ", "bili"],
|
|
43
|
+
["xhs8", "xhs8"],
|
|
44
|
+
[368, "368"], // 配置被手改成数字也能救回来
|
|
45
|
+
];
|
|
46
|
+
for (const [input, want] of valid) {
|
|
47
|
+
assert.equal(normalizeRefCode(input), want, `合法值 ${JSON.stringify(input)} 应归一化为 ${want}`);
|
|
48
|
+
}
|
|
49
|
+
const invalid = ["12", "a", "ab", "1234567890123", "36 8", "36-8", "-368", "3.68", "", " ", null, undefined, {}, [], 12, 3.68, -368];
|
|
50
|
+
for (const input of invalid) {
|
|
51
|
+
assert.equal(normalizeRefCode(input), "", `非法值 ${JSON.stringify(input)} 必须是空串(调用方据此省略字段)`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("ref 解析优先级:命令行 --ref 优先;非法/缺值的命令行不悄悄回落环境变量", () => {
|
|
56
|
+
assert.equal(resolveRefCode(["--ref", "368"]), "368");
|
|
57
|
+
assert.equal(resolveRefCode(["--ref", "368"], { DSH_REF: "999" }), "368", "命令行优先于 DSH_REF");
|
|
58
|
+
assert.equal(resolveRefCode([], { DSH_REF: "456" }), "456", "命令行没给时用 DSH_REF");
|
|
59
|
+
assert.equal(resolveRefCode([], { DSH_REF: "x" }), "", "环境变量非法 → 忽略");
|
|
60
|
+
assert.equal(resolveRefCode(["--ref", "x"], { DSH_REF: "456" }), "", "命令行给了非法值 → 忽略,不回落到环境变量(避免来路不明的归因)");
|
|
61
|
+
assert.equal(resolveRefCode(["--ref"], { DSH_REF: "456" }), "", "--ref 后面没值 → 忽略");
|
|
62
|
+
assert.equal(resolveRefCode([], {}), "");
|
|
63
|
+
assert.equal(resolveRefCode(undefined, undefined), "", "参数异常也不得抛错");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("配置合并:本次没带/非法 ref 时保留旧值(升级清空 = 归因永久丢失)", () => {
|
|
67
|
+
const old = { ref_code: "111", phone: "13800000000" };
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
applyRefCodeToConfig(old, []).ref_code,
|
|
70
|
+
"111",
|
|
71
|
+
"★升级(不带 --ref)必须保留配置里已有的 ref_code"
|
|
72
|
+
);
|
|
73
|
+
assert.equal(applyRefCodeToConfig(old, ["--ref", "x"]).ref_code, "111", "非法值不得覆盖旧值");
|
|
74
|
+
assert.equal(applyRefCodeToConfig(old, ["--ref", "368"]).ref_code, "368", "带合法 --ref → 覆盖");
|
|
75
|
+
assert.equal(applyRefCodeToConfig({}, ["--ref", "368"]).ref_code, "368", "首次安装 → 写入");
|
|
76
|
+
assert.ok(!("ref_code" in applyRefCodeToConfig({}, [])), "没有就没有:绝不写空串占位");
|
|
77
|
+
assert.equal(old.ref_code, "111", "不得原地改传入对象(调用方后面还要整份写回)");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ───────────────────────── B. bridge:真的往 /api/devices 发什么 ─────────────────────────
|
|
81
|
+
|
|
82
|
+
/** 起一个 127.0.0.1 假账号 API + 假 tunnel,拉真 bridge 进程,等它完成设备登记后交给断言。 */
|
|
83
|
+
async function withBridge(configFile, fn) {
|
|
84
|
+
const seen = { devices: null };
|
|
85
|
+
const api = http.createServer((req, res) => {
|
|
86
|
+
let raw = "";
|
|
87
|
+
req.on("data", (c) => { raw += c; });
|
|
88
|
+
req.on("end", () => {
|
|
89
|
+
if (req.url === "/api/device-login") {
|
|
90
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
91
|
+
res.end(JSON.stringify({ token: "token-1" }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (req.url === "/api/devices") {
|
|
95
|
+
try { seen.devices = JSON.parse(raw); } catch { seen.devices = { parseError: raw }; }
|
|
96
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
97
|
+
res.end(JSON.stringify({ device: { id: "dev-000000000009" } }));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
101
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
105
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
106
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
107
|
+
|
|
108
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-channel-ref-"));
|
|
109
|
+
const configPath = path.join(tempDir, "config.json");
|
|
110
|
+
if (configFile) await writeFile(configPath, JSON.stringify(configFile));
|
|
111
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
112
|
+
env: {
|
|
113
|
+
...process.env,
|
|
114
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
115
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
116
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
117
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
118
|
+
DSH_BRIDGE_DEVICE_ID: "dev-000000000009",
|
|
119
|
+
DSH_BRIDGE_CONFIG: configPath,
|
|
120
|
+
},
|
|
121
|
+
stdio: "ignore",
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
for (let i = 0; i < 60 && !seen.devices; i += 1) await delay(100, null, { ref: false });
|
|
125
|
+
assert.ok(seen.devices, "bridge 应在 6 秒内完成设备登记(POST /api/devices)");
|
|
126
|
+
return await fn(seen.devices);
|
|
127
|
+
} finally {
|
|
128
|
+
try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
129
|
+
await new Promise((r) => wss.close(r));
|
|
130
|
+
await new Promise((r) => api.close(r));
|
|
131
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("bridge 设备登记:配置里有 ref_code → POST /api/devices 必须带 ref_code", async () => {
|
|
136
|
+
await withBridge({ ref_code: "368" }, (body) => {
|
|
137
|
+
assert.equal(body.ref_code, "368", "★安装器 --ref 写的 ref_code 必须随设备登记上报(否则服务端无法归因)");
|
|
138
|
+
assert.equal(body.device_id, "dev-000000000009", "既有字段照旧");
|
|
139
|
+
assert.ok(body.pub_key && body.machine_fp, "既有登记字段不得丢失");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("bridge 设备登记:没有/非法 ref_code → 字段整个省略(不发空串、不发脏值)", async () => {
|
|
144
|
+
// ① 配置里没有 ref_code
|
|
145
|
+
await withBridge({}, (body) => {
|
|
146
|
+
assert.ok(!("ref_code" in body), "没有归因码时字段必须缺席(服务端字段可选)");
|
|
147
|
+
});
|
|
148
|
+
// ② 配置里是被手改坏的值
|
|
149
|
+
await withBridge({ ref_code: "not-a-code" }, (body) => {
|
|
150
|
+
assert.ok(!("ref_code" in body), "非法值不得原样上报");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ───────────────────────── C. 安装器:磁盘上的 ref_code ─────────────────────────
|
|
155
|
+
|
|
156
|
+
/** 假 SaaS API:只要 public-config(安装器会读),其余 404。 */
|
|
157
|
+
async function startMockApi() {
|
|
158
|
+
const server = http.createServer((req, res) => {
|
|
159
|
+
if (req.url === "/api/public-config") {
|
|
160
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
161
|
+
res.end(JSON.stringify({ app_url: "http://127.0.0.1/app/" }));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
165
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
166
|
+
});
|
|
167
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
168
|
+
return { server, base: `http://127.0.0.1:${server.address().port}` };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 隔离 HOME 跑安装器(--no-autostart/--no-plugin/--no-restart:不碰真实服务与 profile)。 */
|
|
172
|
+
function runSetup({ relayDir, home, api, args = [], env = {} }) {
|
|
173
|
+
const childEnv = {
|
|
174
|
+
...process.env,
|
|
175
|
+
HOME: home,
|
|
176
|
+
DSH_RELAY_DIR: relayDir,
|
|
177
|
+
DSH_PROFILE_DIR: path.join(home, "dsh-profile"),
|
|
178
|
+
DSH_RELAY_SKIP_SERVICE: "1",
|
|
179
|
+
DSH_RELAY_DEFAULT_API: api,
|
|
180
|
+
DSH_REMOTE_TELEMETRY: "0",
|
|
181
|
+
...env,
|
|
182
|
+
};
|
|
183
|
+
// 开发机上的变量不得泄漏进来(否则「环境变量回退」用例结论不可信)
|
|
184
|
+
for (const k of ["DSH_BRIDGE_CONFIG"]) delete childEnv[k];
|
|
185
|
+
if (!("DSH_REF" in env)) delete childEnv.DSH_REF;
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
execFile(
|
|
188
|
+
process.execPath,
|
|
189
|
+
[SETUP, "--api", api, "--no-autostart", "--no-plugin", "--no-restart", ...args],
|
|
190
|
+
{ env: childEnv, timeout: 60_000 },
|
|
191
|
+
(err, stdout, stderr) => resolve({ code: err ? err.code ?? 1 : 0, stdout: String(stdout || ""), stderr: String(stderr || "") })
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function readSetupConfig(relayDir) {
|
|
197
|
+
return JSON.parse(await readFile(path.join(relayDir, ".dsh-config.json"), "utf8"));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
test("安装器:--ref 写入 ref_code;升级不带 --ref 必须保留;非法值忽略且安装照常成功", async () => {
|
|
201
|
+
const mock = await startMockApi();
|
|
202
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-setup-ref-"));
|
|
203
|
+
try {
|
|
204
|
+
const home = path.join(root, "home");
|
|
205
|
+
await mkdir(home, { recursive: true });
|
|
206
|
+
const relayDir = path.join(root, ".dsh-remote");
|
|
207
|
+
|
|
208
|
+
// ① 首次安装:`npx @mrrisega/dsh-remote --ref 368`(同时给一个不同的 DSH_REF,锁「命令行优先」)
|
|
209
|
+
const first = await runSetup({ relayDir, home, api: mock.base, args: ["--ref", "368"], env: { DSH_REF: "999" } });
|
|
210
|
+
assert.equal(first.code, 0, `安装器应成功退出:${first.stderr}`);
|
|
211
|
+
const cfg1 = await readSetupConfig(relayDir);
|
|
212
|
+
assert.equal(cfg1.ref_code, "368", "★--ref 必须写进 .dsh-config.json 的 ref_code(命令行优先于 DSH_REF)");
|
|
213
|
+
assert.equal(typeof cfg1.ref_code, "string", "契约要求是字符串(不是数字)");
|
|
214
|
+
|
|
215
|
+
// ② 升级(重跑 setup,本次不带 --ref,环境里也没有 DSH_REF)→ 旧值必须原样保留
|
|
216
|
+
const second = await runSetup({ relayDir, home, api: mock.base });
|
|
217
|
+
assert.equal(second.code, 0, `升级也应成功退出:${second.stderr}`);
|
|
218
|
+
const cfg2 = await readSetupConfig(relayDir);
|
|
219
|
+
assert.equal(cfg2.ref_code, "368", "★升级时没带 --ref 不得清空已写入的 ref_code(saveConfig 是整对象覆盖)");
|
|
220
|
+
|
|
221
|
+
// ③ 全新目录 + 非法 --ref:静默忽略,但安装必须照常成功(主链路优先)
|
|
222
|
+
const home2 = path.join(root, "home2");
|
|
223
|
+
await mkdir(home2, { recursive: true });
|
|
224
|
+
const relayDir2 = path.join(root, ".dsh-remote-2");
|
|
225
|
+
const third = await runSetup({ relayDir: relayDir2, home: home2, api: mock.base, args: ["--ref", "x"] });
|
|
226
|
+
assert.equal(third.code, 0, `非法 --ref 绝不能导致安装失败:${third.stderr}`);
|
|
227
|
+
assert.match(third.stdout + third.stderr, /安装完成/, "安装器应走完正常流程并打印「安装完成」");
|
|
228
|
+
const cfg3 = await readSetupConfig(relayDir2);
|
|
229
|
+
assert.ok(!("ref_code" in cfg3), "非法值必须被静默丢弃(不写空串、不写脏值)");
|
|
230
|
+
} finally {
|
|
231
|
+
await new Promise((r) => mock.server.close(r));
|
|
232
|
+
await rm(root, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("安装器:命令行没给 --ref 时用环境变量 DSH_REF", async () => {
|
|
237
|
+
const mock = await startMockApi();
|
|
238
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-setup-ref-env-"));
|
|
239
|
+
try {
|
|
240
|
+
const home = path.join(root, "home");
|
|
241
|
+
await mkdir(home, { recursive: true });
|
|
242
|
+
const relayDir = path.join(root, ".dsh-remote");
|
|
243
|
+
const r = await runSetup({ relayDir, home, api: mock.base, env: { DSH_REF: "456" } });
|
|
244
|
+
assert.equal(r.code, 0, `安装器应成功退出:${r.stderr}`);
|
|
245
|
+
assert.equal((await readSetupConfig(relayDir)).ref_code, "456", "DSH_REF 应作为 --ref 的回退写入配置");
|
|
246
|
+
} finally {
|
|
247
|
+
await new Promise((r2) => mock.server.close(r2));
|
|
248
|
+
await rm(root, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// 设备超额锁定 —— bridge 侧行为回归(契约 docs/device-overquota-lock-contract.md §6)。
|
|
2
|
+
//
|
|
3
|
+
// 【为什么必须有】旧实现收到「设备不可用」只做两件事之一:
|
|
4
|
+
// · 旧版 409 device_limit_exceeded → persistBindError + process.exit(1);
|
|
5
|
+
// · router tunnel-register-err → 写 state.last_error 然后**照常 2s 后重连**。
|
|
6
|
+
// 两者都是"空转":watcher 每 10s 重拉 / 隧道每 2s 重撞,用户看到的是「设备列表里明明有这台电脑,
|
|
7
|
+
// 但手机永远连不上」,日志还被刷屏。本用例走真实 bridge 进程 + 假账号 API + 假 router,
|
|
8
|
+
// 逐条钉住新契约:
|
|
9
|
+
// ① 锁定态**不退出进程**、写 phase=locked 详情、不重连隧道(只 30s 轮询 GET /api/devices);
|
|
10
|
+
// ② 解锁(locked=false)→ 立即重连;设备行不存在(手机端解绑)→ 先登记再连;
|
|
11
|
+
// ③ 旧版 409 device_limit_exceeded 同样转等待(向后兼容,不再 exit(1));
|
|
12
|
+
// ④ device_bind_cap → 专用退出码 5 + .bind-error.json 带 device_id 等详情(watcher 据此指数退避)。
|
|
13
|
+
//
|
|
14
|
+
// ⚠️ 只连 127.0.0.1(test:bridge 带 test-net-guard);轮询间隔用 DSH_BRIDGE_LOCK_POLL_MS 压到亚秒级。
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import { mkdtemp, rm, writeFile } 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
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const BRIDGE = path.join(HERE, "..", "dsh-bridge.mjs");
|
|
29
|
+
const DEVICE_ID = "dev-000000000001";
|
|
30
|
+
const LOCK_POLL_MS = "80";
|
|
31
|
+
|
|
32
|
+
const sleep = (ms) => delay(ms, null, { ref: false });
|
|
33
|
+
async function waitFor(fn, { timeout = 5000, step = 40, label = "" } = {}) {
|
|
34
|
+
const deadline = Date.now() + timeout;
|
|
35
|
+
for (;;) {
|
|
36
|
+
if (await fn()) return true;
|
|
37
|
+
if (Date.now() > deadline) throw new Error(`等待超时${label ? `(${label})` : ""}`);
|
|
38
|
+
await sleep(step);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function jsonOf(file) {
|
|
42
|
+
try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 起一个假账号 API + 假 router;`scenario` 决定 register/getDevices/ws 的剧本。
|
|
47
|
+
* @returns {Promise<{child, api, wss, port, stateFile, bindErrorFile, counts, close}>}
|
|
48
|
+
*/
|
|
49
|
+
async function launch(scenario) {
|
|
50
|
+
const counts = { register: 0, get: 0, login: 0, ws: 0 };
|
|
51
|
+
const registrations = [];
|
|
52
|
+
const api = http.createServer((req, res) => {
|
|
53
|
+
const send = (status, body) => {
|
|
54
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
55
|
+
res.end(JSON.stringify(body));
|
|
56
|
+
};
|
|
57
|
+
if (req.url === "/api/device-login") {
|
|
58
|
+
counts.login += 1;
|
|
59
|
+
return send(200, { token: `tok-${counts.login}` });
|
|
60
|
+
}
|
|
61
|
+
if (req.url === "/api/devices" && req.method === "POST") {
|
|
62
|
+
counts.register += 1;
|
|
63
|
+
return send(...scenario.onRegister(counts.register, req));
|
|
64
|
+
}
|
|
65
|
+
if (req.url === "/api/devices" && req.method === "GET") {
|
|
66
|
+
counts.get += 1;
|
|
67
|
+
return send(...scenario.onGetDevices(counts.get, req));
|
|
68
|
+
}
|
|
69
|
+
return send(404, { error: { code: "not_found", message: "not found" } });
|
|
70
|
+
});
|
|
71
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
72
|
+
|
|
73
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
74
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
75
|
+
wss.on("connection", (ws) => {
|
|
76
|
+
counts.ws += 1;
|
|
77
|
+
ws.once("message", (raw) => {
|
|
78
|
+
try { registrations.push(JSON.parse(raw.toString())); } catch { registrations.push({ raw: String(raw) }); }
|
|
79
|
+
scenario.onRegisterWs(ws, registrations.length);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-device-locked-"));
|
|
84
|
+
const configFile = path.join(tempDir, "config.json");
|
|
85
|
+
await writeFile(configFile, "{}");
|
|
86
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
87
|
+
env: {
|
|
88
|
+
...process.env,
|
|
89
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
90
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
91
|
+
DSH_BRIDGE_UPSTREAM: "http://127.0.0.1:1", // 不做端口发现:本用例只测锁定逻辑
|
|
92
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
93
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
94
|
+
DSH_BRIDGE_DEVICE_ID: DEVICE_ID,
|
|
95
|
+
DSH_BRIDGE_CONFIG: configFile,
|
|
96
|
+
DSH_BRIDGE_LOCK_POLL_MS: LOCK_POLL_MS,
|
|
97
|
+
DSH_WECHAT: "0", // 微信通道与本用例无关,别让它发起额外的网络请求
|
|
98
|
+
DSH_REMOTE_TELEMETRY: "0",
|
|
99
|
+
},
|
|
100
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
101
|
+
});
|
|
102
|
+
let stderr = "";
|
|
103
|
+
child.stderr.on("data", (c) => { stderr += String(c); });
|
|
104
|
+
|
|
105
|
+
const relayDir = path.dirname(configFile);
|
|
106
|
+
return {
|
|
107
|
+
child,
|
|
108
|
+
api,
|
|
109
|
+
wss,
|
|
110
|
+
counts,
|
|
111
|
+
registrations,
|
|
112
|
+
stderrText: () => stderr,
|
|
113
|
+
stateFile: path.join(relayDir, ".dsh-bridge-state.json"),
|
|
114
|
+
bindErrorFile: path.join(relayDir, ".bind-error.json"),
|
|
115
|
+
async close() {
|
|
116
|
+
try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
117
|
+
await new Promise((r) => wss.close(r));
|
|
118
|
+
await new Promise((r) => api.close(r));
|
|
119
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 201 响应体(契约 §4.2 形状):device + locked + policy。 */
|
|
125
|
+
const okRegister = () => [201, {
|
|
126
|
+
device: { id: DEVICE_ID },
|
|
127
|
+
locked: false,
|
|
128
|
+
policy: { plan: "free", limit: 1, online: 1, unlock_monthly: 3, registered: 1, locked_count: 0, upgrade_to: "pro_max", lock_enabled: true }
|
|
129
|
+
}];
|
|
130
|
+
const lockedFrame = {
|
|
131
|
+
type: "tunnel-register-err",
|
|
132
|
+
code: "device_locked",
|
|
133
|
+
message: "已超出当前套餐设备额度,升级 Pro Max 可多台设备同时使用",
|
|
134
|
+
plan: "free",
|
|
135
|
+
limit: 1,
|
|
136
|
+
upgrade_to: "pro_max"
|
|
137
|
+
};
|
|
138
|
+
const okFrame = { type: "tunnel-register-ok", caps: [] };
|
|
139
|
+
|
|
140
|
+
test("锁定态:不退出、写 phase=locked 详情、期间不重连;解锁后自动重连", async () => {
|
|
141
|
+
const ctx = await launch({
|
|
142
|
+
// 第一次隧道注册 → 被 router 以 device_locked 拒绝;之后才允许成功
|
|
143
|
+
onRegisterWs(ws, n) {
|
|
144
|
+
if (n === 1) ws.send(JSON.stringify(lockedFrame));
|
|
145
|
+
else ws.send(JSON.stringify(okFrame));
|
|
146
|
+
},
|
|
147
|
+
onRegister: () => okRegister(),
|
|
148
|
+
// 前两拍仍锁定(验证"等待期间不重连"),第三拍起解锁
|
|
149
|
+
onGetDevices(getCount) {
|
|
150
|
+
return [200, {
|
|
151
|
+
devices: [{ id: DEVICE_ID, device_name: "test-host", locked: getCount < 3 }],
|
|
152
|
+
policy: { plan: "free", limit: 1, registered: 2, locked_count: getCount < 3 ? 1 : 0, upgrade_to: "pro_max", lock_enabled: true }
|
|
153
|
+
}];
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
// ① 锁定态:进程活着 + 状态文件 phase=locked + locked 详情(面板据此显示升级引导)
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
const st = jsonOf(ctx.stateFile);
|
|
160
|
+
return st && st.phase === "locked" && st.locked && st.locked.limit === 1;
|
|
161
|
+
}, { label: "phase=locked" });
|
|
162
|
+
assert.equal(ctx.child.exitCode, null, "锁定态**不得退出进程**(旧实现 exit(1) 是空转)");
|
|
163
|
+
const st = jsonOf(ctx.stateFile);
|
|
164
|
+
assert.equal(st.locked.plan, "free");
|
|
165
|
+
assert.equal(st.locked.upgrade_to, "pro_max");
|
|
166
|
+
assert.match(String(st.locked.message), /额度/);
|
|
167
|
+
assert.equal(st.last_error, null, "锁定不是「注册错误」,last_error 必须清空");
|
|
168
|
+
|
|
169
|
+
// ② .bind-error.json 富化(契约 §6.3/§8.4):带 device_id/plan/limit/upgrade_to
|
|
170
|
+
await waitFor(() => existsSync(ctx.bindErrorFile), { label: "bind-error" });
|
|
171
|
+
const be = jsonOf(ctx.bindErrorFile);
|
|
172
|
+
assert.equal(be.code, "device_locked");
|
|
173
|
+
assert.equal(be.device_id, DEVICE_ID);
|
|
174
|
+
assert.equal(be.plan, "free");
|
|
175
|
+
assert.equal(be.limit, 1);
|
|
176
|
+
assert.equal(be.upgrade_to, "pro_max");
|
|
177
|
+
|
|
178
|
+
// ③ 等待期间**不重连隧道**(只轮询 /api/devices;轮询带 Bearer token)
|
|
179
|
+
await waitFor(() => ctx.counts.get >= 2, { label: "poll >= 2" });
|
|
180
|
+
assert.equal(ctx.registrations.length, 1, "锁定等待期间不得重连隧道(否则每 2s 白撞一次 router)");
|
|
181
|
+
assert.equal(ctx.child.exitCode, null, "等待期间进程必须活着");
|
|
182
|
+
|
|
183
|
+
// ④ 解锁 → 自动重连(第二次 WS 注册)
|
|
184
|
+
await waitFor(() => ctx.registrations.length === 2, { label: "解锁后重连" });
|
|
185
|
+
assert.equal(ctx.registrations[0].deviceId, DEVICE_ID);
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
const cur = jsonOf(ctx.stateFile);
|
|
188
|
+
return cur && cur.phase === "online";
|
|
189
|
+
}, { label: "重连后 phase=online" });
|
|
190
|
+
assert.equal(existsSync(ctx.bindErrorFile), false, "解锁重连成功后应清掉 .bind-error.json");
|
|
191
|
+
} finally {
|
|
192
|
+
await ctx.close();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("等待期间设备行被解绑(列表里没有本机)→ 先重新登记再重连", async () => {
|
|
197
|
+
const ctx = await launch({
|
|
198
|
+
onRegisterWs(ws, n) {
|
|
199
|
+
if (n === 1) ws.send(JSON.stringify(lockedFrame));
|
|
200
|
+
else ws.send(JSON.stringify(okFrame));
|
|
201
|
+
},
|
|
202
|
+
onRegister: () => okRegister(),
|
|
203
|
+
onGetDevices() {
|
|
204
|
+
// 手机端把本机解绑了:列表里没有本机 → bridge 必须 registerDeviceInAccount 后重连
|
|
205
|
+
return [200, { devices: [], policy: { plan: "free", limit: 1, registered: 0, locked_count: 0, upgrade_to: "pro_max", lock_enabled: true } }];
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
try {
|
|
209
|
+
await waitFor(() => {
|
|
210
|
+
const st = jsonOf(ctx.stateFile);
|
|
211
|
+
return st && st.phase === "locked";
|
|
212
|
+
}, { label: "phase=locked" });
|
|
213
|
+
await waitFor(() => ctx.counts.register >= 2, { label: "重新登记" });
|
|
214
|
+
await waitFor(() => ctx.registrations.length === 2, { label: "重连" });
|
|
215
|
+
assert.equal(ctx.child.exitCode, null);
|
|
216
|
+
} finally {
|
|
217
|
+
await ctx.close();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("旧版服务端 409 device_limit_exceeded:不再 exit(1) 空转,改为轮询等待后重连", async () => {
|
|
222
|
+
const ctx = await launch({
|
|
223
|
+
onRegisterWs(ws) { ws.send(JSON.stringify(okFrame)); },
|
|
224
|
+
// 旧服务端:按套餐额度拒绝登记(新契约下已改为「可超绑 + 锁定」,这里验证向后兼容)
|
|
225
|
+
onRegister() {
|
|
226
|
+
return [409, { error: { code: "device_limit_exceeded", message: "当前套餐最多绑定 1 台设备,请先解绑旧设备" } }];
|
|
227
|
+
},
|
|
228
|
+
// 解绑后本机变为可用
|
|
229
|
+
onGetDevices() {
|
|
230
|
+
return [200, { devices: [{ id: DEVICE_ID, locked: false }], policy: { plan: "free", limit: 1, registered: 1, locked_count: 0, upgrade_to: "pro_max", lock_enabled: true } }];
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
try {
|
|
234
|
+
await waitFor(() => {
|
|
235
|
+
const st = jsonOf(ctx.stateFile);
|
|
236
|
+
return st && st.phase === "locked";
|
|
237
|
+
}, { label: "旧 409 → phase=locked" });
|
|
238
|
+
assert.equal(ctx.child.exitCode, null, "旧版 409 必须转为等待,不得 exit(1)");
|
|
239
|
+
// 旧服务端不返回 limit:从文案「最多绑定 1 台」里尽力解析(面板文案要能说 N 台)
|
|
240
|
+
const st = jsonOf(ctx.stateFile);
|
|
241
|
+
assert.equal(st.locked.limit, 1, "应从旧版文案解析出额度,避免面板显示「N 台」占位");
|
|
242
|
+
await waitFor(() => ctx.registrations.length === 1, { label: "解锁后首次连隧道" });
|
|
243
|
+
assert.equal(ctx.child.exitCode, null);
|
|
244
|
+
} finally {
|
|
245
|
+
await ctx.close();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("device_bind_cap:写 .bind-error.json + 专用退出码 5(watcher 据此指数退避)", async () => {
|
|
250
|
+
const ctx = await launch({
|
|
251
|
+
onRegisterWs() { /* 不应连上:登记就失败了 */ },
|
|
252
|
+
onRegister() {
|
|
253
|
+
return [409, { error: { code: "device_bind_cap", message: "已绑定设备数已达上限(50 台),请先在手机端「管理电脑」解绑" } }];
|
|
254
|
+
},
|
|
255
|
+
onGetDevices() { return [200, { devices: [] }]; }
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
const code = await new Promise((resolve) => {
|
|
259
|
+
const timer = setTimeout(() => resolve("timeout"), 8000);
|
|
260
|
+
ctx.child.once("exit", (c) => { clearTimeout(timer); resolve(c); });
|
|
261
|
+
});
|
|
262
|
+
assert.equal(code, 5, "device_bind_cap 必须用专用退出码 5 退出(不能与瞬时失败共用 exit 1)");
|
|
263
|
+
const be = jsonOf(ctx.bindErrorFile);
|
|
264
|
+
assert.ok(be, "必须写 .bind-error.json 供面板展示");
|
|
265
|
+
assert.equal(be.code, "device_bind_cap");
|
|
266
|
+
assert.equal(be.device_id, DEVICE_ID, "详情里的 device_id 不能丢(P0:面板/诊断靠它定位)");
|
|
267
|
+
assert.equal(ctx.counts.ws, 0, "登记失败时不该去连隧道");
|
|
268
|
+
} finally {
|
|
269
|
+
await ctx.close();
|
|
270
|
+
}
|
|
271
|
+
});
|