@mrrisega/dsh-remote 0.7.9 → 0.7.10-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.
Files changed (60) hide show
  1. package/README.md +9 -2
  2. package/clients/dsh-remote/agent/README.md +412 -0
  3. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  4. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  5. package/clients/dsh-remote/agent/codex.mjs +736 -0
  6. package/clients/dsh-remote/agent/detect.mjs +399 -0
  7. package/clients/dsh-remote/agent/events.mjs +511 -0
  8. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  9. package/clients/dsh-remote/agent/index.mjs +339 -0
  10. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  11. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  12. package/clients/dsh-remote/agent/proc.mjs +179 -0
  13. package/clients/dsh-remote/agent/version.mjs +148 -0
  14. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  15. package/clients/dsh-remote/decision-poller.mjs +235 -0
  16. package/clients/dsh-remote/dsh-bridge.mjs +208 -1
  17. package/clients/dsh-remote/mobile-adapter.mjs +775 -11
  18. package/clients/dsh-remote/push-reporter.mjs +533 -0
  19. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  20. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  21. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  22. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  23. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  24. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  25. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  26. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  27. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  28. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  29. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  30. package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
  31. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  32. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
  33. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
  34. package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
  35. package/dsh-setup.mjs +5 -0
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +350 -8
  38. package/packages/dsh-remote-web/lib/index.js +26 -0
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
  59. package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
  60. package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
