@mrrisega/dsh-remote 0.6.3 → 0.6.4-beta.10

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.
@@ -1,9 +1,16 @@
1
- // 「首次安装需要重启 DeepSeek harness」回归(0.6.2 新增)。
1
+ // 「插件装载 / 刷新 / 重启」回归(0.6.2 新增,0.6.4-beta.9 语义修订)。
2
2
  //
3
3
  // 产品诉求(用户原话):
4
- // 1. 安装完成后桌面端 UI 直接可用,并提示「首次安装需要重启 DeepSeek harness」,旁边给「重启」按钮;
5
- // 2. 常驻入口最底下要有「重启 DeepSeek harness」按钮;
6
- // 3. 首次打开(尚未重启)时,该按钮要出现在上方显眼位置。
4
+ // 1. 安装完成后桌面端 UI 直接可用;
5
+ // 2. 常驻入口最底下要有「重启 dsh web」按钮(排查用,始终可达);
6
+ // 3. 需要用户动一下时,该提示要出现在上方显眼位置。
7
+ //
8
+ // ⚠️ 0.6.4-beta.9 语义修订(用户反馈「能热加载了,这个设置窗口就不需要了」):
9
+ // 插件已改为 profile patch **热加载**装载(HMR 监听 cordis.patch.yml,存盘约 1 秒生效),
10
+ // 运行环境(bridge)又是独立 launchd 进程 —— 所以「装插件 / 在线更新完成」都**不再需要重启
11
+ // DeepSeek harness**,那条横幅确实是误导。现在只在一种情形提示用户:
12
+ // **磁盘上的插件文件比当前进程新**(= 运行中被市场安装/在线更新改写),文案是
13
+ // 「插件已更新,刷新页面即可生效」,并且**不自动重启**(刷新零风险,重启会打断用户会话)。
7
14
  //
8
15
  // 机制:dsh web 的插件(宿主半 + 浏览器半)都在**进程启动时**装载,市场安装/在线更新只是把文件
9
16
  // 写进 profile,当前进程里既没有 /dsh-remote/* 路由也没有面板入口 → 必须重启才生效。
@@ -12,7 +19,7 @@
12
19
  import assert from "node:assert/strict";
13
20
  import http from "node:http";
14
21
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
15
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
22
+ import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises";
16
23
  import os from "node:os";
17
24
  import path from "node:path";
18
25
  import test from "node:test";
