@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.
- package/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/clients/dsh-remote/mobile-adapter.mjs +803 -16
- package/clients/dsh-remote/test/bridge-guard.test.mjs +169 -0
- package/clients/dsh-remote/test/e2ee-shim-selfheal.test.mjs +264 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +75 -0
- package/clients/dsh-remote/test/mobile-adapter-compat.test.mjs +175 -0
- package/clients/dsh-remote/test/mobile-adapter-copy.test.mjs +56 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
- package/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/dsh-setup.mjs +225 -6
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/index.js +140 -3
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +803 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
- package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
- 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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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("阈值常量 =
|
|
270
|
+
test("阈值常量 = 20 秒(12 秒低于真实 p90 15.3s → 17.6% 正常加载被误报)", () => {
|
|
238
271
|
const script = adapterScript();
|
|
239
|
-
|
|
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("解析期绝不创建提示层:
|
|
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(
|
|
306
|
+
env.fireTimer(20000);
|
|
271
307
|
const bar = env.findBar();
|
|
272
|
-
assert.ok(bar, "
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
300
|
-
|
|
301
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|