@mrrisega/dsh-remote 0.7.2-beta.1 → 0.7.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,408 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 镜像页「加载看门狗」回归(2026-10-17 真实事故)。
4
+ *
5
+ * 事故现场:用户本机 `~/.dsh-remote`(bridge 运行时 + 账号配对信息/设备密钥)被误删 →
6
+ * bridge 起不来、设备离线;手机端镜像页第一次加载**干等 47 秒**才报错,页面上没有任何可操作入口
7
+ * (只有悬浮菜单里一行"已等 N 秒…复制给客服")。同一天面板也没有"需要重新安装/登录"的明确提示。
8
+ *
9
+ * 本文件锁死镜像页这一半的行为(脚本用 vm 跑真实注入体,指令级断言,不是文本 grep):
10
+ * ① 阈值常量 = 12 秒(原来是 25 秒 —— 用户实测 25 秒提示已经太晚);
11
+ * ② 解析期绝不创建提示层;**slow 之后**才懒创建,且只创建一次(不重复插入);
12
+ * ③ 三个按钮的行为:重试 = location.reload / 返回设备列表 = 导航到 /app/ /
13
+ * 复制诊断信息 = maLoadCopyLine(含 UA 与耗时)写进剪贴板(不可用时把整句显示出来);
14
+ * ④ ✕ 关闭后本页不再弹;认出官方界面后自动收起(判错也不会长期挂着);
15
+ * ⑤ 注入体不含 `</script>`、语法可编译;动画只在 prefers-reduced-motion: no-preference 下挂
16
+ * (老浏览器没有 matchMedia 也不会有动画 —— 用纯 CSS 特性门,不依赖 JS 判断);
17
+ * ⑥ mirror_load 遥测字段与"一次语义"不变(ms/outcome/at,outcome=slow)。
18
+ */
19
+ import assert from "node:assert/strict";
20
+ import test from "node:test";
21
+ import vm from "node:vm";
22
+ import { injectMobileAdapter } from "../mobile-adapter.mjs";
23
+
24
+ /** 取出注入脚本原文(不加壳,原样执行)。 */
25
+ function adapterScript() {
26
+ const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
27
+ const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
28
+ assert.ok(m, "应能从注入结果里取出脚本");
29
+ return m[1];
30
+ }
31
+ /** 取出注入样式原文(源码契约类断言用)。 */
32
+ function adapterCss() {
33
+ const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
34
+ const m = /<style id="[^"]*-css"[^>]*>([\s\S]*?)<\/style>/.exec(html);
35
+ assert.ok(m, "应能从注入结果里取出样式");
36
+ return m[1];
37
+ }
38
+
39
+ // ── 极简假 DOM(只覆盖本用例需要的那点能力:class/attr 选择器 + 事件委托冒泡) ──────────────
40
+
41
+ function matchesSel(el, sel) {
42
+ const s = String(sel).trim();
43
+ if (!s) return false;
44
+ const parts = s.match(/^[a-zA-Z][\w-]*|\.[^.[\s]+|\[[^\]]+\]/g);
45
+ if (!parts) return false;
46
+ for (const p of parts) {
47
+ if (p[0] === ".") { if (!el._cls.has(p.slice(1))) return false; continue; }
48
+ if (p[0] === "[") {
49
+ const m = /^([\w-]+)(?:="([^"]*)")?$/.exec(p.slice(1, -1));
50
+ if (!m) return false;
51
+ if (!(m[1] in el.attrs)) return false;
52
+ if (m[2] !== undefined && String(el.attrs[m[1]]) !== m[2]) return false;
53
+ continue;
54
+ }
55
+ if (el.tagName !== p.toUpperCase()) return false;
56
+ }
57
+ return true;
58
+ }
59
+ const matchesAny = (el, sel) => String(sel).split(",").some((one) => matchesSel(el, one));
60
+
61
+ class Ev {
62
+ constructor(type) {
63
+ this.type = type;
64
+ this.target = null;
65
+ this.defaultPrevented = false;
66
+ }
67
+ preventDefault() { this.defaultPrevented = true; }
68
+ stopPropagation() {}
69
+ }
70
+
71
+ class El {
72
+ constructor(tag = "div") {
73
+ this.tagName = String(tag).toUpperCase();
74
+ this.attrs = {};
75
+ this.children = [];
76
+ this.parent = null;
77
+ this._cls = new Set();
78
+ this._listeners = new Map(); // type -> fn[]
79
+ this.style = { setProperty() {}, removeProperty() {} };
80
+ this._text = "";
81
+ this.hidden = false;
82
+ this.classList = {
83
+ add: (...cs) => cs.forEach((c) => this._cls.add(c)),
84
+ remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
85
+ contains: (c) => this._cls.has(c),
86
+ toggle: (c, on) => { const want = on === undefined ? !this._cls.has(c) : !!on; if (want) this._cls.add(c); else this._cls.delete(c); return want; },
87
+ };
88
+ }
89
+ get className() { return [...this._cls].join(" "); }
90
+ set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
91
+ get textContent() { return this._text; }
92
+ set textContent(v) { this._text = String(v); }
93
+ set innerHTML(html) {
94
+ this._html = String(html);
95
+ this.children = [];
96
+ for (const m of String(html).matchAll(/<(\w+)([^>]*)>/g)) {
97
+ const el = new El(m[1]);
98
+ for (const a of (m[2] || "").matchAll(/([\w-]+)="([^"]*)"/g)) el.attrs[a[1]] = a[2];
99
+ if (el.attrs.class) el.className = el.attrs.class;
100
+ this.appendChild(el);
101
+ }
102
+ }
103
+ get innerHTML() { return this._html || ""; }
104
+ getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
105
+ setAttribute(n, v) { this.attrs[n] = String(v); }
106
+ removeAttribute(n) { delete this.attrs[n]; }
107
+ hasAttribute(n) { return n in this.attrs; }
108
+ appendChild(c) { c.parent = this; this.children.push(c); return c; }
109
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parent = null; return c; }
110
+ remove() { if (this.parent) this.parent.removeChild(this); }
111
+ prepend() {}
112
+ focus() {}
113
+ blur() {}
114
+ setPointerCapture() {}
115
+ scrollIntoView() {}
116
+ get isConnected() { return true; }
117
+ contains(node) { let n = node; while (n) { if (n === this) return true; n = n.parent; } return false; }
118
+ descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]); }
119
+ querySelector(sel) { return this.descendants().find((e) => matchesAny(e, sel)) || null; }
120
+ querySelectorAll(sel) { return this.descendants().filter((e) => matchesAny(e, sel)); }
121
+ addEventListener(type, fn) { (this._listeners.get(type) || this._listeners.set(type, []).get(type)).push(fn); }
122
+ removeEventListener() {}
123
+ /** 事件冒泡:目标先跑,再沿 parent 链向上(提示条用的是事件委托)。 */
124
+ dispatchEvent(ev) {
125
+ ev.target = this;
126
+ let n = this;
127
+ while (n) {
128
+ for (const fn of n._listeners.get(ev.type) || []) fn(ev);
129
+ n = n.parent;
130
+ }
131
+ return !ev.defaultPrevented;
132
+ }
133
+ click() { return this.dispatchEvent(new Ev("click")); }
134
+ getBoundingClientRect() { return { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 }; }
135
+ }
136
+
137
+ /**
138
+ * 空镜像页环境:**没有任何官方界面**(正是"bridge 挂了/页面没加载出来"的现场)。
139
+ * 定时器全部记录不真跑:用例自己按需 fire(12 秒看门狗、1 秒秒数刷新都由用例驱动)。
140
+ */
141
+ function harness(opts = {}) {
142
+ const timers = new Map(); // id -> { fn, ms }
143
+ const intervals = new Map(); // id -> fn
144
+ const fetches = [];
145
+ let timerSeq = 0;
146
+ let intervalSeq = 0;
147
+ const clicks = { reload: 0, assign: [], clipboard: [] };
148
+
149
+ const html = new El("html");
150
+ html.outerHTML = "<html><head></head><body></body></html>"; // 无官方特征 → HOSTISH 为 false
151
+ const body = new El("body");
152
+
153
+ const doc = new El("#document");
154
+ doc.tagName = "#DOCUMENT";
155
+ doc.documentElement = html;
156
+ doc.body = body;
157
+ doc.readyState = "complete";
158
+ doc.createElement = (t) => {
159
+ if (opts.createElementThrows && String(t) === "div") throw new Error("DOM 挂了(模拟异常)");
160
+ return new El(t);
161
+ };
162
+ doc.createTextNode = (t) => ({ nodeValue: t });
163
+ doc.getElementById = () => null;
164
+ const docQS = El.prototype.querySelector.bind(doc);
165
+ doc.querySelector = (sel) => (opts.officialReady ? new El("div") : docQS(sel));
166
+ doc.querySelectorAll = () => [];
167
+ doc.addEventListener = () => {};
168
+ doc.removeEventListener = () => {};
169
+
170
+ const loc = {
171
+ href: "https://n.risegao.cn:13443/remote/dev-x/",
172
+ pathname: "/remote/dev-x/",
173
+ origin: "https://n.risegao.cn:13443",
174
+ reload() { clicks.reload += 1; },
175
+ assign(to) { clicks.assign.push(String(to)); },
176
+ };
177
+ const win = {
178
+ innerWidth: 390, innerHeight: 844, devicePixelRatio: 2,
179
+ localStorage: { _d: {}, getItem(k) { return k in this._d ? this._d[k] : null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } },
180
+ location: loc,
181
+ MutationObserver: class { observe() {} disconnect() {} },
182
+ ResizeObserver: class { observe() {} disconnect() {} },
183
+ requestAnimationFrame: (fn) => { fn(); return 1; },
184
+ setTimeout: (fn, ms) => { const id = ++timerSeq; timers.set(id, { fn, ms }); try { id.unref?.(); } catch { /* 忽略 */ } return id; },
185
+ clearTimeout: (id) => timers.delete(id),
186
+ setInterval: (fn) => { const id = ++intervalSeq; intervals.set(id, fn); return id; },
187
+ clearInterval: (id) => intervals.delete(id),
188
+ addEventListener: () => {},
189
+ removeEventListener: () => {},
190
+ getComputedStyle: () => ({ getPropertyValue: () => "", pointerEvents: "auto", zIndex: "" }),
191
+ fetch: (input, init) => { fetches.push({ input: String(input), init }); return Promise.resolve({ ok: true, status: 200 }); },
192
+ };
193
+ const navigator = { userAgent: opts.userAgent || "iPhone Safari (load-watch test)", maxTouchPoints: 5, platform: "iPhone" };
194
+ if (opts.clipboard !== false) {
195
+ navigator.clipboard = { writeText: (text) => { clicks.clipboard.push(String(text)); return Promise.resolve(); } };
196
+ }
197
+ const ctx = vm.createContext({
198
+ window: win, document: doc, console: { log() {}, warn() {}, error() {} },
199
+ navigator, location: loc, localStorage: win.localStorage,
200
+ setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
201
+ setInterval: win.setInterval, clearInterval: win.clearInterval,
202
+ MutationObserver: win.MutationObserver, ResizeObserver: win.ResizeObserver,
203
+ requestAnimationFrame: win.requestAnimationFrame,
204
+ fetch: win.fetch, performance: { now: () => Date.now() },
205
+ URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
206
+ HTMLElement: El,
207
+ });
208
+ ctx.globalThis = ctx;
209
+ ctx.self = win;
210
+
211
+ const findBar = () => body.children.find((c) => c._cls.has("dsh-ma-loadbar")) || null;
212
+ const barBtn = (role) => (findBar() ? findBar().querySelector('[data-role="' + role + '"]') : null);
213
+ const findWatchTimer = (ms) => [...timers.values()].find((t) => t.ms === ms) || null;
214
+ return {
215
+ ctx, win, doc, body, timers, intervals, fetches, clicks, navigator,
216
+ findBar, barBtn, findWatchTimer,
217
+ /** 触发某个已登记的 setTimeout 回调(用例手工推进时间轴)。 */
218
+ fireTimer(ms) {
219
+ const t = findWatchTimer(ms);
220
+ assert.ok(t, `应登记了 ${ms}ms 的定时器`);
221
+ t.fn();
222
+ },
223
+ /** 触发所有 setInterval(秒数刷新 / 自适应兜底)。 */
224
+ tickIntervals() { for (const fn of [...intervals.values()]) fn(); },
225
+ };
226
+ }
227
+
228
+ function runScript(env) {
229
+ vm.runInContext(adapterScript(), env.ctx, { timeout: 5000 });
230
+ return env;
231
+ }
232
+
233
+ // ══════════════════════════════════════════════════════════════════════════════
234
+ // ① 阈值与注入体硬约束
235
+ // ══════════════════════════════════════════════════════════════════════════════
236
+
237
+ test("阈值常量 = 12 秒(25 秒太晚:用户实测干等 47 秒才看到任何东西)", () => {
238
+ const script = adapterScript();
239
+ assert.match(script, /var MA_LOAD_WATCH_MS = 12 \* 1000;/, "看门狗阈值常量必须是 12 秒");
240
+ assert.match(script, /\}, MA_LOAD_WATCH_MS\);/, "setTimeout 必须用该常量(不得再散落魔法数)");
241
+ assert.ok(!/25 \* 1000/.test(script), "不得残留 25 秒阈值");
242
+ // 语义保留:认出官方界面就不提示(宁可不提示也不要日常误报)
243
+ assert.match(script, /if \(maLoadReadyNow\(\)\) return;/, "认出官方界面就不该提示");
244
+ });
245
+
246
+ test("注入体:不含 `</script>`、语法可编译、无 matchMedia 依赖(老浏览器/减少动效走纯 CSS 降级)", () => {
247
+ const script = adapterScript();
248
+ assert.equal(script.includes("</script>"), false, "注入体内出现 </script> 会提前闭合标签,页面直接坏掉");
249
+ assert.doesNotThrow(() => new vm.Script(script), "注入体必须可编译(vm.Script 不执行,只解析)");
250
+ assert.equal(script.includes("matchMedia"), false,
251
+ "不做 matchMedia 判断:动画只在 CSS 的 prefers-reduced-motion: no-preference 下挂 → 老浏览器天然无动画");
252
+ const css = adapterCss();
253
+ assert.match(css, /@media \(prefers-reduced-motion: no-preference\)/, "动效必须放在 no-preference 特性门里");
254
+ assert.match(css, /@media \(prefers-reduced-motion: reduce\)/, "显式要求减少动效时必须无动画");
255
+ assert.match(css, /prefers-reduced-motion: reduce\)[\s\S]{0,200}animation: none !important/, "reduce 分支要显式关掉动画");
256
+ });
257
+
258
+ // ══════════════════════════════════════════════════════════════════════════════
259
+ // ② 解析期不碰 DOM;slow 之后才懒创建、且只创建一次
260
+ // ══════════════════════════════════════════════════════════════════════════════
261
+
262
+ test("解析期绝不创建提示层:12 秒没到之前 body 里一个 dsh-ma-loadbar 都没有", () => {
263
+ const env = runScript(harness());
264
+ assert.equal(env.findBar(), null, "解析期就挂提示层 = 页面还没坏就可能挡住用户(历史事故根因),绝对不许");
265
+ assert.equal(env.body.children.length, 0, "解析期不得往 body 塞任何适配层节点");
266
+ });
267
+
268
+ test("slow 之后才出现提示层,且只创建一次(重复触发不会插第二份)", () => {
269
+ const env = runScript(harness());
270
+ env.fireTimer(12000);
271
+ const bar = env.findBar();
272
+ assert.ok(bar, "12 秒仍认不出官方界面 → 必须出现底部可操作提示条");
273
+ assert.equal(bar.hidden, false, "出现后应是可见状态");
274
+ assert.match(bar.textContent || bar.querySelector('[data-role="loadbar-txt"]').textContent,
275
+ /电脑端可能未连接 \/ 页面加载较慢/, "文案必须明确说明可能的原因");
276
+ assert.match(bar.querySelector('[data-role="loadbar-txt"]').textContent, /已等待 \d+ 秒/, "必须带已等待秒数");
277
+ // 再触发一次(模拟重复路径/bfcache 恢复)→ 不得插第二份
278
+ env.fireTimer(12000);
279
+ const bars = env.body.children.filter((c) => c._cls.has("dsh-ma-loadbar"));
280
+ assert.equal(bars.length, 1, "提示层只能有一份(重复插入会叠字/叠按钮)");
281
+ });
282
+
283
+ test("已认出官方界面时看门狗不提示(宁可不提示,也不要在正常页面上打扰)", () => {
284
+ const env = runScript(harness({ officialReady: true }));
285
+ env.fireTimer(12000);
286
+ assert.equal(env.findBar(), null, "官方界面已就绪 → 不得出现任何提示层");
287
+ });
288
+
289
+ test("提示层创建过程中抛异常也不影响页面(吞掉,绝不把页面拖挂)", () => {
290
+ const env = runScript(harness({ createElementThrows: true }));
291
+ assert.doesNotThrow(() => env.fireTimer(12000), "创建提示层失败必须被吞掉");
292
+ assert.equal(env.findBar(), null, "创建失败 → 页面保持原样(没有半成品节点)");
293
+ });
294
+
295
+ // ══════════════════════════════════════════════════════════════════════════════
296
+ // ③ 三个按钮的点击行为(假 DOM + 桩)
297
+ // ══════════════════════════════════════════════════════════════════════════════
298
+
299
+ test("按钮「重试」= location.reload()", () => {
300
+ const env = runScript(harness());
301
+ env.fireTimer(12000);
302
+ env.barBtn("loadbar-retry").click();
303
+ assert.equal(env.clicks.reload, 1, "点「重试」必须真的刷新页面");
304
+ });
305
+
306
+ test("按钮「返回设备列表」= 导航回 /app/(用户在镜像页里唯一的主动退路)", () => {
307
+ const env = runScript(harness());
308
+ env.fireTimer(12000);
309
+ env.barBtn("loadbar-back").click();
310
+ assert.deepEqual(env.clicks.assign, ["/app/"], "「返回设备列表」必须导航到 APP 外壳根路径");
311
+ });
312
+
313
+ test("按钮「复制诊断信息」= maLoadCopyLine(含 UA 与耗时)写进剪贴板", async () => {
314
+ const env = runScript(harness());
315
+ env.fireTimer(12000);
316
+ env.barBtn("loadbar-copy").click();
317
+ await new Promise((r) => setImmediate(r));
318
+ assert.equal(env.clicks.clipboard.length, 1, "点一次应复制一次");
319
+ const line = env.clicks.clipboard[0];
320
+ assert.match(line, /dsh-remote 手机端加载疑似卡住/, "沿用既有诊断话术(可直接粘给客服)");
321
+ assert.match(line, /等待 \d+ 秒/, "必须含耗时");
322
+ assert.match(line, /UA=iPhone Safari/, "必须含 UA(便于定位机型/浏览器)");
323
+ assert.match(env.findBar().querySelector('[data-role="loadbar-txt"]').textContent, /已复制诊断信息/, "复制成功要给可见反馈");
324
+ });
325
+
326
+ test("剪贴板不可用时,把整句诊断信息显示在提示条上(至少能截图)", async () => {
327
+ const env = runScript(harness({ clipboard: false }));
328
+ env.fireTimer(12000);
329
+ env.barBtn("loadbar-copy").click();
330
+ await new Promise((r) => setImmediate(r));
331
+ assert.equal(env.clicks.clipboard.length, 0, "前提:没有剪贴板");
332
+ assert.match(env.findBar().querySelector('[data-role="loadbar-txt"]').textContent, /dsh-remote 手机端加载疑似卡住/,
333
+ "剪贴板不可用时必须把整句显示出来,用户才能截图");
334
+ });
335
+
336
+ // ══════════════════════════════════════════════════════════════════════════════
337
+ // ④ ✕ 关闭后本页不再弹;认出界面后自动收起
338
+ // ══════════════════════════════════════════════════════════════════════════════
339
+
340
+ test("点 ✕ 关闭后本页不再弹(不骚扰),且秒数刷新也停掉", () => {
341
+ const env = runScript(harness());
342
+ env.fireTimer(12000);
343
+ const bar = env.findBar();
344
+ assert.equal(bar.hidden, false, "前提:提示条可见");
345
+ env.barBtn("loadbar-close").click();
346
+ assert.equal(bar.hidden, true, "点 ✕ 应收起");
347
+ // 之后的重复触发(看门狗重入 / 秒数刷新)都不得再弹出来
348
+ env.fireTimer(12000);
349
+ env.tickIntervals();
350
+ assert.equal(bar.hidden, true, "关闭后本页不再弹(只提示一次)");
351
+ assert.equal(env.body.children.filter((c) => c._cls.has("dsh-ma-loadbar")).length, 1, "也不得再创建新节点");
352
+ });
353
+
354
+ test("用户一直没关、但页面后来加载出来了 → 提示条自动收起(判错也不会长期挂着)", () => {
355
+ const env = runScript(harness());
356
+ env.fireTimer(12000);
357
+ const bar = env.findBar();
358
+ assert.equal(bar.hidden, false, "前提:提示条可见");
359
+ // 模拟"页面其实已经好了":让就绪判定命中官方 frame/输入框特征
360
+ env.doc.querySelector = (sel) => (/pI_x6G_frame|ma-frame|sidebar-collapsed|details-collapsed|rightbar-collapsed|composer-input|contenteditable|role="textbox"|textarea/.test(String(sel)) ? new El("div") : null);
361
+ env.tickIntervals(); // 1 秒秒数刷新那一拍顺便复核"是不是已经好了"
362
+ assert.equal(bar.hidden, true, "认出官方界面后提示条必须自动收起(安全失败方向)");
363
+ });
364
+
365
+ // ══════════════════════════════════════════════════════════════════════════════
366
+ // ⑤ 提示层本身的形态契约(不是全屏层、不遮挡、可关闭、按钮齐全)
367
+ // ══════════════════════════════════════════════════════════════════════════════
368
+
369
+ test("提示层是**底部轻量条**:不是全屏覆盖层、限高、层级低于 FAB(自救出口永远点得到)", () => {
370
+ const css = adapterCss();
371
+ const barRule = /div\.dsh-ma-loadbar\s*\{([\s\S]*?)\}/.exec(css);
372
+ assert.ok(barRule, "必须有 div.dsh-ma-loadbar 的样式规则");
373
+ const body = barRule[1];
374
+ assert.match(body, /position:\s*fixed/, "提示条固定在视口上(滚动时不跑)");
375
+ assert.match(body, /bottom:\s*calc\(/, "必须贴底部(不是居中/顶部遮罩)");
376
+ assert.ok(!/inset:\s*0/.test(body), "不得是全屏层(inset:0)");
377
+ assert.ok(!/top:\s*0(px)?;/.test(body), "不得同时钉 top:0(那就变成全屏覆盖了)");
378
+ assert.match(body, /max-height:/, "必须限高(内容再长也不盖住主界面)");
379
+ assert.match(body, /overflow-y:\s*auto/, "限高后内容要能滚动");
380
+ assert.match(body, /z-index:\s*(\d+)/, "应有显式层级");
381
+ const z = Number(/z-index:\s*(\d+)/.exec(body)[1]);
382
+ assert.ok(z < 400, `提示条层级(${z})必须低于 FAB(400) —— 否则会压住"返回设备列表"的自救按钮`);
383
+ assert.ok(!/z-index:\s*\d{7,}/.test(css), "不得出现百万级 z-index(压住一切 = 判断错就锁死用户)");
384
+ assert.match(css, /div\.dsh-ma-loadbar\[hidden\]\s*\{\s*display:\s*none\s*!important/, "隐藏态必须显式 display:none(别被 display:flex 盖过)");
385
+ // 四个可点元素齐全
386
+ const script = adapterScript();
387
+ for (const role of ["loadbar-retry", "loadbar-back", "loadbar-copy", "loadbar-close"]) {
388
+ assert.match(script, new RegExp(`data-role="${role}"`), `提示条应含 ${role} 按钮`);
389
+ }
390
+ });
391
+
392
+ // ══════════════════════════════════════════════════════════════════════════════
393
+ // ⑥ mirror_load 遥测:字段与"一次语义"不变
394
+ // ══════════════════════════════════════════════════════════════════════════════
395
+
396
+ test("slow 时照旧上报 mirror_load 兜底样本:字段 ms/outcome/at 不变、整页只报一次", () => {
397
+ const env = runScript(harness());
398
+ env.fireTimer(12000);
399
+ const posts = env.fetches.filter((f) => String(f.input).includes("/dsh-remote/telemetry/mirror-load"));
400
+ assert.equal(posts.length, 1, "slow 兜底样本每次页面只发一条(reported 一次语义)");
401
+ const body = JSON.parse(posts[0].init.body);
402
+ assert.deepEqual(Object.keys(body).sort(), ["at", "ms", "outcome"], "字段必须仍是 ms/outcome/at(后台契约不动)");
403
+ assert.equal(body.outcome, "slow", "看门狗兜底样本的 outcome 必须是 slow");
404
+ assert.ok(Number.isFinite(body.ms) && body.ms >= 0 && body.ms <= 600000, "ms 必须被钳制在 [0,600000]");
405
+ // 再触发一次:reported 已置位 → 不得重复上报
406
+ env.fireTimer(12000);
407
+ assert.equal(env.fetches.filter((f) => String(f.input).includes("mirror-load")).length, 1, "同一页只报一次");
408
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.2-beta.1",
3
+ "version": "0.7.2",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",