@@ -52,8 +59,16 @@ async function setup({ list = "" } = {}) {
52
59
  await writeFile(path.join(fakeBin, "npx"), "#!/bin/sh\nexit 0\n"); await chmodSync(path.join(fakeBin, "npx"), 0o755);
53
60
 
54
61
  const saved = {};
55
- for (const k of ["PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
62
+ for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
56
63
  "DSH_RELAY_RESTART_DRYRUN", "DSH_SETUP_NPX_DIR"]) saved[k] = process.env[k];
64
+ // HOME 必须一起伪造:本用例会 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
65
+ // 而插件的自启动 plist 路径写作 join(homedir(), "Library/LaunchAgents/…")。
66
+ // 不伪造 HOME 就会把**指向测试临时目录**的 plist 覆盖到开发者真实的
67
+ // ~/Library/LaunchAgents/com.dshremote.bridge.plist(实测污染,且临时目录随即被删 →
68
+ // 真实 bridge 自启动彻底失效)。这是测试对开发者机器的破坏,必须隔离。
69
+ const fakeHome = path.join(root, "home");
70
+ await mkdir(path.join(fakeHome, "Library", "LaunchAgents"), { recursive: true });
71
+ process.env.HOME = fakeHome;
57
72
  process.env.PATH = `${fakeBin}:${saved.PATH}`;
58
73
  process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
59
74
  process.env.DSH_TEST_LIST = list;
@@ -99,7 +114,7 @@ async function serve(routes) {
99
114
 
100
115
  // ─────────────────────────── 宿主半:状态机 ───────────────────────────
101
116
 
102
- test("首次安装(运行环境在本进程内补齐)→ 自动标记「需要重启」并在 /status 下发", async () => {
117
+ test("首次安装(运行环境在本进程内补齐)→ **不再**提示重启 harness(热加载后已无必要)", async () => {
103
118
  const env = await setup();
104
119
  try {
105
120
  await writeConfig(env.relayDir);
@@ -110,16 +125,52 @@ test("首次安装(运行环境在本进程内补齐)→ 自动标记「需
110
125
  const { host, base } = await serve(routes);
111
126
  try {
112
127
  const before = await (await fetch(`${base}/dsh-remote/status`)).json();
113
- assert.equal(before.restart.pending, false, "运行环境补齐前不该提示重启");
128
+ assert.equal(before.restart.pending, false, "运行环境补齐前不该有提示");
114
129
 
115
130
  // 模拟后台 npx 安装完成:固化运行时落盘
116
131
  await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
117
- await waitFor(async () => readFile(env.stateFile, "utf8").then(() => true).catch(() => false));
132
+ await sleep(400); // 给自愈调度一轮时间(以前这一轮会 markRestartPending)
118
133
 
119
134
  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/);
135
+ // bridge 是独立 launchd 进程、插件走 patch 热加载 → 补齐运行环境不需要重启 harness。
136
+ // 旧行为(提示"首次安装需要重启 DeepSeek harness")已被用户实测判定为误导,故断言必须为 false。
137
+ assert.equal(after.restart.pending, false,
138
+ "运行环境补齐**不该**再提示重启 harness(热加载 + 独立 bridge 进程)");
139
+ } finally { host.close(); routes.dispose(); }
140
+ } finally { await env.restore(); }
141
+ });
142
+
143
+ test("补装把**同版本**插件重写一遍 → 不得提示(用户实测的误报)", async () => {
144
+ // 事故复盘:安装器/自愈补装会把 profile 副本整目录重写,即使是**同一个版本**。
145
+ // 旧实现只看"插件文件 mtime 晚于进程启动"→ 误判成"有新插件要装载"→ 弹出需要重启/刷新的横幅,
146
+ // 而用户刷新后横幅还在(因为第二次补装又写了一遍)、最后靠重启才消失。
147
+ // 现在判的是「磁盘版本 != 运行版本」,版本相同就什么都不提示。
148
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
149
+ const fn = src.slice(src.indexOf("function pluginInstalledAfterBoot("), src.indexOf("/** 本进程的启动命令"));
150
+ assert.match(fn, /diskVersion !== runningVersion/, "必须比较版本,而不是只看时间戳");
151
+ assert.match(fn, /PLUGIN_VERSION/, "必须用本进程装载的版本号做比较");
152
+ });
153
+
154
+ test("插件文件晚于本进程启动 → 提示「刷新页面」,且**不**自动重启", async () => {
155
+ const env = await setup();
156
+ try {
157
+ await writeConfig(env.relayDir);
158
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
159
+ const routes = boot(env.relayDir);
160
+ const { host, base } = await serve(routes);
161
+ try {
162
+ const cur = await (await fetch(`${base}/dsh-remote/status`)).json();
163
+ // 用**真实的 bootId** 写回状态:否则 settleRestartState 会判定"这是上个进程写的 = 已重启过"
164
+ // 而把提示结清(那样就测不到"同进程内保留提示"这条语义了)。
165
+ await writeFile(env.stateFile, JSON.stringify({
166
+ pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
167
+ at: Date.now() - 3000, bootId: cur.restart.bootId
168
+ }));
169
+
170
+ const st = await (await fetch(`${base}/dsh-remote/status`)).json();
171
+ assert.equal(st.restart.pending, true, "插件被运行时改写 → 应提示");
172
+ assert.equal(st.restart.kind, "refresh", "kind 必须是 refresh(刷新语义,不是重启)");
173
+ assert.match(String(st.restart.reason), /刷新页面/);
123
174
  } finally { host.close(); routes.dispose(); }
124
175
  } finally { await env.restore(); }
125
176
  });
@@ -140,18 +191,31 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
140
191
  } finally { host.close(); routes.dispose(); }
141
192
  } finally { await a.restore(); }
142
193
 
143
- // 场景 B:状态由**当前进程**写下(用户还没点重启,只是刷新了页面 / 插件重新装载)→ 必须保留。
144
- // 走真实链路:插件自己写 pending(运行环境在本进程内补齐),再在同一进程内重新装载一次。
194
+ // 场景 B:状态由**当前进程**写下(用户还没处理,只是刷新了页面 / 插件重新装载)→ 必须保留提示。
195
+ // 注:0.6.4-beta.9 起"运行环境在本进程内补齐"不再写待重启(热加载后无必要),
196
+ // 所以这里直接按**真实 bootId** 写一份 kind=refresh 状态来构造该场景。
145
197
  const b = await setup();
146
198
  try {
147
199
  await writeConfig(b.relayDir);
200
+ await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
148
201
  process.env.DSH_RELAY_SELFHEAL_MS = "200";
202
+ // 本进程的 bootId:必须与 lib/index.js 的 BOOT_ID 完全一致,否则 settleRestartState
203
+ // 会判定"状态来自上一个进程 = 重启已完成"而把提示结清。
204
+ // 构造方式与 lib 相同;lib 对外不导出它,状态文件只在有 pending 时才写,故这里自行推出。
205
+ const sameProcessBootId = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
149
206
  const routes1 = boot(b.relayDir);
150
207
  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, "运行环境补齐后应标记待重启");
208
+ await writeFile(b.stateFile, JSON.stringify({
209
+ pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
210
+ at: Date.now() - 3000, bootId: sameProcessBootId
211
+ }));
212
+ // 把状态文件的 mtime 置为"刚写过":pluginInstalledAfterBoot() 判的是"插件文件晚于本进程启动"。
213
+ // 不这么做,第二次 apply() 的条件 `!pending && pluginInstalledAfterBoot()` 会为真,
214
+ // 于是 markRestartPending 又写一次(新 at/bootId),测的就不是"settle 不清"这条语义了。
215
+ const nowMs = Date.now() / 1000;
216
+ await utimes(b.stateFile, nowMs, nowMs);
217
+ const st1b = await (await fetch(`${base1}/dsh-remote/status`)).json();
218
+ assert.equal(st1b.restart.pending, true, "同进程写下的待处理状态应保留");
155
219
  host1.close();
156
220
  routes1.dispose();
157
221
 
@@ -161,7 +225,7 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
161
225
  try {
162
226
  const st2 = await (await fetch(`${base2}/dsh-remote/status`)).json();
163
227
  assert.equal(st2.restart.pending, true, "同一进程内(还没真重启)不得误撤提示");
164
- assert.equal(st2.restart.bootId, st1.restart.bootId, "bootId 应在同一进程内稳定不变");
228
+ assert.equal(st2.restart.bootId, sameProcessBootId, "bootId 应在同一进程内稳定不变");
165
229
  } finally { host2.close(); routes2.dispose(); }
166
230
  } finally { await b.restore(); }
167
231
  });