@@ -0,0 +1,407 @@
1
+ // 「设备连上即出手机端入口」回归(浏览器半,0.7.10 / W1-T1 + W1-T2)。
2
+ //
3
+ // 背景(2026-11 复盘,业主质疑"这个功能应该已经有了"):
4
+ // 二维码链路本身**早就存在**(GET /dsh-remote/access-key → qr_data_url → 25s 自动轮换 →
5
+ // 进入 online 自动重取)。缺的不是功能,是两件事:
6
+ // ① **没连上也在出码**:生产里 100 人反复取一次性密钥(中位 4 次、最高 205 次),
7
+ // 而本机 bridge 从未在中继注册 —— 密钥接口走账号 API,不依赖 bridge 是否注册,
8
+ // 于是面板照常签发、用户扫了却进不去,只能刷新/重试,每次又签一把新的。
9
+ // ② **收尾没人管**:安装引导停在「注册/登录手机号」,用户在电脑端登录完就以为结束了,
10
+ // 129 人设备在线却从未在手机端登录(C1)。二维码只存在于这个面板里,且没说"拿手机扫它"。
11
+ //
12
+ // 本用例锁死改动后的状态机:
13
+ // ① 旧 host(响应无 connect 字段)→ 行为与改动前完全一致(登录即取 key、出二维码);
14
+ // ② 阶段已知且非 online → **不重复签发**、不出二维码,给「连上后自动出现」的说明;
15
+ // ③ 推进到 online → 自动出二维码 + 三步激活引导 + 「复制链接发到手机」;
16
+ // ④ mobile_sessions 出现未取消会话 → 引导收敛成一行 ✅ 已激活(这是唯一的完成信号);
17
+ // ⑤ 从 online 掉线 → 立刻撤掉失效二维码(不留"扫了也进不去"的码);
18
+ // ⑥ 连接失败分档:上游不通 / 端口占用 / 权限 / PATH(npx) / Node / 网络 → 各有原因判断;
19
+ // ⑦ 失败块里「一键修复」直连 POST /dsh-remote/self/update(同一个重装动作,不用往下翻卡)。
20
+ import assert from "node:assert/strict";
21
+ import { readFileSync } from "node:fs";
22
+ import test from "node:test";
23
+ import vm from "node:vm";
24
+
25
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
26
+
27
+ function walk(node, visit) {
28
+ if (node == null) return;
29
+ if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
30
+ if (typeof node !== "object") return;
31
+ visit(node);
32
+ for (const child of node.children || []) walk(child, visit);
33
+ }
34
+ function find(tree, predicate) {
35
+ let match;
36
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
37
+ return match;
38
+ }
39
+ function textHas(tree, substr) {
40
+ return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
41
+ }
42
+ function nodeWithText(tree, substr) {
43
+ return find(tree, (node) => typeof node.props?.onClick === "function"
44
+ && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
45
+ }
46
+
47
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
48
+
49
+ const NOT_LOGGED = { ok: true, config: { phone: "", hasPhone: false, mode: "saas", deviceId: "" }, service: { running: false }, remoteUrl: "https://app.test/" };
50
+ const LOGGED = { ok: true, config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" }, service: { running: false }, remoteUrl: "https://app.test/" };
51
+ const KEY_URL = "https://app.test/a/K1";
52
+ const ACTIVE_SESSION = [{ id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null }];
53
+
54
+ function connectPayload(phase, extra) {
55
+ return Object.assign({
56
+ phase,
57
+ online: phase === "online",
58
+ text: {
59
+ no_account: "请先登录手机号账号(下方「🔑 账号」卡片),登录后会自动完成剩余步骤",
60
+ no_runtime: "正在准备运行环境(首次约 1~2 分钟)…",
61
+ installing: "正在准备运行环境(首次约 1~2 分钟)…",
62
+ starting: "正在启动 Bridge…",
63
+ connecting: "正在连接中继…",
64
+ online: "已连接 ✅ 现在可以用手机扫码访问",
65
+ error: "连接失败,请按下方提示处理",
66
+ }[phase],
67
+ detail: "",
68
+ retryable: true,
69
+ deviceId: "dev-x",
70
+ runtimeReady: true,
71
+ installing: phase === "installing",
72
+ bridgeRunning: phase !== "starting" && phase !== "no_runtime",
73
+ registered: phase === "online",
74
+ registerSource: phase === "online" ? "account_api" : "",
75
+ attempts: 0,
76
+ nextRetryInMs: 0,
77
+ error: null,
78
+ logPath: "/home/u/.dsh-remote/.dsh-bridge.log",
79
+ installLogPath: "/home/u/.dsh-remote/.dsh-setup-install.log",
80
+ diagnostics: "dsh-remote 连接诊断\n连接阶段: " + phase,
81
+ }, extra || {});
82
+ }
83
+
84
+ /** 迷你 React(真实 useEffect + 可控假定时器),与 connect-ui / login-selfheal-ui 同一手法。 */
85
+ function loadPlugin(opts = {}) {
86
+ let moduleFactory;
87
+ const registered = new Map();
88
+ const injects = new Map();
89
+ const requests = [];
90
+ const clipboard = [];
91
+ const states = [];
92
+ let hook = 0;
93
+ let effectCursor = 0;
94
+ let effectSlots = [];
95
+ let pending = [];
96
+
97
+ let clock = 0;
98
+ let timerSeq = 1;
99
+ const timers = new Map();
100
+ const job = (fn) => { try { fn(); } catch (e) { /* 由断言暴露 */ } };
101
+ const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
102
+ const fakeSetInterval = (fn, ms) => { const id = timerSeq++; const every = Math.max(1, Number(ms) || 1); timers.set(id, { at: clock + every, fn, every }); return id; };
103
+ const fakeClear = (id) => { timers.delete(id); };
104
+ async function advance(ms) {
105
+ const target = clock + ms;
106
+ for (let guard = 0; guard < 2000; guard++) {
107
+ let due = null;
108
+ for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
109
+ if (!due) break;
110
+ const [id, t] = due;
111
+ clock = t.at;
112
+ if (t.every) t.at = clock + t.every; else timers.delete(id);
113
+ job(t.fn);
114
+ await flush();
115
+ }
116
+ clock = target;
117
+ await flush(); await flush();
118
+ }
119
+
120
+ const react = {
121
+ createElement(type, props, ...children) { return { type, props: props || {}, children }; },
122
+ useState(initial) {
123
+ const index = hook++;
124
+ if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial;
125
+ return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
126
+ },
127
+ useEffect(fn, deps) {
128
+ const index = effectCursor++;
129
+ const prev = effectSlots[index];
130
+ const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
131
+ if (changed) pending.push({ index, fn, deps: deps ? [...deps] : deps });
132
+ },
133
+ useCallback(fn) { return fn; },
134
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
135
+ };
136
+
137
+ const response = (status, body) => Promise.resolve({
138
+ ok: status >= 200 && status < 300,
139
+ status,
140
+ text: async () => JSON.stringify(body),
141
+ });
142
+
143
+ let statusBody = opts.status || NOT_LOGGED;
144
+ let connBody = opts.connect === undefined ? null : connectPayload(opts.connect);
145
+ let connCalls = 0;
146
+ let keyCalls = 0;
147
+ let sessCalls = 0;
148
+ let sessions = opts.sessions || [];
149
+
150
+ const makeEl = () => ({
151
+ tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
152
+ setAttribute(k, v) { this.attributes[k] = v; },
153
+ getAttribute(k) { return this.attributes[k] ?? null; },
154
+ appendChild(c) { this.children.push(c); },
155
+ addEventListener() {}, removeEventListener() {},
156
+ });
157
+ const doc = {
158
+ hidden: false,
159
+ createElement: makeEl,
160
+ head: makeEl(),
161
+ body: makeEl(),
162
+ querySelector() { return null; },
163
+ querySelectorAll() { return []; },
164
+ getElementById() { return null; },
165
+ addEventListener() {},
166
+ removeEventListener() {},
167
+ };
168
+ class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
169
+ const localStorage = {
170
+ _s: new Map(),
171
+ getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
172
+ setItem(k, v) { this._s.set(k, String(v)); },
173
+ removeItem(k) { this._s.delete(k); },
174
+ };
175
+
176
+ const sandbox = {
177
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
178
+ document: doc,
179
+ localStorage,
180
+ MutationObserver: MutationObserverMock,
181
+ navigator: { clipboard: { writeText: async (t) => { clipboard.push(String(t)); } } },
182
+ setTimeout: fakeSetTimeout,
183
+ clearTimeout: fakeClear,
184
+ setInterval: fakeSetInterval,
185
+ clearInterval: fakeClear,
186
+ fetch(path, options = {}) {
187
+ requests.push({ path, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
188
+ if (path === "/dsh-remote/status") return response(200, statusBody);
189
+ if (path === "/dsh-remote/bridge-status") {
190
+ connCalls += 1;
191
+ return response(200, connBody ? { ok: true, connect: connBody } : { ok: true });
192
+ }
193
+ if (path === "/dsh-remote/connect/retry") return response(200, { ok: true, retried: true, connect: connBody || null });
194
+ if (path === "/dsh-remote/self/update") return response(202, { ok: true, channel: "latest" });
195
+ if (path === "/dsh-remote/self") return response(200, { ok: true, version: "0.7.10", runtimeReady: true });
196
+ if (path === "/dsh-remote/self/update-log") return response(200, { ok: true, running: false, log: "" });
197
+ if (path === "/dsh-remote/self/update-check") return response(200, { ok: true, current: "0.7.10", latest: "0.7.10", outdated: false });
198
+ if (path === "/dsh-remote/access-key") {
199
+ keyCalls += 1;
200
+ return response(200, { ok: true, url: KEY_URL, key: "K1", expires_at: Date.now() + 1800000, ttl_ms: 1800000, qr_data_url: "data:image/png;base64,AAAA" });
201
+ }
202
+ if (path === "/dsh-remote/mobile-sessions") {
203
+ sessCalls += 1;
204
+ return response(200, { ok: true, sessions });
205
+ }
206
+ if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "138****0000", plan: "free", plan_source: "plan" } });
207
+ if (path === "/dsh-remote/quota") return response(200, { ok: true, quota: null });
208
+ if (path === "/dsh-remote/remote-url") return response(200, { ok: true, remoteUrl: "https://app.test/", publicConfig: {} });
209
+ return response(200, { ok: true });
210
+ },
211
+ Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
212
+ };
213
+
214
+ vm.runInNewContext(SOURCE, sandbox);
215
+ const plugin = moduleFactory((name) => {
216
+ assert.equal(name, "react");
217
+ return react;
218
+ });
219
+ plugin.apply({
220
+ slots: {
221
+ inject(name, cb) { injects.set(name, cb); cb(); },
222
+ register(meta, component) { registered.set(meta.id, component); return () => {}; },
223
+ },
224
+ });
225
+
226
+ return {
227
+ requests, states, clipboard,
228
+ counts() { return { connCalls, keyCalls, sessCalls }; },
229
+ render() {
230
+ hook = 0; effectCursor = 0; pending = [];
231
+ const tree = registered.get("dsh-remote")({ close() {} });
232
+ const todos = pending; pending = [];
233
+ for (const t of todos) {
234
+ const prev = effectSlots[t.index];
235
+ if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
236
+ const cleanup = t.fn();
237
+ effectSlots[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
238
+ }
239
+ return tree;
240
+ },
241
+ async settle(times = 3) {
242
+ for (let i = 0; i < times; i++) { this.render(); await flush(); await flush(); }
243
+ return this.render();
244
+ },
245
+ advance,
246
+ async login() {
247
+ statusBody = LOGGED;
248
+ await advance(30_000); // 等 30s 状态轮询把登录态带进面板
249
+ return this.settle();
250
+ },
251
+ setConnect(phase, extra) { connBody = connectPayload(phase, extra); },
252
+ setConnectRaw(body) { connBody = body; },
253
+ setSessions(list) { sessions = list; },
254
+ };
255
+ }
256
+
257
+ /** 二维码是否被渲染出来(img[alt=远程访问二维码])。 */
258
+ function qrShown(tree) {
259
+ return !!find(tree, (n) => n.props?.alt === "远程访问二维码");
260
+ }
261
+
262
+ test("向后兼容:旧 host(/bridge-status 无 connect 字段)→ 登录后照旧签发访问链接并出二维码", async () => {
263
+ const plugin = loadPlugin({ connect: undefined });
264
+ await plugin.settle();
265
+ let tree = await plugin.login();
266
+ assert.ok(plugin.counts().keyCalls >= 1, "旧 host 判断不了阶段 → 必须维持原行为(登录即取一次密钥)");
267
+ assert.ok(qrShown(tree), "旧 host 上二维码应照旧渲染");
268
+ assert.ok(find(tree, (n) => n.children?.includes(KEY_URL)), "旧 host 上访问链接应照旧展示");
269
+ });
270
+
271
+ test("设备未连上(starting):不再重复签发密钥、不出二维码,改给「连上后自动出现」的说明", async () => {
272
+ const plugin = loadPlugin({ connect: "starting" });
273
+ await plugin.settle();
274
+ let tree = await plugin.login();
275
+ // 首次渲染时 phase 未知 → 允许取一次(向后兼容路径);此后阶段已知且非 online,不得再取。
276
+ const keyAfterLogin = plugin.counts().keyCalls;
277
+ await plugin.advance(80_000); // 跨过 25s 轮换 ×3
278
+ tree = await plugin.settle();
279
+ assert.equal(plugin.counts().keyCalls, keyAfterLogin, "非 online 阶段不得按 25s 轮换继续签发密钥");
280
+ assert.ok(!qrShown(tree), "设备没连上时不得渲染二维码(扫了也进不去)");
281
+ assert.ok(!find(tree, (n) => n.children?.includes(KEY_URL)), "设备没连上时不得展示访问链接");
282
+ assert.ok(textHas(tree, "连上后二维码/访问链接会自动出现在这里"), "要说明「连上后会自动出现、无需刷新」");
283
+ assert.ok(!textHas(tree, "生成访问链接"), "不得再给一个「生成访问链接」按钮诱导用户空签");
284
+ assert.ok(textHas(tree, "立即检测连接"), "要保留一条可操作项(立即检测连接),不留死胡同");
285
+ });
286
+
287
+ test("推进到 online:自动出二维码 + 三步激活引导 + 「复制链接发到手机」", async () => {
288
+ const plugin = loadPlugin({ connect: "connecting" });
289
+ await plugin.settle();
290
+ let tree = await plugin.login();
291
+ assert.ok(!qrShown(tree), "connecting 阶段不应出二维码");
292
+
293
+ plugin.setConnect("online");
294
+ await plugin.advance(2600);
295
+ tree = await plugin.settle();
296
+ assert.ok(qrShown(tree), "online 后应自动渲染二维码(无需刷新/无需点按钮)");
297
+ assert.ok(textHas(tree, "3 步用上手机"), "第一屏要有激活引导标题");
298
+ assert.ok(textHas(tree, "本机已连接"), "步骤 1 应显示本机已连接");
299
+ assert.ok(textHas(tree, "用手机摄像头/微信扫下面的二维码"), "步骤 2 要明确「拿手机扫这个码」");
300
+ assert.ok(textHas(tree, "手机打开后即完成登录"), "步骤 3 要说清「打开即完成」");
301
+ assert.ok(textHas(tree, "不扫码也可以:手机浏览器打开 https://app.test/"), "要给出不扫码的第二种入口(app 地址)");
302
+ const copyBtn = nodeWithText(tree, "复制链接发到手机");
303
+ assert.ok(copyBtn, "要有一键「复制链接发到手机」");
304
+ copyBtn.props.onClick();
305
+ await flush(); await flush();
306
+ assert.deepEqual(plugin.clipboard, [KEY_URL], "复制按钮应把访问链接写进剪贴板");
307
+ });
308
+
309
+ test("手机已登录(mobile_sessions 有未取消会话)→ 激活引导收敛成一行 ✅ 已激活,三步消失", async () => {
310
+ const plugin = loadPlugin({ connect: "online", sessions: ACTIVE_SESSION });
311
+ await plugin.settle();
312
+ const tree = await plugin.login();
313
+ assert.ok(textHas(tree, "已激活:手机已经登录过这台电脑"), "已激活态应给完成反馈(这是唯一的完成信号)");
314
+ assert.ok(!textHas(tree, "3 步用上手机"), "已激活后不再显示三步引导");
315
+ assert.ok(textHas(tree, "要给另一台手机用"), "要留一句「再给另一台手机用怎么办」");
316
+ });
317
+
318
+ test("从 online 掉线:立刻撤掉失效二维码(不留「扫了也进不去」的码)", async () => {
319
+ const plugin = loadPlugin({ connect: "online" });
320
+ await plugin.settle();
321
+ let tree = await plugin.login();
322
+ await plugin.settle();
323
+ assert.ok(qrShown(tree), "online 时先出二维码");
324
+
325
+ plugin.setConnect("error", {
326
+ error: { code: "bridge_exit", message: "后台服务进程已退出" },
327
+ detail: "正在自动重启后台服务…",
328
+ });
329
+ await plugin.advance(16000); // online → 15s 慢轮询
330
+ tree = await plugin.settle();
331
+ assert.ok(!qrShown(tree), "掉线后不得继续展示二维码");
332
+ assert.ok(!find(tree, (n) => n.children?.includes(KEY_URL)), "掉线后不得继续展示访问链接");
333
+ assert.ok(textHas(tree, "连上后二维码/访问链接会自动出现在这里"), "改为说明「连上后自动出现」");
334
+ });
335
+
336
+ test("连接失败分档:上游不通 / 端口占用 / 权限 / PATH(npx) / Node / 网络 各有原因判断", async () => {
337
+ const cases = [
338
+ {
339
+ name: "上游 dsh web 没起",
340
+ extra: { upstreamReachable: false, upstreamError: "connect ECONNREFUSED 127.0.0.1:3080", error: { code: "upstream_unreachable", message: "bridge 要连的本机 dsh web 地址探测不通" } },
341
+ expect: "找不到这台电脑上的 dsh web",
342
+ },
343
+ {
344
+ name: "端口被占用",
345
+ extra: { error: { code: "bridge_exit", message: "Error: listen EADDRINUSE: address already in use 127.0.0.1:3080" } },
346
+ expect: "端口被别的程序占用了",
347
+ },
348
+ {
349
+ name: "权限不足",
350
+ extra: { error: { code: "install_failed", message: "npm install 失败:EACCES permission denied" } },
351
+ expect: "没有写入权限",
352
+ },
353
+ {
354
+ name: "PATH / npx",
355
+ extra: { error: { code: "npx_cmd_unavailable", message: "'npx' 不是内部或外部命令,也不是可运行的程序" } },
356
+ expect: "本机找不到安装命令",
357
+ },
358
+ {
359
+ name: "Node 缺失",
360
+ extra: { error: { code: "node_missing", message: "spawn node ENOENT" } },
361
+ expect: "本机没有 Node.js,或版本太低",
362
+ },
363
+ {
364
+ name: "网络 / 软件源",
365
+ extra: { error: { code: "npm_unreachable", message: "npm ERR! network ETIMEDOUT registry.npmjs.org" } },
366
+ expect: "网络或软件源连不上",
367
+ },
368
+ ];
369
+ for (const c of cases) {
370
+ const plugin = loadPlugin({ connect: "error" });
371
+ plugin.setConnect("error", Object.assign({ detail: "", attempts: 2, nextRetryInMs: 5000 }, c.extra));
372
+ await plugin.settle();
373
+ const tree = await plugin.login();
374
+ assert.ok(textHas(tree, "🔧 原因判断:" + c.expect), `「${c.name}」应给出原因判断「${c.expect}」`);
375
+ }
376
+ });
377
+
378
+ test("失败块「一键修复」→ POST /dsh-remote/self/update(同一个重装动作,不用往下翻卡找)", async () => {
379
+ const plugin = loadPlugin({ connect: "error" });
380
+ plugin.setConnect("error", { error: { code: "bridge_wedged", message: "后台守护已运行 120 秒仍拉不起 bridge" }, attempts: 2, nextRetryInMs: 5000 });
381
+ await plugin.settle();
382
+ let tree = await plugin.login();
383
+
384
+ const repairBtn = nodeWithText(tree, "一键修复 / 重装运行环境");
385
+ assert.ok(repairBtn, "失败块里必须有「一键修复 / 重装运行环境」主按钮");
386
+ repairBtn.props.onClick();
387
+ await flush(); await flush();
388
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/self/update" && r.method === "POST"),
389
+ "点一键修复应 POST /dsh-remote/self/update");
390
+
391
+ tree = await plugin.settle();
392
+ assert.ok(textHas(tree, "已开始重装运行环境"), "启动修复后要有明确反馈(不是点了没反应)");
393
+ });
394
+
395
+ test("锁定态不出「一键修复」:主行动是升级 Pro Max(额度问题不是故障)", async () => {
396
+ const plugin = loadPlugin({ connect: "error" });
397
+ plugin.setConnect("error", {
398
+ error: { code: "device_locked", message: "本机已被锁定(超出当前套餐设备额度)" },
399
+ locked: { message: "本机已被锁定(当前套餐可同时使用 1 台)", limit: 1, upgradeTo: "pro_max" },
400
+ detail: "升级 Pro Max 可多台设备同时使用;或到手机端「管理电脑」解绑其他设备后本机会自动解锁。",
401
+ });
402
+ await plugin.settle();
403
+ const tree = await plugin.login();
404
+ assert.ok(textHas(tree, "升级 Pro Max"), "锁定态主行动应是升级 Pro Max");
405
+ assert.ok(!textHas(tree, "一键修复 / 重装运行环境"), "锁定不是故障,不该诱导重装修复");
406
+ assert.ok(!textHas(tree, "🔧 原因判断"), "锁定态用专用文案,不走故障分档");
407
+ });
@@ -0,0 +1,70 @@
1
+ // 插件侧「复制诊断信息」带出 E2EE 死会话拒绝计数(0.7.10 / W1-T3)。
2
+ //
3
+ // 背景:WS 升级被拒里 71.8% 曾是 e2ee_session_locked(163 用户);0.7.3+ 已闭环
4
+ // (自动重握手 + 清内存会话 + 每页最多一次受控 reload)。但「闭环之后还剩多少」以前
5
+ // 只有 nginx/router 侧算得出,客户端支持包答不出 —— 用户报「手机老是要重新解锁」时,
6
+ // 我们没法从诊断信息里判断是不是还在被死会话打。
7
+ //
8
+ // 现在 bridge 把拒绝次数写进 `.dsh-bridge-state.json`(见 clients/dsh-remote 的同名用例),
9
+ // 插件 node 半把它带进「复制诊断信息」——**只在真的发生过时**输出(正常用户零新增噪音)。
10
+ import assert from "node:assert/strict";
11
+ import http from "node:http";
12
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import test from "node:test";
16
+
17
+ import { apply } from "../lib/index.js";
18
+
19
+ function boot(relayDir) {
20
+ const routes = new Map();
21
+ const disposers = [];
22
+ apply({
23
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
24
+ effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
25
+ logger: { info() {}, warn() {} },
26
+ }, { relayDir });
27
+ routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
28
+ return routes;
29
+ }
30
+ async function serve(routes) {
31
+ const host = http.createServer((req, res) => {
32
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
33
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
34
+ });
35
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
36
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
37
+ }
38
+
39
+ test("★ 诊断信息:有拒绝计数 → 带一行「E2EE 会话拒绝」;没有则不出现(零噪音)", async () => {
40
+ const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-e2ee-diag-"));
41
+ try {
42
+ await mkdir(dir, { recursive: true });
43
+ await writeFile(path.join(dir, ".dsh-config.json"), JSON.stringify({
44
+ phone: "13800000000", password: "pw", device_id: "", api_url: "http://127.0.0.1:1"
45
+ }));
46
+ const routes = boot(dir);
47
+ const { host, base } = await serve(routes);
48
+ try {
49
+ // ① 没有计数(正常用户):诊断里不得出现这一行
50
+ const clean = await (await fetch(`${base}/dsh-remote/bridge-status`)).json();
51
+ assert.ok(clean.connect && typeof clean.connect.diagnostics === "string", "应返回连接诊断");
52
+ assert.ok(!/E2EE 会话拒绝/.test(clean.connect.diagnostics), "没有拒绝记录时不得新增噪音行");
53
+
54
+ // ② 有计数(发生过死会话):诊断里带出次数
55
+ await writeFile(path.join(dir, ".dsh-bridge-state.json"), JSON.stringify({
56
+ e2ee_ws_reject_locked: 3, e2ee_ws_reject_disabled: 1, e2ee_ws_reject_at: Date.now()
57
+ }));
58
+ const dirty = await (await fetch(`${base}/dsh-remote/bridge-status`)).json();
59
+ assert.match(dirty.connect.diagnostics, /E2EE 会话拒绝: 会话失效 3 次 \/ 未启用 1 次/,
60
+ "诊断信息必须带上死会话拒绝计数(=「WS 拒绝里 e2ee_locked 占比」的客户端可观测字段)");
61
+ assert.equal(dirty.connect.e2eeReject && dirty.connect.e2eeReject.locked, 3,
62
+ "字段也应通过 connect 下发(面板/其它端可复用)");
63
+ } finally {
64
+ host.close();
65
+ routes.dispose();
66
+ }
67
+ } finally {
68
+ await rm(dir, { recursive: true, force: true });
69
+ }
70
+ });
@@ -67,6 +67,22 @@ test("安装输出:「已创建自启动服务」路径只在汇总里打印一
67
67
  assert.ok(/自启动服务: \$\{svc\.path/.test(src), "汇总里应展示自启动服务路径");
68
68
  });
69
69
 
70
+ test("安装输出:装完就告诉用户「手机端下一步」(扫码或打开地址),不把引导停在「登录手机号」", () => {
71
+ // 背景(2026-11 复盘,C1 129 人):安装引导原来只到「注册/登录手机号」为止 ——
72
+ // 用户以为装完/登录完就结束了:设备在线了、人却从没去过手机端。
73
+ // 现在汇总里必须把手机端的两条路都说清(扫码免登录 / 打开地址用同一手机号登录)。
74
+ const summary = src.slice(src.indexOf("const L = [];"), src.indexOf('console.log("\\n" + L.join'));
75
+ assert.ok(summary.length > 0, "应能定位到安装汇总段");
76
+ assert.ok(/远程访问地址: \$\{pub\.app_url \|\| DEFAULT_APP_URL\}/.test(summary), "SaaS 汇总要给出手机端 app 地址");
77
+ assert.ok(/手机端下一步:/.test(summary), "汇总必须给出「手机端下一步」");
78
+ assert.ok(/用同一手机号登录/.test(summary), "要说清手机上用同一手机号登录");
79
+ assert.ok(/设备列表/.test(summary), "要指到手机端的「设备列表」这个落点");
80
+ assert.ok(/扫那里的二维码/.test(summary), "要给出扫码这条免登录路径");
81
+ // 只打印一次(同一引导不得在多处重复)
82
+ const hits = src.match(/手机端下一步[::]/g) || [];
83
+ assert.equal(hits.length, 1, `「手机端下一步」应只出现一次(实际 ${hits.length} 处)`);
84
+ });
85
+
70
86
  test("安装输出:dsh web 未运行时的措辞 —— 说明会自动启动,且不出现「启动失败」误导", () => {
71
87
  assert.ok(/async function isDshWebUp/.test(src), "应探测 dsh web 是否在运行(127.0.0.1:3080)");
72
88
  const block = src.slice(src.indexOf("const webUp = await isDshWebUp();"));