@mrrisega/dsh-remote 0.6.3 → 0.6.4-beta.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.
- package/README.md +7 -1
- package/clients/dsh-remote/dsh-bridge.mjs +85 -2
- package/clients/dsh-remote/test/harness-cookie-selfheal.test.mjs +118 -0
- package/dsh-setup.mjs +15 -2
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +163 -4
- package/packages/dsh-remote-web/lib/index.js +646 -26
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +516 -0
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +415 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// 「登录后自动闭环」UI 回归(浏览器半,0.6.4;真实 useEffect + 可控假定时器 + 可控 document.hidden)。
|
|
2
|
+
//
|
|
3
|
+
// 现场(2026-09 生产数据):注册后手机端一直是空设备列表,用户在设置面板反复点「生成访问链接」
|
|
4
|
+
// (auth.key_create 10~23 次)却始终没有 device.bind——他们看不到「到底卡在哪一步」,也不知道
|
|
5
|
+
// 要不要刷新页面。本用例锁死面板侧的自动闭环:
|
|
6
|
+
// ① 登录后立刻进入启动/连接流程(2.5s 短轮询自动推进,不需要点任何按钮);
|
|
7
|
+
// ② 阶段推进到 online → UI 自动变成「已连接 ✅ 现在可以用手机扫码访问」,并自动重取二维码与
|
|
8
|
+
// 已授权设备列表(全程不刷新页面);
|
|
9
|
+
// ③ error 阶段必须有可操作项:重试 / 复制诊断信息 / 日志路径(不留死胡同);
|
|
10
|
+
// ④ document.hidden 时短轮询暂停(后台标签页不空转),回前台立即补一次;online 后退避到 15s;
|
|
11
|
+
// ⑤ 面板注册带注册来源 reg_source=panel_register(增长口径)。
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import vm from "node:vm";
|
|
16
|
+
|
|
17
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
18
|
+
|
|
19
|
+
function walk(node, visit) {
|
|
20
|
+
if (node == null) return;
|
|
21
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
22
|
+
if (typeof node !== "object") return;
|
|
23
|
+
visit(node);
|
|
24
|
+
for (const child of node.children || []) walk(child, visit);
|
|
25
|
+
}
|
|
26
|
+
function find(tree, predicate) {
|
|
27
|
+
let match;
|
|
28
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
29
|
+
return match;
|
|
30
|
+
}
|
|
31
|
+
function textHas(tree, substr) {
|
|
32
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
33
|
+
}
|
|
34
|
+
function nodeWithText(tree, substr) {
|
|
35
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
36
|
+
}
|
|
37
|
+
/** 按 placeholder 取输入框并注入值(走真实 onChange,与用户输入同路径)。 */
|
|
38
|
+
function typeInto(tree, placeholder, value) {
|
|
39
|
+
const el = find(tree, (node) => node.type === "input" && node.props && node.props.placeholder === placeholder);
|
|
40
|
+
assert.ok(el, "应找到 placeholder 为「" + placeholder + "」的输入框");
|
|
41
|
+
el.props.onChange({ target: { value } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
45
|
+
|
|
46
|
+
const NOT_LOGGED = { ok: true, config: { phone: "", hasPhone: false, mode: "saas", deviceId: "" }, service: { running: false }, remoteUrl: "https://app.test/" };
|
|
47
|
+
const LOGGED = { ok: true, config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" }, service: { running: false }, remoteUrl: "https://app.test/" };
|
|
48
|
+
const KEY_URL = "https://app.test/a/K1";
|
|
49
|
+
const SESSIONS = [{ id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null }];
|
|
50
|
+
|
|
51
|
+
function connectPayload(phase, extra) {
|
|
52
|
+
return Object.assign({
|
|
53
|
+
phase,
|
|
54
|
+
online: phase === "online",
|
|
55
|
+
text: {
|
|
56
|
+
no_account: "请先登录手机号账号(下方「🔑 账号」卡片),登录后会自动完成剩余步骤",
|
|
57
|
+
no_runtime: "正在准备运行环境(首次约 1~2 分钟)…",
|
|
58
|
+
installing: "正在准备运行环境(首次约 1~2 分钟)…",
|
|
59
|
+
starting: "正在启动 Bridge…",
|
|
60
|
+
connecting: "正在连接中继…",
|
|
61
|
+
online: "已连接 ✅ 现在可以用手机扫码访问",
|
|
62
|
+
error: "连接失败,请按下方提示处理",
|
|
63
|
+
}[phase],
|
|
64
|
+
detail: "",
|
|
65
|
+
retryable: true,
|
|
66
|
+
deviceId: "dev-x",
|
|
67
|
+
runtimeReady: true,
|
|
68
|
+
installing: phase === "installing",
|
|
69
|
+
bridgeRunning: phase !== "starting" && phase !== "no_runtime",
|
|
70
|
+
registered: phase === "online",
|
|
71
|
+
registerSource: phase === "online" ? "account_api" : "",
|
|
72
|
+
attempts: 0,
|
|
73
|
+
nextRetryInMs: 0,
|
|
74
|
+
error: null,
|
|
75
|
+
logPath: "/home/u/.dsh-remote/.dsh-bridge.log",
|
|
76
|
+
installLogPath: "/home/u/.dsh-remote/.dsh-setup-install.log",
|
|
77
|
+
diagnostics: "dsh-remote 连接诊断\n插件版本: 0.6.3\n连接阶段: " + phase,
|
|
78
|
+
}, extra || {});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 迷你 React(真实 useEffect:依赖比较 + 提交后执行 + cleanup)+ 可控假定时器 +
|
|
83
|
+
* 可控 document.hidden / visibilitychange(用于验证「隐藏即暂停、回前台立即刷新」)。
|
|
84
|
+
*/
|
|
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
|
+
const listeners = new Map();
|
|
97
|
+
|
|
98
|
+
let clock = 0;
|
|
99
|
+
let timerSeq = 1;
|
|
100
|
+
const timers = new Map();
|
|
101
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 内部错误由断言暴露 */ } };
|
|
102
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
103
|
+
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; };
|
|
104
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
105
|
+
async function advance(ms) {
|
|
106
|
+
const target = clock + ms;
|
|
107
|
+
for (let guard = 0; guard < 2000; guard++) {
|
|
108
|
+
let due = null;
|
|
109
|
+
for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
|
|
110
|
+
if (!due) break;
|
|
111
|
+
const [id, t] = due;
|
|
112
|
+
clock = t.at;
|
|
113
|
+
if (t.every) t.at = clock + t.every; else timers.delete(id);
|
|
114
|
+
job(t.fn);
|
|
115
|
+
await flush();
|
|
116
|
+
}
|
|
117
|
+
clock = target;
|
|
118
|
+
await flush(); await flush();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const react = {
|
|
122
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
123
|
+
useState(initial) {
|
|
124
|
+
const index = hook++;
|
|
125
|
+
if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial;
|
|
126
|
+
return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
|
|
127
|
+
},
|
|
128
|
+
useEffect(fn, deps) {
|
|
129
|
+
const index = effectCursor++;
|
|
130
|
+
const prev = effectSlots[index];
|
|
131
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
132
|
+
if (changed) pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
133
|
+
},
|
|
134
|
+
useCallback(fn) { return fn; },
|
|
135
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const response = (status, body) => Promise.resolve({
|
|
139
|
+
ok: status >= 200 && status < 300,
|
|
140
|
+
status,
|
|
141
|
+
text: async () => JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
let statusBody = opts.status || NOT_LOGGED;
|
|
145
|
+
let connBody = opts.connect === undefined ? null : connectPayload(opts.connect);
|
|
146
|
+
let connCalls = 0;
|
|
147
|
+
let keyCalls = 0;
|
|
148
|
+
let sessCalls = 0;
|
|
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(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
|
|
156
|
+
removeEventListener(type, fn) {
|
|
157
|
+
const list = listeners.get(type) || [];
|
|
158
|
+
const i = list.indexOf(fn);
|
|
159
|
+
if (i >= 0) list.splice(i, 1);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const doc = {
|
|
163
|
+
hidden: false,
|
|
164
|
+
createElement: makeEl,
|
|
165
|
+
head: makeEl(),
|
|
166
|
+
body: makeEl(),
|
|
167
|
+
querySelector() { return null; },
|
|
168
|
+
querySelectorAll() { return []; },
|
|
169
|
+
getElementById() { return null; },
|
|
170
|
+
addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
|
|
171
|
+
removeEventListener(type, fn) {
|
|
172
|
+
const list = listeners.get(type) || [];
|
|
173
|
+
const i = list.indexOf(fn);
|
|
174
|
+
if (i >= 0) list.splice(i, 1);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
178
|
+
const localStorage = {
|
|
179
|
+
_s: new Map(),
|
|
180
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
181
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
182
|
+
removeItem(k) { this._s.delete(k); },
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const sandbox = {
|
|
186
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
187
|
+
document: doc,
|
|
188
|
+
localStorage,
|
|
189
|
+
MutationObserver: MutationObserverMock,
|
|
190
|
+
navigator: { clipboard: { writeText: async (t) => { clipboard.push(String(t)); } } },
|
|
191
|
+
setTimeout: fakeSetTimeout,
|
|
192
|
+
clearTimeout: fakeClear,
|
|
193
|
+
setInterval: fakeSetInterval,
|
|
194
|
+
clearInterval: fakeClear,
|
|
195
|
+
fetch(path, options = {}) {
|
|
196
|
+
requests.push({ path, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
|
|
197
|
+
if (path === "/dsh-remote/status") return response(200, statusBody);
|
|
198
|
+
if (path === "/dsh-remote/bridge-status") {
|
|
199
|
+
connCalls += 1;
|
|
200
|
+
return response(200, connBody ? { ok: true, connect: connBody } : { ok: true });
|
|
201
|
+
}
|
|
202
|
+
if (path === "/dsh-remote/connect/retry") {
|
|
203
|
+
return response(200, { ok: true, retried: true, connect: connBody || null });
|
|
204
|
+
}
|
|
205
|
+
if (path === "/dsh-remote/access-key") {
|
|
206
|
+
keyCalls += 1;
|
|
207
|
+
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" });
|
|
208
|
+
}
|
|
209
|
+
if (path === "/dsh-remote/mobile-sessions") {
|
|
210
|
+
sessCalls += 1;
|
|
211
|
+
return response(200, { ok: true, sessions: opts.sessions || SESSIONS });
|
|
212
|
+
}
|
|
213
|
+
if (path === "/dsh-remote/captcha") return response(200, { captcha_id: "cap-1", svg: "<svg></svg>" });
|
|
214
|
+
if (path === "/dsh-remote/register") return response(201, { ok: true, token: "jwt-reg" });
|
|
215
|
+
if (path === "/dsh-remote/config") return response(200, statusBody);
|
|
216
|
+
if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "138****0000", plan: "free", plan_source: "plan" } });
|
|
217
|
+
if (path === "/dsh-remote/quota") return response(200, { ok: true, quota: null });
|
|
218
|
+
if (path === "/dsh-remote/remote-url") return response(200, { ok: true, remoteUrl: "https://app.test/", publicConfig: {} });
|
|
219
|
+
return response(200, { ok: true });
|
|
220
|
+
},
|
|
221
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
225
|
+
const plugin = moduleFactory((name) => {
|
|
226
|
+
assert.equal(name, "react");
|
|
227
|
+
return react;
|
|
228
|
+
});
|
|
229
|
+
plugin.apply({
|
|
230
|
+
slots: {
|
|
231
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
232
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
requests, states, clipboard,
|
|
238
|
+
counts() { return { connCalls, keyCalls, sessCalls }; },
|
|
239
|
+
/** 渲染一轮并执行本轮需要运行的 effects(含 cleanup)。 */
|
|
240
|
+
render() {
|
|
241
|
+
hook = 0; effectCursor = 0; pending = [];
|
|
242
|
+
const tree = registered.get("dsh-remote")({ close() {} });
|
|
243
|
+
const todos = pending; pending = [];
|
|
244
|
+
for (const t of todos) {
|
|
245
|
+
const prev = effectSlots[t.index];
|
|
246
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
247
|
+
const cleanup = t.fn();
|
|
248
|
+
effectSlots[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
249
|
+
}
|
|
250
|
+
return tree;
|
|
251
|
+
},
|
|
252
|
+
async settle(times = 3) {
|
|
253
|
+
for (let i = 0; i < times; i++) { this.render(); await flush(); await flush(); }
|
|
254
|
+
return this.render();
|
|
255
|
+
},
|
|
256
|
+
advance,
|
|
257
|
+
/** 登录成功(面板读到登录态)→ 登录态 effect 重跑,自动开始推进连接。 */
|
|
258
|
+
async login() {
|
|
259
|
+
statusBody = LOGGED;
|
|
260
|
+
await advance(30_000); // 等 30s 状态轮询把登录态带进面板
|
|
261
|
+
return this.settle();
|
|
262
|
+
},
|
|
263
|
+
/** 连接阶段推进(node 半短轮询的应答变化)。 */
|
|
264
|
+
setConnect(phase, extra) { connBody = connectPayload(phase, extra); },
|
|
265
|
+
setConnectRaw(body) { connBody = body; },
|
|
266
|
+
hide() { doc.hidden = true; },
|
|
267
|
+
show() { doc.hidden = false; for (const fn of listeners.get("visibilitychange") || []) job(fn); },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test("登录后自动进入连接流程:无需点按钮,短轮询 2.5s 自动推进并显示阶段文案", async () => {
|
|
272
|
+
const plugin = loadPlugin({ connect: "starting" });
|
|
273
|
+
let tree = await plugin.settle();
|
|
274
|
+
assert.equal(plugin.counts().connCalls, 0, "未登录不该轮询连接阶段");
|
|
275
|
+
|
|
276
|
+
tree = await plugin.login();
|
|
277
|
+
assert.ok(textHas(tree, "正在启动 Bridge…"), "登录后应自动显示「正在启动 Bridge…」");
|
|
278
|
+
const after = plugin.counts().connCalls;
|
|
279
|
+
assert.ok(after >= 1, "登录后应立即拉一次连接阶段");
|
|
280
|
+
|
|
281
|
+
// 短轮询自动推进:2.5s 一轮(用户什么都不用做)
|
|
282
|
+
await plugin.advance(2600);
|
|
283
|
+
assert.ok(plugin.counts().connCalls > after, "非 online 阶段应 2.5s 短轮询自动推进");
|
|
284
|
+
tree = plugin.render();
|
|
285
|
+
assert.ok(textHas(tree, "正在启动 Bridge…"), "阶段文案应持续可见");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("阶段推进到 online:UI 自动变化并自动重取二维码/设备列表(无需刷新页面)", async () => {
|
|
289
|
+
const plugin = loadPlugin({ connect: "connecting" });
|
|
290
|
+
await plugin.settle();
|
|
291
|
+
let tree = await plugin.login();
|
|
292
|
+
assert.ok(textHas(tree, "正在连接中继…"), "先显示「正在连接中继…」");
|
|
293
|
+
const keyBefore = plugin.counts().keyCalls;
|
|
294
|
+
const sessBefore = plugin.counts().sessCalls;
|
|
295
|
+
|
|
296
|
+
// node 半下一次返回 online(设备已在中继注册成功)
|
|
297
|
+
plugin.setConnect("online");
|
|
298
|
+
await plugin.advance(2600);
|
|
299
|
+
tree = await plugin.settle();
|
|
300
|
+
|
|
301
|
+
assert.ok(textHas(tree, "已连接 ✅ 现在可以用手机扫码访问"), "阶段推进后 UI 应自动变成「已连接 ✅」");
|
|
302
|
+
assert.ok(plugin.counts().keyCalls > keyBefore, "进入 online 应自动重取一次性访问链接(不用点「生成访问链接」)");
|
|
303
|
+
assert.ok(plugin.counts().sessCalls > sessBefore, "进入 online 应自动刷新已授权设备列表");
|
|
304
|
+
assert.ok(find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "应直接渲染出可扫码的二维码");
|
|
305
|
+
assert.ok(textHas(tree, "设备已登记到你的账号"), "应说明设备已登记到账号(手机端能看到)");
|
|
306
|
+
assert.ok(!textHas(tree, "正在连接中继…"), "旧阶段文案应被自动替换");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("error 阶段:给出重试 + 复制诊断信息 + 日志路径,点重试走 /connect/retry(不留死胡同)", async () => {
|
|
310
|
+
const plugin = loadPlugin({
|
|
311
|
+
connect: "error",
|
|
312
|
+
status: LOGGED,
|
|
313
|
+
});
|
|
314
|
+
plugin.setConnect("error", {
|
|
315
|
+
error: { code: "launchd_crash", message: "后台服务反复启动失败(launchd state=spawn scheduled,lastExit=1)" },
|
|
316
|
+
detail: "已自动清理失效的自启动项并重新拉起,稍候会自动恢复。",
|
|
317
|
+
attempts: 3,
|
|
318
|
+
nextRetryInMs: 8000,
|
|
319
|
+
});
|
|
320
|
+
await plugin.settle();
|
|
321
|
+
let tree = await plugin.login();
|
|
322
|
+
|
|
323
|
+
assert.ok(textHas(tree, "后台服务反复启动失败"), "应显示可读的失败原因");
|
|
324
|
+
assert.ok(textHas(tree, "已自动重试 3 次"), "应告诉用户已自动重试过(不是让用户干等)");
|
|
325
|
+
assert.ok(textHas(tree, "秒后自动再试"), "应给出自动重试倒计时");
|
|
326
|
+
assert.ok(textHas(tree, ".dsh-bridge.log"), "应给出「查看日志」的路径");
|
|
327
|
+
const retryBtn = nodeWithText(tree, "重试");
|
|
328
|
+
const diagBtn = nodeWithText(tree, "复制诊断信息");
|
|
329
|
+
assert.ok(retryBtn, "error 阶段必须提供「重试」按钮");
|
|
330
|
+
assert.ok(diagBtn, "error 阶段必须提供「复制诊断信息」按钮");
|
|
331
|
+
|
|
332
|
+
// 复制诊断信息 → 剪贴板拿到 node 半拼好的诊断块
|
|
333
|
+
diagBtn.props.onClick();
|
|
334
|
+
await flush(); await flush();
|
|
335
|
+
assert.equal(plugin.clipboard.length, 1, "应把诊断信息写进剪贴板");
|
|
336
|
+
assert.match(plugin.clipboard[0], /dsh-remote 连接诊断/);
|
|
337
|
+
assert.match(plugin.clipboard[0], /连接阶段: error/);
|
|
338
|
+
|
|
339
|
+
// 点重试 → POST /dsh-remote/connect/retry,并按新状态重渲染
|
|
340
|
+
plugin.setConnect("starting");
|
|
341
|
+
retryBtn.props.onClick();
|
|
342
|
+
await flush(); await flush();
|
|
343
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/connect/retry" && r.method === "POST"), "点重试应调用 /dsh-remote/connect/retry");
|
|
344
|
+
tree = await plugin.settle();
|
|
345
|
+
assert.ok(textHas(tree, "正在启动 Bridge…"), "重试后应显示新的阶段状态");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("轮询策略:document.hidden 时暂停短轮询,回前台立即补一次;online 后退避到 15s", async () => {
|
|
349
|
+
const plugin = loadPlugin({ connect: "connecting" });
|
|
350
|
+
await plugin.settle();
|
|
351
|
+
await plugin.login();
|
|
352
|
+
const base = plugin.counts().connCalls;
|
|
353
|
+
|
|
354
|
+
// 后台标签页:完全不发请求(不空转)
|
|
355
|
+
plugin.hide();
|
|
356
|
+
await plugin.advance(20_000);
|
|
357
|
+
assert.equal(plugin.counts().connCalls, base, "页面隐藏时不得继续轮询");
|
|
358
|
+
|
|
359
|
+
// 回前台:立即补一次(用户切回来就看到最新状态,不需要刷新页面)
|
|
360
|
+
plugin.show();
|
|
361
|
+
await flush(); await flush();
|
|
362
|
+
assert.equal(plugin.counts().connCalls, base + 1, "回前台应立刻刷新一次");
|
|
363
|
+
|
|
364
|
+
// 仍非 online → 继续 2.5s 短轮询
|
|
365
|
+
const fast = plugin.counts().connCalls;
|
|
366
|
+
await plugin.advance(2600);
|
|
367
|
+
assert.ok(plugin.counts().connCalls > fast, "非 online 阶段应继续 2.5s 短轮询");
|
|
368
|
+
|
|
369
|
+
// 进入 online → 退避:12s 窗口内最多再补一次(2.5s 节奏会打 4~5 次),随后按 15s 慢节奏保活
|
|
370
|
+
plugin.setConnect("online");
|
|
371
|
+
await plugin.advance(2600);
|
|
372
|
+
const onlineCalls = plugin.counts().connCalls;
|
|
373
|
+
await plugin.advance(12_000);
|
|
374
|
+
const extra = plugin.counts().connCalls - onlineCalls;
|
|
375
|
+
assert.ok(extra <= 2, `online 后应退避(12s 内实际 ${extra} 次)`);
|
|
376
|
+
await plugin.advance(16_000);
|
|
377
|
+
assert.ok(plugin.counts().connCalls > onlineCalls + extra, "online 后仍应按 ~15s 节奏保活(新设备扫码后列表能自动出现)");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("面板注册带注册来源 reg_source=panel_register(增长口径:电脑端 vs 手机端网页注册)", async () => {
|
|
381
|
+
const plugin = loadPlugin({ status: NOT_LOGGED });
|
|
382
|
+
let tree = await plugin.settle();
|
|
383
|
+
const regTab = nodeWithText(tree, "注册");
|
|
384
|
+
assert.ok(regTab, "应有「注册」标签");
|
|
385
|
+
regTab.props.onClick();
|
|
386
|
+
tree = await plugin.settle();
|
|
387
|
+
|
|
388
|
+
typeInto(tree, "11 位手机号", "13800000000");
|
|
389
|
+
typeInto(tree, "至少 8 位", "password123");
|
|
390
|
+
typeInto(tree, "再次输入密码", "password123");
|
|
391
|
+
typeInto(tree, "6 位验证码", "218937");
|
|
392
|
+
tree = await plugin.settle();
|
|
393
|
+
|
|
394
|
+
// 提交按钮(不是上面的「注册」标签页):className 带 dru-btn-primary 且文本恰为「注册」
|
|
395
|
+
const submit = find(tree, (n) => typeof n.props?.onClick === "function"
|
|
396
|
+
&& String(n.props.className || "").includes("dru-btn-primary")
|
|
397
|
+
&& (n.children || []).some((c) => c === "注册"));
|
|
398
|
+
assert.ok(submit, "应能找到注册提交按钮");
|
|
399
|
+
submit.props.onClick();
|
|
400
|
+
await flush(); await flush();
|
|
401
|
+
|
|
402
|
+
const req = plugin.requests.filter((r) => r.path === "/dsh-remote/register").pop();
|
|
403
|
+
assert.ok(req, "应提交 /dsh-remote/register");
|
|
404
|
+
assert.equal(req.body.reg_source, "panel_register", "面板注册必须带 reg_source=panel_register");
|
|
405
|
+
assert.equal(req.body.phone, "13800000000");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("旧版 host(响应无 connect 字段):回退既有文案,行为不变", async () => {
|
|
409
|
+
const plugin = loadPlugin({ status: LOGGED });
|
|
410
|
+
plugin.setConnectRaw(undefined); // /bridge-status 返回 {ok:true}(没有 connect)
|
|
411
|
+
await plugin.settle();
|
|
412
|
+
const tree = await plugin.login();
|
|
413
|
+
assert.ok(textHas(tree, "等待设备连接"), "没有 connect 时回退到既有「等待设备连接」文案");
|
|
414
|
+
assert.ok(!textHas(tree, "正在启动 Bridge…"), "不应伪造阶段文案");
|
|
415
|
+
});
|