@mrrisega/dsh-remote 0.6.1-beta.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ // 交流群二维码(运营配置)在插件侧的两处展示回归:
2
+ // ① 设置面板:「💬 加入交流群」按钮 → 点击弹出二维码大图(未配置后台码 → 按钮不存在);
3
+ // ② 用户反馈页:底部直接展示企微交流群二维码 + 文案。
4
+ // 数据来源:node 半 GET /dsh-remote/community(读公开配置 community.qrcode 并拼绝对地址)。
5
+ // 采用真实 useEffect(依赖比较 + 提交后执行),与 login-selfheal-ui.test.mjs 同一套 vm 挂载方式。
6
+ import assert from "node:assert/strict";
7
+ import http from "node:http";
8
+ import { readFileSync } from "node:fs";
9
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import test from "node:test";
13
+ import vm from "node:vm";
14
+ import { apply } from "../lib/index.js";
15
+
16
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
17
+ const QR_PATH = "/qr/group-qr.png";
18
+ const API_URL = "https://relay.test/relay-api";
19
+ const QR_ABS = API_URL + QR_PATH;
20
+
21
+ function walk(node, visit) {
22
+ if (node == null) return;
23
+ if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
24
+ if (typeof node !== "object") return;
25
+ visit(node);
26
+ for (const child of node.children || []) walk(child, visit);
27
+ }
28
+ function find(tree, predicate) {
29
+ let match;
30
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
31
+ return match;
32
+ }
33
+ function textHas(tree, substr) {
34
+ return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
35
+ }
36
+ function nodeWithText(tree, substr) {
37
+ return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
38
+ }
39
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
40
+
41
+ /**
42
+ * 迷你 React(真实 useEffect)+ 假定时器;fetch 覆盖 /dsh-remote/* 面板接口。
43
+ * @param {object} opts - community:/dsh-remote/community 的返回值(默认已配置二维码)
44
+ */
45
+ function loadPlugin(opts = {}) {
46
+ let moduleFactory;
47
+ const registered = new Map();
48
+ const injects = new Map();
49
+ const requests = [];
50
+
51
+ let clock = 0;
52
+ let timerSeq = 1;
53
+ const timers = new Map();
54
+ const job = (fn) => { try { fn(); } catch (e) { /* 断言会暴露问题 */ } };
55
+ const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
56
+ 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; };
57
+ const fakeClear = (id) => { timers.delete(id); };
58
+
59
+ // ── 迷你 React:真实 useState/useEffect + 递归渲染函数组件(FeedbackCard 等嵌套组件) ──
60
+ // 每个组件实例按「树中位置路径」持有自己的 hook 槽位(近似 React 的位置化协调),故子组件状态
61
+ // 可跨渲染保持;effects 在对应子树展开后执行(cleanup 语义与 React 一致)。
62
+ const scopes = new Map(); // key -> { hook, effectCursor, slots, pending }
63
+ const scopeStack = [];
64
+ const currentScope = () => scopeStack[scopeStack.length - 1];
65
+
66
+ const react = {
67
+ createElement(type, props, ...children) { return { type, props: props || {}, children }; },
68
+ useState(initial) {
69
+ const sc = currentScope();
70
+ const index = sc.hook++;
71
+ if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
72
+ return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
73
+ },
74
+ useEffect(fn, deps) {
75
+ const sc = currentScope();
76
+ const index = sc.effectCursor++;
77
+ const prev = sc.effects[index];
78
+ const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
79
+ if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
80
+ },
81
+ useCallback(fn) { return fn; },
82
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
83
+ };
84
+
85
+ /** 渲染一棵树:函数组件递归展开(含 effects),普通节点保留并递归子节点。 */
86
+ function renderNode(node, path) {
87
+ if (node == null) return node;
88
+ if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
89
+ if (typeof node !== "object") return node;
90
+ if (typeof node.type === "function") {
91
+ const name = node.type.name || "anon";
92
+ const key = path + ":" + name;
93
+ const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
94
+ sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
95
+ scopes.set(key, sc);
96
+ scopeStack.push(sc);
97
+ let out;
98
+ try {
99
+ out = node.type(node.props || {});
100
+ } finally {
101
+ scopeStack.pop();
102
+ }
103
+ const expanded = renderNode(out, key);
104
+ const todos = sc.pending;
105
+ sc.pending = [];
106
+ for (const t of todos) {
107
+ const prev = sc.effects[t.index];
108
+ if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
109
+ const cleanup = t.fn();
110
+ sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
111
+ }
112
+ return expanded;
113
+ }
114
+ return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
115
+ }
116
+
117
+ const community = opts.community !== undefined ? opts.community : { ok: true, qrcode: QR_ABS, wechat: "dsh-remote-support" };
118
+ const status = opts.status || {
119
+ ok: true,
120
+ config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
121
+ service: { running: true },
122
+ remoteUrl: "https://relay.test/app/",
123
+ };
124
+ const response = (s, body) => Promise.resolve({ ok: s >= 200 && s < 300, status: s, text: async () => JSON.stringify(body) });
125
+
126
+ const makeEl = () => ({
127
+ tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
128
+ setAttribute(k, v) { this.attributes[k] = v; },
129
+ getAttribute(k) { return this.attributes[k] ?? null; },
130
+ appendChild(c) { this.children.push(c); },
131
+ addEventListener() {}, removeEventListener() {},
132
+ });
133
+ const doc = {
134
+ hidden: false,
135
+ createElement: makeEl,
136
+ head: makeEl(),
137
+ body: makeEl(),
138
+ querySelector() { return null; },
139
+ querySelectorAll() { return []; },
140
+ getElementById() { return null; },
141
+ addEventListener() {}, removeEventListener() {},
142
+ };
143
+ class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
144
+ const localStorage = {
145
+ _s: new Map(),
146
+ getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
147
+ setItem(k, v) { this._s.set(k, String(v)); },
148
+ removeItem(k) { this._s.delete(k); },
149
+ };
150
+
151
+ const sandbox = {
152
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
153
+ document: doc,
154
+ localStorage,
155
+ MutationObserver: MutationObserverMock,
156
+ navigator: { clipboard: { writeText: async () => {} } },
157
+ setTimeout: fakeSetTimeout,
158
+ clearTimeout: fakeClear,
159
+ setInterval: fakeSetInterval,
160
+ clearInterval: fakeClear,
161
+ fetch(path, options = {}) {
162
+ requests.push({ path, method: options.method || "GET" });
163
+ if (path === "/dsh-remote/status") return response(200, status);
164
+ if (path === "/dsh-remote/community") return response(200, community);
165
+ return response(200, { ok: true });
166
+ },
167
+ Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
168
+ };
169
+
170
+ vm.runInNewContext(SOURCE, sandbox);
171
+ const plugin = moduleFactory((name) => {
172
+ assert.equal(name, "react");
173
+ return react;
174
+ });
175
+ plugin.apply({
176
+ slots: {
177
+ inject(name, cb) { injects.set(name, cb); cb(); },
178
+ register(meta, component) { registered.set(meta.id, component); return () => {}; },
179
+ },
180
+ });
181
+
182
+ return {
183
+ requests,
184
+ /** 渲染 → 让在途请求落地 → 再渲染(返回最新树)。 */
185
+ async settle(times = 2) {
186
+ let tree = null;
187
+ for (let i = 0; i < times; i++) { tree = this.render(); await flush(); await flush(); }
188
+ return this.render();
189
+ },
190
+ render() {
191
+ const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
192
+ return renderNode(root, "root");
193
+ },
194
+ };
195
+ }
196
+
197
+ test("node 半 /dsh-remote/community:读公开配置 community.qrcode 并拼成企业端绝对地址", async () => {
198
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-community-"));
199
+ const relay = http.createServer((req, res) => {
200
+ const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
201
+ if (req.url.startsWith("/api/public-config")) {
202
+ return send(200, {
203
+ service: "dsh-remote",
204
+ app_url: "https://relay.test/app/",
205
+ api_url: API_URL,
206
+ community: { qrcode: QR_PATH, wechat: "dsh-remote-support" },
207
+ });
208
+ }
209
+ send(404, { error: { code: "not_found" } });
210
+ });
211
+ await new Promise((r) => relay.listen(0, "127.0.0.1", r));
212
+ const port = relay.address().port;
213
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
214
+ phone: "13800000000", password: "pw", api_url: `http://127.0.0.1:${port}`, bridge_secret: "s",
215
+ }));
216
+ const routes = new Map();
217
+ apply({ webServer: { register(rt) { routes.set(rt.path, rt.handler); return () => {}; } }, effect(reg) { return reg(); }, logger: { info() {}, warn() {} } }, { relayDir: tempDir });
218
+ const host = http.createServer((req, res) => {
219
+ const url = new URL(req.url, "http://x");
220
+ const h = routes.get(url.pathname);
221
+ (h || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
222
+ });
223
+ await new Promise((r) => host.listen(0, "127.0.0.1", r));
224
+ try {
225
+ const base = `http://127.0.0.1:${host.address().port}`;
226
+ const r = await (await fetch(`${base}/dsh-remote/community`)).json();
227
+ assert.equal(r.ok, true);
228
+ // 相对路径按「api_url + 路径」拼绝对地址(与手机端 PWA 的 API_BASE + qr 同一约定)
229
+ assert.equal(r.qrcode, `http://127.0.0.1:${port}${QR_PATH}`);
230
+ assert.equal(r.wechat, "dsh-remote-support");
231
+ } finally {
232
+ host.close();
233
+ relay.close();
234
+ await rm(tempDir, { recursive: true, force: true });
235
+ }
236
+ });
237
+
238
+ test("设置面板:后台已配置 → 「加入交流群」按钮出现;点击弹出二维码大图;关闭后收起", async () => {
239
+ const plugin = loadPlugin();
240
+ let tree = await plugin.settle();
241
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/community"), "面板应拉取交流群配置");
242
+
243
+ const btn = nodeWithText(tree, "加入交流群");
244
+ assert.ok(btn, "应出现「💬 加入交流群」按钮");
245
+ assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "未点击时不应有弹窗");
246
+
247
+ btn.props.onClick();
248
+ tree = plugin.render();
249
+ assert.ok(find(tree, (n) => n.props?.className === "dru-community-qr"), "点击后应弹出二维码");
250
+ const img = find(tree, (n) => n.props?.className === "dru-community-qr");
251
+ assert.equal(img.props.src, QR_ABS, "二维码用 node 半下发的绝对地址");
252
+ assert.equal(img.props.alt, "企微交流群二维码");
253
+ assert.ok(textHas(tree, "加入企微交流群"), "弹窗应有标题");
254
+ assert.ok(textHas(tree, "扫码入群"), "弹窗应有入群说明");
255
+
256
+ // 关闭:点「关闭」→ 弹窗消失
257
+ const closeBtn = nodeWithText(tree, "关闭");
258
+ assert.ok(closeBtn, "弹窗应有「关闭」按钮");
259
+ closeBtn.props.onClick();
260
+ tree = plugin.render();
261
+ assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "关闭后弹窗应消失");
262
+ });
263
+
264
+ test("设置面板:后台未配置二维码 → 不展示任何入口(不留空壳按钮)", async () => {
265
+ const plugin = loadPlugin({ community: { ok: true, qrcode: "", wechat: "" } });
266
+ const tree = await plugin.settle();
267
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/community"), "仍应尝试拉取");
268
+ assert.ok(!nodeWithText(tree, "加入交流群"), "未配置时不展示按钮");
269
+ assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "未配置时不展示二维码");
270
+ });
271
+
272
+ test("用户反馈页:底部展示企微交流群二维码(含客服微信兜底文案)", async () => {
273
+ const plugin = loadPlugin();
274
+ await plugin.settle();
275
+ // 进入「用户反馈」视图
276
+ let tree = plugin.render();
277
+ const entry = nodeWithText(tree, "用户反馈");
278
+ assert.ok(entry, "应有「用户反馈」入口");
279
+ entry.props.onClick();
280
+ tree = await plugin.settle();
281
+
282
+ assert.ok(textHas(tree, "加入企微交流群"), "反馈页应展示交流群标题");
283
+ const img = find(tree, (n) => n.props?.className === "dru-community-qr");
284
+ assert.ok(img, "反馈页应展示二维码图片");
285
+ assert.equal(img.props.src, QR_ABS);
286
+ assert.ok(textHas(tree, "dsh-remote-support"), "应展示客服微信兜底文案");
287
+ });
@@ -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
+ });