@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 镜像页「旧浏览器兼容层」(2026-10-18 真机事故)。
|
|
4
|
+
*
|
|
5
|
+
* 事故:用户在 iPhone(iOS 16.1 / 17.3)打开镜像页永远卡住、出不来对话界面。
|
|
6
|
+
* mirror_diag 上报抓到真实错误:
|
|
7
|
+
* · TypeError: Promise.withResolvers is not a function (Safari 17.4+ 才有)
|
|
8
|
+
* · AbortSignal.any is not a function (Safari 17.4+ 才有)
|
|
9
|
+
* · SyntaxError: Unexpected token '{'(iOS 16.1 连新语法都不支持 → 只能提示升级)
|
|
10
|
+
*
|
|
11
|
+
* 本文件锁三件事:
|
|
12
|
+
* ① 注入体最前面**真的补齐**了这两个运行时 API(vm 里删掉原生实现后跑,断言可用);
|
|
13
|
+
* ② 语法级不支持 / 典型兼容报错时,会显示**顶部轻量提示条**(可关闭、非全屏、z-index 低于 FAB);
|
|
14
|
+
* ③ 提示条只在"界面没就绪"时出现,绝不打扰正常页面。
|
|
15
|
+
*/
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import vm from "node:vm";
|
|
19
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
20
|
+
|
|
21
|
+
/** 取注入脚本原文。 */
|
|
22
|
+
function adapterScript() {
|
|
23
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
24
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
25
|
+
assert.ok(m, "应能从注入结果里取出脚本");
|
|
26
|
+
return m[1];
|
|
27
|
+
}
|
|
28
|
+
function adapterCss() {
|
|
29
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
30
|
+
const m = /<style id="[^"]*-css"[^>]*>([\s\S]*?)<\/style>/.exec(html);
|
|
31
|
+
assert.ok(m, "应能从注入结果里取出样式");
|
|
32
|
+
return m[1];
|
|
33
|
+
}
|
|
34
|
+
/** 只取兼容层代码段(有 marker,便于独立执行)。 */
|
|
35
|
+
function compatBlock() {
|
|
36
|
+
const src = adapterScript();
|
|
37
|
+
const i = src.indexOf("===DSH-MA-COMPAT-START===");
|
|
38
|
+
const j = src.indexOf("===DSH-MA-COMPAT-END===");
|
|
39
|
+
assert.ok(i > 0 && j > i, "兼容层应有 START/END 标记");
|
|
40
|
+
const bodyStart = src.indexOf("*/", i) + 2; // START 注释结束
|
|
41
|
+
const bodyEnd = src.lastIndexOf("/*", j); // END 注释开始(不要把它带进来)
|
|
42
|
+
return src.slice(bodyStart, bodyEnd);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("★ 补齐 Promise.withResolvers 与 AbortSignal.any(老 Safari 缺这两项 → 页面永远起不来)", async () => {
|
|
46
|
+
// 造一个"老 Safari"环境:独立 fake realm —— 不污染宿主全局(Promise 无 withResolvers、
|
|
47
|
+
// AbortSignal 无 any;AbortController/Signal 行为与浏览器一致:abort 会派发 abort 事件)。
|
|
48
|
+
const listeners = new WeakMap();
|
|
49
|
+
class FakeAbortSignal {
|
|
50
|
+
constructor() { this.aborted = false; this.reason = undefined; }
|
|
51
|
+
addEventListener(type, fn) {
|
|
52
|
+
if (type !== "abort") return;
|
|
53
|
+
const arr = listeners.get(this) || [];
|
|
54
|
+
arr.push(fn);
|
|
55
|
+
listeners.set(this, arr);
|
|
56
|
+
}
|
|
57
|
+
removeEventListener() {}
|
|
58
|
+
}
|
|
59
|
+
class FakeAbortController {
|
|
60
|
+
constructor() { this.signal = new FakeAbortSignal(); }
|
|
61
|
+
abort(reason) {
|
|
62
|
+
const sig = this.signal;
|
|
63
|
+
if (sig.aborted) return;
|
|
64
|
+
sig.aborted = true;
|
|
65
|
+
sig.reason = reason;
|
|
66
|
+
for (const fn of listeners.get(sig) || []) {
|
|
67
|
+
try { fn({ target: sig }); } catch (e) { /* 忽略 */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const sandbox = { console, setTimeout, clearTimeout, AbortSignal: FakeAbortSignal, AbortController: FakeAbortController, Array, Object };
|
|
72
|
+
sandbox.globalThis = sandbox;
|
|
73
|
+
// 老 Safari:没有 withResolvers。用包装函数暴露 Promise 构造器(不带静态 withResolvers)
|
|
74
|
+
sandbox.Promise = function PromiseCompat(executor) { return new Promise(executor); };
|
|
75
|
+
|
|
76
|
+
vm.runInNewContext(compatBlock(), sandbox, { timeout: 5000 });
|
|
77
|
+
|
|
78
|
+
// ① withResolvers:结构正确、能 resolve
|
|
79
|
+
assert.equal(typeof sandbox.Promise.withResolvers, "function", "必须补出 Promise.withResolvers");
|
|
80
|
+
const wr = sandbox.Promise.withResolvers();
|
|
81
|
+
assert.equal(typeof wr.resolve, "function");
|
|
82
|
+
assert.equal(typeof wr.reject, "function");
|
|
83
|
+
assert.ok(wr.promise instanceof Promise);
|
|
84
|
+
let got = null;
|
|
85
|
+
wr.promise.then((v) => { got = v; });
|
|
86
|
+
wr.resolve(42);
|
|
87
|
+
await new Promise((r) => setImmediate(r));
|
|
88
|
+
assert.equal(got, 42, "补齐后的 promise 必须真的能用");
|
|
89
|
+
|
|
90
|
+
// ② AbortSignal.any:任一 signal 中断 → 合并 signal 跟着中断
|
|
91
|
+
assert.equal(typeof sandbox.AbortSignal.any, "function", "必须补出 AbortSignal.any");
|
|
92
|
+
const c1 = new sandbox.AbortController();
|
|
93
|
+
const c2 = new sandbox.AbortController();
|
|
94
|
+
const combined = sandbox.AbortSignal.any([c1.signal, c2.signal]);
|
|
95
|
+
assert.equal(combined.aborted, false);
|
|
96
|
+
c2.abort();
|
|
97
|
+
assert.equal(combined.aborted, true, "其中一个 abort → 合并 signal 必须 aborted");
|
|
98
|
+
|
|
99
|
+
// ③ 已经 aborted 的输入 → 立即返回 aborted 的 signal
|
|
100
|
+
const c3 = new sandbox.AbortController();
|
|
101
|
+
c3.abort();
|
|
102
|
+
assert.equal(sandbox.AbortSignal.any([c3.signal]).aborted, true);
|
|
103
|
+
|
|
104
|
+
// ④ 常用 ES2023 方法兜底(这些也在老 Safari 上缺失)
|
|
105
|
+
assert.equal(typeof sandbox.Array.prototype.findLast, "function");
|
|
106
|
+
assert.equal(typeof sandbox.Array.prototype.toSorted, "function");
|
|
107
|
+
assert.equal(typeof sandbox.Object.hasOwn, "function");
|
|
108
|
+
assert.equal(vm.runInNewContext("[1,2,3].findLast(function(n){return n<3})", sandbox), 2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("★ 语法级不支持(老 Safari 解析不了新语法)→ 探测为不兼容并给出原因", () => {
|
|
112
|
+
const sandbox = { console, setTimeout, clearTimeout };
|
|
113
|
+
sandbox.globalThis = sandbox;
|
|
114
|
+
sandbox.Promise = Promise;
|
|
115
|
+
sandbox.AbortSignal = AbortSignal;
|
|
116
|
+
// 模拟"新语法解析失败":Function 构造器抛 SyntaxError(CSP 场景抛 EvalError 不应误判 —— 见下一条)
|
|
117
|
+
sandbox.Function = function () { const e = new SyntaxError("Unexpected token '{'"); throw e; };
|
|
118
|
+
vm.runInNewContext(compatBlock(), sandbox, { timeout: 5000 });
|
|
119
|
+
const compat = vm.runInNewContext("maCompat", sandbox);
|
|
120
|
+
assert.equal(compat.ok, false, "语法不支持必须判为不兼容");
|
|
121
|
+
assert.match(compat.why, /系统版本过旧/, "要给出人能看懂的原因(系统版本过旧)");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("★ CSP 禁止 eval 抛 EvalError 时**不得**误判为老浏览器(否则正常用户会被吓到)", () => {
|
|
125
|
+
const sandbox = { console, setTimeout, clearTimeout };
|
|
126
|
+
sandbox.globalThis = sandbox;
|
|
127
|
+
sandbox.Promise = Promise;
|
|
128
|
+
sandbox.AbortSignal = AbortSignal;
|
|
129
|
+
// 补上 polyfill,确保唯一失败点是 EvalError
|
|
130
|
+
sandbox.Function = function () { const e = new Error("Blocked by CSP"); e.name = "EvalError"; throw e; };
|
|
131
|
+
// 让 withResolvers 存在,避免被当成缺失
|
|
132
|
+
sandbox.Promise = Object.assign(Promise, { withResolvers: Promise.withResolvers || function () { let a, b; const p = new Promise((r, j) => { a = r; b = j; }); return { promise: p, resolve: a, reject: b }; } });
|
|
133
|
+
vm.runInNewContext(compatBlock(), sandbox, { timeout: 5000 });
|
|
134
|
+
const compat = vm.runInNewContext("maCompat", sandbox);
|
|
135
|
+
assert.equal(typeof compat, "object");
|
|
136
|
+
// EvalError 不应产生"系统版本过旧"
|
|
137
|
+
assert.ok(!/系统版本过旧/.test(compat.why || ""), `CSP 的 EvalError 不得被判成语法不支持,实际 why=${compat.why}`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("★ 兼容提示条:顶部轻量条(非全屏、可关闭、z-index 低于 FAB),两个出口按钮齐全", () => {
|
|
141
|
+
const src = adapterScript();
|
|
142
|
+
const css = adapterCss();
|
|
143
|
+
assert.match(src, /dsh-ma-compatbar/, "注入体必须有兼容提示条");
|
|
144
|
+
assert.match(css, /div\.dsh-ma-compatbar\s*\{/, "必须有对应样式");
|
|
145
|
+
// 顶部轻量条:fixed top,限高;不得全屏
|
|
146
|
+
const barCss = css.slice(css.indexOf("div.dsh-ma-compatbar {"), css.indexOf("div.dsh-ma-compatbar[hidden]"));
|
|
147
|
+
assert.match(barCss, /position:\s*fixed/, "提示条应为浮层");
|
|
148
|
+
assert.match(barCss, /top:\s*0/, "应贴顶部(不挡对话区操作)");
|
|
149
|
+
assert.match(barCss, /max-height:/, "必须限高,不能占满屏");
|
|
150
|
+
assert.doesNotMatch(barCss, /(height|inset):\s*(100vh|0;)/, "不得写成全屏覆盖层(历史事故:全屏层曾永久盖住界面)");
|
|
151
|
+
// z-index 低于 FAB(400),保证悬浮菜单永远可点
|
|
152
|
+
const z = /z-index:\s*(\d+)/.exec(barCss);
|
|
153
|
+
assert.ok(z && Number(z[1]) < 400, `z-index 必须低于 FAB 400,实际 ${z && z[1]}`);
|
|
154
|
+
// 关闭按钮 + 两个出口
|
|
155
|
+
assert.match(src, /dsh-ma-compatbar-x/, "必须有 ✕ 关闭");
|
|
156
|
+
assert.match(src, /返回设备列表/, "必须有「返回设备列表」出口");
|
|
157
|
+
assert.match(src, /复制诊断信息/, "必须有「复制诊断信息」出口");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("★ 提示只在界面没就绪时出现(正常页面绝不被这条打扰)", () => {
|
|
161
|
+
const src = adapterScript();
|
|
162
|
+
const fn = src.slice(src.indexOf("function maShowCompatBar("), src.indexOf("/** 页面脚本报出"));
|
|
163
|
+
assert.match(fn, /maLoadReadyNow\(\)/, "必须先判断页面已就绪,就绪则不提示");
|
|
164
|
+
assert.match(fn, /maLoadReadyNow\(\)\) return/, "就绪时直接 return");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("★ 注入体完整性:无 </script>、可编译(老浏览器兜底代码不能自己把文档截断)", () => {
|
|
168
|
+
const src = adapterScript();
|
|
169
|
+
assert.ok(!/<\/script/i.test(src), "注入体不得含 </script>");
|
|
170
|
+
assert.doesNotThrow(() => new vm.Script(src), "注入体必须能被解析(否则页面直接白屏)");
|
|
171
|
+
// 兼容层必须出现在脚本最前面(要在应用模块执行前补齐 API)
|
|
172
|
+
const compatIdx = src.indexOf("===DSH-MA-COMPAT-START===");
|
|
173
|
+
const firstAppRef = src.indexOf("__DSH_TRANSPORT__");
|
|
174
|
+
assert.ok(compatIdx > 0 && compatIdx < firstAppRef, "兼容层必须排在宿主相关逻辑之前(模块执行前)");
|
|
175
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* C9(2026-10-20 诊断):**文案必须与实现一致**。
|
|
4
|
+
*
|
|
5
|
+
* 现场(03-加载卡住与打不开.md §2.4 D3):「远程控制」面板硬编码「会话密钥只存在本机内存里,
|
|
6
|
+
* 刷新即失效」,而实现早已把派生 MK 写 localStorage、交接单同时写 sessionStorage + localStorage,
|
|
7
|
+
* 镜像页 `_boot()` 会从 localStorage 恢复(e2ee-shim-script.js 的「本机记忆恢复」分支)。
|
|
8
|
+
* 照着旧文案,客服与用户会做出**错误动作**(以为刷新必丢密钥 / 以为没加密就不能用)。
|
|
9
|
+
*
|
|
10
|
+
* 本文件锁三件事:
|
|
11
|
+
* ① 旧说法必须从注入体里消失(它是事实错误,不是"措辞问题");
|
|
12
|
+
* ② 新说法必须同时讲清三条路径:已记住本机(刷新自动恢复)/ 提示失效时回列表重新解锁 / 明文可用但不加密;
|
|
13
|
+
* ③ 这句话是**客服话术**的唯一来源之一,措辞里必须带可执行动作(「返回设备列表重新解锁」)。
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
18
|
+
|
|
19
|
+
function adapterScript() {
|
|
20
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
21
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
22
|
+
assert.ok(m, "应能从注入结果里取出脚本");
|
|
23
|
+
return m[1];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 取出菜单底部那句「返回设备列表 / 加密说明」的原文。 */
|
|
27
|
+
function menuNote() {
|
|
28
|
+
const script = adapterScript();
|
|
29
|
+
const m = /dsh-ma-menu-note"?>([\s\S]*?)<\/div>/.exec(script);
|
|
30
|
+
assert.ok(m, "应能找到菜单里的说明行(dsh-ma-menu-note)");
|
|
31
|
+
return m[1].replace(/' \+/g, "").replace(/["']/g, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("★ 注入体里不得再出现「只存在本机内存里 / 刷新即失效」这类与实现不符的说法", () => {
|
|
35
|
+
const script = adapterScript();
|
|
36
|
+
assert.equal(script.includes("刷新即失效"), false,
|
|
37
|
+
"实现会从 localStorage 恢复加密会话 —— 说「刷新即失效」是事实错误,会让用户做出错误动作");
|
|
38
|
+
assert.equal(script.includes("只存在本机内存里"), false, "会话密钥里「会持久化的解锁材料」与「只存内存的会话密钥」必须分清");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("★ 新文案讲清三条路径:已记住本机自动恢复 / 提示失效→回列表重新解锁 / 明文可用但不加密", () => {
|
|
42
|
+
const note = menuNote();
|
|
43
|
+
assert.match(note, /记住本机/, "必须说明已记住本机(这是实现的事实,也是刷新后仍加密的原因)");
|
|
44
|
+
assert.match(note, /刷新[^。]*自动恢复/, "必须说明刷新会自动恢复加密(不是重新输入密码)");
|
|
45
|
+
assert.match(note, /加密会话已失效/, "必须点名那个提示语(用户看到的原话,客服才能对上)");
|
|
46
|
+
assert.match(note, /返回设备列表重新解锁/, "必须给出**可执行动作**(客服话术的核心)");
|
|
47
|
+
assert.match(note, /明文模式下功能可用/, "必须说明明文也能用(否则用户以为没加密=没得用)");
|
|
48
|
+
assert.match(note, /不经端到端加密|不加密/, "必须明说明文不加密(安全边界不能含糊)");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("C9 客服话术与 UI 文案同源:返回设备列表 / 重新解锁 的措辞一致", () => {
|
|
52
|
+
const note = menuNote();
|
|
53
|
+
// 客服话术(发给 lead 的交付说明)就是这句:
|
|
54
|
+
// 「端到端加密已记住本机,刷新会自动恢复;若提示『加密会话已失效』,回设备列表重新点选设备解锁即可;不想解锁也能用,只是明文不加密。」
|
|
55
|
+
assert.match(note, /返回设备列表重新解锁/, "UI 与客服话术必须用同一个动作短语(避免用户理解成两个操作)");
|
|
56
|
+
});
|
|
@@ -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";
|