@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.
- 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 +552 -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-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 +552 -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
|
@@ -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
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|