@mrrisega/dsh-remote 0.6.4-beta.2 → 0.6.4-beta.4
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 +54 -0
- package/docs/telemetry.md +145 -0
- package/dsh-setup.mjs +55 -32
- package/package.json +4 -3
- package/packages/dsh-remote-web/lib/client.js +102 -1
- package/packages/dsh-remote-web/lib/index.js +539 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/auto-restart.test.mjs +297 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +72 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +600 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// 【自动化】首次安装/更新后「自动重启 DeepSeek harness」的安全回归(浏览器半,0.6.4)。
|
|
2
|
+
//
|
|
3
|
+
// 目标:用户装完不需要点任何按钮 —— 面板发现「待重启」后自己倒计时重启,重启后自动恢复页面
|
|
4
|
+
// (复用既有 restartHarness + waitHarnessBack:轮询到 harness 回来即自动刷新)。
|
|
5
|
+
// 但「自动重启进程」是危险动作,本用例锁死四条安全阀:
|
|
6
|
+
// ① 只在面板可见时计时(用户没在看就不动);
|
|
7
|
+
// ② 15 秒倒计时内可一键取消,取消标记按本次事件(restart.at)持久化,同一事件不再自动重启;
|
|
8
|
+
// ③ 面板上有其他操作在跑(busy≠"")时暂停计时,不打断用户正在做的事;
|
|
9
|
+
// ④ 只重启一次(重启后 pending 结清即停);无待重启时一个请求都不发。
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import vm from "node:vm";
|
|
14
|
+
|
|
15
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
16
|
+
|
|
17
|
+
function walk(node, visit) {
|
|
18
|
+
if (node == null) return;
|
|
19
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
20
|
+
if (typeof node !== "object") return;
|
|
21
|
+
visit(node);
|
|
22
|
+
for (const child of node.children || []) walk(child, visit);
|
|
23
|
+
}
|
|
24
|
+
function find(tree, predicate) {
|
|
25
|
+
let match;
|
|
26
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
27
|
+
return match;
|
|
28
|
+
}
|
|
29
|
+
function textHas(tree, substr) {
|
|
30
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
31
|
+
}
|
|
32
|
+
function nodeWithText(tree, substr) {
|
|
33
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
37
|
+
|
|
38
|
+
const NOT_LOGGED = { ok: true, config: { phone: "", hasPhone: false, mode: "saas", deviceId: "" }, service: { running: false }, remoteUrl: "https://app.test/" };
|
|
39
|
+
const LOGGED = { ok: true, config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" }, service: { running: true }, remoteUrl: "https://app.test/" };
|
|
40
|
+
const KEY_URL = "https://app.test/a/K1";
|
|
41
|
+
const SESSIONS = [{ id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null }];
|
|
42
|
+
function statusWithRestart(pending, at) {
|
|
43
|
+
return Object.assign({}, LOGGED, { restart: { pending: !!pending, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness", at: at || 0 } });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadPlugin(opts = {}) {
|
|
47
|
+
let moduleFactory;
|
|
48
|
+
const registered = new Map();
|
|
49
|
+
const injects = new Map();
|
|
50
|
+
const requests = [];
|
|
51
|
+
const clipboard = [];
|
|
52
|
+
const states = [];
|
|
53
|
+
let hook = 0;
|
|
54
|
+
let effectCursor = 0;
|
|
55
|
+
let effectSlots = [];
|
|
56
|
+
let pending = [];
|
|
57
|
+
const listeners = new Map();
|
|
58
|
+
|
|
59
|
+
let clock = 0;
|
|
60
|
+
let timerSeq = 1;
|
|
61
|
+
const timers = new Map();
|
|
62
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 内部错误由断言暴露 */ } };
|
|
63
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
64
|
+
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; };
|
|
65
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
66
|
+
async function advance(ms) {
|
|
67
|
+
const target = clock + ms;
|
|
68
|
+
for (let guard = 0; guard < 2000; guard++) {
|
|
69
|
+
let due = null;
|
|
70
|
+
for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
|
|
71
|
+
if (!due) break;
|
|
72
|
+
const [id, t] = due;
|
|
73
|
+
clock = t.at;
|
|
74
|
+
if (t.every) t.at = clock + t.every; else timers.delete(id);
|
|
75
|
+
job(t.fn);
|
|
76
|
+
await flush();
|
|
77
|
+
}
|
|
78
|
+
clock = target;
|
|
79
|
+
await flush(); await flush();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const react = {
|
|
83
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
84
|
+
useState(initial) {
|
|
85
|
+
const index = hook++;
|
|
86
|
+
if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial;
|
|
87
|
+
return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
|
|
88
|
+
},
|
|
89
|
+
useEffect(fn, deps) {
|
|
90
|
+
const index = effectCursor++;
|
|
91
|
+
const prev = effectSlots[index];
|
|
92
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
93
|
+
if (changed) pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
94
|
+
},
|
|
95
|
+
useCallback(fn) { return fn; },
|
|
96
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const response = (status, body) => Promise.resolve({
|
|
100
|
+
ok: status >= 200 && status < 300,
|
|
101
|
+
status,
|
|
102
|
+
text: async () => JSON.stringify(body),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let statusBody = opts.status || NOT_LOGGED;
|
|
106
|
+
let restartCalls = 0;
|
|
107
|
+
const connBody = null;
|
|
108
|
+
let connCalls = 0;
|
|
109
|
+
let keyCalls = 0;
|
|
110
|
+
let sessCalls = 0;
|
|
111
|
+
|
|
112
|
+
const makeEl = () => ({
|
|
113
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
114
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
115
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
116
|
+
appendChild(c) { this.children.push(c); },
|
|
117
|
+
addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
|
|
118
|
+
removeEventListener(type, fn) {
|
|
119
|
+
const list = listeners.get(type) || [];
|
|
120
|
+
const i = list.indexOf(fn);
|
|
121
|
+
if (i >= 0) list.splice(i, 1);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const doc = {
|
|
125
|
+
hidden: false,
|
|
126
|
+
createElement: makeEl,
|
|
127
|
+
head: makeEl(),
|
|
128
|
+
body: makeEl(),
|
|
129
|
+
querySelector() { return null; },
|
|
130
|
+
querySelectorAll() { return []; },
|
|
131
|
+
getElementById() { return null; },
|
|
132
|
+
addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
|
|
133
|
+
removeEventListener(type, fn) {
|
|
134
|
+
const list = listeners.get(type) || [];
|
|
135
|
+
const i = list.indexOf(fn);
|
|
136
|
+
if (i >= 0) list.splice(i, 1);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
140
|
+
const localStorage = {
|
|
141
|
+
// 支持预置存储(验证「取消标记跨刷新生效」:getItem 走真实读取路径)
|
|
142
|
+
_s: new Map(Object.entries(opts.localStorage || {})),
|
|
143
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
144
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
145
|
+
removeItem(k) { this._s.delete(k); },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const sandbox = {
|
|
149
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
150
|
+
document: doc,
|
|
151
|
+
localStorage,
|
|
152
|
+
MutationObserver: MutationObserverMock,
|
|
153
|
+
navigator: { clipboard: { writeText: async (t) => { clipboard.push(String(t)); } } },
|
|
154
|
+
setTimeout: fakeSetTimeout,
|
|
155
|
+
clearTimeout: fakeClear,
|
|
156
|
+
setInterval: fakeSetInterval,
|
|
157
|
+
clearInterval: fakeClear,
|
|
158
|
+
fetch(path, options = {}) {
|
|
159
|
+
requests.push({ path, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
|
|
160
|
+
if (path === "/dsh-remote/status") return response(200, statusBody);
|
|
161
|
+
if (path === "/dsh-remote/bridge-status") { connCalls += 1; return response(200, { ok: true }); }
|
|
162
|
+
if (path === "/dsh-remote/harness/restart") {
|
|
163
|
+
restartCalls += 1;
|
|
164
|
+
// 重启后 harness 会带着新的 bootId 回来:模拟「待重启已结清」
|
|
165
|
+
statusBody = Object.assign({}, statusBody, { restart: { pending: false, kind: "", reason: "", at: statusBody.restart?.at || 0 } });
|
|
166
|
+
return response(200, Object.assign({}, statusBody, { ok: true, mode: "launchd" }));
|
|
167
|
+
}
|
|
168
|
+
if (path === "/dsh-remote/access-key") {
|
|
169
|
+
keyCalls += 1;
|
|
170
|
+
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" });
|
|
171
|
+
}
|
|
172
|
+
if (path === "/dsh-remote/mobile-sessions") {
|
|
173
|
+
sessCalls += 1;
|
|
174
|
+
return response(200, { ok: true, sessions: opts.sessions || SESSIONS });
|
|
175
|
+
}
|
|
176
|
+
if (path === "/dsh-remote/captcha") return response(200, { captcha_id: "cap-1", svg: "<svg></svg>" });
|
|
177
|
+
if (path === "/dsh-remote/register") return response(201, { ok: true, token: "jwt-reg" });
|
|
178
|
+
if (path === "/dsh-remote/config") return response(200, statusBody);
|
|
179
|
+
if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "138****0000", plan: "free", plan_source: "plan" } });
|
|
180
|
+
if (path === "/dsh-remote/quota") return response(200, { ok: true, quota: null });
|
|
181
|
+
if (path === "/dsh-remote/remote-url") return response(200, { ok: true, remoteUrl: "https://app.test/", publicConfig: {} });
|
|
182
|
+
return response(200, { ok: true });
|
|
183
|
+
},
|
|
184
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
188
|
+
const plugin = moduleFactory((name) => {
|
|
189
|
+
assert.equal(name, "react");
|
|
190
|
+
return react;
|
|
191
|
+
});
|
|
192
|
+
plugin.apply({
|
|
193
|
+
slots: {
|
|
194
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
195
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
requests, states, clipboard,
|
|
201
|
+
counts() { return { connCalls, keyCalls, sessCalls, restartCalls }; },
|
|
202
|
+
/** 改写状态响应(模拟 pending 被结清/用户取消后再次出现等)。 */
|
|
203
|
+
setStatus(body) { statusBody = body; },
|
|
204
|
+
/** 渲染一轮并执行本轮需要运行的 effects(含 cleanup)。 */
|
|
205
|
+
render() {
|
|
206
|
+
hook = 0; effectCursor = 0; pending = [];
|
|
207
|
+
const tree = registered.get("dsh-remote")({ close() {} });
|
|
208
|
+
const todos = pending; pending = [];
|
|
209
|
+
for (const t of todos) {
|
|
210
|
+
const prev = effectSlots[t.index];
|
|
211
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
212
|
+
const cleanup = t.fn();
|
|
213
|
+
effectSlots[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
214
|
+
}
|
|
215
|
+
return tree;
|
|
216
|
+
},
|
|
217
|
+
async settle(times = 3) {
|
|
218
|
+
for (let i = 0; i < times; i++) { this.render(); await flush(); await flush(); }
|
|
219
|
+
return this.render();
|
|
220
|
+
},
|
|
221
|
+
advance,
|
|
222
|
+
/** 登录成功(面板读到登录态)→ 登录态 effect 重跑,自动开始推进连接。 */
|
|
223
|
+
async login() {
|
|
224
|
+
statusBody = LOGGED;
|
|
225
|
+
await advance(30_000); // 等 30s 状态轮询把登录态带进面板
|
|
226
|
+
return this.settle();
|
|
227
|
+
},
|
|
228
|
+
/** 连接阶段推进(node 半短轮询的应答变化)。 */
|
|
229
|
+
setConnect(phase, extra) { connBody = connectPayload(phase, extra); },
|
|
230
|
+
setConnectRaw(body) { connBody = body; },
|
|
231
|
+
hide() { doc.hidden = true; },
|
|
232
|
+
show() { doc.hidden = false; for (const fn of listeners.get("visibilitychange") || []) job(fn); },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
test("待重启 + 面板可见 → 15 秒后自动重启(用户零操作),重启后 pending 结清即停止", async () => {
|
|
239
|
+
const plugin = loadPlugin({ status: statusWithRestart(true, 1111) });
|
|
240
|
+
let tree = await plugin.settle();
|
|
241
|
+
assert.ok(textHas(tree, "首次安装需要重启 DeepSeek harness"), "应显示待重启横幅");
|
|
242
|
+
assert.ok(textHas(tree, "秒后自动重启"), "应显示自动重启倒计时文案");
|
|
243
|
+
assert.equal(plugin.counts().restartCalls, 0, "倒计时期间不得提前重启");
|
|
244
|
+
|
|
245
|
+
await plugin.advance(14000);
|
|
246
|
+
assert.equal(plugin.counts().restartCalls, 0, "14 秒时还没到点");
|
|
247
|
+
await plugin.advance(2000);
|
|
248
|
+
assert.equal(plugin.counts().restartCalls, 1, "15 秒到点应自动重启一次");
|
|
249
|
+
|
|
250
|
+
tree = await plugin.settle();
|
|
251
|
+
assert.ok(!textHas(tree, "秒后自动重启"), "重启后不应残留倒计时");
|
|
252
|
+
await plugin.advance(60000);
|
|
253
|
+
assert.equal(plugin.counts().restartCalls, 1, "pending 结清后不得反复重启");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("点「取消自动重启」→ 不再自动重启,且标记按事件持久化(刷新后仍不自动重启)", async () => {
|
|
257
|
+
const plugin = loadPlugin({ status: statusWithRestart(true, 2222) });
|
|
258
|
+
const tree = await plugin.settle();
|
|
259
|
+
const cancelBtn = nodeWithText(tree, "取消自动重启");
|
|
260
|
+
assert.ok(cancelBtn, "倒计时期间必须提供取消入口");
|
|
261
|
+
cancelBtn.props.onClick();
|
|
262
|
+
assert.ok(textHas(plugin.render(), "已取消自动重启"), "取消后应有明确反馈");
|
|
263
|
+
|
|
264
|
+
await plugin.advance(120000);
|
|
265
|
+
assert.equal(plugin.counts().restartCalls, 0, "取消后不得自动重启");
|
|
266
|
+
|
|
267
|
+
// 模拟用户刷新页面:同一 pending 事件(at=2222)仍不自动重启
|
|
268
|
+
const plugin2 = loadPlugin({ status: statusWithRestart(true, 2222), localStorage: { "dsh-remote-auto-restart-cancelled": "2222" } });
|
|
269
|
+
await plugin2.settle();
|
|
270
|
+
await plugin2.advance(120000);
|
|
271
|
+
assert.equal(plugin2.counts().restartCalls, 0, "刷新后仍尊重取消标记");
|
|
272
|
+
|
|
273
|
+
// 但下一次安装/更新(新的 at)应重新自动触发
|
|
274
|
+
const plugin3 = loadPlugin({ status: statusWithRestart(true, 3333), localStorage: { "dsh-remote-auto-restart-cancelled": "2222" } });
|
|
275
|
+
await plugin3.settle();
|
|
276
|
+
await plugin3.advance(16000);
|
|
277
|
+
assert.equal(plugin3.counts().restartCalls, 1, "新的待重启事件应重新自动重启");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("面板不可见 → 倒计时暂停;回到前台继续", async () => {
|
|
281
|
+
const plugin = loadPlugin({ status: statusWithRestart(true, 4444) });
|
|
282
|
+
await plugin.settle();
|
|
283
|
+
plugin.hide();
|
|
284
|
+
await plugin.advance(60000);
|
|
285
|
+
assert.equal(plugin.counts().restartCalls, 0, "页面隐藏时绝不能在用户没看到的情况下重启进程");
|
|
286
|
+
plugin.show();
|
|
287
|
+
await plugin.advance(16000);
|
|
288
|
+
assert.equal(plugin.counts().restartCalls, 1, "回到前台应继续倒计时并最终重启");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("无待重启 → 一个请求都不发;横幅不出现", async () => {
|
|
292
|
+
const plugin = loadPlugin({ status: LOGGED });
|
|
293
|
+
const tree = await plugin.settle();
|
|
294
|
+
await plugin.advance(60000);
|
|
295
|
+
assert.equal(plugin.counts().restartCalls, 0);
|
|
296
|
+
assert.ok(!textHas(tree, "自动重启"), "无待重启时不应出现自动重启文案");
|
|
297
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const SETUP = join(HERE, "..", "..", "..", "dsh-setup.mjs");
|
|
24
|
+
|
|
25
|
+
const src = readFileSync(SETUP, "utf8");
|
|
26
|
+
|
|
27
|
+
test("安装输出:拼接汇总时「下一步」引导只有一处文案源(不再多处重复打印)", () => {
|
|
28
|
+
// 引导语只由汇总的 L.push 产出:selfHosted 与非自建各一条(互斥分支,用户只会看到一条)
|
|
29
|
+
const pushes = src.match(/L\.push\(" 下一步[::]/g) || [];
|
|
30
|
+
assert.equal(pushes.length, 2, `「下一步」应只由汇总的 2 个互斥分支产出(实际 ${pushes.length} 处)`);
|
|
31
|
+
// 源码里不得再有其它直接打印引导的地方
|
|
32
|
+
const direct = src.match(/console\.log\((?!.*L\.join)[^)]*下一步/g) || [];
|
|
33
|
+
assert.equal(direct.length, 0, "除汇总外不得再直接打印引导");
|
|
34
|
+
// 插件阶段不得再打印用户引导
|
|
35
|
+
const converge = src.slice(src.indexOf("function convergePluginActivation"), src.indexOf("async function pluginCmd"));
|
|
36
|
+
assert.ok(!/下一步|打开 dsh web → 设置/.test(converge), "插件阶段不应再重复打印引导");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("安装输出:「已创建自启动服务」路径只在汇总里打印一次", () => {
|
|
40
|
+
const createMsgs = src.match(/已创建自启动服务/g) || [];
|
|
41
|
+
assert.equal(createMsgs.length, 0, "创建路径不再单独打印(汇总里统一展示),避免同一信息两遍");
|
|
42
|
+
assert.ok(/自启动服务: \$\{svc\.path/.test(src), "汇总里应展示自启动服务路径");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("安装输出:dsh web 未运行时的措辞 —— 说明会自动启动,且不出现「启动失败」误导", () => {
|
|
46
|
+
assert.ok(/async function isDshWebUp/.test(src), "应探测 dsh web 是否在运行(127.0.0.1:3080)");
|
|
47
|
+
const block = src.slice(src.indexOf("const webUp = await isDshWebUp();"));
|
|
48
|
+
// 未运行分支必须明说"会自动启动"
|
|
49
|
+
assert.ok(/dsh web 当前没有运行/.test(block), "未运行分支应直说检测结果");
|
|
50
|
+
assert.ok(/自动启动,无需任何命令/.test(block), "未运行分支应说明何时自动启动");
|
|
51
|
+
assert.ok(/不是安装出错/.test(block), "未运行分支应明确否定「安装出错」");
|
|
52
|
+
// 未运行分支的**用户可见文案**里不能出现「失败」(真失败分支才有)
|
|
53
|
+
const notUpBranch = block.slice(block.indexOf("if (!st.ok && !webUp)"), block.indexOf("} else if (!st.ok) {"));
|
|
54
|
+
// 只看 L.push(...) 的实参(注释里的说明文字不算用户可见文案)
|
|
55
|
+
const texts = (notUpBranch.match(/L\.push\((\s*)"([^"]*)"/g) || [])
|
|
56
|
+
.map((m) => (m.match(/"([^"]*)"/) || [])[1]).join("\n");
|
|
57
|
+
assert.ok(texts.length > 0, "该分支应有用户可见文案");
|
|
58
|
+
assert.ok(!/失败/.test(texts), `dsh web 未运行≠bridge 启动失败,该分支文案不得出现「失败」:\n${texts}`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("安装输出:真失败(dsh web 在运行但 bridge 没起来)必须给原因与日志路径", () => {
|
|
62
|
+
const block = src.slice(src.indexOf("const webUp = await isDshWebUp();"));
|
|
63
|
+
const failBranch = block.slice(block.indexOf("else if (!st.ok)"), block.indexOf("if (selfHosted) {", block.indexOf("else if (!st.ok)")));
|
|
64
|
+
assert.ok(/bridge 未能启动/.test(failBranch), "真失败分支要明说未能启动");
|
|
65
|
+
assert.ok(/\.dsh-bridge\.log/.test(failBranch), "真失败分支要给出日志路径供排查");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("安装输出:服务状态字符串仍是插件面板可解析的稳定契约", () => {
|
|
69
|
+
// 插件半按 running / 未运行 解析,不能因为排版调整改口径
|
|
70
|
+
assert.ok(/\? `✅ 运行中/.test(src), "running 口径保留(带 pid)");
|
|
71
|
+
assert.ok(/`未运行 \(\$\{st\.detail \|\| st\.status\}\)`/.test(src), "未运行口径保留(带原因)");
|
|
72
|
+
});
|