@@ -291,15 +355,11 @@ function loadPlugin(statusBody) {
291
355
  return { requests, states, render() { hook = 0; return registered.get("dsh-remote")({ close() {} }); } };
292
356
  }
293
357
 
294
- const STATUS_FIRST_INSTALL = {
358
+ // 现在唯一会提示用户的状态是 kind=refresh(插件文件被运行时改写 → 刷新页面即可)
359
+ const STATUS_REFRESH = {
295
360
  ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
296
361
  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 生效" },
362
+ restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效", at: Date.now() },
303
363
  };
304
364
  const STATUS_IDLE = {
305
365
  ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
@@ -307,21 +367,26 @@ const STATUS_IDLE = {
307
367
  restart: { pending: false },
308
368
  };
309
369
 
310
- test("UI:待重启 → 顶部醒目提示「首次安装需要重启 DeepSeek harness」+ 重启按钮", () => {
370
+ test("UI:插件更新后 → 顶部提示「刷新页面即可生效」+ 刷新按钮(不再喊重启 harness)", () => {
311
371
  const plugin = loadPlugin({
312
372
  ok: true,
313
373
  config: { phone: "13800000000", deviceId: "dev-x" },
314
374
  service: { running: true, runtimeReady: true, launchd: {} },
315
- restart: { pending: true, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness" },
375
+ restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效" },
316
376
  });
317
- plugin.states[0] = STATUS_FIRST_INSTALL; // mock 的 useEffect 不执行 → 直接注入面板状态
377
+ plugin.states[0] = STATUS_REFRESH; // mock 的 useEffect 不执行 → 直接注入面板状态
318
378
  const tree = plugin.render();
319
- assert.ok(textHas(tree, "首次安装需要重启 DeepSeek harness"), "顶部应出现首次安装重启提示");
379
+ assert.ok(textHas(tree, "插件已更新,刷新页面即可生效"), "顶部应出现刷新提示");
380
+ // 不能再说"需要重启 DeepSeek harness" —— 热加载后那是误导(用户实测反馈)
381
+ assert.ok(!textHas(tree, "需要重启 DeepSeek harness"), "不应再出现「需要重启 DeepSeek harness」");
320
382
  const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
321
383
  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);
384
+ // 主按钮应是「刷新页面」
385
+ const refreshBtn = find(tree, (n) => n.props && String(n.props.className || "").includes("dru-btn-primary") &&
386
+ (n.children || []).some((c) => typeof c === "string" && c.includes("刷新页面")));
387
+ assert.ok(refreshBtn, "提示块内主按钮应是「刷新页面」");
388
+ // 重启按钮仍可达(兜底),但不再是主按钮
389
+ assert.ok(textHas(tree, "重启 dsh web"), "应保留「重启 dsh web」作为兜底入口");
325
390
  });
326
391
 
327
392
  test("UI:常驻入口最底部始终有「重启 DeepSeek harness」按钮;不需要重启时也不消失", () => {
@@ -333,28 +398,29 @@ test("UI:常驻入口最底部始终有「重启 DeepSeek harness」按钮;
333
398
  });
334
399
  plugin.states[0] = STATUS_IDLE;
335
400
  const tree = plugin.render();
336
- assert.ok(!textHas(tree, "首次安装需要重启 DeepSeek harness"), "不需要重启时不该出现顶部提示");
401
+ assert.ok(!textHas(tree, "刷新页面即可生效"), "不需要动作时不该出现顶部提示");
337
402
  const foot = find(tree, (n) => n.props && n.props.className === "dru-restart-foot");
338
403
  assert.ok(foot, "底部应常驻重启按钮区(dru-restart-foot)");
339
- assert.ok(textHas(foot, "重启 DeepSeek harness"), "常驻区应含重启按钮文案");
404
+ assert.ok(textHas(foot, "重启 dsh web"), "常驻区应含重启按钮文案(排查兜底,始终可达)");
340
405
  });
341
406
 
342
- test("UI:点「重启」→ 调用 /dsh-remote/harness/restart", async () => {
407
+ test("UI:点「重启 dsh web」→ 调用 /dsh-remote/harness/restart", async () => {
343
408
  const plugin = loadPlugin({
344
409
  ok: true,
345
410
  config: { phone: "13800000000", deviceId: "dev-x" },
346
411
  service: { running: true, runtimeReady: true, launchd: {} },
347
- restart: { pending: true, kind: "update", reason: "已在线更新,需要重启 DeepSeek harness 生效" },
412
+ restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效" },
348
413
  });
349
- plugin.states[0] = STATUS_UPDATE;
414
+ plugin.states[0] = STATUS_REFRESH;
350
415
  const tree = plugin.render();
351
- // 顶部提示块内的那个按钮(常驻脚注里也有一个)
416
+ // 顶部提示块内的兜底重启按钮(常驻脚注里也有一个)
352
417
  const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
353
418
  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")));
419
+ (n.children || []).some((c) => typeof c === "string" && c.includes("重启 dsh web")));
355
420
  assert.ok(btn, "顶部提示块内应能定位到重启按钮");
356
421
  btn.props.onClick();
357
422
  await new Promise((r) => setImmediate(r));
358
423
  assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/harness/restart" && r.method === "POST"),
359
424
  "点击后应 POST /dsh-remote/harness/restart,实际 " + JSON.stringify(plugin.requests));
360
425
  });
426
+
@@ -0,0 +1,78 @@
1
+ /**
2
+ * macOS 自启动(launchd)契约测试(node:test,零框架)。
3
+ *
4
+ * 背景:用户(macOS 26.1)反馈自启动服务登记后**从未被派生**(`runs = 0`),
5
+ * 安装脚本报「自启动服务启动失败:服务未在运行」。根因:
6
+ * ① macOS 26 会把 `gui/<uid>` domain 置于 on-demand-only 模式,`RunAtLoad` / `KeepAlive`
7
+ * 全部失效(只登记不派生);系统日志:`pending spawn, domain in on-demand-only mode`。
8
+ * 迁到 `user/<uid>` domain(并带 `LimitLoadToSessionType`)才能恢复派生与崩溃自愈。
9
+ * ② 旧实现 `bootstrap` 后**立刻** `print` 一次就判定失败 —— launchd 派生是异步的。
10
+ * ③ 从不调用 `launchctl kickstart`,所以被挂起时没有任何东西会把它拉起来。
11
+ * ④ plist 的 PATH 缺 `/usr/sbin`、`/sbin`,bridge 日志里刷 `ioreg: command not found`。
12
+ *
13
+ * 本机实测(macOS 14.4.1,同机验证):
14
+ * · `bootstrap user/501 <plist 无 LimitLoadToSessionType>` → rc=5 `Input/output error`
15
+ * · 带 `[Aqua, Background]` 后:user/501 与 gui/501 都装载成功且 `runs = 1`
16
+ * · user/501 下 `kill -9` 后 launchd 自动重建 → `runs = 2`(KeepAlive 真生效)
17
+ */
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { readFileSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, join } from "node:path";
24
+
25
+ const SETUP = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "dsh-setup.mjs");
26
+ const src = readFileSync(SETUP, "utf8");
27
+
28
+ test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的必要条件)", () => {
29
+ assert.match(
30
+ src,
31
+ /<key>LimitLoadToSessionType<\/key><array><string>Aqua<\/string><string>Background<\/string><\/array>/,
32
+ "缺该键时 user/<uid> bootstrap 直接 EIO(rc=5);macOS 26 上它也是恢复派生的前提"
33
+ );
34
+ // RunAtLoad / KeepAlive 仍需保留:user domain 下它们才真正生效
35
+ assert.match(src, /<key>RunAtLoad<\/key><true\/>/, "RunAtLoad 不能删(user domain 下靠它开机自启)");
36
+ assert.match(src, /<key>KeepAlive<\/key><true\/>/, "KeepAlive 不能删(user domain 下靠它崩溃自愈)");
37
+ });
38
+
39
+ test("launchd 修复:启动走 user → gui 阶梯,且每次都 kickstart", () => {
40
+ assert.ok(/function startBridgeDarwin/.test(src), "应有专门的 darwin 启动阶梯函数");
41
+ const fn = src.slice(src.indexOf("function startBridgeDarwin"), src.indexOf("function restartBridgeService"));
42
+ // 首选 user domain(macOS 26 上唯一支持自启动语义的 domain)
43
+ assert.ok(/for \(const domain of \[`user\/\$\{uid\}`, `gui\/\$\{uid\}`\]\)/.test(fn), "应为 user → gui 的阶梯顺序");
44
+ assert.ok(/launchctl kickstart -k/.test(fn), "bootstrap 只是登记,必须 kickstart 才能确保这次起来");
45
+ // 清掉另一个 domain 的残留,避免两处并存跑出两个 bridge
46
+ assert.ok(/bootout \$\{d\}\/\$\{BRIDGE_LABEL\}/.test(fn), "切换到某 domain 时应清掉另一 domain 的历史注册");
47
+ // 终极兜底:launchd 托管不了也要保证「现在能用」
48
+ assert.ok(/spawnDetachedBridge/.test(fn), "launchd 不可用时应退化为后台进程兜底");
49
+ assert.ok(/degraded-detached/.test(fn), "兜底状态要与完全失败区分开");
50
+ });
51
+
52
+ test("launchd 修复:不再只 print 一次就判定失败(异步派生必须轮询)", () => {
53
+ assert.ok(/function waitLaunchdRunning/.test(src), "应有轮询等待函数");
54
+ const fn = src.slice(src.indexOf("function launchdState"), src.indexOf("function spawnDetachedBridge"));
55
+ assert.ok(/for \(let i = 0; i < tries; i \+= 1\)/.test(fn), "应循环轮询而非单次检查");
56
+ assert.ok(/sleepSync\(gapMs\)/.test(fn), "轮询之间要真正等待");
57
+ assert.ok(/tries = 20/.test(fn), "总等待时长应足够 launchd 完成异步派生");
58
+ // 必须能识别「登记了但从未派生」这一 macOS 26 特征,才能给出正确建议而不是干等
59
+ assert.ok(/pended nondemand spawn/.test(fn) && /on-demand-only/.test(fn), "应识别 on-demand-only 挂起特征");
60
+ });
61
+
62
+ test("launchd 修复:被挂起后靠 kickstart 起来时,不得报成完全成功", () => {
63
+ assert.ok(/selfHealing/.test(src), "应回报是否具备崩溃自愈能力");
64
+ assert.ok(/本机 launchd 处于 on-demand-only/.test(src), "安装输出要如实说明不支持崩溃自愈");
65
+ const summary = src.slice(src.indexOf("const svcFragile"), src.indexOf("const L = [];"));
66
+ assert.ok(/svcFragile/.test(summary), "汇总里应区分「可自愈」与「仅拉起一次」");
67
+ });
68
+
69
+ test("launchd 修复:服务 PATH 必须含 /usr/sbin 与 /sbin(否则 ioreg 找不到)", () => {
70
+ const m = src.match(/const SERVICE_PATH = "([^"]+)"/);
71
+ assert.ok(m, "应集中定义 SERVICE_PATH");
72
+ for (const dir of ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
73
+ assert.ok(m[1].split(":").includes(dir), `PATH 缺少 ${dir}`);
74
+ }
75
+ // plist 与 systemd unit 都必须用这个常量,不能各写各的
76
+ assert.ok(/\$\{SERVICE_PATH\}/.test(src), "plist/systemd 应使用 SERVICE_PATH 常量");
77
+ assert.ok(!/Environment=PATH=\/usr\/local\/bin:\/usr\/bin:\/bin/.test(src), "systemd unit 不应再写死旧 PATH");
78
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * 「patch 热加载激活」契约测试(node:test,零框架)。
3
+ *
4
+ * 背景:harness 的 web profile 是 `patchReload: "live"`,启动时加载 `@deepseek-ai/cordis-plugin-hmr`
5
+ * 并监听 profile 的 `cordis.patch.yml` —— 存盘后约 1 秒重新 compose 并**动态装载**插件,
6
+ * 不需要重启 dsh web(插件市场自己就用这套机制)。而 `dsh.profile.bundles` 只在启动时读、
7
+ * 没有 watcher,所以「bundles 激活」必须重启 —— 这正是用户装完看不到「远程控制」面板的原因。
8
+ *
9
+ * 因此安装器改为:**激活点 = patch 行**(热加载),bundles 那条同时移除(单一激活点,
10
+ * 否则 dsh web 启动即报「重复 ID」崩溃)。
11
+ *
12
+ * 这些用例在**临时 profile**(mkdtemp)里跑真实的 `dsh-setup.mjs plugin` 子进程,
13
+ * 断言生成的文件产物;绝不触碰开发者真实的 ~/.dsh/profiles/**。
14
+ */
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { execFileSync, spawnSync } from "node:child_process";
19
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, cpSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
25
+ const SETUP = join(ROOT, "dsh-setup.mjs");
26
+
27
+ const PLUGIN_BUNDLES = ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"];
28
+
29
+ /** 造一个临时 profile(默认 patch 是新 profile 的空文档占位 `[]`)。 */
30
+ function makeProfile({ patch = "# dsh profile patch\n[]\n", bundles = PLUGIN_BUNDLES } = {}) {
31
+ const dir = mkdtempSync(join(tmpdir(), "dshac-"));
32
+ writeFileSync(join(dir, "package.json"), JSON.stringify({
33
+ name: "dsh-profile-web", private: true, dependencies: {},
34
+ dsh: { profile: { bundles: [...bundles], patchReload: "live" } }
35
+ }, null, 2) + "\n");
36
+ writeFileSync(join(dir, "cordis.patch.yml"), patch);
37
+ return dir;
38
+ }
39
+
40
+ /** 跑真实的 plugin 安装(子进程,指向临时 profile 与临时配置目录)。stdout+stderr 一并返回。 */
41
+ function installPluginWithStderr(profileDir, relayDir) {
42
+ const r = spawnSync(process.execPath, [SETUP, "plugin", "--profile", profileDir], {
43
+ encoding: "utf8",
44
+ env: { ...process.env, DSH_RELAY_DIR: relayDir, DSH_RELAY_SKIP_SERVICE: "1" }
45
+ });
46
+ return { out: String(r.stdout || ""), err: String(r.stderr || ""), code: r.status };
47
+ }
48
+
49
+ /** 只要 stdout(正常路径够用)。 */
50
+ function installPlugin(profileDir, relayDir) {
51
+ const r = installPluginWithStderr(profileDir, relayDir);
52
+ assert.equal(r.code, 0, `安装子进程应成功退出,stderr=${r.err.slice(0, 200)}`);
53
+ return r.out;
54
+ }
55
+
56
+ function cleanup(...dirs) {
57
+ for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } }
58
+ }
59
+
60
+ const readBundles = (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).dsh.profile.bundles;
61
+ const readPatch = (dir) => readFileSync(join(dir, "cordis.patch.yml"), "utf8");
62
+
63
+ test("patch 激活:写入 insert 行(热加载形态),并从 bundles 移除该插件(单一激活点)", () => {
64
+ const profile = makeProfile();
65
+ const relay = join(profile, "relay");
66
+ try {
67
+ installPlugin(profile, relay);
68
+ const patch = readPatch(profile);
69
+ assert.match(patch, /- insert:/, "应写入 insert 块");
70
+ assert.match(patch, /- id: dsh-remote-web/, "应写入本插件的 id 行");
71
+ assert.match(patch, /name: 'dsh-remote-web'/, "name 必须是裸包名(加载器据此 import)");
72
+ assert.ok(!patch.includes("[]"), "空文档占位 `[]` 必须被清掉(否则整份 patch 解析失败)");
73
+ assert.deepEqual(readBundles(profile), PLUGIN_BUNDLES, "bundles 里不能再有本插件(两处激活=重复 ID 崩溃)");
74
+ } finally { cleanup(profile); }
75
+ });
76
+
77
+ test("patch 激活:relayDir 写的是**配置目录**而不是插件安装目录", () => {
78
+ const profile = makeProfile();
79
+ const relay = join(profile, "relay-config");
80
+ mkdirSync(relay, { recursive: true });
81
+ try {
82
+ installPlugin(profile, relay);
83
+ const patch = readPatch(profile);
84
+ const m = /relayDir: '([^']+)'/.exec(patch);
85
+ assert.ok(m, "应带 relayDir 配置");
86
+ // 面板要读的是该目录下的 .dsh-config.json;写成插件目录会让面板显示成"未登录/空配置"
87
+ assert.equal(m[1], relay, "relayDir 必须是配置目录");
88
+ assert.ok(!m[1].includes("dsh-remote-web-plugin"), "relayDir 不得指向插件源码目录");
89
+ } finally { cleanup(profile); }
90
+ });
91
+
92
+ test("patch 激活:幂等 —— 重复安装不会写出第二个 id(否则重复 ID 崩溃)", () => {
93
+ const profile = makeProfile();
94
+ const relay = join(profile, "relay");
95
+ try {
96
+ installPlugin(profile, relay);
97
+ const first = readPatch(profile);
98
+ installPlugin(profile, relay);
99
+ const second = readPatch(profile);
100
+ assert.equal(second, first, "第二次安装不应再改 patch");
101
+ const ids = second.match(/- id: dsh-remote-web/g) || [];
102
+ assert.equal(ids.length, 1, `id 行必须恰好一条,实际 ${ids.length}`);
103
+ } finally { cleanup(profile); }
104
+ });
105
+
106
+ test("patch 激活:既有其它插件条目必须原样保留(不能吃掉别人的配置)", () => {
107
+ const other = [
108
+ "# dsh profile patch",
109
+ "[]",
110
+ "",
111
+ "# --- mba-feishu managed block ---",
112
+ "- insert:",
113
+ " - id: mba-feishu",
114
+ " name: 'mba-feishu'",
115
+ " config:",
116
+ " transport: stdio",
117
+ " args:",
118
+ " - 'mcp'",
119
+ "# --- end mba-feishu managed block ---",
120
+ ""
121
+ ].join("\n");
122
+ const profile = makeProfile({ patch: other });
123
+ const relay = join(profile, "relay");
124
+ try {
125
+ installPlugin(profile, relay);
126
+ const patch = readPatch(profile);
127
+ assert.match(patch, /id: mba-feishu/, "其它插件的条目必须保留");
128
+ assert.match(patch, /transport: stdio/, "其它插件的字段必须保留");
129
+ assert.match(patch, /- 'mcp'/, "其它插件的嵌套列表必须保留");
130
+ assert.match(patch, /# --- end mba-feishu managed block ---/, "注释块必须保留");
131
+ assert.match(patch, /- id: dsh-remote-web/, "同时写入我们的条目");
132
+ assert.equal((patch.match(/^- insert:/gm) || []).length, 2, "应有两个独立的 insert 块");
133
+ } finally { cleanup(profile); }
134
+ });
135
+
136
+ test("安全护栏:patch 形态异常时**拒写**并回退 bundles(绝不把能用的 profile 弄坏)", () => {
137
+ const broken = "# 坏 patch:顶层塞了非列表内容\nthis is not a list ===\n- insert:\n - id: other\n name: 'other'\n";
138
+ const profile = makeProfile({ patch: broken });
139
+ const relay = join(profile, "relay");
140
+ try {
141
+ const { out, err } = installPluginWithStderr(profile, relay);
142
+ const all = out + err; // 警告走 stderr,必须一并检查
143
+ assert.match(all, /无法写入 patch 激活行/, "应明确报告拒写");
144
+ assert.match(all, /bundles 形态/, "应回退 bundles 保证功能可用");
145
+ assert.equal(readPatch(profile), broken, "坏文件必须一字未改(拒写,不是先写再看)");
146
+ assert.ok(readBundles(profile).includes("dsh-remote-web"), "回退时应把插件加进 bundles");
147
+ } finally { cleanup(profile); }
148
+ });
149
+
150
+ test("安全护栏:基座含空文档占位 `[]` 时也要先清掉(否则整份 patch 解析失败)", () => {
151
+ const profile = makeProfile({ patch: "[]\n" });
152
+ const relay = join(profile, "relay");
153
+ try {
154
+ installPlugin(profile, relay);
155
+ const patch = readPatch(profile);
156
+ assert.ok(!/^\[\]$/m.test(patch), "占位 [] 必须被移除");
157
+ assert.match(patch, /- id: dsh-remote-web/, "并写入我们的条目");
158
+ } finally { cleanup(profile); }
159
+ });
160
+
161
+ test("安全护栏:原子写回 —— 不留 .tmp 残留,且末尾换行规范", () => {
162
+ const profile = makeProfile();
163
+ const relay = join(profile, "relay");
164
+ try {
165
+ installPlugin(profile, relay);
166
+ assert.ok(!readPatch(profile).includes(".dsh-remote.tmp"), "不应把临时文件名写进内容");
167
+ const leftovers = execFileSync("ls", ["-a", profile], { encoding: "utf8" })
168
+ .split("\n").filter((f) => f.includes(".tmp"));
169
+ assert.deepEqual(leftovers, [], `不应留下临时文件: ${leftovers.join(",")}`);
170
+ assert.ok(readPatch(profile).endsWith("\n"), "文件应以换行结尾");
171
+ } finally { cleanup(profile); }
172
+ });
173
+
174
+ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(否则热挂载验证被跳过)", () => {
175
+ // 事故复盘:convergePluginActivation 已经返回 {hotPatch},但 pluginCmd 没把它 return 出去,
176
+ // 于是 setup() 拿不到激活形态 → 跳过热挂载验证 → 明明热挂载成功却仍报「需要重启」。
177
+ const src = readFileSync(SETUP, "utf8");
178
+ const call = src.match(/return convergePluginActivation\(profileDir[^\n]*/);
179
+ assert.ok(call, "pluginCmd 必须 return convergePluginActivation(...) 的结果");
180
+ // setup() 必须真的消费这个结果
181
+ assert.match(src, /pluginResult = await pluginCmd\(\[\]\)/, "setup 应保存 pluginCmd 的返回值");
182
+ assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 安装脚本输出契约测试(node:test,零框架)。
3
+ *
4
+ * 背景:安装输出一度太长且重复 —— 同一段「下一步: 打开 dsh web → 设置 → 远程控制」
5
+ * 在插件阶段和结尾汇总各打印一遍,「已创建自启动服务」也打印两遍;更糟的是 dsh web
6
+ * 没开时只甩一句「自启动服务启动失败」,用户以为安装坏了(其实是正常状态:bridge 依赖
7
+ * dsh web,dsh web 没开时它起来即退)。
8
+ *
9
+ * 这里盯三条边界:
10
+ * ① 输出里「下一步」引导只出现一次(去重不靠人眼);
11
+ * ② dsh web 未运行 + bridge 未跑 → 明确说明"打开 dsh web 后会自动启动",且不出现
12
+ * 「启动失败」这种误导措辞;
13
+ * ③ dsh web 在运行但 bridge 没跑 → 是真的异常,必须给出原因与日志路径。
14
+ */
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { readFileSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { dirname, join } from "node:path";
21
+ import { readdirSync } from "node:fs";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const SETUP = join(HERE, "..", "..", "..", "dsh-setup.mjs");
25
+
26
+ const src = readFileSync(SETUP, "utf8");
27
+
28
+ test("测试隔离:unset 系统操作开关的用例必须同时伪造 HOME(否则污染真实自启动)", () => {
29
+ // 事故复盘:harness-restart 用例 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
30
+ // 但没伪造 HOME → 插件写 plist 的路径是 join(homedir(), "Library/LaunchAgents/…"),
31
+ // 于是把**指向测试临时目录**的 plist 覆盖到开发者真实的 ~/Library/LaunchAgents,
32
+ // 临时目录随即被删 → 真实 bridge 自启动彻底失效(本机实测踩到,macOS 26 用户反馈排查时发现)。
33
+ // 静态护栏:凡 unset 该开关的用例,文件里必须出现 process.env.HOME =。
34
+ const rows = readdirSync(HERE).filter((f) => f.endsWith(".test.mjs"));
35
+ const offenders = [];
36
+ for (const f of rows) {
37
+ const text = readFileSync(join(HERE, f), "utf8");
38
+ if (/delete process\.env\.DSH_RELAY_SKIP_SERVICE/.test(text) && !/process\.env\.HOME\s*=/.test(text)) offenders.push(f);
39
+ }
40
+ assert.deepEqual(offenders, [], `这些用例会碰真实 HOME(必须伪造 HOME): ${offenders.join(", ")}`);
41
+ // 反向确认:护栏本身确有保护对象,否则是空断言
42
+ const guarded = rows.filter((f) => /delete process\.env\.DSH_RELAY_SKIP_SERVICE/.test(readFileSync(join(HERE, f), "utf8")));
43
+ assert.ok(guarded.length >= 3, `应至少有 3 个用例走真实分支(实际 ${guarded.length})`);
44
+ });
45
+
46
+ test("安装输出:拼接汇总时「下一步」引导只有一处文案源(不再多处重复打印)", () => {
47
+ // 引导语只由汇总的 L.push 产出:自建 / 热挂载 / 需重启 / 普通 四个**互斥**分支,
48
+ // 运行时只可能走其中一条 → 用户始终只看到一条引导。
49
+ const pushes = src.match(/L\.push\(" 下一步[::]/g) || [];
50
+ assert.equal(pushes.length, 4, `「下一步」应只由汇总的 4 个互斥分支产出(实际 ${pushes.length} 处)`);
51
+ // 源码里不得再有其它直接打印引导的地方
52
+ const direct = src.match(/console\.log\((?!.*L\.join)[^)]*下一步/g) || [];
53
+ assert.equal(direct.length, 0, "除汇总外不得再直接打印引导");
54
+ // 插件阶段不得再打印用户引导
55
+ const converge = src.slice(src.indexOf("function convergePluginActivation"), src.indexOf("async function pluginCmd"));
56
+ assert.ok(!/下一步|打开 dsh web → 设置/.test(converge), "插件阶段不应再重复打印引导");
57
+ // 互斥性:这些分支必须挂在同一条 if/else-if 链上,否则可能同时打出两条引导
58
+ const chain = src.slice(src.indexOf("} else if (hotMounted) {"), src.indexOf('L.push("\\n" + L.join'));
59
+ assert.ok(chain.length > 0, "应能定位到引导分支链");
60
+ assert.ok(/else if \(needRestart && restartResult/.test(chain), "热挂载与需重启必须在同一条 else-if 链上");
61
+ assert.ok(/else if \(needRestart\)/.test(chain), "兜底的手动重启分支也必须在同一条链上");
62
+ });
63
+
64
+ test("安装输出:「已创建自启动服务」路径只在汇总里打印一次", () => {
65
+ const createMsgs = src.match(/已创建自启动服务/g) || [];
66
+ assert.equal(createMsgs.length, 0, "创建路径不再单独打印(汇总里统一展示),避免同一信息两遍");
67
+ assert.ok(/自启动服务: \$\{svc\.path/.test(src), "汇总里应展示自启动服务路径");
68
+ });
69
+
70
+ test("安装输出:dsh web 未运行时的措辞 —— 说明会自动启动,且不出现「启动失败」误导", () => {
71
+ assert.ok(/async function isDshWebUp/.test(src), "应探测 dsh web 是否在运行(127.0.0.1:3080)");
72
+ const block = src.slice(src.indexOf("const webUp = await isDshWebUp();"));
73
+ // 未运行分支必须明说"会自动启动"
74
+ assert.ok(/dsh web 当前没有运行/.test(block), "未运行分支应直说检测结果");
75
+ assert.ok(/自动启动,无需任何命令/.test(block), "未运行分支应说明何时自动启动");
76
+ assert.ok(/不是安装出错/.test(block), "未运行分支应明确否定「安装出错」");
77
+ // 未运行分支的**用户可见文案**里不能出现「失败」(真失败分支才有)
78
+ const notUpBranch = block.slice(block.indexOf("if (!st.ok && !webUp)"), block.indexOf("} else if (!st.ok) {"));
79
+ // 只看 L.push(...) 的实参(注释里的说明文字不算用户可见文案)
80
+ const texts = (notUpBranch.match(/L\.push\((\s*)"([^"]*)"/g) || [])
81
+ .map((m) => (m.match(/"([^"]*)"/) || [])[1]).join("\n");
82
+ assert.ok(texts.length > 0, "该分支应有用户可见文案");
83
+ assert.ok(!/失败/.test(texts), `dsh web 未运行≠bridge 启动失败,该分支文案不得出现「失败」:\n${texts}`);
84
+ });
85
+
86
+ test("安装输出:真失败(dsh web 在运行但 bridge 没起来)必须给原因与日志路径", () => {
87
+ const block = src.slice(src.indexOf("const webUp = await isDshWebUp();"));
88
+ const failBranch = block.slice(block.indexOf("else if (!st.ok)"), block.indexOf("if (selfHosted) {", block.indexOf("else if (!st.ok)")));
89
+ assert.ok(/bridge 未能启动/.test(failBranch), "真失败分支要明说未能启动");
90
+ assert.ok(/\.dsh-bridge\.log/.test(failBranch), "真失败分支要给出日志路径供排查");
91
+ });
92
+
93
+ test("安装输出:跳过/不支持自启动时不得自相矛盾地显示「运行中」", () => {
94
+ const block = src.slice(src.indexOf("const webUp = await isDshWebUp();"));
95
+ // 三态判定必须存在:未安装 与 运行中/未运行 分开
96
+ assert.ok(/const svcSkipped = st\.status === "skipped"/.test(block), "应识别 skipped/unsupported/无路径三态");
97
+ assert.ok(/未安装(本次显式跳过)/.test(block), "显式跳过要说清是跳过");
98
+ assert.ok(/未安装(当前平台不支持自启动)/.test(block), "平台不支持要说清原因");
99
+ // 跳过分支的文案里不能出现「运行中」
100
+ const skippedBranch = block.slice(block.indexOf("if (svcSkipped) {"), block.indexOf("} else if (!st.ok && !webUp) {"));
101
+ const texts = (skippedBranch.match(/L\.push\((`[^`]*`|"[^"]*")/g) || []).join("\n");
102
+ assert.ok(!/运行中/.test(texts), `未安装自启动服务时不得声称「运行中」:\n${texts}`);
103
+ // 未安装时也不该承诺「打开 dsh web 就会自动启动」(那是自启动服务在管)
104
+ assert.ok(!/自动启动/.test(texts), "未安装自启动服务时不得承诺自动启动");
105
+ });
106
+
107
+ test("安装输出:服务状态字符串仍是插件面板可解析的稳定契约", () => {
108
+ // 插件半按 running / 未运行 解析,不能因为排版调整改口径
109
+ assert.ok(/\? `✅ 运行中/.test(src), "running 口径保留(带 pid)");
110
+ assert.ok(/`未运行 \(\$\{st\.detail \|\| st\.status\}\)`/.test(src), "未运行口径保留(带原因)");
111
+ });