@mrrisega/dsh-remote 0.7.2 → 0.7.3-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.
Files changed (25) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +214 -9
  2. package/clients/dsh-remote/e2ee-shim-script.js +98 -18
  3. package/clients/dsh-remote/mirror-diag-script.js +19 -0
  4. package/clients/dsh-remote/mobile-adapter.mjs +803 -16
  5. package/clients/dsh-remote/test/bridge-guard.test.mjs +169 -0
  6. package/clients/dsh-remote/test/e2ee-shim-selfheal.test.mjs +264 -0
  7. package/clients/dsh-remote/test/mirror-diag.test.mjs +75 -0
  8. package/clients/dsh-remote/test/mobile-adapter-compat.test.mjs +175 -0
  9. package/clients/dsh-remote/test/mobile-adapter-copy.test.mjs +56 -0
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
  11. package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
  12. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
  13. package/clients/dsh-remote/upstream-discovery.mjs +6 -1
  14. package/dsh-setup.mjs +225 -6
  15. package/package.json +1 -1
  16. package/packages/dsh-remote-web/lib/index.js +140 -3
  17. package/packages/dsh-remote-web/package.json +1 -1
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +803 -16
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
  23. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
  24. package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
  25. package/packages/dsh-remote-web/test/windows-compat.test.mjs +82 -12
@@ -142,13 +142,23 @@ function harness(opts = {}) {
142
142
  const timers = new Map(); // id -> { fn, ms }
143
143
  const intervals = new Map(); // id -> fn
144
144
  const fetches = [];
145
+ const signals = []; // 被包装的 fetch 收到的 init.signal(C4:在途请求取消的断言入口)
146
+ const winListeners = new Map();// window 上登记的事件监听(C4:资源 error 捕获靠它)
145
147
  let timerSeq = 0;
146
148
  let intervalSeq = 0;
147
149
  const clicks = { reload: 0, assign: [], clipboard: [] };
148
150
 
149
151
  const html = new El("html");
150
- html.outerHTML = "<html><head></head><body></body></html>"; // 无官方特征 → HOSTISH 为 false
152
+ /* hostFeature=true 模拟"官方文档已到、但界面还没挂载"的现场(HOSTISH 会读 outerHTML 找官方特征)。
153
+ 默认 false = 连文档都没起来(bridge 挂了),此时适配层连采样器都不该装。 */
154
+ html.outerHTML = opts.hostFeature
155
+ /* ⚠️ 必须用 HOSTISH() 真正认的特征(RUNTIME_HOST_RE 里的 pI_x6G_frame / data-sidebar-collapsed),
156
+ 而不是 __ModuleLoader__(那个只用于注入前的 HTML 门):否则适配层连 boot 都提前 return,
157
+ 采样器/菜单都不会装 —— 用例就测不到"官方文档已到、界面还没挂载"这个现场。 */
158
+ ? '<html><head><title>DeepSeek Harness</title></head><body><div class="pI_x6G_frame" data-sidebar-collapsed="true"></div></body></html>'
159
+ : "<html><head></head><body></body></html>";
151
160
  const body = new El("body");
161
+ if (opts.bootFailText) body.innerText = String(opts.bootFailText);
152
162
 
153
163
  const doc = new El("#document");
154
164
  doc.tagName = "#DOCUMENT";
@@ -174,9 +184,12 @@ function harness(opts = {}) {
174
184
  reload() { clicks.reload += 1; },
175
185
  assign(to) { clicks.assign.push(String(to)); },
176
186
  };
187
+ /* sessionStorage:C4 的"整页自动重试次数"闸门用它(每标签页最多 2 次)。 */
188
+ const store = opts.store || { _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]; } };
177
189
  const win = {
178
190
  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]; } },
191
+ localStorage: store,
192
+ sessionStorage: store,
180
193
  location: loc,
181
194
  MutationObserver: class { observe() {} disconnect() {} },
182
195
  ResizeObserver: class { observe() {} disconnect() {} },
