@mrrisega/dsh-remote 0.6.0 → 0.6.1

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,307 @@
1
+ // 首次安装后「立即登录」面板自愈 的回归(浏览器半,真实 useEffect + 假定时器)。
2
+ //
3
+ // 现场:登录成功(loggedIn 翻真)后,面板立刻请求二维码与已授权设备列表;此时中继握手可能还没就绪
4
+ // (bridge 刚被重启、device-login 共享密钥刚补齐/正在轮换),旧版只显示一次红字、**不重试**,
5
+ // 必须手动刷新页面才恢复。本用例锁死新的自愈行为:
6
+ // ① 登录后自动发起 access-key + mobile-sessions;
7
+ // ② 首次失败(503 retryable) → 黄字「自动重试」+「立即重试」按钮(不吓人、可操作);
8
+ // ③ 退避到点自动重试 → 成功即自动清除提示、渲染二维码与设备列表(无需刷新页面);
9
+ // ④ 未登录时绝不请求企业端、也不出现误导性红字。
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
+ /** 取包含某文案的节点(用于点它的 onClick)。 */
33
+ function nodeWithText(tree, substr) {
34
+ return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
35
+ }
36
+
37
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
38
+ const NOT_LOGGED = { ok: true, config: { phone: "", hasPhone: false, mode: "saas", deviceId: "dev-x" }, 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
+
43
+ /**
44
+ * 迷你 React(真实 useEffect:依赖比较 + 提交后执行 + cleanup)+ 可控假定时器。
45
+ * @param {object} opts - { failAccessKey:[次数], failSessions:[次数], status: 状态响应 }
46
+ */
47
+ function loadPlugin(opts = {}) {
48
+ let moduleFactory;
49
+ const registered = new Map();
50
+ const injects = new Map();
51
+ const requests = [];
52
+ const states = [];
53
+ let hook = 0;
54
+ let effectCursor = 0;
55
+ let effectSlots = [];
56
+ let pending = [];
57
+
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 < 400; 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(); 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
+ // 企业端代理响应:可控失败次数 + 默认成功
100
+ let statusBody = opts.status || NOT_LOGGED;
101
+ let keyFails = opts.failAccessKey || 0;
102
+ let sessFails = opts.failSessions || 0;
103
+ let keyCalls = 0;
104
+ let sessCalls = 0;
105
+ const response = (status, body) => Promise.resolve({
106
+ ok: status >= 200 && status < 300,
107
+ status,
108
+ text: async () => JSON.stringify(body),
109
+ });
110
+ const NOT_READY = opts.failBody || { ok: false, error: "账号已登录,但中继连接尚未就绪(正在建立安全通道),请稍后重试", hint: "relay_not_ready", retryable: true };
111
+ const FAIL_STATUS = opts.failStatus || 503;
112
+
113
+ const makeEl = () => ({
114
+ tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
115
+ setAttribute(k, v) { this.attributes[k] = v; },
116
+ getAttribute(k) { return this.attributes[k] ?? null; },
117
+ appendChild(c) { this.children.push(c); },
118
+ addEventListener() {}, removeEventListener() {},
119
+ });
120
+ const doc = {
121
+ hidden: false,
122
+ createElement: makeEl,
123
+ head: makeEl(),
124
+ body: makeEl(),
125
+ querySelector() { return null; },
126
+ querySelectorAll() { return []; },
127
+ getElementById() { return null; },
128
+ addEventListener() {}, removeEventListener() {},
129
+ };
130
+ class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
131
+ const localStorage = {
132
+ _s: new Map(),
133
+ getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
134
+ setItem(k, v) { this._s.set(k, String(v)); },
135
+ removeItem(k) { this._s.delete(k); },
136
+ };
137
+
138
+ const sandbox = {
139
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
140
+ document: doc,
141
+ localStorage,
142
+ MutationObserver: MutationObserverMock,
143
+ navigator: { clipboard: { writeText: async () => {} } },
144
+ setTimeout: fakeSetTimeout,
145
+ clearTimeout: fakeClear,
146
+ setInterval: fakeSetInterval,
147
+ clearInterval: fakeClear,
148
+ fetch(path, options = {}) {
149
+ requests.push({ path, method: options.method || "GET" });
150
+ if (path === "/dsh-remote/status") return response(200, statusBody);
151
+ if (path === "/dsh-remote/access-key") {
152
+ keyCalls += 1;
153
+ if (keyCalls <= keyFails) return response(FAIL_STATUS, NOT_READY);
154
+ 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" });
155
+ }
156
+ if (path === "/dsh-remote/mobile-sessions") {
157
+ sessCalls += 1;
158
+ if (sessCalls <= sessFails) return response(FAIL_STATUS, NOT_READY);
159
+ return response(200, { ok: true, sessions: SESSIONS });
160
+ }
161
+ if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "138****0000", plan: "free", plan_source: "plan" } });
162
+ if (path === "/dsh-remote/quota") return response(200, { ok: true, quota: null });
163
+ if (path === "/dsh-remote/remote-url") return response(200, { ok: true, remoteUrl: "https://app.test/", publicConfig: {} });
164
+ return response(200, { ok: true });
165
+ },
166
+ Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
167
+ };
168
+
169
+ vm.runInNewContext(SOURCE, sandbox);
170
+ const plugin = moduleFactory((name) => {
171
+ assert.equal(name, "react");
172
+ return react;
173
+ });
174
+ plugin.apply({
175
+ slots: {
176
+ inject(name, cb) { injects.set(name, cb); cb(); },
177
+ register(meta, component) { registered.set(meta.id, component); return () => {}; },
178
+ },
179
+ });
180
+
181
+ return {
182
+ requests,
183
+ states,
184
+ /** 渲染一轮并执行本轮需要运行的 effects(含 cleanup)。 */
185
+ render() {
186
+ hook = 0; effectCursor = 0; pending = [];
187
+ const tree = registered.get("dsh-remote")({ close() {} });
188
+ const todos = pending; pending = [];
189
+ for (const t of todos) {
190
+ const prev = effectSlots[t.index];
191
+ if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
192
+ const cleanup = t.fn();
193
+ effectSlots[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
194
+ }
195
+ return tree;
196
+ },
197
+ advance,
198
+ /** 渲染 → 让在途请求落地 → 再渲染(返回最新树),模拟真实的重渲染节奏。 */
199
+ async settle(times = 2) {
200
+ let tree = null;
201
+ for (let i = 0; i < times; i++) {
202
+ tree = this.render();
203
+ await flush(); await flush();
204
+ }
205
+ return this.render();
206
+ },
207
+ /** 模拟「账号登录成功」:改写状态响应 + 推进 30s 轮询心跳,让面板看到登录态。 */
208
+ async login() {
209
+ statusBody = LOGGED;
210
+ await advance(30_000);
211
+ return this.settle();
212
+ },
213
+ counts() { return { keyCalls, sessCalls }; },
214
+ };
215
+ }
216
+
217
+ test("未登录:不请求企业端、不出误导性红字;登录后自动拉取二维码与设备列表", async () => {
218
+ const plugin = loadPlugin();
219
+ let tree = await plugin.settle(); // 挂载 → refresh() 读状态
220
+
221
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/access-key"), "未登录不得请求二维码接口");
222
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions"), "未登录不得请求设备列表");
223
+ assert.ok(textHas(tree, "登录下方「🔑 账号」卡片中的手机号账号后"), "未登录应给登录引导而非报错");
224
+ assert.ok(!textHas(tree, "尚未登录"), "未登录也不该出现接口报错红字");
225
+
226
+ tree = await plugin.login();
227
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/access-key"), "登录后应自动请求一次性访问地址");
228
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions"), "登录后应自动请求已授权设备列表");
229
+ assert.ok(find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "登录后应直接渲染二维码");
230
+ });
231
+
232
+ test("登录瞬间中继未就绪:首次失败自动重试,退避到点即恢复(无需刷新页面)", async () => {
233
+ const plugin = loadPlugin({ failAccessKey: 1, failSessions: 1 });
234
+ let tree = await plugin.settle();
235
+ tree = await plugin.login();
236
+
237
+ // 首次失败:黄字提示 + 可点的「立即重试」,而不是死红字(旧版行为)
238
+ assert.equal(plugin.counts().keyCalls, 1, "登录后第一次请求二维码");
239
+ assert.equal(plugin.counts().sessCalls, 1, "登录后第一次请求设备列表");
240
+ assert.ok(textHas(tree, "中继连接尚未就绪"), "失败应给「中继连接尚未就绪」的可重试提示");
241
+ assert.ok(textHas(tree, "秒后自动重试"), "应说明会自动重试");
242
+ assert.ok(nodeWithText(tree, "立即重试"), "提示旁应提供「立即重试」按钮");
243
+ assert.ok(!find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "首次失败时还没有二维码");
244
+
245
+ // 退避 1.2s 后自动重试 → 成功 → 提示清除、二维码与设备列表就位
246
+ await plugin.advance(1300);
247
+ assert.equal(plugin.counts().keyCalls, 2, "退避到点应自动重试二维码");
248
+ assert.equal(plugin.counts().sessCalls, 2, "退避到点应自动重试设备列表");
249
+
250
+ tree = await plugin.settle();
251
+ assert.ok(find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "重试成功后应渲染二维码");
252
+ assert.ok(textHas(tree, "已授权设备 1"), "重试成功后设备列表应加载完成(无需手动展开/刷新)");
253
+ assert.ok(!textHas(tree, "中继连接尚未就绪"), "成功后应自动清除重试提示");
254
+ assert.ok(!textHas(tree, "加载已授权设备失败"), "成功路径上不得残留红字错误");
255
+ });
256
+
257
+ test("点「立即重试」:不等退避窗口,立刻重发并恢复", async () => {
258
+ const plugin = loadPlugin({ failAccessKey: 1, failSessions: 1 });
259
+ await plugin.settle();
260
+ const tree0 = await plugin.login();
261
+ assert.equal(plugin.counts().keyCalls, 1);
262
+
263
+ const retryBtn = nodeWithText(tree0, "立即重试");
264
+ assert.ok(retryBtn, "应能找到「立即重试」按钮");
265
+ retryBtn.props.onClick();
266
+ const tree = await plugin.settle();
267
+
268
+ assert.equal(plugin.counts().keyCalls, 2, "手动重试应立即重发二维码请求");
269
+ assert.ok(find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "手动重试后应渲染二维码");
270
+ });
271
+
272
+ test("持续失败:退避重试上限后停在红字并保留「立即重试」,不无限刷请求", async () => {
273
+ const plugin = loadPlugin({ failAccessKey: 99, failSessions: 99 });
274
+ await plugin.settle();
275
+ await plugin.login();
276
+ await plugin.advance(2000); // 第 1 次自动重试
277
+ await plugin.advance(4000); // 第 2 次
278
+ await plugin.advance(8000); // 第 3 次
279
+ await plugin.advance(60_000); // 之后不再自动重试(除 25s 轮换)
280
+ const after = plugin.counts();
281
+ assert.ok(after.sessCalls <= 4, `设备列表重试应有上限(实际 ${after.sessCalls})`);
282
+ const tree = await plugin.settle();
283
+ assert.ok(nodeWithText(tree, "立即重试"), "失败后应保留可操作的重试入口");
284
+ });
285
+
286
+ test("不可重试失败(401 密码失效 / 未登录):直接红字提示,不做无意义重试", async () => {
287
+ const plugin = loadPlugin({
288
+ failAccessKey: 99,
289
+ failSessions: 99,
290
+ failStatus: 401,
291
+ failBody: { ok: false, error: "本机保存的账号密码已被中继拒绝:请用新密码重新登录", hint: "relogin_required", retryable: false },
292
+ });
293
+ await plugin.settle();
294
+ await plugin.login();
295
+ assert.equal(plugin.counts().sessCalls, 1);
296
+
297
+ await plugin.advance(10_000); // 远超退避窗口(但短于 25s 的二维码周期轮换)
298
+ assert.equal(plugin.counts().sessCalls, 1, "不可重试类失败不得自动重发(设备列表)");
299
+ assert.equal(plugin.counts().keyCalls, 1, "不可重试类失败不得自动重发(二维码)");
300
+ await plugin.advance(20_000); // 25s 轮换后设备列表仍不自动重发(仅二维码按周期换新)
301
+ assert.equal(plugin.counts().sessCalls, 1, "设备列表没有周期轮换,失败后不得空转");
302
+
303
+ const tree = await plugin.settle();
304
+ assert.ok(textHas(tree, "加载已授权设备失败:本机保存的账号密码已被中继拒绝"), "应显示真实原因红字");
305
+ assert.ok(textHas(tree, "获取一次性访问地址失败:本机保存的账号密码已被中继拒绝"), "二维码卡同样显示真实原因");
306
+ assert.ok(!textHas(tree, "秒后自动重试"), "不可重试时不应出现自动重试提示");
307
+ });