@mrrisega/dsh-remote 0.6.6 → 0.6.7-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.
- package/README.md +30 -8
- package/clients/dsh-remote/dsh-bridge.mjs +1 -1
- package/clients/dsh-remote/e2ee-shim.mjs +4 -4
- package/clients/dsh-remote/mobile-adapter.mjs +1 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +4 -4
- package/docs/self-hosting.md +27 -9
- package/dsh-setup.mjs +593 -41
- package/package.json +3 -3
- package/packages/dsh-remote-web/lib/client.js +57 -6
- package/packages/dsh-remote-web/lib/index.js +578 -70
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/community-qr-ui.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +3 -3
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +82 -0
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +400 -0
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +3 -1
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +94 -0
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +1 -1
- package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +466 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 兼容回归(0.6.7 新增)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(真实事故,用户诊断报告 + 本地逐条复现):
|
|
5
|
+
* dsh-remote-web 0.6.6 及更早的整套「服务状态 / 启停 / 重启」按 macOS/Linux 写死,
|
|
6
|
+
* 在 Windows 上**运行期必死**——安装与网络都正常,坏的全是运行期:
|
|
7
|
+
* ① launchTarget() 无条件调 process.getuid():Windows 没有这个 API(typeof !== "function")
|
|
8
|
+
* → /dsh-remote/status 与 /bridge-status 双双 500,
|
|
9
|
+
* 面板永久红字「读取状态失败: process.getuid is not a function」、状态卡在「查询中…」;
|
|
10
|
+
* ② spawn("npx.cmd") 不带 shell(Node ≥20.12 起)同步抛 EINVAL → 运行环境永远补不上;
|
|
11
|
+
* ③ restartHarness 生成 `#!/bin/sh` 再 spawn("/bin/sh"):Windows 无 /bin/sh → ENOENT,
|
|
12
|
+
* 而那个 ChildProcess 没有 'error' 监听 → 未捕获异常**打挂 dsh web**;
|
|
13
|
+
* 更糟的是它先把「已调度重启」写进日志并返回成功;
|
|
14
|
+
* ④ manualStatus() 用 pgrep/ps → Windows 恒为空 → 永远显示「已停止」。
|
|
15
|
+
*
|
|
16
|
+
* 这些分支无法在 macOS/Linux CI 上真跑,所以插件支持 DSH_RELAY_PLATFORM=win32
|
|
17
|
+
* 显式覆盖平台(见 lib/index.js 的 osPlatform())。本用例就是**在开发机上把 win32 分支跑一遍**:
|
|
18
|
+
* 每个用例都断言「Windows 上会走哪条路」,而不是只看源码长得像不像。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ 隔离要求:走真实分支的用例必须伪造 HOME 并把 DSH_RELAY_SKIP_SERVICE 关掉
|
|
21
|
+
* (见 setup-output.test.mjs 里的静态护栏:unset 该开关的用例必须同时伪造 HOME)。
|
|
22
|
+
*/
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import http from "node:http";
|
|
25
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
26
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import test from "node:test";
|
|
31
|
+
import { apply } from "../lib/index.js";
|
|
32
|
+
|
|
33
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
|
34
|
+
const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
|
|
35
|
+
const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8");
|
|
36
|
+
const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
|
|
37
|
+
|
|
38
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
39
|
+
|
|
40
|
+
/** 假 launchctl:被调到就记一行日志(用它证明「Windows 上根本没去调 launchctl」)。 */
|
|
41
|
+
const FAKE_LAUNCHCTL = `#!/bin/sh
|
|
42
|
+
printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
|
|
43
|
+
exit 0
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
/** 收集并恢复环境变量;同时伪造 HOME,避免任何真实系统状态被碰到。 */
|
|
47
|
+
async function makeEnv({ forceWin = true, skipService = true } = {}) {
|
|
48
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-win-"));
|
|
49
|
+
const fakeBin = path.join(root, "bin");
|
|
50
|
+
const fakeHome = path.join(root, "home");
|
|
51
|
+
const relayDir = path.join(root, "relay");
|
|
52
|
+
await mkdir(fakeBin, { recursive: true });
|
|
53
|
+
await mkdir(fakeHome, { recursive: true });
|
|
54
|
+
await mkdir(relayDir, { recursive: true });
|
|
55
|
+
await writeFile(path.join(fakeBin, "launchctl"), FAKE_LAUNCHCTL);
|
|
56
|
+
await chmod(path.join(fakeBin, "launchctl"), 0o755);
|
|
57
|
+
for (const n of ["pgrep", "ps"]) {
|
|
58
|
+
await writeFile(path.join(fakeBin, n), "#!/bin/sh\nexit 0\n");
|
|
59
|
+
await chmod(path.join(fakeBin, n), 0o755);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const saved = {};
|
|
63
|
+
const keys = ["HOME", "PATH", "DSH_RELAY_PLATFORM", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_RESTART_DRYRUN",
|
|
64
|
+
"DSH_SETUP_NPX_DIR", "DSH_TEST_LAUNCH_LOG", "DSH_REMOTE_TELEMETRY", "DSH_RELAY_SELFHEAL_MS"];
|
|
65
|
+
for (const k of keys) saved[k] = process.env[k];
|
|
66
|
+
process.env.HOME = fakeHome;
|
|
67
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${saved.PATH || ""}`;
|
|
68
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
69
|
+
process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
|
|
70
|
+
process.env.DSH_SETUP_NPX_DIR = fakeBin;
|
|
71
|
+
if (forceWin) process.env.DSH_RELAY_PLATFORM = "win32";
|
|
72
|
+
else delete process.env.DSH_RELAY_PLATFORM;
|
|
73
|
+
if (skipService) process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
74
|
+
else delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
root, relayDir, fakeBin, fakeHome,
|
|
78
|
+
launchLog: path.join(root, "launchctl.log"),
|
|
79
|
+
async restore() {
|
|
80
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
81
|
+
await rm(root, { recursive: true, force: true });
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function boot(relayDir) {
|
|
87
|
+
const routes = new Map();
|
|
88
|
+
const disposers = [];
|
|
89
|
+
apply({
|
|
90
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
91
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
92
|
+
logger: { info() {}, warn() {} },
|
|
93
|
+
}, { relayDir });
|
|
94
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } };
|
|
95
|
+
return routes;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function serve(routes) {
|
|
99
|
+
const host = http.createServer((req, res) => {
|
|
100
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
101
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
102
|
+
});
|
|
103
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
104
|
+
return {
|
|
105
|
+
base: `http://127.0.0.1:${host.address().port}`,
|
|
106
|
+
async close() { await new Promise((r) => host.close(r)); },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function get(base, p) {
|
|
111
|
+
const res = await fetch(base + p);
|
|
112
|
+
let body = null;
|
|
113
|
+
try { body = await res.json(); } catch { /* 非 JSON */ }
|
|
114
|
+
return { status: res.status, body };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function post(base, p) {
|
|
118
|
+
const res = await fetch(base + p, { method: "POST" });
|
|
119
|
+
let body = null;
|
|
120
|
+
try { body = await res.json(); } catch { /* 非 JSON */ }
|
|
121
|
+
return { status: res.status, body };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const writeConfig = (relayDir, extra = {}) =>
|
|
125
|
+
writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
126
|
+
api_url: "http://127.0.0.1:1", ...extra,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
/** 起一个真实存活的长命进程,用来当「bridge 进程」的替身(pid 存活检查要真 pid)。 */
|
|
130
|
+
function startFakeProcess() {
|
|
131
|
+
const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 120000)"], { stdio: "ignore", detached: false });
|
|
132
|
+
return { pid: child.pid, stop: () => { try { child.kill("SIGKILL"); } catch { /* 已退出 */ } } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─────────────────────── ① 致命点:process.getuid 与 launchd 短路 ───────────────────────
|
|
136
|
+
|
|
137
|
+
test("win32:/status 与 /bridge-status 不再 500(process.getuid 短路 + launchd 不适用)", async () => {
|
|
138
|
+
const env = await makeEnv({ forceWin: true, skipService: true });
|
|
139
|
+
const routes = boot(env.relayDir);
|
|
140
|
+
const srv = await serve(routes);
|
|
141
|
+
try {
|
|
142
|
+
await writeConfig(env.relayDir);
|
|
143
|
+
const st = await get(srv.base, "/dsh-remote/status");
|
|
144
|
+
assert.equal(st.status, 200, `win32 上 /status 必须 200(旧版因 process.getuid 抛错而 500):${JSON.stringify(st.body)}`);
|
|
145
|
+
assert.equal(st.body.ok, true);
|
|
146
|
+
assert.equal(st.body.service.platform, "win32", "必须如实下发平台,供面板给平台正确的文案");
|
|
147
|
+
assert.equal(st.body.service.serviceManager, "detached", "Windows 没有 launchd/systemd:服务管理器应为 detached");
|
|
148
|
+
assert.equal(st.body.service.launchd.running, false, "Windows 上 launchd 状态恒为未运行(而不是抛错)");
|
|
149
|
+
|
|
150
|
+
const bs = await get(srv.base, "/dsh-remote/bridge-status");
|
|
151
|
+
assert.equal(bs.status, 200, `/bridge-status 也必须 200:${JSON.stringify(bs.body)}`);
|
|
152
|
+
assert.ok(bs.body.connect, "应下发连接阶段对象");
|
|
153
|
+
assert.equal(bs.body.connect.phase, "no_account", "未登录时阶段应为 no_account");
|
|
154
|
+
} finally {
|
|
155
|
+
await srv.close();
|
|
156
|
+
routes.dispose();
|
|
157
|
+
await env.restore();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("win32:不去调用不存在的 launchctl(假 launchctl 一次都不该被调到)", async () => {
|
|
162
|
+
const env = await makeEnv({ forceWin: true, skipService: true });
|
|
163
|
+
const routes = boot(env.relayDir);
|
|
164
|
+
const srv = await serve(routes);
|
|
165
|
+
try {
|
|
166
|
+
await writeConfig(env.relayDir);
|
|
167
|
+
await get(srv.base, "/dsh-remote/status");
|
|
168
|
+
await get(srv.base, "/dsh-remote/bridge-status");
|
|
169
|
+
assert.equal(existsSync(env.launchLog), false,
|
|
170
|
+
`Windows 上没有 launchctl,任何状态查询都不该去 shell 里调它(旧版在 Linux 上也会白调一次)`);
|
|
171
|
+
} finally {
|
|
172
|
+
await srv.close();
|
|
173
|
+
routes.dispose();
|
|
174
|
+
await env.restore();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ─────────────────────── ② 致命点:npx 调用方式 ───────────────────────
|
|
179
|
+
|
|
180
|
+
test("win32:npx 补装走 node + npx-cli.js(绝不 spawn npx.cmd 而不带 shell)", async () => {
|
|
181
|
+
// 走真实分支:DSH_RELAY_SKIP_SERVICE 必须关掉,否则 ensureRuntime 直接返回(测试隔离)
|
|
182
|
+
const env = await makeEnv({ forceWin: true, skipService: false });
|
|
183
|
+
const marker = path.join(env.root, "npx-cli-ran.json");
|
|
184
|
+
const cliDir = path.join(env.fakeBin, "node_modules", "npm", "bin");
|
|
185
|
+
await mkdir(cliDir, { recursive: true });
|
|
186
|
+
// 假的 npx-cli.js:把拿到的 argv 落盘,证明确实是「node 直接跑它」而不是 cmd.exe 跑 .cmd
|
|
187
|
+
await writeFile(path.join(cliDir, "npx-cli.js"),
|
|
188
|
+
`import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(marker)}, JSON.stringify({ argv: process.argv.slice(1), execPath: process.execPath }));\nprocess.exit(0);\n`);
|
|
189
|
+
|
|
190
|
+
const routes = boot(env.relayDir);
|
|
191
|
+
const srv = await serve(routes);
|
|
192
|
+
try {
|
|
193
|
+
// relayDir 里没有 dsh-setup.mjs → runtimeReady=false → /start 会转后台补装
|
|
194
|
+
const r = await post(srv.base, "/dsh-remote/start");
|
|
195
|
+
assert.equal(r.body.status, "provisioning", `缺运行环境应转后台补装:${JSON.stringify(r.body)}`);
|
|
196
|
+
assert.equal(r.body.runtimeMissing, true);
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < 40 && !existsSync(marker); i += 1) await sleep(50);
|
|
199
|
+
assert.ok(existsSync(marker), "补装进程没有被拉起来(npx-cli.js 未执行)");
|
|
200
|
+
const seen = JSON.parse(readFileSync(marker, "utf8"));
|
|
201
|
+
assert.equal(seen.execPath, process.execPath, "必须用当前 node 直接跑 CLI(不经 shell)");
|
|
202
|
+
assert.ok(seen.argv[0].endsWith(path.join("npm", "bin", "npx-cli.js")), `argv[0] 应是 npx-cli.js:${seen.argv[0]}`);
|
|
203
|
+
assert.ok(seen.argv.includes("--yes"), `应带上 --yes:${JSON.stringify(seen.argv)}`);
|
|
204
|
+
assert.ok(seen.argv.some((a) => a.startsWith("@mrrisega/dsh-remote")),
|
|
205
|
+
`应带上要安装的包名:${JSON.stringify(seen.argv)}`);
|
|
206
|
+
} finally {
|
|
207
|
+
await srv.close();
|
|
208
|
+
routes.dispose();
|
|
209
|
+
await env.restore();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("win32:找不到 npx-cli.js 时退回 npx.cmd **必须带 shell:true**(否则 EINVAL)", () => {
|
|
214
|
+
// 这是 0.6.6 的死因:Node ≥20.12 起 spawn("npx.cmd") 不带 shell 同步抛 EINVAL。
|
|
215
|
+
// 行为无法在本机复现(这里没有 .cmd),所以锁源码契约:.cmd 分支必须 shell: true。
|
|
216
|
+
const fn = INDEX_SRC.slice(INDEX_SRC.indexOf("function npxInvocation()"), INDEX_SRC.indexOf("/** 子进程环境:把 node 目录补进 PATH"));
|
|
217
|
+
assert.ok(/kind: "node-npx-cli"/.test(fn), "应优先用 node + npx-cli.js");
|
|
218
|
+
const cmdBranches = fn.match(/command: [^,]+, args: \[\], shell: ([a-z]+), kind: "npx-cmd[^"]*"/g) || [];
|
|
219
|
+
assert.ok(cmdBranches.length >= 1, "应有 npx.cmd 回退分支");
|
|
220
|
+
for (const b of cmdBranches) {
|
|
221
|
+
assert.match(b, /shell: true/, `npx.cmd 分支必须 shell:true(否则 Windows 上 EINVAL):${b}`);
|
|
222
|
+
}
|
|
223
|
+
// 两个调用点都必须把 shell 传下去
|
|
224
|
+
const spawnSites = INDEX_SRC.match(/safeSpawn\(npx\.command, \[\.\.\.npx\.args[^\n]*/g) || [];
|
|
225
|
+
assert.equal(spawnSites.length, 2, `npx 的两个调用点(补装 / 在线更新)都要走 safeSpawn`);
|
|
226
|
+
assert.ok(/shell: npx\.shell/.test(INDEX_SRC), "必须把 npx.shell 传给 spawn");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ─────────────────────── ③ 致命点:spawn 失败不得打挂 dsh web ───────────────────────
|
|
230
|
+
|
|
231
|
+
test("win32:重启 harness 生成 node 助手(不再 /bin/sh),且生成的脚本语法有效", async () => {
|
|
232
|
+
// 走真实分支:隔离开关会让 restartHarness 提前返回 skipped(dry-run 也进不去)。
|
|
233
|
+
// 这里只 dry-run(只生成脚本文本,不 spawn 任何东西),并把自愈间隔拉长避免后台补装被打起来。
|
|
234
|
+
const env = await makeEnv({ forceWin: true, skipService: false });
|
|
235
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "600000";
|
|
236
|
+
const routes = boot(env.relayDir);
|
|
237
|
+
const srv = await serve(routes);
|
|
238
|
+
try {
|
|
239
|
+
process.env.DSH_RELAY_RESTART_DRYRUN = "1";
|
|
240
|
+
const r = await post(srv.base, "/dsh-remote/harness/restart");
|
|
241
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
242
|
+
assert.equal(r.body.status, "dry-run");
|
|
243
|
+
assert.equal(r.body.mode, "relaunch", "Windows 无监管者:应走 relaunch");
|
|
244
|
+
const script = String(r.body.script || "");
|
|
245
|
+
assert.ok(!/^#!\/bin\/sh/m.test(script), "绝不能生成 #!/bin/sh 脚本(Windows 没有 /bin/sh)");
|
|
246
|
+
assert.ok(!/kill -TERM|kill -KILL/.test(script), "不得使用 POSIX 信号命令");
|
|
247
|
+
assert.ok(script.includes("taskkill"), "Windows 强杀应走 taskkill");
|
|
248
|
+
assert.ok(script.includes("appendFileSync"), "应自己写日志文件(不依赖 shell 重定向)");
|
|
249
|
+
// 「先校验再动手」:旧实现最大的风险是"杀掉却起不来"
|
|
250
|
+
assert.ok(/重启中止/.test(script), "重新拉起前必须先校验 node/入口/工作目录存在,否则中止(绝不先杀自己)");
|
|
251
|
+
|
|
252
|
+
// 生成的脚本必须是**合法可执行的 JS**:它是字符串拼出来的,语法错就是白屏式失败
|
|
253
|
+
const scriptPath = path.join(env.root, "helper.mjs");
|
|
254
|
+
await writeFile(scriptPath, script);
|
|
255
|
+
const check = spawnSync(process.execPath, ["--check", scriptPath], { encoding: "utf8" });
|
|
256
|
+
assert.equal(check.status, 0, `生成的 Windows 重启助手语法必须合法:${check.stderr}`);
|
|
257
|
+
} finally {
|
|
258
|
+
delete process.env.DSH_RELAY_RESTART_DRYRUN;
|
|
259
|
+
await srv.close();
|
|
260
|
+
routes.dispose();
|
|
261
|
+
await env.restore();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("契约:插件里不存在「裸 spawn」——每个子进程都必须有 'error' 监听(否则打挂宿主)", () => {
|
|
266
|
+
// 事故原文:spawn("/bin/sh") 在 Windows ENOENT,而 ChildProcess 没有 'error' 监听
|
|
267
|
+
// → 未捕获异常把 dsh web 打挂,且代码已经先把「已调度重启」写进日志、返回成功。
|
|
268
|
+
// 生成的 Windows 助手源码是模板字符串(它自己挂了 error 监听),先从「真实代码」里剔除
|
|
269
|
+
const helperStart = INDEX_SRC.indexOf("function buildRestartHelperSource(");
|
|
270
|
+
const helperEnd = INDEX_SRC.indexOf("async function restartHarness(");
|
|
271
|
+
const realCode = INDEX_SRC.slice(0, helperStart) + INDEX_SRC.slice(helperEnd);
|
|
272
|
+
const rawSpawns = realCode.match(/= spawn\(/g) || [];
|
|
273
|
+
assert.equal(rawSpawns.length, 2, `真实代码里只允许 safeSpawn / spawnAndConfirm 两处 spawn(实际 ${rawSpawns.length} 处)`);
|
|
274
|
+
assert.ok(/function safeSpawn\([\s\S]*?= spawn\(/.test(realCode), "safeSpawn 自己 spawn");
|
|
275
|
+
assert.ok(/function spawnAndConfirm\([\s\S]*?= spawn\(/.test(realCode), "spawnAndConfirm 自己 spawn");
|
|
276
|
+
assert.ok(/function safeSpawn\(/.test(INDEX_SRC), "应有统一 spawn 包装(挂 'error' 监听)");
|
|
277
|
+
assert.ok(/function spawnAndConfirm\(/.test(INDEX_SRC), "重启路径必须等 spawn 真的成功再报成功");
|
|
278
|
+
const restart = INDEX_SRC.slice(INDEX_SRC.indexOf("async function restartHarness("), INDEX_SRC.indexOf("// ---------- relay API 代理"));
|
|
279
|
+
// POSIX 仍用 /bin/sh(既有行为不动),但 Windows 必须改用 node —— 判据写死在这里
|
|
280
|
+
assert.ok(/const runner = isWindows\(\) \? process\.execPath : "\/bin\/sh";/.test(restart),
|
|
281
|
+
"Windows 必须用 node 跑 .mjs 助手(/bin/sh 在 Windows 上 ENOENT,且旧版因此打挂 dsh web)");
|
|
282
|
+
assert.ok(/isWindows\(\) \? RESTART_HELPER_FILE : RESTART_SCRIPT_FILE/.test(restart),
|
|
283
|
+
"Windows 落 .mjs 助手文件,POSIX 落 .sh");
|
|
284
|
+
assert.ok(/await spawnAndConfirm\(/.test(restart), "重启必须等确认,失败要返回 ok:false 而不是假装成功");
|
|
285
|
+
assert.ok(/调度失败/.test(restart), "spawn 失败要留下日志与失败返回");
|
|
286
|
+
// 生成的 Windows 助手里面的 spawn 也必须挂 error 监听(助手在模板字符串里,单独取)
|
|
287
|
+
const helper = INDEX_SRC.slice(INDEX_SRC.indexOf("function buildRestartHelperSource("), INDEX_SRC.indexOf("async function restartHarness("));
|
|
288
|
+
assert.ok(/child\.on\("error"/.test(helper), "Windows 助手的重新拉起也要挂 error 监听");
|
|
289
|
+
assert.ok(/重启中止/.test(helper), "助手必须先校验再杀旧进程");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ─────────────────────── ④ 致命点:进程发现(Windows 无 pgrep/ps) ───────────────────────
|
|
293
|
+
|
|
294
|
+
test("win32:bridge 状态靠 pid 文件发现(Windows 没有 pgrep/ps 也能报「运行中」)", async () => {
|
|
295
|
+
const env = await makeEnv({ forceWin: true, skipService: true });
|
|
296
|
+
const routes = boot(env.relayDir);
|
|
297
|
+
const srv = await serve(routes);
|
|
298
|
+
const fake = startFakeProcess();
|
|
299
|
+
try {
|
|
300
|
+
await writeConfig(env.relayDir);
|
|
301
|
+
// 插件/安装器在 Windows 上会把 watcher 与 bridge 的 pid 落到配置目录
|
|
302
|
+
await writeFile(path.join(env.relayDir, ".dsh-watcher.pid"), String(process.pid)); // 本进程应被排除
|
|
303
|
+
await writeFile(path.join(env.relayDir, ".dsh-bridge.pid"), String(fake.pid));
|
|
304
|
+
|
|
305
|
+
const st = await get(srv.base, "/dsh-remote/status");
|
|
306
|
+
assert.equal(st.body.service.running, true, "pid 文件指向存活进程 → 必须报「运行中」(旧版恒为「已停止」)");
|
|
307
|
+
assert.deepEqual(st.body.service.manual.bridge, [fake.pid], "bridge 列表应包含 pid 文件里的 pid");
|
|
308
|
+
assert.deepEqual(st.body.service.manual.watcher, [], "不得把宿主自身(process.pid)算成 bridge 守护");
|
|
309
|
+
|
|
310
|
+
const bs = await get(srv.base, "/dsh-remote/bridge-status");
|
|
311
|
+
assert.equal(bs.body.connect.bridgeRunning, true, "连接阶段也必须认出 bridge 在跑");
|
|
312
|
+
|
|
313
|
+
// 进程退出 → pid 文件成为残留,必须立刻恢复成「未运行」
|
|
314
|
+
fake.stop();
|
|
315
|
+
for (let i = 0; i < 40; i += 1) {
|
|
316
|
+
const again = await get(srv.base, "/dsh-remote/status");
|
|
317
|
+
if (again.body.service.running === false) break;
|
|
318
|
+
await sleep(100);
|
|
319
|
+
}
|
|
320
|
+
const after = await get(srv.base, "/dsh-remote/status");
|
|
321
|
+
assert.equal(after.body.service.running, false, "pid 已死 → 不得再报「运行中」(残留 pid 文件必须被判活拦掉)");
|
|
322
|
+
} finally {
|
|
323
|
+
fake.stop();
|
|
324
|
+
await srv.close();
|
|
325
|
+
routes.dispose();
|
|
326
|
+
await env.restore();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("win32:启动 bridge 走 detached watcher(dsh-setup.mjs run),并落 pid 文件", async () => {
|
|
331
|
+
const env = await makeEnv({ forceWin: true, skipService: false });
|
|
332
|
+
// 放一个假的固化运行环境:它自己写 pid 文件后退出(模拟 dsh-setup run 的启动动作)
|
|
333
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"),
|
|
334
|
+
"// 假运行环境:只证明被拉起过\n");
|
|
335
|
+
const routes = boot(env.relayDir);
|
|
336
|
+
const srv = await serve(routes);
|
|
337
|
+
try {
|
|
338
|
+
await writeConfig(env.relayDir);
|
|
339
|
+
const r = await post(srv.base, "/dsh-remote/start");
|
|
340
|
+
// 真 watcher 需要 ws 等依赖,这里只验证「命令与句柄是 Windows 该有的样子」:
|
|
341
|
+
// 拉起失败也必须走正常失败返回(绝不抛异常打挂宿主)。
|
|
342
|
+
assert.ok([200, 500].includes(r.status), `不得 5xx 崩溃:${r.status}`);
|
|
343
|
+
assert.ok(r.body && typeof r.body.status === "string", JSON.stringify(r.body));
|
|
344
|
+
const pidFile = path.join(env.relayDir, ".dsh-watcher.pid");
|
|
345
|
+
if (existsSync(pidFile)) {
|
|
346
|
+
assert.match(readFileSync(pidFile, "utf8").trim(), /^\d+$/, "pid 文件内容必须是纯 pid");
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
await srv.close();
|
|
350
|
+
routes.dispose();
|
|
351
|
+
await env.restore();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("win32:停止 bridge 只结束 pid 文件里的进程,不碰 launchctl/systemctl", async () => {
|
|
356
|
+
const env = await makeEnv({ forceWin: true, skipService: true });
|
|
357
|
+
const routes = boot(env.relayDir);
|
|
358
|
+
const srv = await serve(routes);
|
|
359
|
+
const fake = startFakeProcess();
|
|
360
|
+
try {
|
|
361
|
+
await writeConfig(env.relayDir);
|
|
362
|
+
await writeFile(path.join(env.relayDir, ".dsh-bridge.pid"), String(fake.pid));
|
|
363
|
+
const r = await post(srv.base, "/dsh-remote/stop");
|
|
364
|
+
assert.equal(r.body.status, "skipped", "测试隔离开关置位时应如实跳过,不做真实系统操作");
|
|
365
|
+
assert.equal(existsSync(env.launchLog), false, "不得调用 launchctl");
|
|
366
|
+
} finally {
|
|
367
|
+
fake.stop();
|
|
368
|
+
await srv.close();
|
|
369
|
+
routes.dispose();
|
|
370
|
+
await env.restore();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ─────────────────────── ⑤ 跨平台基础设施 ───────────────────────
|
|
375
|
+
|
|
376
|
+
test("跨平台:PATH 用平台分隔符拼接(写死 \":\" 会让 Windows 上 node/npx 全找不到)", () => {
|
|
377
|
+
const spawnEnvFn = INDEX_SRC.slice(INDEX_SRC.indexOf("function spawnEnv("), INDEX_SRC.indexOf("function safeSpawn("));
|
|
378
|
+
assert.ok(/join\(delimiter\)/.test(spawnEnvFn), "必须用 node:path 的 delimiter 拼 PATH");
|
|
379
|
+
assert.ok(!/join\(":"\)/.test(spawnEnvFn), "不得写死 POSIX 的 \":\"");
|
|
380
|
+
assert.ok(/isWindows\(\) \? \[\] : \[/.test(spawnEnvFn), "POSIX 专属目录不得追加到 Windows 的 PATH");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("跨平台:不再有未加守卫的 process.getuid / /bin/sh / sleep 命令", () => {
|
|
384
|
+
// getuid 只允许出现在 currentUid()(带 typeof 守卫)与 darwin 分支里
|
|
385
|
+
const lines = INDEX_SRC.split("\n");
|
|
386
|
+
lines.forEach((line, i) => {
|
|
387
|
+
if (!/process\.getuid\(\)/.test(line)) return;
|
|
388
|
+
if (/^\s*(\/\/|\*|\/\*)/.test(line)) return; // 注释里的说明文字不算调用
|
|
389
|
+
const ok = /typeof process\.getuid === "function"/.test(line) || /const uid = currentUid\(\)|const uid = process\.getuid\(\)/.test(line);
|
|
390
|
+
assert.ok(ok, `第 ${i + 1} 行直接调用了 process.getuid()(Windows 上没有该 API): ${line.trim()}`);
|
|
391
|
+
});
|
|
392
|
+
const codeLines = INDEX_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l));
|
|
393
|
+
assert.ok(!codeLines.some((l) => /spawn\("\/bin\/sh"/.test(l)), "不得直接 spawn /bin/sh");
|
|
394
|
+
assert.ok(codeLines.some((l) => /isWindows\(\) \? process\.execPath : "\/bin\/sh"/.test(l)),
|
|
395
|
+
"必须按平台选 runner(Windows=node / POSIX=/bin/sh)");
|
|
396
|
+
assert.ok(!/execSync\("sleep/.test(INDEX_SRC), "Windows 没有 sleep 命令:应改用 Atomics.wait 的 sleepSync");
|
|
397
|
+
assert.ok(/function sleepSync\(/.test(INDEX_SRC), "应有跨平台同步睡眠");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("win32:dsh-setup 注册任务计划程序登录任务(不再只说「平台不支持」)", () => {
|
|
401
|
+
assert.ok(/const WIN_TASK_NAME = "dsh-remote-bridge"/.test(SETUP_SRC), "应有任务名常量");
|
|
402
|
+
assert.ok(/schtasks\(\["\/Create", "\/TN", WIN_TASK_NAME, "\/TR", tr, "\/SC", "ONLOGON"/.test(SETUP_SRC),
|
|
403
|
+
"应注册 ONLOGON 登录任务(非管理员可建)");
|
|
404
|
+
assert.ok(/if \(IS_WIN\) return writeWindowsTask\(\);/.test(SETUP_SRC), "writeAutostartFile 应有 win32 分支");
|
|
405
|
+
assert.ok(/schtasks\(\["\/Query", "\/TN", WIN_TASK_NAME\]\)/.test(SETUP_SRC), "应有任务存在性查询");
|
|
406
|
+
assert.ok(/schtasks\(\["\/Run", "\/TN", WIN_TASK_NAME\]\)/.test(SETUP_SRC), "启动 bridge 应触发该任务");
|
|
407
|
+
// 走 spawnSync 而不是 sh():/TR 里必然带引号,经 cmd.exe 会把嵌套引号绞碎
|
|
408
|
+
assert.ok(/spawnSync\("schtasks"/.test(SETUP_SRC), "schtasks 必须用 spawnSync(避免 cmd.exe 的引号转义坑)");
|
|
409
|
+
// 安装流程里那个无条件 getuid 是 Windows 上的第二条死路(安装直接 TypeError 中断)
|
|
410
|
+
const restartFn = SETUP_SRC.slice(SETUP_SRC.indexOf("function restartDshWeb()"), SETUP_SRC.indexOf("function restartDshWebWindows()"));
|
|
411
|
+
const uidAt = restartFn.indexOf("process.getuid()");
|
|
412
|
+
const darwinAt = restartFn.indexOf('if (process.platform === "darwin")');
|
|
413
|
+
assert.ok(uidAt > -1, "darwin 分支仍然需要 uid(不要顺手删掉)");
|
|
414
|
+
assert.ok(darwinAt > -1 && darwinAt < uidAt,
|
|
415
|
+
"getuid 必须在 darwin 分支**之内**:放在函数首行会让 Windows 安装直接 TypeError 中断");
|
|
416
|
+
// Windows 下桥接进程发现的 pid 文件
|
|
417
|
+
assert.ok(/WATCHER_PID_FILE = "\.dsh-watcher\.pid"/.test(SETUP_SRC), "安装器也要写同名 pid 文件(插件半据此发现进程)");
|
|
418
|
+
assert.ok(/WATCHER_PID_FILE = "\.dsh-watcher\.pid"/.test(INDEX_SRC), "插件半与安装器的 pid 文件名必须一致");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("win32:彻底卸载会删掉任务计划条目(否则下次登录又被拉起来)", () => {
|
|
422
|
+
const uninstall = INDEX_SRC.slice(INDEX_SRC.indexOf("function uninstallRuntime("), INDEX_SRC.indexOf("// ---------- DeepSeek harness 重启"));
|
|
423
|
+
assert.ok(/schtasks", \["\/Delete", "\/TN", WIN_TASK_NAME, "\/F"\]/.test(uninstall), "卸载应删除 Windows 登录任务");
|
|
424
|
+
assert.ok(/servicePlatform: isDarwin\(\) \? "launchd"/.test(uninstall), "servicePlatform 应包含 Windows 口径");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("win32:dsh-setup 生成的「重启 dsh web」助手也是合法可执行的 JS", async () => {
|
|
428
|
+
// 安装器在 Windows 上重启 dsh web 用的是自己拼出来的 .mjs 助手(不走 /bin/sh)。
|
|
429
|
+
// 它是字符串拼的 → 语法一旦写错,用户的"一键安装"就会在最后一步静默断掉,
|
|
430
|
+
// 所以在开发机上把这段模板抠出来做一次 node --check。
|
|
431
|
+
const fn = SETUP_SRC.slice(SETUP_SRC.indexOf("function restartDshWebWindows()"), SETUP_SRC.indexOf("/** 读某进程的完整命令行"));
|
|
432
|
+
const start = fn.indexOf("const src = `");
|
|
433
|
+
const end = fn.indexOf("\n`;", start);
|
|
434
|
+
assert.ok(start > -1 && end > start, "应能从 restartDshWebWindows 里取出助手模板");
|
|
435
|
+
const template = fn.slice(start + "const src = `".length, end);
|
|
436
|
+
const generated = template.replace(/\$\{plan\}/g, '{"pid":1,"argv":["a"],"log":"b"}');
|
|
437
|
+
assert.ok(generated.includes('spawnSync("taskkill"'), "强杀应走 taskkill");
|
|
438
|
+
assert.ok(/child\.on\("error"/.test(generated), "重新拉起必须挂 error 监听(不能打挂安装器)");
|
|
439
|
+
assert.ok(/重启中止/.test(generated), "必须先校验可执行文件存在再动旧进程");
|
|
440
|
+
|
|
441
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-setup-helper-"));
|
|
442
|
+
try {
|
|
443
|
+
const file = path.join(dir, "helper.mjs");
|
|
444
|
+
await writeFile(file, generated);
|
|
445
|
+
const check = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
|
|
446
|
+
assert.equal(check.status, 0, `生成的 dsh web 重启助手语法必须合法:${check.stderr}`);
|
|
447
|
+
} finally {
|
|
448
|
+
await rm(dir, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ─────────────────────── ⑥ 前端:失败不再静默 ───────────────────────
|
|
453
|
+
|
|
454
|
+
test("前端:bridge-status 轮询失败不再静默(旧版让面板永久停在「查询中…」)", () => {
|
|
455
|
+
assert.ok(/connFailRef\.v \+= 1/.test(CLIENT_SRC), "轮询失败必须被计数(不再静默吞掉)");
|
|
456
|
+
assert.ok(!/\.catch\(function \(\) \{ \/\* 轮询失败静默/.test(CLIENT_SRC), "不得保留「空 catch 静默重试」的旧写法");
|
|
457
|
+
assert.ok(/CONN_FAIL_VISIBLE/.test(CLIENT_SRC), "应有可见阈值");
|
|
458
|
+
const catchBlock = CLIENT_SRC.slice(CLIENT_SRC.indexOf("/dsh-remote/bridge-status\").then"), CLIENT_SRC.indexOf("connInFlight.v = false"));
|
|
459
|
+
assert.ok(/status_unreachable/.test(catchBlock), "连续失败时应给出可见的错误阶段与原因");
|
|
460
|
+
assert.ok(/connFailRef\.v = 0/.test(CLIENT_SRC), "成功必须清零(一次抖动不该把面板染红)");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("前端:平台文案不再默认 macOS(Windows 用户看不懂「自启动服务」)", () => {
|
|
464
|
+
assert.ok(/serviceManager === "detached"/.test(CLIENT_SRC), "面板应识别 Windows 的 detached 模式");
|
|
465
|
+
assert.match(CLIENT_SRC, /Windows 任务计划程序 dsh-remote-bridge/, "卸载说明要写明 Windows 侧会删掉什么");
|
|
466
|
+
});
|