@@ -185,22 +198,29 @@ function harness(opts = {}) {
185
198
  clearTimeout: (id) => timers.delete(id),
186
199
  setInterval: (fn) => { const id = ++intervalSeq; intervals.set(id, fn); return id; },
187
200
  clearInterval: (id) => intervals.delete(id),
188
- addEventListener: () => {},
201
+ addEventListener: (type, fn) => { const arr = winListeners.get(type) || []; arr.push(fn); winListeners.set(type, arr); },
189
202
  removeEventListener: () => {},
190
203
  getComputedStyle: () => ({ getPropertyValue: () => "", pointerEvents: "auto", zIndex: "" }),
191
- fetch: (input, init) => { fetches.push({ input: String(input), init }); return Promise.resolve({ ok: true, status: 200 }); },
204
+ fetch: opts.fetch || ((input, init) => {
205
+ fetches.push({ input: String(input), init });
206
+ return Promise.resolve({ ok: true, status: 200, headers: { get: () => null } });
207
+ }),
192
208
  };
193
209
  const navigator = { userAgent: opts.userAgent || "iPhone Safari (load-watch test)", maxTouchPoints: 5, platform: "iPhone" };
194
210
  if (opts.clipboard !== false) {
195
211
  navigator.clipboard = { writeText: (text) => { clicks.clipboard.push(String(text)); return Promise.resolve(); } };
196
212
  }
