@mrrisega/dsh-remote 0.6.1-beta.1 → 0.6.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 +23 -1
- package/package.json +3 -2
- package/packages/dsh-remote-web/lib/client.js +176 -5
- package/packages/dsh-remote-web/lib/index.js +408 -23
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/account-switch.test.mjs +7 -0
- package/packages/dsh-remote-web/test/community-qr-ui.test.mjs +287 -0
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +360 -0
- package/packages/dsh-remote-web/test/runtime-provision.test.mjs +302 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// 「插件市场装完 → bridge 起不来」回归(0.6.2)。
|
|
2
|
+
//
|
|
3
|
+
// 现场(用户机器,2026-09):
|
|
4
|
+
// 市场只装了「插件半」(dsh-remote-ui / dsh-remote-web),桌面运行环境半
|
|
5
|
+
// (固化运行时 <relayDir>/dsh-setup.mjs + clients + ws)从未装上;
|
|
6
|
+
// 用户登录那一刻 /dsh-remote/login 与面板「启动 bridge」都会调 startBridge(),
|
|
7
|
+
// 旧实现无条件写 plist(ProgramArguments 指向并不存在的 dsh-setup.mjs)并 bootstrap
|
|
8
|
+
// → launchd KeepAlive 无限重拉:日志 20+ 次 MODULE_NOT_FOUND,`runs = 23 / last exit code = 1`。
|
|
9
|
+
// 更糟的是崩溃循环中 `launchctl list` 的 PID 列会闪现已死 pid(本机实测 `40213 1`),
|
|
10
|
+
// 旧 launchdStatus 据此谎报 running=true → scheduleRuntime 判定「已在运行」直接停摆,
|
|
11
|
+
// 自动补装(ensureRuntime)永不执行,用户永远恢复不了。
|
|
12
|
+
//
|
|
13
|
+
// 本用例锁死五点(每点对应一处修复):
|
|
14
|
+
// 1. launchdStatus:print 成功时以 state 为准,绝不回退 list(崩溃循环不再谎报运行中);
|
|
15
|
+
// 2. startBridge:运行环境缺失时拒绝 bootstrap,不生成指向空路径的 plist,改为后台补装;
|
|
16
|
+
// 3. scheduleRuntime:运行环境检查先于「服务是否在跑」,并摘除失效自启动(止住崩溃循环);
|
|
17
|
+
// 4. apply():插件加载即开始补装运行环境——不再等用户先登录(市场安装路径的第一步);
|
|
18
|
+
// 5. 正常路径不受影响:运行环境就绪时照常 bootstrap(fake launchctl 全程接管,不碰真机)。
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import test from "node:test";
|
|
26
|
+
import { apply } from "../lib/index.js";
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
|
|
30
|
+
/** 轮询等待条件成立(固定 sleep 在整包并行/高负载下会假失败)。 */
|
|
31
|
+
async function waitFor(fn, { timeout = 4000, step = 50 } = {}) {
|
|
32
|
+
const deadline = Date.now() + timeout;
|
|
33
|
+
for (;;) {
|
|
34
|
+
if (await fn()) return true;
|
|
35
|
+
if (Date.now() > deadline) return false;
|
|
36
|
+
await sleep(step);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 假 launchctl:把每次调用记进日志,并按环境变量回放 print/list 的输出。 */
|
|
41
|
+
const FAKE_LAUNCHCTL = `#!/bin/sh
|
|
42
|
+
printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
|
|
43
|
+
case "$1" in
|
|
44
|
+
print)
|
|
45
|
+
if [ -n "$DSH_TEST_PRINT_FAIL" ]; then printf 'Could not find service\\n'; exit 113; fi
|
|
46
|
+
printf 'state = %s\\n' "$DSH_TEST_STATE"
|
|
47
|
+
printf 'runs = %s\\n' "$DSH_TEST_RUNS"
|
|
48
|
+
printf 'last exit code = %s\\n' "$DSH_TEST_EXIT"
|
|
49
|
+
printf 'active count = 0\\n'
|
|
50
|
+
;;
|
|
51
|
+
list)
|
|
52
|
+
printf '%s\\n' "$DSH_TEST_LIST"
|
|
53
|
+
;;
|
|
54
|
+
esac
|
|
55
|
+
exit 0
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/** 假 npx:只记录被调用的参数与 npm 源,绝不做真实安装。 */
|
|
59
|
+
const FAKE_NPX = `#!/bin/sh
|
|
60
|
+
printf '%s|%s\\n' "$*" "$npm_config_registry" >> "$DSH_TEST_NPX_LOG"
|
|
61
|
+
exit 0
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 搭建隔离环境:假 HOME(plist 落在这里)+ 假 launchctl/pgrep/ps/npx + 临时 relayDir。
|
|
66
|
+
* 调用方负责在 finally 里 restore()。
|
|
67
|
+
*/
|
|
68
|
+
async function setup({ state = "running", runs = 0, exitCode = 0, list = "", printFail = false } = {}) {
|
|
69
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-runtime-prov-"));
|
|
70
|
+
const fakeHome = path.join(root, "home");
|
|
71
|
+
const fakeBin = path.join(root, "bin");
|
|
72
|
+
const relayDir = path.join(root, "relay");
|
|
73
|
+
await mkdir(fakeBin, { recursive: true });
|
|
74
|
+
await mkdir(fakeHome, { recursive: true });
|
|
75
|
+
await mkdir(relayDir, { recursive: true });
|
|
76
|
+
|
|
77
|
+
const launchLog = path.join(root, "launchctl.log");
|
|
78
|
+
const npxLog = path.join(root, "npx.log");
|
|
79
|
+
for (const [name, script] of [
|
|
80
|
+
["launchctl", FAKE_LAUNCHCTL],
|
|
81
|
+
["pgrep", "#!/bin/sh\nexit 1\n"],
|
|
82
|
+
["ps", "#!/bin/sh\nexit 0\n"],
|
|
83
|
+
["npx", FAKE_NPX],
|
|
84
|
+
]) {
|
|
85
|
+
await writeFile(path.join(fakeBin, name), script);
|
|
86
|
+
await chmodSync(path.join(fakeBin, name), 0o755);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const saved = {};
|
|
90
|
+
for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_NPX_LOG", "DSH_TEST_STATE", "DSH_TEST_RUNS",
|
|
91
|
+
"DSH_TEST_EXIT", "DSH_TEST_LIST", "DSH_TEST_PRINT_FAIL", "DSH_RELAY_SKIP_SERVICE", "DSH_SETUP_NPX_DIR",
|
|
92
|
+
"DSH_RELAY_SELFHEAL_MS"]) {
|
|
93
|
+
saved[k] = process.env[k];
|
|
94
|
+
}
|
|
95
|
+
process.env.HOME = fakeHome;
|
|
96
|
+
process.env.PATH = `${fakeBin}:${saved.PATH}`;
|
|
97
|
+
process.env.DSH_TEST_LAUNCH_LOG = launchLog;
|
|
98
|
+
process.env.DSH_TEST_NPX_LOG = npxLog;
|
|
99
|
+
process.env.DSH_TEST_STATE = state;
|
|
100
|
+
process.env.DSH_TEST_RUNS = String(runs);
|
|
101
|
+
process.env.DSH_TEST_EXIT = String(exitCode);
|
|
102
|
+
process.env.DSH_TEST_LIST = list;
|
|
103
|
+
if (printFail) process.env.DSH_TEST_PRINT_FAIL = "1"; else delete process.env.DSH_TEST_PRINT_FAIL;
|
|
104
|
+
process.env.DSH_SETUP_NPX_DIR = fakeBin; // 假 npx 优先(显式覆盖 > 当前 node 目录)
|
|
105
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 走「真实」分支,但系统命令全是假的
|
|
106
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "5000"; // 除非用例自己调小,否则 watcher 不参与
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
root, fakeHome, fakeBin, relayDir, launchLog, npxLog,
|
|
110
|
+
plistPath: path.join(fakeHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist"),
|
|
111
|
+
async restore() {
|
|
112
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
113
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
114
|
+
}
|
|
115
|
+
await rm(root, { recursive: true, force: true });
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 装载插件路由(HOME/PATH 已由 setup 指向假环境)。
|
|
122
|
+
* ctx.effect 的返回值即 disposer(卸载自愈 watcher 等定时器)——必须收集并在用例结束
|
|
123
|
+
* 时调用:否则前序用例的自愈定时器会跨用例继续跑,用 ap 到的 HOME 去摘后一个用例的 plist。
|
|
124
|
+
*/
|
|
125
|
+
function boot(relayDir) {
|
|
126
|
+
const routes = new Map();
|
|
127
|
+
const disposers = [];
|
|
128
|
+
apply({
|
|
129
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
130
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
131
|
+
logger: { info() {}, warn() {} },
|
|
132
|
+
}, { relayDir });
|
|
133
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } };
|
|
134
|
+
return routes;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function serve(routes) {
|
|
138
|
+
const host = http.createServer((req, res) => {
|
|
139
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
140
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
141
|
+
});
|
|
142
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
143
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 写一份最小配置(api_url 指向必然拒连的本地端口,避免测试期间访问真实中继)。 */
|
|
147
|
+
async function writeConfig(relayDir, extra = {}) {
|
|
148
|
+
await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
149
|
+
phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1",
|
|
150
|
+
...extra,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 预置「正在安装中」标记(pid 用当前进程 → 不会被 apply 的残留清理误删)→ 挡住真实 npx。 */
|
|
155
|
+
async function plantInstallMarker(relayDir) {
|
|
156
|
+
await writeFile(path.join(relayDir, ".dsh-setup-installing"), JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
|
|
160
|
+
const exists = (p) => existsSync(p);
|
|
161
|
+
|
|
162
|
+
test("崩溃循环不再谎报「运行中」:print=spawn scheduled + list 闪现已死 pid → running=false、crashing=true", async () => {
|
|
163
|
+
// 复刻现场:launchctl list 报 `40213 1 com.dshremote.bridge`(进程其实早没了)
|
|
164
|
+
const env = await setup({ state: "spawn scheduled", runs: 23, exitCode: 1, list: "999998\t1\tcom.dshremote.bridge" });
|
|
165
|
+
try {
|
|
166
|
+
await writeConfig(env.relayDir);
|
|
167
|
+
await plantInstallMarker(env.relayDir); // 运行环境缺失 + 正在补装
|
|
168
|
+
const routes = boot(env.relayDir);
|
|
169
|
+
const { host, base } = await serve(routes);
|
|
170
|
+
try {
|
|
171
|
+
const st = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
172
|
+
assert.equal(st.service.running, false, "崩溃循环中 list 的瞬时 pid 绝不能被当成「运行中」");
|
|
173
|
+
assert.equal(st.service.launchd.running, false);
|
|
174
|
+
assert.equal(st.service.launchd.pid, null, "不应回传已死进程的 pid");
|
|
175
|
+
assert.equal(st.service.launchd.crashing, true, "state≠running + lastExit≠0 → 崩溃循环");
|
|
176
|
+
assert.equal(st.service.launchd.runs, 23);
|
|
177
|
+
assert.equal(st.service.launchd.lastExitCode, 1);
|
|
178
|
+
assert.equal(st.service.runtimeReady, false, "缺 dsh-setup.mjs → 运行环境未就绪(面板据此提示补装中)");
|
|
179
|
+
} finally { host.close(); routes.dispose(); }
|
|
180
|
+
} finally { await env.restore(); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("print 不可用时回退 list:pid 已死 → 不报运行;pid 存活 → 报运行", async () => {
|
|
184
|
+
const dead = await setup({ printFail: true, list: "999998\t1\tcom.dshremote.bridge" });
|
|
185
|
+
try {
|
|
186
|
+
await writeConfig(dead.relayDir);
|
|
187
|
+
await plantInstallMarker(dead.relayDir);
|
|
188
|
+
const routes = boot(dead.relayDir);
|
|
189
|
+
const { host, base } = await serve(routes);
|
|
190
|
+
try {
|
|
191
|
+
const st = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
192
|
+
assert.equal(st.service.launchd.running, false, "回退路径必须校验 pid 存活(999998 不存在)");
|
|
193
|
+
} finally { host.close(); routes.dispose(); }
|
|
194
|
+
} finally { await dead.restore(); }
|
|
195
|
+
|
|
196
|
+
const alive = await setup({ printFail: true, list: `${process.pid}\t0\tcom.dshremote.bridge` });
|
|
197
|
+
try {
|
|
198
|
+
await writeFile(path.join(alive.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
199
|
+
await writeConfig(alive.relayDir);
|
|
200
|
+
const routes = boot(alive.relayDir);
|
|
201
|
+
const { host, base } = await serve(routes);
|
|
202
|
+
try {
|
|
203
|
+
const st = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
204
|
+
assert.equal(st.service.launchd.running, true, "真实存活 pid → 正常运行");
|
|
205
|
+
} finally { host.close(); routes.dispose(); }
|
|
206
|
+
} finally { await alive.restore(); }
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("运行环境缺失 → /dsh-remote/start 拒绝 bootstrap:不生成指向空路径的 plist,转为后台补装", async () => {
|
|
210
|
+
const env = await setup({ state: "spawn scheduled", runs: 3, exitCode: 1 });
|
|
211
|
+
try {
|
|
212
|
+
await writeConfig(env.relayDir);
|
|
213
|
+
await plantInstallMarker(env.relayDir);
|
|
214
|
+
const routes = boot(env.relayDir);
|
|
215
|
+
const { host, base } = await serve(routes);
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch(`${base}/dsh-remote/start`, { method: "POST" });
|
|
218
|
+
const body = await res.json();
|
|
219
|
+
assert.equal(body.ok, false, "运行环境缺失时不得报「已启动」");
|
|
220
|
+
assert.equal(body.status, "provisioning");
|
|
221
|
+
assert.equal(body.runtimeMissing, true);
|
|
222
|
+
assert.match(String(body.detail), /运行环境|自动安装/);
|
|
223
|
+
assert.equal(exists(env.plistPath), false, "绝不能写一个指向不存在脚本的 plist(旧版崩溃循环的根源)");
|
|
224
|
+
const log = await readOrEmpty(env.launchLog);
|
|
225
|
+
assert.ok(!log.includes("bootstrap"), "不得 bootstrap:实际调用 " + log);
|
|
226
|
+
} finally { host.close(); routes.dispose(); }
|
|
227
|
+
} finally { await env.restore(); }
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("自愈:崩溃循环 + 运行环境缺失 → 摘除失效自启动(bootout+删 plist)并转入补装", async () => {
|
|
231
|
+
const env = await setup({ state: "spawn scheduled", runs: 23, exitCode: 1, list: "999998\t1\tcom.dshremote.bridge" });
|
|
232
|
+
try {
|
|
233
|
+
await writeConfig(env.relayDir);
|
|
234
|
+
await plantInstallMarker(env.relayDir);
|
|
235
|
+
// 现场同款失效自启动:ProgramArguments 指向并不存在的 dsh-setup.mjs
|
|
236
|
+
await mkdir(path.dirname(env.plistPath), { recursive: true });
|
|
237
|
+
await writeFile(env.plistPath, `<?xml version="1.0" encoding="UTF-8"?>
|
|
238
|
+
<plist version="1.0"><dict>
|
|
239
|
+
<key>Label</key><string>com.dshremote.bridge</string>
|
|
240
|
+
<key>ProgramArguments</key><array><string>/usr/bin/node</string><string>${path.join(env.relayDir, "dsh-setup.mjs")}</string><string>run</string></array>
|
|
241
|
+
</dict></plist>`);
|
|
242
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "200"; // 把 12s 轮询压到 200ms,亚秒级验证
|
|
243
|
+
const routes = boot(env.relayDir);
|
|
244
|
+
await waitFor(async () => (await readOrEmpty(env.launchLog)).includes("bootout"));
|
|
245
|
+
const log = await readOrEmpty(env.launchLog);
|
|
246
|
+
assert.ok(log.includes("bootout"), "崩溃循环的作业已加载在 launchd 里,只删 plist 无效,必须 bootout:实际 " + log);
|
|
247
|
+
assert.equal(exists(env.plistPath), false, "失效自启动文件应被摘除");
|
|
248
|
+
const installLog = await readOrEmpty(path.join(env.relayDir, ".dsh-setup-install.log"));
|
|
249
|
+
assert.match(installLog, /摘除失效自启动/, "应留下可诊断的日志");
|
|
250
|
+
routes.dispose();
|
|
251
|
+
} finally { await env.restore(); }
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("apply() 即开始补装运行环境(市场安装路径,无需先登录)", async () => {
|
|
255
|
+
const env = await setup();
|
|
256
|
+
try {
|
|
257
|
+
// 只有插件半:没有 dsh-setup.mjs,也没有任何账号配置
|
|
258
|
+
const routes = boot(env.relayDir);
|
|
259
|
+
await waitFor(async () => (await readOrEmpty(env.npxLog)) !== "");
|
|
260
|
+
const npxLog = await readOrEmpty(env.npxLog);
|
|
261
|
+
assert.match(npxLog, /--yes @mrrisega\/dsh-remote@/, "应在插件加载时就后台跑安装器,不等用户登录:实际 " + npxLog);
|
|
262
|
+
assert.match(npxLog, /registry\.npmjs\.org/, "首次补装优先官方源(镜像滞后会装到旧版)");
|
|
263
|
+
const installLog = await readOrEmpty(path.join(env.relayDir, ".dsh-setup-install.log"));
|
|
264
|
+
assert.match(installLog, /\[auto-install\] npx 退出/, "安装子进程退出应落日志");
|
|
265
|
+
routes.dispose();
|
|
266
|
+
} finally { await env.restore(); }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("运行环境已就绪 → 不重复安装", async () => {
|
|
270
|
+
const env = await setup();
|
|
271
|
+
try {
|
|
272
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
273
|
+
await writeConfig(env.relayDir);
|
|
274
|
+
const routes = boot(env.relayDir);
|
|
275
|
+
const { host, base } = await serve(routes);
|
|
276
|
+
try {
|
|
277
|
+
const self = await (await fetch(`${base}/dsh-remote/self`)).json();
|
|
278
|
+
assert.equal(self.runtimeReady, true);
|
|
279
|
+
} finally { host.close(); routes.dispose(); }
|
|
280
|
+
await sleep(300);
|
|
281
|
+
assert.equal(await readOrEmpty(env.npxLog), "", "运行环境已就绪时不得再跑安装器");
|
|
282
|
+
} finally { await env.restore(); }
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("正常路径不受影响:运行环境就绪 → 照常 bootstrap(plist 入口指向真实运行时)", async () => {
|
|
286
|
+
const env = await setup({ state: "running", runs: 1, exitCode: 0 });
|
|
287
|
+
try {
|
|
288
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
289
|
+
await writeConfig(env.relayDir);
|
|
290
|
+
const routes = boot(env.relayDir);
|
|
291
|
+
const { host, base } = await serve(routes);
|
|
292
|
+
try {
|
|
293
|
+
const body = await (await fetch(`${base}/dsh-remote/start`, { method: "POST" })).json();
|
|
294
|
+
assert.equal(body.ok, true, "运行环境就绪时启动应成功:" + JSON.stringify(body.detail || ""));
|
|
295
|
+
assert.equal(body.status, "running");
|
|
296
|
+
const plist = readFileSync(env.plistPath, "utf8");
|
|
297
|
+
assert.ok(plist.includes(path.join(env.relayDir, "dsh-setup.mjs")), "plist 入口应指向已就绪的运行时");
|
|
298
|
+
assert.ok(plist.includes("ThrottleInterval"), "应有节流,避免崩溃时空转重拉");
|
|
299
|
+
assert.match(await readOrEmpty(env.launchLog), /bootstrap/);
|
|
300
|
+
} finally { host.close(); routes.dispose(); }
|
|
301
|
+
} finally { await env.restore(); }
|
|
302
|
+
});
|