@mrrisega/dsh-remote 0.6.13 → 0.6.15
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 +124 -14
- package/clients/dsh-remote/dsh-events.mjs +22 -2
- package/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/clients/dsh-remote/mobile-adapter.mjs +283 -1
- package/clients/dsh-remote/test/boot-tip.test.mjs +158 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
- package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +196 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
- package/clients/dsh-remote/upstream-discovery.mjs +448 -0
- package/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/dsh-setup.mjs +225 -23
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +31 -1
- package/packages/dsh-remote-web/lib/index.js +464 -15
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +124 -14
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +283 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +448 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -23
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +16 -1
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
- package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +201 -0
- package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
- package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +170 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux(含**飞牛 fnOS / 群晖这类 NAS**)能真正跑起 bridge —— 0.6.15 回归。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-09-25 用户诊断原文,插件 0.6.14,平台 linux):
|
|
5
|
+
* 连接阶段: starting(正在启动 Bridge…)
|
|
6
|
+
* bridge 进程: 未运行(pid=-,launchd state=-,崩溃循环=否)
|
|
7
|
+
* 后台守护: 未运行
|
|
8
|
+
* 上游 dsh web: http://127.0.0.1:3080(默认值 3080(未取到 dsh web 实际监听端口),⚠️ 不可达)
|
|
9
|
+
* 安装日志: [dsh-remote-web] 自动启动 bridge 未成功(unsupported): 当前平台(linux)暂不支持自启动服务
|
|
10
|
+
*
|
|
11
|
+
* 根因(两条,缺一不可):
|
|
12
|
+
* ① `startBridge` 只认 macOS 的 launchd —— 非 darwin 且非 win32 时直接返回 `unsupported`,
|
|
13
|
+
* 而 Linux 恰恰既不是 darwin 也不是 win32 → **bridge 从来没被拉起来过**。
|
|
14
|
+
* 自愈轮次每轮都调它、每轮都被挡回,面板于是永远停在「正在启动 Bridge…」。
|
|
15
|
+
* 用户以为"端口没匹配上",反复改端口号(改到 3080 也不好使)—— 因为根本没人在起进程。
|
|
16
|
+
* ② 自愈的就绪判据只对 Windows 放行(`isWindows() && manualStatus().watcher.length`),
|
|
17
|
+
* Linux 即使被拉起来也会每轮重复"写 unit + 起进程"。
|
|
18
|
+
*
|
|
19
|
+
* 这里在开发机上把 **linux 分支真跑一遍**(DSH_RELAY_PLATFORM=linux + 伪造 HOME/PATH),
|
|
20
|
+
* 断言的是行为(真的派生了守护进程、状态不再说"暂不支持"、systemd 不可用时如实降级),
|
|
21
|
+
* 而不是"源码长得像不像"。
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ 隔离:走真实分支的用例必须伪造 HOME 并去掉 DSH_RELAY_SKIP_SERVICE
|
|
24
|
+
* (static 护栏见 setup-output.test.mjs)。
|
|
25
|
+
*/
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
import http from "node:http";
|
|
28
|
+
import * as nodeFs from "node:fs";
|
|
29
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import test from "node:test";
|
|
35
|
+
|
|
36
|
+
import { apply } from "../lib/index.js";
|
|
37
|
+
import {
|
|
38
|
+
__noteAutostartFailure,
|
|
39
|
+
__clearAutostartFailure,
|
|
40
|
+
} from "../lib/index.js";
|
|
41
|
+
import {
|
|
42
|
+
allLoopbackListeningPorts,
|
|
43
|
+
candidatePorts,
|
|
44
|
+
looksLikeDshRemoteSelf,
|
|
45
|
+
procNetLoopbackPorts,
|
|
46
|
+
probeDshWeb
|
|
47
|
+
} from "../../../clients/dsh-remote/upstream-discovery.mjs";
|
|
48
|
+
|
|
49
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
|
|
51
|
+
/** 假 systemctl:`show-environment` 是否成功由 env 开关决定(模拟"有没有 user systemd 会话")。 */
|
|
52
|
+
const FAKE_SYSTEMCTL = `#!/bin/sh
|
|
53
|
+
case "$*" in
|
|
54
|
+
*show-environment*) [ "$DSH_TEST_HAVE_SYSTEMD" = "1" ] && { echo "PATH=/usr/bin"; exit 0; } || exit 1 ;;
|
|
55
|
+
*is-active*) [ "$DSH_TEST_HAVE_SYSTEMD" = "1" ] && { echo active; exit 0; } || exit 3 ;;
|
|
56
|
+
esac
|
|
57
|
+
exit 0
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
async function makeEnv({ haveSystemd = false, skipService = false } = {}) {
|
|
61
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-linux-"));
|
|
62
|
+
const fakeBin = path.join(root, "bin");
|
|
63
|
+
const fakeHome = path.join(root, "home");
|
|
64
|
+
const relayDir = path.join(root, "relay");
|
|
65
|
+
await mkdir(fakeBin, { recursive: true });
|
|
66
|
+
await mkdir(fakeHome, { recursive: true });
|
|
67
|
+
await mkdir(relayDir, { recursive: true });
|
|
68
|
+
await writeFile(path.join(fakeBin, "systemctl"), FAKE_SYSTEMCTL);
|
|
69
|
+
await chmod(path.join(fakeBin, "systemctl"), 0o755);
|
|
70
|
+
for (const n of ["pgrep", "ps"]) {
|
|
71
|
+
await writeFile(path.join(fakeBin, n), "#!/bin/sh\nexit 0\n");
|
|
72
|
+
await chmod(path.join(fakeBin, n), 0o755);
|
|
73
|
+
}
|
|
74
|
+
// 假运行环境入口:**没有 import**(manifest 为空 → runtimeReady=true),被派生前写一个 marker 然后长睡,
|
|
75
|
+
// 好让 pid 文件里的进程真的活着(状态判定要读真实 pid)。
|
|
76
|
+
await writeFile(path.join(relayDir, "dsh-setup.mjs"), `
|
|
77
|
+
import { writeFileSync } from "node:fs";
|
|
78
|
+
try { writeFileSync(process.env.DSH_TEST_SPAWN_MARKER, "spawned"); } catch {}
|
|
79
|
+
setTimeout(() => {}, 60000);
|
|
80
|
+
`);
|
|
81
|
+
await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
82
|
+
api_url: "http://127.0.0.1:1",
|
|
83
|
+
local_key: "test-local-key",
|
|
84
|
+
device_id: "dev-test-linux"
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const saved = {};
|
|
88
|
+
const keys = ["HOME", "PATH", "DSH_RELAY_PLATFORM", "DSH_RELAY_SKIP_SERVICE", "DSH_REMOTE_TELEMETRY",
|
|
89
|
+
"DSH_RELAY_SELFHEAL_MS", "DSH_TEST_HAVE_SYSTEMD", "DSH_TEST_SPAWN_MARKER", "XDG_RUNTIME_DIR",
|
|
90
|
+
"DSH_BRIDGE_UPSTREAM", "DSH_WEB_URL"];
|
|
91
|
+
for (const k of keys) saved[k] = process.env[k];
|
|
92
|
+
process.env.HOME = fakeHome;
|
|
93
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${saved.PATH || ""}`;
|
|
94
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
95
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "600000"; // 关掉自愈定时器:本例要的是"确定性的一次调用结果"
|
|
96
|
+
process.env.DSH_RELAY_PLATFORM = "linux";
|
|
97
|
+
process.env.DSH_TEST_HAVE_SYSTEMD = haveSystemd ? "1" : "0";
|
|
98
|
+
process.env.DSH_TEST_SPAWN_MARKER = path.join(root, "spawned.marker");
|
|
99
|
+
delete process.env.XDG_RUNTIME_DIR; // NAS 上没有 runtime dir
|
|
100
|
+
delete process.env.DSH_BRIDGE_UPSTREAM;
|
|
101
|
+
delete process.env.DSH_WEB_URL;
|
|
102
|
+
if (skipService) process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
103
|
+
else delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
root, relayDir, fakeBin, fakeHome,
|
|
107
|
+
marker: path.join(root, "spawned.marker"),
|
|
108
|
+
async restore() {
|
|
109
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
110
|
+
await rm(root, { recursive: true, force: true });
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function boot(relayDir) {
|
|
116
|
+
const routes = new Map();
|
|
117
|
+
const disposers = [];
|
|
118
|
+
apply({
|
|
119
|
+
webServer: { port: 0, register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
120
|
+
get(name) { return name === "loader" ? null : null; },
|
|
121
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
122
|
+
logger: { info() {}, warn() {} },
|
|
123
|
+
}, { relayDir });
|
|
124
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } };
|
|
125
|
+
return routes;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function serve(routes) {
|
|
129
|
+
const host = http.createServer((req, res) => {
|
|
130
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
131
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
132
|
+
});
|
|
133
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
134
|
+
return {
|
|
135
|
+
base: `http://127.0.0.1:${host.address().port}`,
|
|
136
|
+
async close() { await new Promise((r) => host.close(r)); },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function getJson(base, p) {
|
|
141
|
+
const res = await fetch(base + p);
|
|
142
|
+
let body = null;
|
|
143
|
+
try { body = await res.json(); } catch { /* 非 JSON */ }
|
|
144
|
+
return { status: res.status, body };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function postJson(base, p) {
|
|
148
|
+
const res = await fetch(base + p, { method: "POST" });
|
|
149
|
+
let body = null;
|
|
150
|
+
try { body = await res.json(); } catch { /* 非 JSON */ }
|
|
151
|
+
return { status: res.status, body };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const killPidFile = (relayDir) => {
|
|
155
|
+
try {
|
|
156
|
+
const pid = Number(readFileSync(path.join(relayDir, ".dsh-watcher.pid"), "utf8").trim());
|
|
157
|
+
if (Number.isInteger(pid) && pid > 1) process.kill(pid, "SIGKILL");
|
|
158
|
+
} catch { /* 没有就没有 */ }
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ─────────────────── ① 主用例:没有 user systemd(飞牛/NAS)时必须真的把守护拉起来 ───────────────────
|
|
162
|
+
|
|
163
|
+
test("★ linux 无 user systemd(NAS):bridge 必须被真的拉起,绝不再回「暂不支持自启动」", async () => {
|
|
164
|
+
const env = await makeEnv({ haveSystemd: false });
|
|
165
|
+
const routes = boot(env.relayDir);
|
|
166
|
+
const srv = await serve(routes);
|
|
167
|
+
try {
|
|
168
|
+
const retry = await postJson(srv.base, "/dsh-remote/connect/retry");
|
|
169
|
+
assert.equal(retry.status, 200);
|
|
170
|
+
const connect = retry.body.connect;
|
|
171
|
+
assert.ok(connect, "必须下发连接阶段");
|
|
172
|
+
assert.ok(!/暂不支持自启动/.test(connect.detail || ""),
|
|
173
|
+
`★ 0.6.14 的死路必须消失,实际 detail: ${connect.detail}`);
|
|
174
|
+
assert.ok(!["error"].includes(connect.phase) || !/unsupported/.test(JSON.stringify(connect.error || {})),
|
|
175
|
+
"不得把「平台不支持」当成错误结论抛给用户");
|
|
176
|
+
|
|
177
|
+
// 真的派生了守护进程:marker 由被派生的 dsh-setup.mjs 写出
|
|
178
|
+
for (let i = 0; i < 100 && !existsSync(env.marker); i += 1) await sleep(50);
|
|
179
|
+
assert.ok(existsSync(env.marker), "★ 必须真的派生了 bridge 守护进程(0.6.14 在 Linux 上什么都不会发生)");
|
|
180
|
+
assert.ok(existsSync(path.join(env.relayDir, ".dsh-watcher.pid")), "派生的守护必须写 pid 文件(状态判定要用)");
|
|
181
|
+
|
|
182
|
+
// 状态接口要如实说明走的是"后台进程"而不是 systemd
|
|
183
|
+
const st = await getJson(srv.base, "/dsh-remote/status");
|
|
184
|
+
assert.equal(st.status, 200);
|
|
185
|
+
assert.equal(st.body.service.platform, "linux");
|
|
186
|
+
assert.equal(st.body.service.serviceManager, "detached",
|
|
187
|
+
"没有 user systemd 会话时,服务管理器必须如实报 detached(否则面板会给错的文案)");
|
|
188
|
+
} finally {
|
|
189
|
+
killPidFile(env.relayDir);
|
|
190
|
+
await srv.close();
|
|
191
|
+
routes.dispose();
|
|
192
|
+
await env.restore();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("★ linux 有 user systemd:写 unit + enable + restart,状态报 systemd(可自愈优先)", async () => {
|
|
197
|
+
const env = await makeEnv({ haveSystemd: true });
|
|
198
|
+
process.env.XDG_RUNTIME_DIR = path.join(env.root, "run");
|
|
199
|
+
await mkdir(process.env.XDG_RUNTIME_DIR, { recursive: true });
|
|
200
|
+
const routes = boot(env.relayDir);
|
|
201
|
+
const srv = await serve(routes);
|
|
202
|
+
try {
|
|
203
|
+
const retry = await postJson(srv.base, "/dsh-remote/connect/retry");
|
|
204
|
+
assert.equal(retry.status, 200);
|
|
205
|
+
const unit = path.join(env.fakeHome, ".config", "systemd", "user", "dsh-bridge.service");
|
|
206
|
+
assert.ok(existsSync(unit), "有 user systemd 会话时必须写 unit(开机自启 + 崩溃自愈)");
|
|
207
|
+
const text = await readFile(unit, "utf8");
|
|
208
|
+
assert.match(text, /ExecStart=.*dsh-setup\.mjs run/, "unit 必须指向运行环境入口");
|
|
209
|
+
assert.match(text, /DSH_BRIDGE_UPSTREAM=/, "★ unit 必须带上游地址:否则开机自启的守护会回落到写死的 3080");
|
|
210
|
+
|
|
211
|
+
const st = await getJson(srv.base, "/dsh-remote/status");
|
|
212
|
+
assert.equal(st.body.service.serviceManager, "systemd", "有会话时如实报 systemd");
|
|
213
|
+
} finally {
|
|
214
|
+
await srv.close();
|
|
215
|
+
routes.dispose();
|
|
216
|
+
await env.restore();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ─────────────────── ①b 停止路径:Linux 也不能是「没有 launchd 服务可停」的死路 ───────────────────
|
|
221
|
+
|
|
222
|
+
test("★ linux:/dsh-remote/stop 必须真的把守护停掉(旧版只会回「没有 launchd 服务可停」)", async () => {
|
|
223
|
+
const env = await makeEnv({ haveSystemd: false });
|
|
224
|
+
const routes = boot(env.relayDir);
|
|
225
|
+
const srv = await serve(routes);
|
|
226
|
+
try {
|
|
227
|
+
await postJson(srv.base, "/dsh-remote/connect/retry"); // 先把守护拉起来
|
|
228
|
+
for (let i = 0; i < 100 && !existsSync(env.marker); i += 1) await sleep(50);
|
|
229
|
+
assert.ok(existsSync(env.marker), "前置条件:守护已被拉起");
|
|
230
|
+
const pid = Number(readFileSync(path.join(env.relayDir, ".dsh-watcher.pid"), "utf8").trim());
|
|
231
|
+
assert.ok(Number.isInteger(pid) && pid > 1, "pid 文件里应有真实 pid");
|
|
232
|
+
|
|
233
|
+
const stop = await postJson(srv.base, "/dsh-remote/stop");
|
|
234
|
+
assert.equal(stop.status, 200, `停止必须成功,实际 ${JSON.stringify(stop.body && stop.body.detail)}`);
|
|
235
|
+
assert.ok(!/launchd/.test(String(stop.body.detail || "")), "不该再说「没有 launchd 服务可停」");
|
|
236
|
+
// 进程真的没了。⚠️ 不能用 `process.kill(pid, 0)` 单独判:已死但**尚未被 reap** 的进程
|
|
237
|
+
// 处于僵尸态(Z),对它发信号仍然成功 —— 直接断言会把"其实已经停了"判成失败(实测踩到)。
|
|
238
|
+
const stat = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).stdout.trim();
|
|
239
|
+
assert.ok(stat === "" || /^Z/.test(stat),
|
|
240
|
+
`★ 停止后守护进程必须真的退出(当前 stat=${stat || "已不存在"})—— 否则切换账号会继续用旧凭据跑`);
|
|
241
|
+
} finally {
|
|
242
|
+
killPidFile(env.relayDir);
|
|
243
|
+
await srv.close();
|
|
244
|
+
routes.dispose();
|
|
245
|
+
await env.restore();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ─────────────────── ② 上游发现:NAS 上 dsh web 在非默认端口(实测 2298) ───────────────────
|
|
250
|
+
|
|
251
|
+
test("★ 候选端口包含「本机所有回环监听端口」,且排在静态 3080/43120 之前", () => {
|
|
252
|
+
const list = candidatePorts({ relayDir: "", env: {}, platform: "linux", listenPorts: [2298, 22] });
|
|
253
|
+
const hit = list.find((e) => e.port === 2298);
|
|
254
|
+
assert.ok(hit, "★ 2298 必须在候选里 —— 飞牛用户就是卡在它不在候选里");
|
|
255
|
+
assert.equal(hit.source, "listen");
|
|
256
|
+
// ⚠️ 不能断言"2298 的下标 < 3080 的下标":开发机自己就可能在 3080 上跑着 dsh web,
|
|
257
|
+
// 那样 3080 会先以 process 来源入表(去重后 static 那条就没了)→ 断言与机器状态耦合。
|
|
258
|
+
// 要锁的是**不变量**:监听端口整段排在静态兜底候选之前。
|
|
259
|
+
const firstListen = list.findIndex((e) => e.source === "listen");
|
|
260
|
+
const firstStatic = list.findIndex((e) => e.source === "static");
|
|
261
|
+
assert.ok(firstListen >= 0 && firstStatic >= 0, "两个来源都必须存在");
|
|
262
|
+
assert.ok(firstListen < firstStatic, "监听端口必须排在静态候选之前,否则永远轮不到它");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("身份校验 /dsh-remote/self:认自己的路由、拒陌生 JSON", () => {
|
|
266
|
+
assert.equal(looksLikeDshRemoteSelf(200, '{"ok":true,"version":"0.6.15","channel":"latest","runtimeReady":true,"relayDir":"/x/.dsh-remote"}'), true);
|
|
267
|
+
assert.equal(looksLikeDshRemoteSelf(200, '{"hello":"world"}'), false);
|
|
268
|
+
assert.equal(looksLikeDshRemoteSelf(404, '{"relayDir":"/x"}'), false, "404 不算");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("★ 行为:首页认不出来但 /dsh-remote/self 认得出 → 仍判为 dsh web(登录页/门户场景)", async () => {
|
|
272
|
+
// 模拟"首页是登录页/门户,一个 dsh 特征都没有"的部署:只有插件自己的路由能证明身份
|
|
273
|
+
const host = http.createServer((req, res) => {
|
|
274
|
+
if (new URL(req.url, "http://x").pathname === "/dsh-remote/self") {
|
|
275
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
276
|
+
res.end('{"ok":true,"version":"0.6.15","channel":"latest","runtimeReady":true,"relayDir":"/x"}');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
280
|
+
res.end("<html><title>Welcome</title><body>please sign in</body></html>");
|
|
281
|
+
});
|
|
282
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
283
|
+
const url = `http://127.0.0.1:${host.address().port}`;
|
|
284
|
+
try {
|
|
285
|
+
assert.equal(await probeDshWeb(url), true, "★ 第二条判据必须救回这类部署");
|
|
286
|
+
} finally {
|
|
287
|
+
await new Promise((r) => host.close(r));
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("Linux:/proc/net/tcp 解析(NAS 上没有任何外部命令也能列出监听端口)", () => {
|
|
292
|
+
// 只能断言"真机上这个函数不抛、且返回整数端口"(内容随环境变化,不锁死具体值)
|
|
293
|
+
const ports = procNetLoopbackPorts();
|
|
294
|
+
assert.ok(Array.isArray(ports));
|
|
295
|
+
for (const p of ports) assert.ok(Number.isInteger(p) && p > 0 && p < 65536, `端口非法: ${p}`);
|
|
296
|
+
const all = allLoopbackListeningPorts("linux");
|
|
297
|
+
assert.ok(Array.isArray(all));
|
|
298
|
+
for (const p of all) assert.ok(Number.isInteger(p) && p > 0 && p < 65536, `端口非法: ${p}`);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("静态护栏:本文件里的关键接线必须还在(lest 回归时静默失效)", () => {
|
|
302
|
+
const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
|
|
303
|
+
assert.match(src, /if \(isLinux\(\)\) return startBridgeLinux\(relayDir\)/, "startBridge 必须有 Linux 分支");
|
|
304
|
+
assert.match(src, /if \(!isDarwin\(\) && manualStatus\(relayDir\)\.watcher\.length > 0\)/, "自愈就绪判据必须覆盖 Linux");
|
|
305
|
+
assert.match(src, /async function discoverUpstreamSelf\(/, "插件半必须能主动发现上游");
|
|
306
|
+
assert.match(src, /\.\.\/runtime\/clients\/dsh-remote\/upstream-discovery\.mjs/, "发现实现必须复用运行时那一份(不写第三份)");
|
|
307
|
+
assert.ok(!/当前平台(\$\{osPlatform\(\)\})暂不支持自启动服务/.test(src) || /isLinux\(\)/.test(src),
|
|
308
|
+
"Linux 不再走 unsupported 分支");
|
|
309
|
+
void nodeFs;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ─────────────────── ③ 日志噪音:同一条失败不许每轮刷一遍 ───────────────────
|
|
313
|
+
|
|
314
|
+
test("★ 用户反馈锁:同一条自动启动失败**不许每轮刷日志**(实测被刷了 13~19 遍)", async () => {
|
|
315
|
+
// 现场:两条用户反馈的安装日志里,同一行
|
|
316
|
+
// `自动启动 bridge 未成功(unsupported): 当前平台(linux)暂不支持自启动服务`
|
|
317
|
+
// 分别重复 19 次 / 13 次(自愈每轮都重试,每次都写一行)。用户复制过来的诊断里全是重复行,
|
|
318
|
+
// 第一现场被淹没 —— 那次我们只能靠"数行数 ÷ 每分钟行数"才推出时间线。
|
|
319
|
+
const env = await makeEnv({ haveSystemd: false });
|
|
320
|
+
const logPath = path.join(env.relayDir, ".dsh-setup-install.log");
|
|
321
|
+
const lines = () => {
|
|
322
|
+
try { return readFileSync(logPath, "utf8").split("\n").filter((l) => l.includes("自动启动 bridge 未成功")); }
|
|
323
|
+
catch { return []; }
|
|
324
|
+
};
|
|
325
|
+
try {
|
|
326
|
+
assert.equal(__noteAutostartFailure(env.relayDir, "unsupported", "当前平台(linux)暂不支持自启动服务"), 1);
|
|
327
|
+
assert.equal(lines().length, 1, "第一次失败必须记一行(否则用户什么都看不到)");
|
|
328
|
+
for (let i = 0; i < 3; i += 1) __noteAutostartFailure(env.relayDir, "unsupported", "当前平台(linux)暂不支持自启动服务");
|
|
329
|
+
assert.equal(lines().length, 1, `同一条结论不得重复刷屏,实际 ${lines().length} 行`);
|
|
330
|
+
assert.ok(lines()[0].includes("自动启动 bridge 未成功"), "第一行要是人话原话");
|
|
331
|
+
// 到 5 次时补一行"还在失败、已 5 次",让用户知道不是一次性的
|
|
332
|
+
__noteAutostartFailure(env.relayDir, "unsupported", "当前平台(linux)暂不支持自启动服务");
|
|
333
|
+
assert.equal(lines().length, 2, "第 5 次补一行累计说明");
|
|
334
|
+
assert.match(lines()[1], /已连续 5 次/, "要说明是「还在失败」,而不是新的故障");
|
|
335
|
+
// 结论变了(换了个原因)→ 必须立刻记新的一行
|
|
336
|
+
__noteAutostartFailure(env.relayDir, "failed", "运行环境入口脚本不存在");
|
|
337
|
+
assert.equal(lines().length, 3, "原因变了就是新信息,要记");
|
|
338
|
+
// 成功后清状态 → 下次失败重新记第一行
|
|
339
|
+
__clearAutostartFailure(env.relayDir);
|
|
340
|
+
assert.equal(__noteAutostartFailure(env.relayDir, "unsupported", "当前平台(linux)暂不支持自启动服务"), 1);
|
|
341
|
+
assert.equal(lines().length, 4);
|
|
342
|
+
} finally {
|
|
343
|
+
killPidFile(env.relayDir);
|
|
344
|
+
await env.restore();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 目录选择器固定为「浏览器内实现」的契约测试。
|
|
4
|
+
*
|
|
5
|
+
* 用户实测(Windows):镜像页点「添加工作区」**没反应**;同功能在 Mac 上正常。
|
|
6
|
+
* 根因:官方 `directory-picker-auto` 只在**绑定非回环**时才改选 browse,否则 darwin/win32 一律挂
|
|
7
|
+
* **原生**选择器 —— 那个对话框弹在**电脑那台机器的屏幕上**,拿手机的人看不到;
|
|
8
|
+
* Windows 那条原生链路还要 koffi 原生模块 + worker 子进程(合成 Alt 抢前台),失败点更多。
|
|
9
|
+
* 远程控制不能把"手机能不能选工作区"押在宿主的绑定地址上,所以插件主动把它钉成 browse。
|
|
10
|
+
*
|
|
11
|
+
* ★ 0.6.15 修正:`native` 与 `browse` 两个后端注册的是**同一个服务名** `ctx.directoryPicker`
|
|
12
|
+
* (seam 文档:one implementation per context, loading a second throws)。所以 0.6.14 的
|
|
13
|
+
* "先建 browse、后摘 auto"**必然**抛 duplicate-service,用户看到的是插件装载报错
|
|
14
|
+
* `service "directoryPicker" has been registered`。唯一可行的顺序是**先摘 auto 再建 browse**,
|
|
15
|
+
* 并在建不起来时把 auto 按原 id 装回去(回滚)。
|
|
16
|
+
*
|
|
17
|
+
* 这里守的是四条护栏:
|
|
18
|
+
* ① 已是 browse → 一根手指都不许动;
|
|
19
|
+
* ② **先摘 auto、后建 browse**(服务名互斥,并存不可能);
|
|
20
|
+
* ③ browse 建不起来 → 回滚:卸掉半成品 + 把 auto 原样装回来(宁可原生可用,不能让选择器消失);
|
|
21
|
+
* ④ 全程不抛异常(插件绝不能把 dsh web 拖挂)。
|
|
22
|
+
*
|
|
23
|
+
* 用法: node --test test/picker-pin.test.mjs
|
|
24
|
+
*/
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import test from "node:test";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
__pinBrowseDirectoryPicker,
|
|
33
|
+
__pickerPinState,
|
|
34
|
+
__PICKER_AUTO_ENTRY_ID,
|
|
35
|
+
__PICKER_AUTO_PACKAGE,
|
|
36
|
+
__PICKER_BROWSE_PACKAGES
|
|
37
|
+
} from "../lib/index.js";
|
|
38
|
+
|
|
39
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 假宿主:loader 记录 create/remove 的**调用顺序**,并像真 cordis 一样维护
|
|
44
|
+
* 「谁注册了 directoryPicker」——这正是 0.6.14 踩坑的地方,模型必须能复现 duplicate-service。
|
|
45
|
+
*/
|
|
46
|
+
function fakeCtx({ kind = "native", failCreateOn = null, failRemove = false } = {}) {
|
|
47
|
+
const calls = [];
|
|
48
|
+
const store = { [__PICKER_AUTO_ENTRY_ID]: { id: __PICKER_AUTO_ENTRY_ID, options: { id: __PICKER_AUTO_ENTRY_ID, name: __PICKER_AUTO_PACKAGE } } };
|
|
49
|
+
let live = kind; // 当前挂着哪一路(服务名互斥:非空即"已注册")
|
|
50
|
+
// 只有**宿主后端**包才注册 ctx.directoryPicker;`dsh-client-ui-*-browse` 是界面半边,不占服务名。
|
|
51
|
+
const isHostBackend = (name) => String(name || "").includes("dsh-host-directory-picker");
|
|
52
|
+
const loader = {
|
|
53
|
+
store,
|
|
54
|
+
async create(options) {
|
|
55
|
+
const name = options?.name || "";
|
|
56
|
+
calls.push(`create:${name}`);
|
|
57
|
+
if (failCreateOn && name === failCreateOn) throw new Error("browse 装不起来");
|
|
58
|
+
if (isHostBackend(name) && live) throw new Error(`service "directoryPicker" has been registered`);
|
|
59
|
+
const id = options?.id || `dyn-${calls.length}`;
|
|
60
|
+
store[id] = { id, options: { ...options, id } };
|
|
61
|
+
if (isHostBackend(name)) live = name.includes("-browse") ? "browse" : "native";
|
|
62
|
+
return id;
|
|
63
|
+
},
|
|
64
|
+
async remove(id) {
|
|
65
|
+
calls.push(`remove:${id}`);
|
|
66
|
+
if (failRemove && id === __PICKER_AUTO_ENTRY_ID) throw new Error("摘不掉");
|
|
67
|
+
const wasHost = isHostBackend(store[id]?.options?.name);
|
|
68
|
+
delete store[id];
|
|
69
|
+
if (wasHost) live = ""; // 服务名随宿主后端条目析构一起释放
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
ctx: {
|
|
74
|
+
get(name) {
|
|
75
|
+
if (name === "loader") return loader;
|
|
76
|
+
if (name === "directoryPicker") return live ? { capability: () => ({ kind: live }) } : null;
|
|
77
|
+
return null;
|
|
78
|
+
},
|
|
79
|
+
logger: { info() {}, warn() {} }
|
|
80
|
+
},
|
|
81
|
+
calls,
|
|
82
|
+
store,
|
|
83
|
+
liveOf: () => live
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const withEnv = async (value, fn) => {
|
|
88
|
+
const prev = process.env.DSH_REMOTE_NATIVE_PICKER;
|
|
89
|
+
if (value === undefined) delete process.env.DSH_REMOTE_NATIVE_PICKER;
|
|
90
|
+
else process.env.DSH_REMOTE_NATIVE_PICKER = value;
|
|
91
|
+
try { return await fn(); } finally {
|
|
92
|
+
if (prev === undefined) delete process.env.DSH_REMOTE_NATIVE_PICKER;
|
|
93
|
+
else process.env.DSH_REMOTE_NATIVE_PICKER = prev;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
test("★ 原生(native)→ 换成 browse:**先摘 auto**(释放服务名)、再建 browse 那一对", async () => {
|
|
98
|
+
const { ctx, calls, store, liveOf } = fakeCtx({ kind: "native" });
|
|
99
|
+
const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(calls, [
|
|
102
|
+
`remove:${__PICKER_AUTO_ENTRY_ID}`,
|
|
103
|
+
`create:${__PICKER_BROWSE_PACKAGES[0]}`,
|
|
104
|
+
`create:${__PICKER_BROWSE_PACKAGES[1]}`
|
|
105
|
+
], "顺序必须是「先摘 auto、后建 browse」——两个后端抢同一个 directoryPicker 服务名,反过来必抛 duplicate-service");
|
|
106
|
+
assert.equal(store[__PICKER_AUTO_ENTRY_ID], undefined, "auto 条目应被摘除(它的析构会卸掉 native 那对,并释放服务名)");
|
|
107
|
+
assert.equal(liveOf(), "browse", "最终挂着的必须是 browse");
|
|
108
|
+
assert.equal(state.action, "pinned");
|
|
109
|
+
assert.equal(state.kind, "browse");
|
|
110
|
+
assert.match(state.detail, /远程/, "详情要写明为什么改(远程看不到原生对话框)");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("已经是 browse → 完全不干预(自建部署 / 绑了 0.0.0.0 的场景)", async () => {
|
|
114
|
+
const { ctx, calls } = fakeCtx({ kind: "browse" });
|
|
115
|
+
const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
|
|
116
|
+
assert.deepEqual(calls, [], "不得创建、也不得摘除任何条目");
|
|
117
|
+
assert.equal(state.action, "none");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("★ browse 宿主装不起来 → 回滚:把 auto **按原 id** 装回来(宁可原生可用,也不能让选择器消失)", async () => {
|
|
121
|
+
const { ctx, calls, store, liveOf } = fakeCtx({ kind: "native", failCreateOn: __PICKER_BROWSE_PACKAGES[0] });
|
|
122
|
+
const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
|
|
123
|
+
assert.deepEqual(calls, [
|
|
124
|
+
`remove:${__PICKER_AUTO_ENTRY_ID}`,
|
|
125
|
+
`create:${__PICKER_BROWSE_PACKAGES[0]}`,
|
|
126
|
+
`create:${__PICKER_AUTO_PACKAGE}`
|
|
127
|
+
], "摘了 auto 却建不起 browse 时,必须把 auto 原样装回来");
|
|
128
|
+
assert.ok(store[__PICKER_AUTO_ENTRY_ID], "auto 条目必须按原 id 还原");
|
|
129
|
+
assert.equal(liveOf(), "native", "还原后用户手上还有原生选择器");
|
|
130
|
+
assert.equal(state.action, "kept_native");
|
|
131
|
+
assert.match(state.detail, /还原原生/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("第二个 browse 包失败 → 卸掉已建的那个 + 还原 auto", async () => {
|
|
135
|
+
const { ctx, calls, store, liveOf } = fakeCtx({ kind: "native", failCreateOn: __PICKER_BROWSE_PACKAGES[1] });
|
|
136
|
+
const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
|
|
137
|
+
assert.equal(calls[0], `remove:${__PICKER_AUTO_ENTRY_ID}`);
|
|
138
|
+
assert.equal(calls[1], `create:${__PICKER_BROWSE_PACKAGES[0]}`);
|
|
139
|
+
assert.equal(calls[2], `create:${__PICKER_BROWSE_PACKAGES[1]}`);
|
|
140
|
+
assert.ok(calls.some((c) => c.startsWith("remove:dyn-")), "失败后必须回滚刚建的宿主");
|
|
141
|
+
assert.ok(store[__PICKER_AUTO_ENTRY_ID], "auto 条目必须还原");
|
|
142
|
+
assert.equal(liveOf(), "native");
|
|
143
|
+
assert.equal(state.action, "kept_native");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("★ auto 摘不掉 → 一根手指都不许再动(不得去建 browse 撞服务名)", async () => {
|
|
147
|
+
const { ctx, calls, store } = fakeCtx({ kind: "native", failRemove: true });
|
|
148
|
+
const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
|
|
149
|
+
assert.deepEqual(calls, [`remove:${__PICKER_AUTO_ENTRY_ID}`], "摘不掉就停手,绝不继续建 browse");
|
|
150
|
+
assert.ok(store[__PICKER_AUTO_ENTRY_ID], "auto 条目必须还在");
|
|
151
|
+
assert.equal(state.action, "kept_native");
|
|
152
|
+
assert.match(state.detail, /保留原生/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("DSH_REMOTE_NATIVE_PICKER=1 → 整体退出,保留系统原生对话框", async () => {
|
|
156
|
+
const { ctx, calls } = fakeCtx({ kind: "native" });
|
|
157
|
+
const state = await withEnv("1", () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
|
|
158
|
+
assert.deepEqual(calls, []);
|
|
159
|
+
assert.equal(state.action, "skipped");
|
|
160
|
+
assert.match(state.detail, /DSH_REMOTE_NATIVE_PICKER/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("服务还没挂上(kind 未知)→ 不抛错、按 skipped 收尾", async () => {
|
|
164
|
+
const { ctx, calls } = fakeCtx({ kind: "" });
|
|
165
|
+
const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
|
|
166
|
+
assert.deepEqual(calls, []);
|
|
167
|
+
assert.equal(state.action, "skipped");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("没有 loader 服务 / ctx.get 抛错 → 静默跳过(绝不把宿主拖挂)", async () => {
|
|
171
|
+
const noLoader = { get: () => null, logger: { info() {}, warn() {} } };
|
|
172
|
+
const throwing = { get() { throw new Error("ctx destroyed"); }, logger: {} };
|
|
173
|
+
const a = await __pinBrowseDirectoryPicker(noLoader, { attempts: 1, gapMs: 1 });
|
|
174
|
+
const b = await __pinBrowseDirectoryPicker(throwing, { attempts: 1, gapMs: 1 });
|
|
175
|
+
assert.equal(a.action, "skipped");
|
|
176
|
+
assert.equal(b.action, "skipped");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("只有 create、没有 remove 的 loader → 不许动手(半套 API 会摘了建不回来)", async () => {
|
|
180
|
+
const halfLoader = { store: {}, async create() { throw new Error("不该被调用"); }, ctx: {} };
|
|
181
|
+
const ctx = { get: (n) => (n === "loader" ? halfLoader : n === "directoryPicker" ? { capability: () => ({ kind: "native" }) } : null), logger: {} };
|
|
182
|
+
const state = await __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 });
|
|
183
|
+
assert.equal(state.action, "skipped");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("诊断面板必须显示目录选择器状态(这类'按钮没反应'才能一眼定位)", () => {
|
|
187
|
+
assert.match(SRC, /`目录选择器: \$\{/, "诊断行缺失");
|
|
188
|
+
assert.match(SRC, /系统原生(远程时对话框弹在电脑那台机器上/, "原生时要给出可操作的解释");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("接线:apply 里挂了选择器固定(eff/effect),并且是 fire-and-forget 不阻塞装载", () => {
|
|
192
|
+
assert.match(SRC, /ctx\.effect\(\(\) => \{[\s\S]{0,200}?pinBrowseDirectoryPicker\(ctx\)[\s\S]{0,120}?\}, "dsh-remote-web: pin browser directory picker"\);/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("回归锁:源码里不得再出现「先建 browse、后摘 auto」的顺序(0.6.14 的坏版本)", () => {
|
|
196
|
+
const swap = SRC.slice(SRC.indexOf("async function swapAutoToBrowse"), SRC.indexOf("async function pinBrowseDirectoryPicker"));
|
|
197
|
+
const iRemove = swap.indexOf("loader.remove(PICKER_AUTO_ENTRY_ID)");
|
|
198
|
+
const iCreate = swap.indexOf("loader.create({ name })");
|
|
199
|
+
assert.ok(iRemove > 0 && iCreate > 0, "找不到摘/建两个动作");
|
|
200
|
+
assert.ok(iRemove < iCreate, "必须先摘 auto 再建 browse(两个后端注册同一个服务名)");
|
|
201
|
+
});
|
|
@@ -233,3 +233,15 @@ test("源码约束:浏览器半提供版本与更新卡片(含彻底卸载
|
|
|
233
233
|
assert.match(source, /dsh-remote\/self\/uninstall/);
|
|
234
234
|
assert.match(source, /再点一次确认彻底卸载/);
|
|
235
235
|
});
|
|
236
|
+
|
|
237
|
+
test("★ 用户反馈锁:已是最新版时,修复入口必须**看得出是修复入口**(不能只叫「重新检查」)", () => {
|
|
238
|
+
// 用户原话(fb_4cc2c9df749c):「我的端口不是 3080,**没找到一键更新的按钮**」。
|
|
239
|
+
// 原因:npm 上已是最新版时,主按钮文案是「重新检查 / 修复」—— 他按字面找"一键更新",
|
|
240
|
+
// 找不到就以为面板没有修复入口。现在文案直接自称修复,并把"该点什么"写在旁边。
|
|
241
|
+
const source = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
242
|
+
assert.match(source, /一键修复 \/ 重装运行环境/, "主按钮必须直接自称修复入口");
|
|
243
|
+
// ⚠️ 只查**字符串字面量**:这条规则写在注释里(说明历史文案)是允许的,出现在按钮上不行。
|
|
244
|
+
assert.ok(!/"重新检查 \/ 修复"/.test(source), "旧的模糊按钮文案不得再出现(用户已经因此找不到入口)");
|
|
245
|
+
assert.match(source, /已是最新版本 v/, "要如实告诉用户「已是最新」,并说明该怎么做");
|
|
246
|
+
assert.match(source, /会重新补装并拉起后台服务/, "要说清这一步到底做什么");
|
|
247
|
+
});
|