213
+ /* 包装:记录每个请求收到的 signal(用例用它断言"先取消在途请求再 reload")。 */
214
+ const rawFetch = win.fetch;
215
+ win.fetch = (input, init) => { signals.push(init && init.signal); return rawFetch(input, init); };
197
216
  const ctx = vm.createContext({
198
217
  window: win, document: doc, console: { log() {}, warn() {}, error() {} },
199
- navigator, location: loc, localStorage: win.localStorage,
218
+ navigator, location: loc, localStorage: store, sessionStorage: store,
200
219
  setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
201
220
  setInterval: win.setInterval, clearInterval: win.clearInterval,
202
221
  MutationObserver: win.MutationObserver, ResizeObserver: win.ResizeObserver,
203
222
  requestAnimationFrame: win.requestAnimationFrame,
223
+ AbortController, AbortSignal,
204
224
  fetch: win.fetch, performance: { now: () => Date.now() },
205
225
  URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
206
226
  HTMLElement: El,
@@ -210,10 +230,23 @@ function harness(opts = {}) {
210
230
 
211
231
  const findBar = () => body.children.find((c) => c._cls.has("dsh-ma-loadbar")) || null;
212
232
  const barBtn = (role) => (findBar() ? findBar().querySelector('[data-role="' + role + '"]') : null);
213
- const findWatchTimer = (ms) => [...timers.values()].find((t) => t.ms === ms) || null;
233
+ const timersByMs = (ms) => [...timers.values()].filter((t) => t.ms === ms);
234
+ const findWatchTimer = (ms) => timersByMs(ms)[0] || null;
214
235
  return {
215
- ctx, win, doc, body, timers, intervals, fetches, clicks, navigator,
236
+ ctx, win, doc, body, timers, intervals, fetches, clicks, navigator, signals, store,
216
237
  findBar, barBtn, findWatchTimer,
238
+ countTimers: (ms) => timersByMs(ms).length,
239
+ /** 触发 window 上的事件(C4:资源 error 在**捕获阶段**被适配层接住)。 */
240
+ fireWindowEvent(type, ev) {
241
+ const arr = winListeners.get(type) || [];
242
+ for (const fn of arr) fn(ev);
243
+ },
244
+ /** 触发**所有**同毫秒的 setTimeout(页面里同毫秒的定时器不止一个时用它)。 */
245
+ fireTimers(ms) {
246
+ const list = timersByMs(ms);
247
+ assert.ok(list.length > 0, `应登记了 ${ms}ms 的定时器`);
248
+ for (const t of list) t.fn();
249
+ },
217
250
  /** 触发某个已登记的 setTimeout 回调(用例手工推进时间轴)。 */
218
251
  fireTimer(ms) {
219
252
  const t = findWatchTimer(ms);
@@ -234,10 +267,13 @@ function runScript(env) {
234
267
  // ① 阈值与注入体硬约束
235
268
  // ══════════════════════════════════════════════════════════════════════════════
236
269
 
237
- test("阈值常量 = 12 秒(25 秒太晚:用户实测干等 47 秒才看到任何东西)", () => {
270
+ test("阈值常量 = 20 秒(12 秒低于真实 p90 15.3s → 17.6% 正常加载被误报)", () => {
238
271
  const script = adapterScript();
239
- assert.match(script, /var MA_LOAD_WATCH_MS = 12 \* 1000;/, "看门狗阈值常量必须是 12 秒");
272
+ /* 2026-10-20 诊断 C4(03 报告 §2.3):mirror_load 858 样本 p50 2682ms / p90 **15285ms** / ≥12s 17.6%
273
+ —— 12 秒阈值比真实 p90 还短,"电脑端可能未连接"成了日常误报。20 秒在 p90 之上留余量。 */
274
+ assert.match(script, /var MA_LOAD_WATCH_MS = 20 \* 1000;/, "看门狗阈值常量必须是 20 秒");
240
275
  assert.match(script, /\}, MA_LOAD_WATCH_MS\);/, "setTimeout 必须用该常量(不得再散落魔法数)");
276
+ assert.ok(!/MA_LOAD_WATCH_MS = 12 \* 1000/.test(script), "不得残留 12 秒阈值(会误报 1/10 的正常加载)");
241
277
  assert.ok(!/25 \* 1000/.test(script), "不得残留 25 秒阈值");
242
278
  // 语义保留:认出官方界面就不提示(宁可不提示也不要日常误报)
243
279
  assert.match(script, /if \(maLoadReadyNow\(\)\) return;/, "认出官方界面就不该提示");
@@ -259,7 +295,7 @@ test("注入体:不含 `</script>`、语法可编译、无 matchMedia 依赖
259
295
  // ② 解析期不碰 DOM;slow 之后才懒创建、且只创建一次
260
296
  // ══════════════════════════════════════════════════════════════════════════════
261
297
 
262
- test("解析期绝不创建提示层:12 秒没到之前 body 里一个 dsh-ma-loadbar 都没有", () => {
298
+ test("解析期绝不创建提示层:20 秒没到之前 body 里一个 dsh-ma-loadbar 都没有", () => {
263
299
  const env = runScript(harness());
264
300
  assert.equal(env.findBar(), null, "解析期就挂提示层 = 页面还没坏就可能挡住用户(历史事故根因),绝对不许");
265
301
  assert.equal(env.body.children.length, 0, "解析期不得往 body 塞任何适配层节点");
@@ -267,28 +303,30 @@ test("解析期绝不创建提示层:12 秒没到之前 body 里一个 dsh-ma-
267
303
 
268
304
  test("slow 之后才出现提示层,且只创建一次(重复触发不会插第二份)", () => {
269
305
  const env = runScript(harness());
270
- env.fireTimer(12000);
306
+ env.fireTimer(20000);
271
307
  const bar = env.findBar();
272
- assert.ok(bar, "12 秒仍认不出官方界面 → 必须出现底部可操作提示条");
308
+ assert.ok(bar, "20 秒仍认不出官方界面 → 必须出现底部可操作提示条");
273
309
  assert.equal(bar.hidden, false, "出现后应是可见状态");
310
+ /* 文案分档(C4):没有 5xx / 没有首屏资源失败时,如实说"网络较慢",
311
+ 不再把什么都归因成"电脑端可能未连接"(截图 09 的反证:面板同时显示 95ms 顺畅)。 */
274
312
  assert.match(bar.textContent || bar.querySelector('[data-role="loadbar-txt"]').textContent,
275
- /电脑端可能未连接 \/ 页面加载较慢/, "文案必须明确说明可能的原因");
313
+ /网络较慢(已等待 \d+ 秒)/, "无失败证据时文案应为「网络较慢」,不再替用户猜原因");
276
314
  assert.match(bar.querySelector('[data-role="loadbar-txt"]').textContent, /已等待 \d+ 秒/, "必须带已等待秒数");
277
315
  // 再触发一次(模拟重复路径/bfcache 恢复)→ 不得插第二份
278
- env.fireTimer(12000);
316
+ env.fireTimer(20000);
279
317
  const bars = env.body.children.filter((c) => c._cls.has("dsh-ma-loadbar"));
280
318
  assert.equal(bars.length, 1, "提示层只能有一份(重复插入会叠字/叠按钮)");
281
319
  });
282
320
 
283
321
  test("已认出官方界面时看门狗不提示(宁可不提示,也不要在正常页面上打扰)", () => {
284
322
  const env = runScript(harness({ officialReady: true }));
285
- env.fireTimer(12000);
323
+ env.fireTimer(20000);
286
324
  assert.equal(env.findBar(), null, "官方界面已就绪 → 不得出现任何提示层");
287
325
  });
288
326
 
289
327
  test("提示层创建过程中抛异常也不影响页面(吞掉,绝不把页面拖挂)", () => {
290
328
  const env = runScript(harness({ createElementThrows: true }));
291
- assert.doesNotThrow(() => env.fireTimer(12000), "创建提示层失败必须被吞掉");
329
+ assert.doesNotThrow(() => env.fireTimer(20000), "创建提示层失败必须被吞掉");
292
330
  assert.equal(env.findBar(), null, "创建失败 → 页面保持原样(没有半成品节点)");
293
331
  });
294
332
 
@@ -296,23 +334,42 @@ test("提示层创建过程中抛异常也不影响页面(吞掉,绝不把
296
334
  // ③ 三个按钮的点击行为(假 DOM + 桩)
297
335
  // ══════════════════════════════════════════════════════════════════════════════
298
336
 
299
- test("按钮「重试」= location.reload()", () => {
300
- const env = runScript(harness());
301
- env.fireTimer(12000);
337
+ test("★ 按钮「重试」:先取消在途请求 + 排队中的自动重试,再 reload(不再制造 499 风暴)", () => {
338
+ /* 2026-10-20 C4 第 3 条:旧实现直接 location.reload() —— 上一轮仍在传输的资源被浏览器 abort,
339
+ nginx 记 499;用户连点几次就是 499 风暴(当天文档导航 22.3% 失败/中断的直接成因)。
340
+ 现在必须先把我们跟踪到的在途请求取消,再刷新。 */
341
+ const env = runScript(harness({ hostFeature: true, fetch: () => new Promise(() => {}) })); // 永不返回 = 在途
342
+ env.win.fetch("/api/session/list"); // 制造一个在途请求(boot 已把 fetch 包上)
343
+ env.fireTimer(20000); // 看门狗把提示条拿出来
344
+ assert.ok(env.signals.length >= 1, "前提:有一个被包装的在途请求(含看门狗触发的遥测请求)");
302
345
  env.barBtn("loadbar-retry").click();
303
- assert.equal(env.clicks.reload, 1, "点「重试」必须真的刷新页面");
346
+ assert.equal(env.clicks.reload, 0, "点击当下不得立刻 reload(先取消在途请求)");
347
+ assert.equal(env.signals[0].aborted, true, "★ 在途请求必须被取消(否则 reload 时它们就是 499)");
348
+ env.fireTimer(120);
349
+ assert.equal(env.clicks.reload, 1, "取消完在途请求后再 reload");
350
+ });
351
+
352
+ test("★ 按钮「重试」有 2 秒冷却:连点不会叠加出多次 reload(人肉重试风暴的机械成因)", () => {
353
+ const env = runScript(harness({ hostFeature: true, fetch: () => new Promise(() => {}) }));
354
+ env.fireTimer(20000);
355
+ env.barBtn("loadbar-retry").click();
356
+ const armed = env.countTimers(120);
357
+ assert.equal(armed, 1, "第一次点击排一个 reload");
358
+ env.barBtn("loadbar-retry").click();
359
+ env.barBtn("loadbar-retry").click();
360
+ assert.equal(env.countTimers(120), armed, "冷却期内连点不得再排 reload");
304
361
  });
305
362
 
306
363
  test("按钮「返回设备列表」= 导航回 /app/(用户在镜像页里唯一的主动退路)", () => {
307
364
  const env = runScript(harness());
308
- env.fireTimer(12000);
365
+ env.fireTimer(20000);
309
366
  env.barBtn("loadbar-back").click();
310
367
  assert.deepEqual(env.clicks.assign, ["/app/"], "「返回设备列表」必须导航到 APP 外壳根路径");
311
368
  });
312
369
 
313
370
  test("按钮「复制诊断信息」= maLoadCopyLine(含 UA 与耗时)写进剪贴板", async () => {
314
371
  const env = runScript(harness());
315
- env.fireTimer(12000);
372
+ env.fireTimer(20000);
316
373
  env.barBtn("loadbar-copy").click();
317
374
  await new Promise((r) => setImmediate(r));
318
375
  assert.equal(env.clicks.clipboard.length, 1, "点一次应复制一次");
@@ -325,7 +382,7 @@ test("按钮「复制诊断信息」= maLoadCopyLine(含 UA 与耗时)写进
325
382
 
326
383
  test("剪贴板不可用时,把整句诊断信息显示在提示条上(至少能截图)", async () => {
327
384
  const env = runScript(harness({ clipboard: false }));
328
- env.fireTimer(12000);
385
+ env.fireTimer(20000);
329
386
  env.barBtn("loadbar-copy").click();
330
387
  await new Promise((r) => setImmediate(r));
331
388
  assert.equal(env.clicks.clipboard.length, 0, "前提:没有剪贴板");
@@ -339,13 +396,13 @@ test("剪贴板不可用时,把整句诊断信息显示在提示条上(至
339
396
 
340
397
  test("点 ✕ 关闭后本页不再弹(不骚扰),且秒数刷新也停掉", () => {
341
398
  const env = runScript(harness());
342
- env.fireTimer(12000);
399
+ env.fireTimer(20000);
343
400
  const bar = env.findBar();
344
401
  assert.equal(bar.hidden, false, "前提:提示条可见");
345
402
  env.barBtn("loadbar-close").click();
346
403
  assert.equal(bar.hidden, true, "点 ✕ 应收起");
347
404
  // 之后的重复触发(看门狗重入 / 秒数刷新)都不得再弹出来
348
- env.fireTimer(12000);
405
+ env.fireTimer(20000);
349
406
  env.tickIntervals();
350
407
  assert.equal(bar.hidden, true, "关闭后本页不再弹(只提示一次)");
351
408
  assert.equal(env.body.children.filter((c) => c._cls.has("dsh-ma-loadbar")).length, 1, "也不得再创建新节点");
@@ -353,7 +410,7 @@ test("点 ✕ 关闭后本页不再弹(不骚扰),且秒数刷新也停掉
353
410
 
354
411
  test("用户一直没关、但页面后来加载出来了 → 提示条自动收起(判错也不会长期挂着)", () => {
355
412
  const env = runScript(harness());
356
- env.fireTimer(12000);
413
+ env.fireTimer(20000);
357
414
  const bar = env.findBar();
358
415
  assert.equal(bar.hidden, false, "前提:提示条可见");
359
416
  // 模拟"页面其实已经好了":让就绪判定命中官方 frame/输入框特征
@@ -395,7 +452,7 @@ test("提示层是**底部轻量条**:不是全屏覆盖层、限高、层级
395
452
 
396
453
  test("slow 时照旧上报 mirror_load 兜底样本:字段 ms/outcome/at 不变、整页只报一次", () => {
397
454
  const env = runScript(harness());
398
- env.fireTimer(12000);
455
+ env.fireTimer(20000);
399
456
  const posts = env.fetches.filter((f) => String(f.input).includes("/dsh-remote/telemetry/mirror-load"));
400
457
  assert.equal(posts.length, 1, "slow 兜底样本每次页面只发一条(reported 一次语义)");
401
458
  const body = JSON.parse(posts[0].init.body);
@@ -403,6 +460,154 @@ test("slow 时照旧上报 mirror_load 兜底样本:字段 ms/outcome/at 不
403
460
  assert.equal(body.outcome, "slow", "看门狗兜底样本的 outcome 必须是 slow");
404
461
  assert.ok(Number.isFinite(body.ms) && body.ms >= 0 && body.ms <= 600000, "ms 必须被钳制在 [0,600000]");
405
462
  // 再触发一次:reported 已置位 → 不得重复上报
406
- env.fireTimer(12000);
463
+ env.fireTimer(20000);
407
464
  assert.equal(env.fetches.filter((f) => String(f.input).includes("mirror-load")).length, 1, "同一页只报一次");
408
465
  });
466
+
467
+ // ══════════════════════════════════════════════════════════════════════════════
468
+ // ⑦ C4(2026-10-20 诊断):首屏关键资源失败 → **自动重试**,而不是只弹提示
469
+ //
470
+ // 现场(03 报告 §2.3):文档 / assets/*.js / plugins 聚合包只要失败一次就永远卡住;
471
+ // 12s 看门狗只弹「已等 N 秒 + 重试」。数据:首屏关键资源失败 1347/18375(7.3%),
472
+ // 高峰窗口 12.4%,涉 55 台设备 —— 全都没有自愈路径。
473
+ // 本组用例锁三件事:① 就地重建失败资源(2s/4s 退避、同 URL 最多 2 次);
474
+ // ② 官方启动失败文案 → 受控整页重试(每标签页最多 2 次,跨刷新续计);
475
+ // ③ 文案按原因分档(电脑端不可达 / 资源失败 / 网络慢)。
476
+ // ══════════════════════════════════════════════════════════════════════════════
477
+
478
+ /** 造一个"加载失败的首屏资源元素"(真实 DOM 里属性会反射到 .src,假 DOM 手工补上)。 */
479
+ function failingScript(src, type) {
480
+ const el = new El("script");
481
+ el.setAttribute("src", src);
482
+ if (type) el.setAttribute("type", type);
483
+ el.src = src;
484
+ return el;
485
+ }
486
+ /** 共享 store:模拟"整页刷新后 sessionStorage 仍在"(防 reload 风暴的闸门靠它跨刷新)。 */
487
+ function makeStore() {
488
+ return { _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]; } };
489
+ }
490
+
491
+ test("★ 首屏关键资源(/assets/*.js)失败一次 → 2 秒后自动重建该 <script>(不再只弹提示)", () => {
492
+ const env = runScript(harness());
493
+ const el = failingScript("/assets/index-abc.js", "module");
494
+ env.body.appendChild(el);
495
+ env.fireWindowEvent("error", { target: el });
496
+ assert.equal(env.countTimers(2000), 1, "失败一次 → 排一个 2 秒后的重试(不是干等用户点)");
497
+ env.fireTimer(2000);
498
+ const retried = env.body.children.filter((c) => c.tagName === "SCRIPT" && c.attrs["data-dsh-ma-retry"] === "1");
499
+ assert.equal(retried.length, 1, "★ 必须真的重建一个 <script>(旧实现只弹提示、页面永远卡住)");
500
+ assert.equal(retried[0].getAttribute("src"), "/assets/index-abc.js", "必须重试同一个 URL");
501
+ assert.equal(retried[0].getAttribute("type"), "module", "模块类型必须保留(否则重建出来的脚本不执行)");
502
+ });
503
+
504
+ test("★ 设备作用域形态(/remote/<dev>/plugins/…)与 manifest 也算首屏关键资源", () => {
505
+ const env = runScript(harness());
506
+ const p = failingScript("/remote/dev-abc123/plugins/??@deepseek-ai/x.js");
507
+ env.body.appendChild(p);
508
+ env.fireWindowEvent("error", { target: p });
509
+ assert.equal(env.countTimers(2000), 1, "设备作用域 URL 归一后仍应命中 /plugins/");
510
+ // 非关键资源(图片/字体等)不重试:不能因为一张图失败就反复打服务端
511
+ const img = new El("img");
512
+ img.src = "/avatars/u1.png";
513
+ env.body.appendChild(img);
514
+ env.fireWindowEvent("error", { target: img });
515
+ assert.equal(env.countTimers(2000), 1, "非首屏关键资源不得触发重试");
516
+ });
517
+
518
+ test("★ 同一个 URL 最多就地重试 2 次,退避 2s→4s(第三次不再排队)", () => {
519
+ const env = runScript(harness());
520
+ const el = failingScript("/plugins/??@deepseek-ai/x.js");
521
+ env.body.appendChild(el);
522
+ env.fireWindowEvent("error", { target: el });
523
+ assert.equal(env.countTimers(2000), 1, "第一次失败 → 2 秒后重试");
524
+ env.fireWindowEvent("error", { target: el });
525
+ assert.equal(env.countTimers(4000), 1, "第二次失败 → 指数退避到 4 秒");
526
+ env.fireWindowEvent("error", { target: el });
527
+ assert.equal(env.countTimers(2000) + env.countTimers(4000), 2, "★ 第三次不再排新重试(上限 2 次,防请求风暴)");
528
+ assert.equal(env.win.__dshMaDiag.retry.res, 0, "还没真正重建过 → 诊断里的 res 计数为 0(重建后才会涨)");
529
+ });
530
+
531
+ test("★ 出现「Failed to load plugins」且界面没就绪 → 2 秒后受控整页刷新(这就是 22:01:44 的现场)", () => {
532
+ const env = runScript(harness({ hostFeature: true, bootFailText: "Failed to load plugins(41 条目未激活)" }));
533
+ const before = env.countTimers(2000); // 菜单里本来就有 2000ms 的 E2EE 状态重绘
534
+ env.fireTimer(6000); // 启动失败文案扫描(6s 那一拍)
535
+ assert.equal(env.countTimers(2000), before + 1, "看到启动失败文案 → 排一个 2 秒后的整页重试");
536
+ env.fireTimers(2000);
537
+ assert.equal(env.clicks.reload, 1, "★ 必须真的刷新一次首屏(旧实现永远卡在 Failed to load plugins)");
538
+ assert.equal(JSON.parse(env.store.getItem("dsh-ma-firstscreen-retry")).n, 1,
539
+ "重试次数必须记在 sessionStorage(刷新后仍然生效,否则每次刷新都又能刷)");
540
+ });
541
+
542
+ test("★ 整页自动重试上限 2 次/标签页,且跨刷新续计(防 reload 风暴)", () => {
543
+ const store = makeStore();
544
+ const bootFail = { hostFeature: true, bootFailText: "Failed to load plugins", store };
545
+ const env1 = runScript(harness(bootFail));
546
+ env1.fireTimer(6000); env1.fireTimers(2000);
547
+ assert.equal(env1.clicks.reload, 1, "第一次自动重试");
548
+ // 模拟整页刷新:新环境、**同一个 sessionStorage**
549
+ const env2 = runScript(harness(bootFail));
550
+ env2.fireTimer(6000);
551
+ assert.equal(env2.countTimers(4000), 1, "第二次自动重试退避到 4 秒(计数从 sessionStorage 恢复)");
552
+ env2.fireTimers(4000);
553
+ assert.equal(env2.clicks.reload, 1, "第二次自动重试");
554
+ // 第三次:额度已用完 → 不再刷新
555
+ const env3 = runScript(harness(bootFail));
556
+ const base2 = env3.countTimers(2000), base4 = env3.countTimers(4000);
557
+ env3.fireTimer(6000);
558
+ assert.equal(env3.countTimers(2000), base2, "★ 同一标签页最多 2 次自动整页重试(不再排 2s)");
559
+ assert.equal(env3.countTimers(4000), base4, "★ 也不再排 4s");
560
+ });
561
+
562
+ test("界面已经就绪时,看到启动失败文案也**不**自动刷新(宁可不救,也不要在正常页面上刷新)", () => {
563
+ const env = runScript(harness({ hostFeature: true, officialReady: true, bootFailText: "Failed to load plugins" }));
564
+ const before = env.countTimers(2000);
565
+ env.fireTimer(6000);
566
+ assert.equal(env.countTimers(2000), before, "官方界面已就绪 → 不得排整页重试");
567
+ assert.equal(env.clicks.reload, 0);
568
+ });
569
+
570
+ // ── 文案分档(03 报告 §2.3「文案归因错误」:不再把什么都归成"电脑端可能未连接") ──────────
571
+
572
+ test("★ 文案分档:受保护 API 5xx(电脑端不可达)→「电脑端可能未连接」", async () => {
573
+ const env = runScript(harness({
574
+ hostFeature: true,
575
+ fetch: () => Promise.resolve({ ok: false, status: 502, headers: { get: () => null } }),
576
+ }));
577
+ env.win.fetch("/api/session/list");
578
+ await new Promise((r) => setImmediate(r));
579
+ env.fireTimer(20000);
580
+ assert.match(env.findBar().querySelector('[data-role="loadbar-txt"]').textContent,
581
+ /电脑端可能未连接(已等待 \d+ 秒)/, "5xx = 电脑端/隧道不可达,这时说「电脑端可能未连接」才是对的");
582
+ });
583
+
584
+ test("★ 文案分档:E2EE 死会话的 502(带 x-dsh-e2ee-error)**不算**电脑端离线", async () => {
585
+ const env = runScript(harness({
586
+ hostFeature: true,
587
+ fetch: () => Promise.resolve({ ok: false, status: 502, headers: { get: (n) => (n === "x-dsh-e2ee-error" ? "unknown_session" : null) } }),
588
+ }));
589
+ env.win.fetch("/api/session/list");
590
+ await new Promise((r) => setImmediate(r));
591
+ env.fireTimer(20000);
592
+ assert.match(env.findBar().querySelector('[data-role="loadbar-txt"]').textContent,
593
+ /网络较慢(已等待 \d+ 秒)/, "桥端答「会话我不认识」不是网络/离线问题,不得误导用户");
594
+ });
595
+
596
+ test("★ 文案分档:首屏资源失败 →「页面资源加载失败,正在重试…」", () => {
597
+ const env = runScript(harness());
598
+ const el = failingScript("/assets/x.js");
599
+ env.body.appendChild(el);
600
+ env.fireWindowEvent("error", { target: el });
601
+ env.fireTimer(20000);
602
+ assert.match(env.findBar().querySelector('[data-role="loadbar-txt"]').textContent,
603
+ /页面资源加载失败,正在重试…(已等待 \d+ 秒)/, "有资源失败证据时要如实说资源失败(并且我们真的在重试)");
604
+ });
605
+
606
+ test("诊断出口:__dshMaDiag / __dshMaRetryStats 暴露 retry / scrim / compat 状态(用户上报现场用)", () => {
607
+ const env = runScript(harness());
608
+ assert.ok(env.win.__dshMaDiag, "必须暴露只读诊断对象");
609
+ assert.equal(typeof env.win.__dshMaDiag.retry.page, "number", "整页重试次数");
610
+ assert.equal(typeof env.win.__dshMaDiag.scrim.activations, "number", "scrim 下闸次数(C6 用户上报「又点不动了」时用)");
611
+ assert.equal(env.win.__dshMaDiag.compatOfficial.on, false, "锚点齐全的页面上不得误进 compat-official");
612
+ assert.equal(env.win.__dshMaRetryStats, env.win.__dshMaDiag.retry, "mirror-diag 直接读 __dshMaRetryStats");
613
+ });
@@ -103,7 +103,12 @@ export function resolveUpstreamHint({ relayDir = "", env = process.env } = {}) {
103
103
 
104
104
  function tryExec(cmd, args, timeoutMs = 4000) {
105
105
  try {
106
- return String(execFileSync(cmd, args, { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] }) || "");
106
+ // windowsHide(C1):这里会跑 netstat/lsof/ps 这类**控制台程序**。watcher/bridge 常以
107
+ // 无控制台方式启动(launchd / 任务计划 wscript / 插件的 detached spawn),此时 Windows 会
108
+ // 为子进程新分配一个可见控制台窗口 → 用户看到"一闪而过"的黑窗(诊断报告 04 §3.2 第二条)。
109
+ // 上游探测在上游请求失败/ws-open 失败时每 15s 冷却反复触发(见 refreshUpstream),所以闪窗成串。
110
+ // POSIX 上该选项被忽略。
111
+ return String(execFileSync(cmd, args, { encoding: "utf8", timeout: timeoutMs, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }) || "");
107
112
  } catch {
108
113
  return "";
109
114
  }