@mrrisega/dsh-remote 0.7.3-beta.1 → 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 (24) 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 +552 -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-copy.test.mjs +56 -0
  9. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
  10. package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
  11. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
  12. package/clients/dsh-remote/upstream-discovery.mjs +6 -1
  13. package/dsh-setup.mjs +225 -6
  14. package/package.json +1 -1
  15. package/packages/dsh-remote-web/lib/index.js +140 -3
  16. package/packages/dsh-remote-web/package.json +1 -1
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +552 -16
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
  22. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
  23. package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
  24. package/packages/dsh-remote-web/test/windows-compat.test.mjs +82 -12
@@ -248,6 +248,9 @@ function harness({
248
248
  // ⚠️ 用 null 表示"官方不写这个属性"(undefined 会命中默认值,测不出"属性缺失"的场景)
249
249
  collapsedAttr = "true", detailsAttr = "true", rightbarAttr = null, variant = "rc1",
250
250
  width = 390, height = 844, withComposer = true, withToggle = true,
251
+ /* C6(2026-10-20):alien = 官方锚点整组改名/被插件换掉(frame 还在,但侧栏列认不出、
252
+ 折叠属性没了、开合按钮也没了)—— 正是"装了 dsh web all 之后"的现场。 */
253
+ alien = false,
251
254
  } = {}) {
252
255
  MutationObserverStub.reset();
253
256
  const intervals = [];
@@ -256,13 +259,14 @@ function harness({
256
259
  + "<title>DeepSeek Harness</title></head><body></body></html>";
257
260
  const body = new El("body");
258
261
  const frame = new El("div", { class: "pI_x6G_frame" });
259
- if (collapsedAttr !== null) frame.setAttribute("data-sidebar-collapsed", collapsedAttr);
260
- if (detailsAttr !== null) frame.setAttribute("data-details-collapsed", detailsAttr);
261
- if (variant === "rc2" && rightbarAttr !== null) frame.setAttribute("data-rightbar-collapsed", rightbarAttr);
262
-
263
- const sidebar = new El("div", { class: variant === "rc2" ? "pI_x6G_sidebarCol" : "pI_x6G_sidebarCol" });
264
- const center = new El("div", { class: "pI_x6G_centerCol" });
265
- const third = new El("div", { class: variant === "rc2" ? "pI_x6G_rightbarCol" : "pI_x6G_detailsCol" });
262
+ if (!alien && collapsedAttr !== null) frame.setAttribute("data-sidebar-collapsed", collapsedAttr);
263
+ if (!alien && detailsAttr !== null) frame.setAttribute("data-details-collapsed", detailsAttr);
264
+ if (!alien && variant === "rc2" && rightbarAttr !== null) frame.setAttribute("data-rightbar-collapsed", rightbarAttr);
265
+
266
+ /* alien:列类名也换掉(官方重新打包换前缀 / 插件自绘了侧栏)—— 适配层应当**认不出**它们。 */
267
+ const sidebar = new El("div", { class: alien ? "Qa1_side" : "pI_x6G_sidebarCol" });
268
+ const center = new El("div", { class: alien ? "Qa1_main" : "pI_x6G_centerCol" });
269
+ const third = new El("div", { class: alien ? "Qa1_extra" : (variant === "rc2" ? "pI_x6G_rightbarCol" : "pI_x6G_detailsCol") });
266
270
  const overlay = new El("div", { class: "pI_x6G_overlayLayer", "data-shell-overlay": "true" });
267
271
  const row = new El("div", { role: "treeitem", "aria-selected": "false", class: "hHd-Xa_sessionRow YDXeBa_sessionRow" });
268
272
  // 抽屉里一条历史会话行的实测位置(侧栏 0..328,避开左上角的汉堡按钮)
@@ -962,7 +966,9 @@ test("★ 加载疑似失败时的提示:菜单里一行 + **底部可操作
962
966
 
963
967
  // 阈值 12 秒:25 秒太晚(用户实测干等 47 秒才看到任何东西)
964
968
  assert.match(script, /if \(maLoadReadyNow\(\)\) return;/, "认出官方界面就不该提示");
965
- assert.match(script, /var MA_LOAD_WATCH_MS = 12 \* 1000;/, "看门狗阈值常量应为 12 秒");
969
+ /* 2026-10-20(C4):阈值 12s → **20s** —— 12 秒比真实 p90(15.3s)还短,17.6% 的正常加载
970
+ 会被误报成"电脑端可能未连接"(03 报告 §2.3);20 秒在 p90 之上留余量、又远低于不可忍耐的 40 秒级。 */
971
+ assert.match(script, /var MA_LOAD_WATCH_MS = 20 \* 1000;/, "看门狗阈值常量应为 20 秒(旧 12 秒低于真实 p90)");
966
972
  assert.match(script, /\}, MA_LOAD_WATCH_MS\);/, "setTimeout 必须用该常量");
967
973
  assert.ok(!/25 \* 1000/.test(script), "不得残留 25 秒阈值");
968
974
  // 一句话要能直接发给客服
@@ -1132,3 +1138,76 @@ test("护栏:延迟角标必须是 FAB 内的子元素(不得新增 body 级
1132
1138
  assert.ok(!/\.dsh-ma-fab-rtt\s*\{[^}]*z-index/.test(css), "角标不得自带 z-index(它活在 FAB 的层叠上下文里)");
1133
1139
  void js;
1134
1140
  });
1141
+
1142
+ // ══════════════════════════════════════════════════════════════════════════════
1143
+ // C6(2026-10-20 诊断,00 §6.2 / 04 §V-4):UI 插件兼容降级(compat-official)
1144
+ //
1145
+ // 现场:用户装了 `dsh web all`(或官方重新打包)后,我们自绘的抽屉/全屏 scrim 依赖的一组官方
1146
+ // 锚点(.pI_x6G_frame、.pI_x6G_sidebarCol、.hHd-Xa_*、data-sidebar-collapsed / data-details-collapsed)
1147
+ // 整组失效,但适配层仍然按旧假设建抽屉 + 遮罩 → 「点哪都没反应」。
1148
+ // 契约:锚点缺失/异常 → compat-official:**不建自绘抽屉、不建 scrim、不代替用户点官方入口**,
1149
+ // 只保留字号/E2EE 徽标(FAB + 菜单);锚点齐全 → 原行为完全不变。
1150
+ // ══════════════════════════════════════════════════════════════════════════════
1151
+
1152
+ const findFabIn = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fab"));
1153
+
1154
+ test("★ C6:官方锚点缺失/异常 → 进入 compat-official,不建自绘抽屉与 scrim", () => {
1155
+ const { js } = injected();
1156
+ const env = run(harness({ alien: true, collapsedAttr: null, detailsAttr: null, withToggle: false }), js);
1157
+ assert.equal(findScrim(env) || null, null,
1158
+ "★ 锚点认不出时绝不建自绘遮罩 —— 最坏结局必须是「没有手势」,不能是「点哪都没反应」");
1159
+ assert.equal(findHamburger(env) || null, null, "也不建自绘抽屉按钮(它只会变成一个点不动的死按钮)");
1160
+ assert.ok(env.html.classList.contains("dsh-ma-compat-official"), "必须留下 compat-official 标记(现场诊断/客服上报用)");
1161
+ assert.equal(env.frame.classList.contains("dsh-ma-frame"), false,
1162
+ "★ 不得给官方 frame 打我们的布局类(CSS 会把三列轨道改成 0/1fr/0,用错的锚点改坏官方布局)");
1163
+ assert.ok(findFabIn(env), "字号 / E2EE 徽标这些纯悬浮件必须保留(compat 只关抽屉与 scrim)");
1164
+ const diag = env.win.__dshMaDiag;
1165
+ assert.equal(diag.compatOfficial.on, true, "诊断里必须能看出当前处于 compat-official");
1166
+ /* 跨 realm 数组不能用 deepEqual 直接比(vm 里构造的 Array 原型不同),用字符串比较。 */
1167
+ assert.equal(diag.compatOfficial.reasons.slice().sort().join(","), "sidebar_anchor_missing,sidebar_toggle_missing",
1168
+ "原因要能说清是哪组锚点认不出(便于按官方新版补识别)");
1169
+ });
1170
+
1171
+ test("★ C6:锚点齐全时原行为不变(抽屉 + 遮罩照旧创建,不进 compat)", () => {
1172
+ const { js } = injected();
1173
+ const env = run(harness({}), js);
1174
+ assert.ok(findScrim(env), "锚点齐全 → 遮罩照旧创建");
1175
+ assert.ok(findHamburger(env), "锚点齐全 → 抽屉按钮照旧创建");
1176
+ assert.equal(env.html.classList.contains("dsh-ma-compat-official"), false, "不得误进降级模式");
1177
+ assert.equal(env.win.__dshMaDiag.compatOfficial.on, false);
1178
+ assert.ok(env.frame.classList.contains("dsh-ma-frame"), "锚点齐全时布局适配照旧生效");
1179
+ });
1180
+
1181
+ test("★ C6:官方 frame 挂上但列还没渲染(瞬时状态)→ 不降级、也不建假入口(等下一拍)", () => {
1182
+ const { js } = injected();
1183
+ const env = harness({});
1184
+ env.frame.children = []; // frame 已挂载、但 React 子列还没渲染
1185
+ const run2 = run(env, js);
1186
+ assert.equal(run2.win.__dshMaDiag.compatOfficial.on, false,
1187
+ "子列还没渲染不是「锚点变了」——不能据此永久降级(官方渲染完就正常了)");
1188
+ assert.equal(findScrim(run2) || null, null, "但也绝不能凭一个空 frame 就建遮罩/抽屉");
1189
+ assert.equal(findHamburger(run2) || null, null);
1190
+ });
1191
+
1192
+ test("★ C6:不代替用户点官方入口 —— 开关关掉后点遮罩也不许点官方 toggle", () => {
1193
+ const { js } = injected();
1194
+ const env = run(harness({ collapsedAttr: "" }), js); // 官方说展开 → 遮罩在拦
1195
+ const clicks = toggleClicks(env);
1196
+ const scrim = findScrim(env);
1197
+ assert.ok(scrim, "前提:遮罩存在");
1198
+ env.win.__dshMaOfficialClicks = false; // 真机排查开关:完全停手
1199
+ scrim.dispatchEvent(new Ev("click", {}));
1200
+ assert.equal(clicks(), 0, "★ 开关关闭时绝不代替用户点官方侧栏按钮(但仍要摘掉我们自己的遮挡类)");
1201
+ assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "点遮罩必须仍能自救(摘掉展开类)");
1202
+ });
1203
+
1204
+ test("★ C6:scrim 不变量与下闸次数必须进诊断(用户报「又点不动了」时的第一手证据)", () => {
1205
+ const { js } = injected();
1206
+ const env = run(harness({ collapsedAttr: "" }), js); // 抽屉展开、遮罩下闸
1207
+ assert.ok(env.win.__dshMaDiag.scrim.activations >= 1, "遮罩实际下闸过 → 计数必须 ≥1");
1208
+ // 模拟"抽屉消失但遮罩还在拦" → 不变量摘除并计数
1209
+ env.sidebar.getBoundingClientRect = () => ({ left: -400, right: -80, top: 0, bottom: 844, width: 320, height: 844 });
1210
+ env.tickTimers(); // 800ms 看门狗那一拍
1211
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "不变量必须摘掉遮罩");
1212
+ assert.ok(env.win.__dshMaDiag.scrim.invariantHits >= 1, "摘除次数必须计数(区分「遮罩在拦」与「遮罩早被摘了」)");
1213
+ });
@@ -159,6 +159,15 @@ function harness({ width = 390, height = 844 } = {}) {
159
159
 
160
160
  const frame = new El("div", { class: "pI_x6G_frame" });
161
161
  frame.setAttribute("data-sidebar-collapsed", "true");
162
+ /* C6(2026-10-20):适配层启动时会先比对官方 DOM 锚点(frame / 侧栏列 / 开合驱动),
163
+ 认不出就进入 compat-official(**不建**抽屉与 scrim)。官方 frame 一挂载就带着三列,
164
+ 所以这里给最小列结构(侧栏可识别 + 中央列 + 第三列),让用例测的仍是"迟到挂载"而不是"锚点缺失"。
165
+ ⚠️ 如果哪天真实官方 DOM 变成"frame 挂上但列还没渲染",适配层会 return pending(不降级),
166
+ 下一拍再看 —— 那时这几条用例仍然成立。 */
167
+ const sidebarCol = new El("div"); sidebarCol.className = "pI_x6G_sidebarCol";
168
+ const centerCol = new El("div"); centerCol.className = "pI_x6G_centerCol";
169
+ const detailsCol = new El("div"); detailsCol.className = "pI_x6G_detailsCol";
170
+ frame.appendChild(sidebarCol); frame.appendChild(centerCol); frame.appendChild(detailsCol);
162
171
  frame.querySelector = () => null; // 本用例不涉及第三列/overlay 识别
163
172
 
164
173
  doc.readyState = "complete";
@@ -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
  }