@mrrisega/dsh-remote 0.6.1 → 0.6.3
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 +10 -1
- package/package.json +5 -3
- package/packages/dsh-remote-web/lib/client.js +91 -1
- package/packages/dsh-remote-web/lib/index.js +371 -23
- package/packages/dsh-remote-web/package.json +5 -2
- package/packages/dsh-remote-web/test/account-switch.test.mjs +7 -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,360 @@
|
|
|
1
|
+
// 「首次安装需要重启 DeepSeek harness」回归(0.6.2 新增)。
|
|
2
|
+
//
|
|
3
|
+
// 产品诉求(用户原话):
|
|
4
|
+
// 1. 安装完成后桌面端 UI 直接可用,并提示「首次安装需要重启 DeepSeek harness」,旁边给「重启」按钮;
|
|
5
|
+
// 2. 常驻入口最底下要有「重启 DeepSeek harness」按钮;
|
|
6
|
+
// 3. 首次打开(尚未重启)时,该按钮要出现在上方显眼位置。
|
|
7
|
+
//
|
|
8
|
+
// 机制:dsh web 的插件(宿主半 + 浏览器半)都在**进程启动时**装载,市场安装/在线更新只是把文件
|
|
9
|
+
// 写进 profile,当前进程里既没有 /dsh-remote/* 路由也没有面板入口 → 必须重启才生效。
|
|
10
|
+
// 因此插件需要:① 持久化「待重启」状态并跨进程结清;② 一条可靠的重启实现(优先交回监管者,
|
|
11
|
+
// 否则用原命令行自拉起);③ 面板顶部醒目提示 + 底部常驻按钮。
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import vm from "node:vm";
|
|
20
|
+
import { apply } from "../lib/index.js";
|
|
21
|
+
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
|
|
24
|
+
|
|
25
|
+
/** 轮询等待条件成立(固定 sleep 在整包并行/高负载下会假失败)。 */
|
|
26
|
+
async function waitFor(fn, { timeout = 4000, step = 50 } = {}) {
|
|
27
|
+
const deadline = Date.now() + timeout;
|
|
28
|
+
for (;;) {
|
|
29
|
+
if (await fn()) return true;
|
|
30
|
+
if (Date.now() > deadline) return false;
|
|
31
|
+
await sleep(step);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 假 launchctl:list 可回放指定 pid/label,print 报未运行(避免任何真实系统状态影响用例)。 */
|
|
36
|
+
const FAKE_LAUNCHCTL = `#!/bin/sh
|
|
37
|
+
printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
|
|
38
|
+
if [ "$1" = list ]; then printf '%s\\n' "$DSH_TEST_LIST"; fi
|
|
39
|
+
if [ "$1" = print ]; then printf 'state = not running\\n'; fi
|
|
40
|
+
exit 0
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
async function setup({ list = "" } = {}) {
|
|
44
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-restart-"));
|
|
45
|
+
const fakeBin = path.join(root, "bin");
|
|
46
|
+
const relayDir = path.join(root, "relay");
|
|
47
|
+
await mkdir(fakeBin, { recursive: true });
|
|
48
|
+
await mkdir(relayDir, { recursive: true });
|
|
49
|
+
await writeFile(path.join(fakeBin, "launchctl"), FAKE_LAUNCHCTL);
|
|
50
|
+
await chmodSync(path.join(fakeBin, "launchctl"), 0o755);
|
|
51
|
+
for (const n of ["pgrep", "ps"]) { await writeFile(path.join(fakeBin, n), "#!/bin/sh\nexit 0\n"); await chmodSync(path.join(fakeBin, n), 0o755); }
|
|
52
|
+
await writeFile(path.join(fakeBin, "npx"), "#!/bin/sh\nexit 0\n"); await chmodSync(path.join(fakeBin, "npx"), 0o755);
|
|
53
|
+
|
|
54
|
+
const saved = {};
|
|
55
|
+
for (const k of ["PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
|
|
56
|
+
"DSH_RELAY_RESTART_DRYRUN", "DSH_SETUP_NPX_DIR"]) saved[k] = process.env[k];
|
|
57
|
+
process.env.PATH = `${fakeBin}:${saved.PATH}`;
|
|
58
|
+
process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
|
|
59
|
+
process.env.DSH_TEST_LIST = list;
|
|
60
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "5000";
|
|
61
|
+
process.env.DSH_SETUP_NPX_DIR = fakeBin;
|
|
62
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
63
|
+
return {
|
|
64
|
+
root, relayDir,
|
|
65
|
+
stateFile: path.join(relayDir, ".dsh-restart-state.json"),
|
|
66
|
+
async restore() {
|
|
67
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
68
|
+
await rm(root, { recursive: true, force: true });
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function writeConfig(relayDir, extra = {}) {
|
|
74
|
+
await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
75
|
+
phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1", ...extra,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function boot(relayDir) {
|
|
80
|
+
const routes = new Map();
|
|
81
|
+
const disposers = [];
|
|
82
|
+
apply({
|
|
83
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
84
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
85
|
+
logger: { info() {}, warn() {} },
|
|
86
|
+
}, { relayDir });
|
|
87
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } };
|
|
88
|
+
return routes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function serve(routes) {
|
|
92
|
+
const host = http.createServer((req, res) => {
|
|
93
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
94
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
95
|
+
});
|
|
96
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
97
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─────────────────────────── 宿主半:状态机 ───────────────────────────
|
|
101
|
+
|
|
102
|
+
test("首次安装(运行环境在本进程内补齐)→ 自动标记「需要重启」并在 /status 下发", async () => {
|
|
103
|
+
const env = await setup();
|
|
104
|
+
try {
|
|
105
|
+
await writeConfig(env.relayDir);
|
|
106
|
+
// 市场刚装完:桌面运行环境还没补齐
|
|
107
|
+
assert.equal(existsSync(path.join(env.relayDir, "dsh-setup.mjs")), false);
|
|
108
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "200";
|
|
109
|
+
const routes = boot(env.relayDir);
|
|
110
|
+
const { host, base } = await serve(routes);
|
|
111
|
+
try {
|
|
112
|
+
const before = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
113
|
+
assert.equal(before.restart.pending, false, "运行环境补齐前不该提示重启");
|
|
114
|
+
|
|
115
|
+
// 模拟后台 npx 安装完成:固化运行时落盘
|
|
116
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
117
|
+
await waitFor(async () => readFile(env.stateFile, "utf8").then(() => true).catch(() => false));
|
|
118
|
+
|
|
119
|
+
const after = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
120
|
+
assert.equal(after.restart.pending, true, "运行环境刚补齐 → 应提示需要重启 DeepSeek harness");
|
|
121
|
+
assert.equal(after.restart.kind, "first-install");
|
|
122
|
+
assert.match(String(after.restart.reason), /首次安装需要重启 DeepSeek harness/);
|
|
123
|
+
} finally { host.close(); routes.dispose(); }
|
|
124
|
+
} finally { await env.restore(); }
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内不撤", async () => {
|
|
128
|
+
// 场景 A:状态来自「上一个进程」→ 本次装载即视为重启完成 → 撤下
|
|
129
|
+
const a = await setup();
|
|
130
|
+
try {
|
|
131
|
+
await writeConfig(a.relayDir);
|
|
132
|
+
await writeFile(path.join(a.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
133
|
+
await writeFile(a.stateFile, JSON.stringify({ pending: true, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness", at: Date.now() - 60000, bootId: "1-1" }));
|
|
134
|
+
const routes = boot(a.relayDir);
|
|
135
|
+
const { host, base } = await serve(routes);
|
|
136
|
+
try {
|
|
137
|
+
const st = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
138
|
+
assert.equal(st.restart.pending, false, "新进程装载 = 重启已发生 → 提示应消失");
|
|
139
|
+
assert.ok(JSON.parse(readFileSync(a.stateFile, "utf8")).lastRestartedAt > 0, "应记录最近一次重启时间");
|
|
140
|
+
} finally { host.close(); routes.dispose(); }
|
|
141
|
+
} finally { await a.restore(); }
|
|
142
|
+
|
|
143
|
+
// 场景 B:状态由**当前进程**写下(用户还没点重启,只是刷新了页面 / 插件重新装载)→ 必须保留。
|
|
144
|
+
// 走真实链路:插件自己写 pending(运行环境在本进程内补齐),再在同一进程内重新装载一次。
|
|
145
|
+
const b = await setup();
|
|
146
|
+
try {
|
|
147
|
+
await writeConfig(b.relayDir);
|
|
148
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "200";
|
|
149
|
+
const routes1 = boot(b.relayDir);
|
|
150
|
+
const { host: host1, base: base1 } = await serve(routes1);
|
|
151
|
+
await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
152
|
+
await waitFor(async () => readFile(b.stateFile, "utf8").then(() => true).catch(() => false));
|
|
153
|
+
const st1 = await (await fetch(`${base1}/dsh-remote/status`)).json();
|
|
154
|
+
assert.equal(st1.restart.pending, true, "运行环境补齐后应标记待重启");
|
|
155
|
+
host1.close();
|
|
156
|
+
routes1.dispose();
|
|
157
|
+
|
|
158
|
+
// 同一进程内再次装载(等价于页面刷新 / 插件重新 apply):bootId 未变 → 提示必须还在
|
|
159
|
+
const routes2 = boot(b.relayDir);
|
|
160
|
+
const { host: host2, base: base2 } = await serve(routes2);
|
|
161
|
+
try {
|
|
162
|
+
const st2 = await (await fetch(`${base2}/dsh-remote/status`)).json();
|
|
163
|
+
assert.equal(st2.restart.pending, true, "同一进程内(还没真重启)不得误撤提示");
|
|
164
|
+
assert.equal(st2.restart.bootId, st1.restart.bootId, "bootId 应在同一进程内稳定不变");
|
|
165
|
+
} finally { host2.close(); routes2.dispose(); }
|
|
166
|
+
} finally { await b.restore(); }
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─────────────────────────── 宿主半:重启实现 ───────────────────────────
|
|
170
|
+
|
|
171
|
+
test("重启实现:无监管者 → 用原命令行自拉起(脚本含 pid/argv/cwd,先 TERM 再 KILL)", async () => {
|
|
172
|
+
const env = await setup({ list: "999\t0\tcom.example.unrelated" }); // list 里没有我们的 pid → 无监管者
|
|
173
|
+
try {
|
|
174
|
+
await writeConfig(env.relayDir);
|
|
175
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
176
|
+
process.env.DSH_RELAY_RESTART_DRYRUN = "1"; // 只生成脚本,不真重启
|
|
177
|
+
const routes = boot(env.relayDir);
|
|
178
|
+
const { host, base } = await serve(routes);
|
|
179
|
+
try {
|
|
180
|
+
const body = await (await fetch(`${base}/dsh-remote/harness/restart`, { method: "POST" })).json();
|
|
181
|
+
assert.equal(body.ok, true);
|
|
182
|
+
assert.equal(body.status, "dry-run");
|
|
183
|
+
assert.equal(body.mode, "relaunch", "没有监管者时应自拉起");
|
|
184
|
+
assert.ok(body.script.includes(`kill -TERM ${process.pid}`), "应先优雅退出旧进程");
|
|
185
|
+
assert.ok(body.script.includes("kill -KILL"), "超时后应强杀");
|
|
186
|
+
assert.ok(body.script.includes(process.execPath), "应复用同一条命令行(node 路径)");
|
|
187
|
+
assert.ok(body.script.includes(process.argv[1]), "应复用同一个 dsh 入口");
|
|
188
|
+
assert.ok(body.script.includes(`cd '${process.cwd()}'`), "应保持工作目录");
|
|
189
|
+
} finally { host.close(); routes.dispose(); }
|
|
190
|
+
} finally { await env.restore(); }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("重启实现:被 launchd 托管 → 交回监管者(kickstart -k,pid 精确匹配,不误伤别的服务)", async () => {
|
|
194
|
+
const env = await setup({ list: `${process.pid}\t0\tcom.user.dshweb\n999\t0\tcom.example.unrelated` });
|
|
195
|
+
try {
|
|
196
|
+
await writeConfig(env.relayDir);
|
|
197
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
198
|
+
process.env.DSH_RELAY_RESTART_DRYRUN = "1";
|
|
199
|
+
const routes = boot(env.relayDir);
|
|
200
|
+
const { host, base } = await serve(routes);
|
|
201
|
+
try {
|
|
202
|
+
const body = await (await fetch(`${base}/dsh-remote/harness/restart`, { method: "POST" })).json();
|
|
203
|
+
if (process.platform !== "darwin") {
|
|
204
|
+
assert.equal(body.mode, "relaunch", "非 macOS 不检测 launchd");
|
|
205
|
+
} else {
|
|
206
|
+
assert.equal(body.mode, "launchd");
|
|
207
|
+
assert.equal(body.target, `gui/${process.getuid()}/com.user.dshweb`, "只重启「pid 等于本进程」的那个作业");
|
|
208
|
+
assert.ok(body.script.includes("launchctl kickstart -k"), "应交给 launchd 重启");
|
|
209
|
+
assert.ok(!body.script.includes("com.example.unrelated"), "绝不触碰无关服务");
|
|
210
|
+
}
|
|
211
|
+
} finally { host.close(); routes.dispose(); }
|
|
212
|
+
} finally { await env.restore(); }
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("测试隔离开关:DSH_RELAY_SKIP_SERVICE=1 → 绝不真实重启", async () => {
|
|
216
|
+
const env = await setup();
|
|
217
|
+
try {
|
|
218
|
+
await writeConfig(env.relayDir);
|
|
219
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
220
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
221
|
+
const routes = boot(env.relayDir);
|
|
222
|
+
const { host, base } = await serve(routes);
|
|
223
|
+
try {
|
|
224
|
+
const res = await fetch(`${base}/dsh-remote/harness/restart`, { method: "POST" });
|
|
225
|
+
const body = await res.json();
|
|
226
|
+
assert.equal(res.status, 500);
|
|
227
|
+
assert.equal(body.ok, false);
|
|
228
|
+
assert.equal(body.status, "skipped");
|
|
229
|
+
assert.match(String(body.error), /跳过真实重启/);
|
|
230
|
+
assert.equal(existsSync(path.join(env.relayDir, ".dsh-restart-harness.sh")), false, "隔离模式下不得落任何重启脚本");
|
|
231
|
+
} finally { host.close(); routes.dispose(); }
|
|
232
|
+
} finally { await env.restore(); }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ─────────────────────────── 浏览器半:UI ───────────────────────────
|
|
236
|
+
|
|
237
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
238
|
+
|
|
239
|
+
function walk(node, visit) {
|
|
240
|
+
if (node == null) return;
|
|
241
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
242
|
+
if (typeof node !== "object") return;
|
|
243
|
+
visit(node);
|
|
244
|
+
for (const child of node.children || []) walk(child, visit);
|
|
245
|
+
}
|
|
246
|
+
function find(tree, predicate) { let m; walk(tree, (n) => { if (!m && predicate(n)) m = n; }); return m; }
|
|
247
|
+
function findAll(tree, predicate) { const out = []; walk(tree, (n) => { if (predicate(n)) out.push(n); }); return out; }
|
|
248
|
+
function textHas(tree, substr) { return !!find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes(substr))); }
|
|
249
|
+
|
|
250
|
+
/** vm 沙箱加载浏览器半,statusBody 决定面板看到的状态。 */
|
|
251
|
+
function loadPlugin(statusBody) {
|
|
252
|
+
let moduleFactory;
|
|
253
|
+
const registered = new Map();
|
|
254
|
+
const requests = [];
|
|
255
|
+
const states = [];
|
|
256
|
+
let hook = 0;
|
|
257
|
+
const react = {
|
|
258
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
259
|
+
useState(initial) {
|
|
260
|
+
const index = hook++;
|
|
261
|
+
if (!(index in states)) states[index] = initial;
|
|
262
|
+
return [states[index], (v) => { states[index] = typeof v === "function" ? v(states[index]) : v; }];
|
|
263
|
+
},
|
|
264
|
+
useEffect() {}, useCallback(fn) { return fn; }, useSyncExternalStore(_s, g) { return g(); },
|
|
265
|
+
};
|
|
266
|
+
const response = (status, body) => Promise.resolve({ ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body) });
|
|
267
|
+
const makeEl = (tag) => ({
|
|
268
|
+
tag, children: [], style: {}, className: "", attributes: {},
|
|
269
|
+
setAttribute(k, v) { this.attributes[k] = v; }, appendChild(c) { this.children.push(c); },
|
|
270
|
+
});
|
|
271
|
+
const doc = { createElement(t) { return makeEl(t); }, head: makeEl("head"), body: makeEl("body"), querySelector() { return null; }, querySelectorAll() { return []; } };
|
|
272
|
+
class MutationObserverMock { observe() {} disconnect() {} }
|
|
273
|
+
const sandbox = {
|
|
274
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; }, location: { reload() {} } },
|
|
275
|
+
document: doc,
|
|
276
|
+
localStorage: { _s: new Map(), getItem(k) { return this._s.has(k) ? this._s.get(k) : null; }, setItem(k, v) { this._s.set(k, String(v)); }, removeItem(k) { this._s.delete(k); } },
|
|
277
|
+
MutationObserver: MutationObserverMock,
|
|
278
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
279
|
+
location: { reload() {} },
|
|
280
|
+
fetch(p, o = {}) {
|
|
281
|
+
requests.push({ path: p, method: o.method || "GET" });
|
|
282
|
+
if (p === "/dsh-remote/status") return response(200, statusBody);
|
|
283
|
+
return response(200, { ok: true });
|
|
284
|
+
},
|
|
285
|
+
setInterval() { return 1; }, clearInterval() {}, setTimeout() { return 1; },
|
|
286
|
+
Set, Symbol,
|
|
287
|
+
};
|
|
288
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
289
|
+
moduleFactory((name) => { assert.equal(name, "react"); return react; })
|
|
290
|
+
.apply({ slots: { inject(_n, cb) { cb(); }, register(meta, component) { registered.set(meta.id, component); return () => {}; } } });
|
|
291
|
+
return { requests, states, render() { hook = 0; return registered.get("dsh-remote")({ close() {} }); } };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const STATUS_FIRST_INSTALL = {
|
|
295
|
+
ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
|
|
296
|
+
service: { running: true, runtimeReady: true, launchd: {} },
|
|
297
|
+
restart: { pending: true, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness" },
|
|
298
|
+
};
|
|
299
|
+
const STATUS_UPDATE = {
|
|
300
|
+
ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
|
|
301
|
+
service: { running: true, runtimeReady: true, launchd: {} },
|
|
302
|
+
restart: { pending: true, kind: "update", reason: "已在线更新,需要重启 DeepSeek harness 生效" },
|
|
303
|
+
};
|
|
304
|
+
const STATUS_IDLE = {
|
|
305
|
+
ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
|
|
306
|
+
service: { running: true, runtimeReady: true, launchd: {} },
|
|
307
|
+
restart: { pending: false },
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
test("UI:待重启 → 顶部醒目提示「首次安装需要重启 DeepSeek harness」+ 重启按钮", () => {
|
|
311
|
+
const plugin = loadPlugin({
|
|
312
|
+
ok: true,
|
|
313
|
+
config: { phone: "13800000000", deviceId: "dev-x" },
|
|
314
|
+
service: { running: true, runtimeReady: true, launchd: {} },
|
|
315
|
+
restart: { pending: true, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness" },
|
|
316
|
+
});
|
|
317
|
+
plugin.states[0] = STATUS_FIRST_INSTALL; // mock 的 useEffect 不执行 → 直接注入面板状态
|
|
318
|
+
const tree = plugin.render();
|
|
319
|
+
assert.ok(textHas(tree, "首次安装需要重启 DeepSeek harness"), "顶部应出现首次安装重启提示");
|
|
320
|
+
const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
|
|
321
|
+
assert.ok(alert, "应有醒目提示块(dru-restart-alert)");
|
|
322
|
+
const btns = findAll(tree, (n) => n.props && String(n.props.className || "").includes("dru-btn") &&
|
|
323
|
+
(n.children || []).some((c) => typeof c === "string" && c.includes("重启 DeepSeek harness")));
|
|
324
|
+
assert.ok(btns.length >= 2, "提示块内 + 底部常驻各一个重启按钮,实际 " + btns.length);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("UI:常驻入口最底部始终有「重启 DeepSeek harness」按钮;不需要重启时也不消失", () => {
|
|
328
|
+
const plugin = loadPlugin({
|
|
329
|
+
ok: true,
|
|
330
|
+
config: { phone: "13800000000", deviceId: "dev-x" },
|
|
331
|
+
service: { running: true, runtimeReady: true, launchd: {} },
|
|
332
|
+
restart: { pending: false },
|
|
333
|
+
});
|
|
334
|
+
plugin.states[0] = STATUS_IDLE;
|
|
335
|
+
const tree = plugin.render();
|
|
336
|
+
assert.ok(!textHas(tree, "首次安装需要重启 DeepSeek harness"), "不需要重启时不该出现顶部提示");
|
|
337
|
+
const foot = find(tree, (n) => n.props && n.props.className === "dru-restart-foot");
|
|
338
|
+
assert.ok(foot, "底部应常驻重启按钮区(dru-restart-foot)");
|
|
339
|
+
assert.ok(textHas(foot, "重启 DeepSeek harness"), "常驻区应含重启按钮文案");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("UI:点「重启」→ 调用 /dsh-remote/harness/restart", async () => {
|
|
343
|
+
const plugin = loadPlugin({
|
|
344
|
+
ok: true,
|
|
345
|
+
config: { phone: "13800000000", deviceId: "dev-x" },
|
|
346
|
+
service: { running: true, runtimeReady: true, launchd: {} },
|
|
347
|
+
restart: { pending: true, kind: "update", reason: "已在线更新,需要重启 DeepSeek harness 生效" },
|
|
348
|
+
});
|
|
349
|
+
plugin.states[0] = STATUS_UPDATE;
|
|
350
|
+
const tree = plugin.render();
|
|
351
|
+
// 顶部提示块内的那个按钮(常驻脚注里也有一个)
|
|
352
|
+
const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
|
|
353
|
+
const btn = find(alert, (n) => n.props && String(n.props.className || "").includes("dru-btn") &&
|
|
354
|
+
(n.children || []).some((c) => typeof c === "string" && c.includes("重启 DeepSeek harness")));
|
|
355
|
+
assert.ok(btn, "顶部提示块内应能定位到重启按钮");
|
|
356
|
+
btn.props.onClick();
|
|
357
|
+
await new Promise((r) => setImmediate(r));
|
|
358
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/harness/restart" && r.method === "POST"),
|
|
359
|
+
"点击后应 POST /dsh-remote/harness/restart,实际 " + JSON.stringify(plugin.requests));
|
|
360
|
+
});
|
|
@@ -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
|
+
});
|