@mrrisega/dsh-remote 0.6.8 → 0.6.9-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/README.md +1 -1
- package/clients/dsh-remote/mobile-adapter.mjs +274 -21
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +682 -0
- package/docs/telemetry.md +5 -1
- package/dsh-setup.mjs +18 -18
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +1496 -232
- package/packages/dsh-remote-web/lib/index.js +329 -29
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/update-stall.test.mjs +315 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mobile-adapter 手机端两个真实事故的回归测试(假 DOM + 事件传播模型 + vm 跑真实注入脚本)。
|
|
4
|
+
*
|
|
5
|
+
* 事故 1(2026-09-19,微信内置浏览器实测反馈)
|
|
6
|
+
* 「用微信语音转文字输入时,它在自动整理文字的过程中会直接触发输入框的发送」——半截话被发出。
|
|
7
|
+
* 取证:官方输入区是 Lexical contenteditable(根元素带 data-composer-input / role=textbox /
|
|
8
|
+
* aria-multiline / data-lexical-editor),官方键位图自带的 IME 守卫只看
|
|
9
|
+
* `event.isComposing || keyCode===229 || (compositionend 后 10ms 窗口)`;
|
|
10
|
+
* 而微信语音转文字没有标准 compositionend 时序 —— 真机 CDP 实测:compositionend 之后
|
|
11
|
+
* 43ms 到达的 Enter 已经 isComposing=false 且超出 10ms 窗口 → 官方按「用户要发送」处理,真的发出去了。
|
|
12
|
+
* 修复:适配层在**捕获阶段**加输入框专用守卫 —— ① 任何宽度下识别为"输入法确认"的 Enter
|
|
13
|
+
* (isComposing / keyCode 229 / compositionend 后 60ms 内)一律 stopPropagation;
|
|
14
|
+
* ② ≤820px 时输入框里的 Enter **只换行不发送**,发送只由官方发送按钮负责。
|
|
15
|
+
*
|
|
16
|
+
* 事故 2(2026-09-19,Windows 电脑通道实测反馈)
|
|
17
|
+
* 「展开左边抽屉,无法选择历史会话,点任何东西都没有反应;界面右边有一个白色的框占了点位置」。
|
|
18
|
+
* 根因:`isDrawer()` 只查 `r.right > 8`,而**向右滑出屏外的第三列**右边坐标是很大的正数
|
|
19
|
+
* (真机实测 left=400 / right=759 / width=358)→ 完全看不见的详情列被判成"展开的详情列" →
|
|
20
|
+
* `dsh-ma-details-open` 常亮 → 那个空的 Details 面板(白框)滑进来盖住整屏;它与抽屉同为
|
|
21
|
+
* z-index:300 且 DOM 顺序在后 → 压住抽屉吃掉了抽屉里所有点击;scrim 同时也常亮拦掉剩余区域。
|
|
22
|
+
* 修复:① `isDrawer()` 必须与视口真正相交(四边都查);② 详情列不再"官方说展开就滑进来";
|
|
23
|
+
* ③ 遮罩不变量:遮罩在拦点击而视口内没有任何可交互抽屉 → 立刻摘掉(800ms 看门狗)。
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 本文件里的假 DOM 除了元素树,还实现了两件事,否则这两类 bug 根本测不出来:
|
|
26
|
+
* · **事件传播模型**(window→document→…→target 的捕获/冒泡 + stopPropagation/preventDefault),
|
|
27
|
+
* IME 守卫的全部价值就是"官方监听器收不到这一下",没有传播语义就无法断言;
|
|
28
|
+
* · **官方窄屏布局的几何模型**(抽屉/详情列的 rect 随 html 上的 dsh-ma-*-open 变化,与 STYLE
|
|
29
|
+
* 里的 CSS 一致),否则"滑出屏外的第三列"这种几何误判无法复现。
|
|
30
|
+
* CSS 里真正决定"谁能接到点击"的 z-index 不在这层模拟里 —— 用例会先断言 CSS 契约
|
|
31
|
+
* (含抽屉展开时 z-index 310 > 遮罩 290 / 详情列 300),再用同一份契约做命中测试;
|
|
32
|
+
* 浏览器里的真实结果另在 headless Chromium + 官方 GUI 上验证过(见报告)。
|
|
33
|
+
*/
|
|
34
|
+
import assert from "node:assert/strict";
|
|
35
|
+
import test from "node:test";
|
|
36
|
+
import vm from "node:vm";
|
|
37
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
38
|
+
|
|
39
|
+
/** 取注入结果里的真实 <script> / <style> 内容(不加壳,原样执行)。 */
|
|
40
|
+
function injected() {
|
|
41
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
42
|
+
const js = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
43
|
+
const css = /<style id="[^"]*-css"[^>]*>([\s\S]*?)<\/style>/.exec(html);
|
|
44
|
+
assert.ok(js && css, "应能取出注入脚本与样式");
|
|
45
|
+
return { js: js[1], css: css[1] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── 极简但带事件传播的假 DOM ────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function matchesSimple(el, sel) {
|
|
51
|
+
const s = String(sel).trim();
|
|
52
|
+
if (!s) return false;
|
|
53
|
+
const parts = s.match(/^[a-zA-Z][\w-]*|\.[^.[\s]+|\[[^\]]+\]/g);
|
|
54
|
+
if (!parts) return false;
|
|
55
|
+
for (const p of parts) {
|
|
56
|
+
if (p[0] === ".") { if (!el._cls.has(p.slice(1))) return false; continue; }
|
|
57
|
+
if (p[0] === "[") {
|
|
58
|
+
const body = p.slice(1, -1);
|
|
59
|
+
const m = /^([\w-]+)(?:([*^$]?=)(?:"([^"]*)"|'([^']*)'|([^\]]*)))?(?:\s+i)?$/.exec(body);
|
|
60
|
+
if (!m) return false;
|
|
61
|
+
const name = m[1], op = m[2], val = m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : m[5]);
|
|
62
|
+
const has = el.attrs[name] !== undefined;
|
|
63
|
+
if (!op) { if (!has) return false; continue; }
|
|
64
|
+
if (!has) return false;
|
|
65
|
+
const actual = String(el.attrs[name]);
|
|
66
|
+
const expect = String(val);
|
|
67
|
+
if (op === "=") { if (actual.toLowerCase() !== expect.toLowerCase()) return false; }
|
|
68
|
+
else if (op === "*=") { if (!actual.toLowerCase().includes(expect.toLowerCase())) return false; }
|
|
69
|
+
else if (op === "^=") { if (!actual.toLowerCase().startsWith(expect.toLowerCase())) return false; }
|
|
70
|
+
else if (op === "$=") { if (!actual.toLowerCase().endsWith(expect.toLowerCase())) return false; }
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (el.tagName !== p.toUpperCase()) return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
function matches(el, sel) {
|
|
78
|
+
return String(sel).split(",").some((one) => matchesSimple(el, one));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class Ev {
|
|
82
|
+
constructor(type, init = {}) {
|
|
83
|
+
this.type = type;
|
|
84
|
+
this.target = null;
|
|
85
|
+
this.defaultPrevented = false;
|
|
86
|
+
this._stopped = false;
|
|
87
|
+
this._path = [];
|
|
88
|
+
Object.assign(this, init);
|
|
89
|
+
}
|
|
90
|
+
stopPropagation() { this._stopped = true; }
|
|
91
|
+
stopImmediatePropagation() { this._stopped = true; this._immediate = true; }
|
|
92
|
+
preventDefault() { this.defaultPrevented = true; }
|
|
93
|
+
composedPath() { return this._path; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class El {
|
|
97
|
+
constructor(tag = "div", attrs = {}) {
|
|
98
|
+
this.tagName = String(tag).toUpperCase();
|
|
99
|
+
this.attrs = { ...attrs };
|
|
100
|
+
this.children = [];
|
|
101
|
+
this.parent = null;
|
|
102
|
+
// class 属性要同时落进 classList(真实 DOM 里 .foo 选择器与 className 是同一份数据)
|
|
103
|
+
this._cls = new Set(String(attrs.class || "").split(/\s+/).filter(Boolean));
|
|
104
|
+
this._l = new Map(); // type -> { capture: [], bubble: [] }
|
|
105
|
+
this.style = { setProperty() {}, removeProperty() {}, display: "" };
|
|
106
|
+
this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
|
|
107
|
+
this._text = "";
|
|
108
|
+
this.hidden = false;
|
|
109
|
+
this.classList = {
|
|
110
|
+
add: (...cs) => cs.forEach((c) => this._cls.add(c)),
|
|
111
|
+
remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
|
|
112
|
+
contains: (c) => this._cls.has(c),
|
|
113
|
+
toggle: (c, on) => {
|
|
114
|
+
const want = on === undefined ? !this._cls.has(c) : !!on;
|
|
115
|
+
if (want) this._cls.add(c); else this._cls.delete(c);
|
|
116
|
+
return want;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
get className() { return [...this._cls].join(" "); }
|
|
121
|
+
set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
|
122
|
+
set innerHTML(html) {
|
|
123
|
+
this._html = String(html);
|
|
124
|
+
this.children = [];
|
|
125
|
+
for (const m of String(html).matchAll(/<(\w+)([^>]*)>/g)) {
|
|
126
|
+
const el = new El(m[1]);
|
|
127
|
+
const cls = /class="([^"]*)"/.exec(m[2] || "");
|
|
128
|
+
if (cls) el.className = cls[1];
|
|
129
|
+
const type = /type="([^"]*)"/.exec(m[2] || "");
|
|
130
|
+
if (type) el.attrs.type = type[1];
|
|
131
|
+
this.appendChild(el);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
get innerHTML() { return this._html || ""; }
|
|
135
|
+
get textContent() { return this._text; }
|
|
136
|
+
set textContent(v) { this._text = String(v); }
|
|
137
|
+
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
|
|
138
|
+
setAttribute(n, v) { this.attrs[n] = String(v); }
|
|
139
|
+
removeAttribute(n) { delete this.attrs[n]; }
|
|
140
|
+
hasAttribute(n) { return n in this.attrs; }
|
|
141
|
+
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
|
142
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parent = null; return c; }
|
|
143
|
+
remove() { if (this.parent) this.parent.removeChild(this); }
|
|
144
|
+
prepend() {}
|
|
145
|
+
focus() { globalThis.__focused = this; }
|
|
146
|
+
blur() {}
|
|
147
|
+
setPointerCapture() {}
|
|
148
|
+
click() { return this.dispatchEvent(new Ev("click", {})); }
|
|
149
|
+
scrollIntoView() {}
|
|
150
|
+
get isConnected() { return true; }
|
|
151
|
+
matches(sel) { return matches(this, sel); }
|
|
152
|
+
closest(sel) { let n = this; while (n) { if (n.matches && n.matches(sel)) return n; n = n.parent; } return null; }
|
|
153
|
+
contains(node) { let n = node; while (n) { if (n === this) return true; n = n.parent; } return false; }
|
|
154
|
+
descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]); }
|
|
155
|
+
querySelector(sel) { return this.descendants().find((e) => matches(e, sel)) || null; }
|
|
156
|
+
querySelectorAll(sel) { return this.descendants().filter((e) => matches(e, sel)); }
|
|
157
|
+
addEventListener(type, fn, opts) {
|
|
158
|
+
const capture = opts === true || (opts && opts.capture === true);
|
|
159
|
+
const box = this._l.get(type) || { capture: [], bubble: [] };
|
|
160
|
+
(capture ? box.capture : box.bubble).push(fn);
|
|
161
|
+
this._l.set(type, box);
|
|
162
|
+
}
|
|
163
|
+
removeEventListener(type, fn, opts) {
|
|
164
|
+
const capture = opts === true || (opts && opts.capture === true);
|
|
165
|
+
const box = this._l.get(type);
|
|
166
|
+
if (!box) return;
|
|
167
|
+
const arr = capture ? box.capture : box.bubble;
|
|
168
|
+
const i = arr.indexOf(fn);
|
|
169
|
+
if (i >= 0) arr.splice(i, 1);
|
|
170
|
+
}
|
|
171
|
+
/** 目标节点自身在目标阶段要跑的两组监听器(捕获组先跑)。 */
|
|
172
|
+
_fire(ev, capture) {
|
|
173
|
+
const box = this._l.get(ev.type);
|
|
174
|
+
if (!box) return;
|
|
175
|
+
for (const fn of capture ? box.capture : box.bubble) {
|
|
176
|
+
fn(ev);
|
|
177
|
+
if (ev._immediate) return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
dispatchEvent(ev) {
|
|
181
|
+
const path = [];
|
|
182
|
+
let n = this;
|
|
183
|
+
while (n) { path.unshift(n); n = n.parent; } // [window, document, html, body, …target]
|
|
184
|
+
ev._path = path.slice();
|
|
185
|
+
ev.target = this;
|
|
186
|
+
for (let i = 0; i < path.length - 1; i++) { // 捕获阶段(自根向下)
|
|
187
|
+
if (ev._stopped) return !ev.defaultPrevented;
|
|
188
|
+
path[i]._fire(ev, true);
|
|
189
|
+
}
|
|
190
|
+
if (!ev._stopped) this._fire(ev, true); // 目标阶段:捕获组
|
|
191
|
+
if (!ev._stopped) this._fire(ev, false); // 目标阶段:冒泡组
|
|
192
|
+
for (let i = path.length - 2; i >= 0; i--) { // 冒泡阶段
|
|
193
|
+
if (ev._stopped) break;
|
|
194
|
+
path[i]._fire(ev, false);
|
|
195
|
+
}
|
|
196
|
+
return !ev.defaultPrevented;
|
|
197
|
+
}
|
|
198
|
+
getBoundingClientRect() { return this._rect; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 官方布局几何模型(与 STYLE 里的 CSS 一致:抽屉/详情列是离屏浮层,靠 html 上的 class 滑入) ──
|
|
202
|
+
const OFF_LEFT = { left: -338, right: -9, top: 0, bottom: 844, width: 328, height: 844 }; // translateX(-103%)
|
|
203
|
+
const IN_LEFT = { left: 0, right: 328, top: 0, bottom: 844, width: 328, height: 844 };
|
|
204
|
+
const OFF_RIGHT = { left: 400, right: 759, top: 0, bottom: 844, width: 358, height: 844 }; // translateX(103%)
|
|
205
|
+
const IN_RIGHT = { left: 31, right: 390, top: 0, bottom: 844, width: 358, height: 844 };
|
|
206
|
+
|
|
207
|
+
/** 从真实 STYLE 里读出 z-index 契约(命中测试据此排序,改坏 CSS 就会失败)。 */
|
|
208
|
+
function zIndexes(css) {
|
|
209
|
+
const num = (re) => {
|
|
210
|
+
const m = re.exec(css);
|
|
211
|
+
assert.ok(m, `CSS 里应能找到该层级规则: ${re}`);
|
|
212
|
+
return Number(m[1]);
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
scrim: num(/div\.dsh-ma-scrim\s*\{[\s\S]{0,200}?z-index:\s*(\d+)/),
|
|
216
|
+
sidebar: num(/div\.pI_x6G_sidebarCol,\s*div\.dsh-ma-sidebar\s*\{[\s\S]{0,400}?z-index:\s*(\d+)/),
|
|
217
|
+
sidebarOpen: num(/html\.dsh-ma-sidebar-open\s+div\.pI_x6G_sidebarCol,[\s\S]{0,120}?\{\s*z-index:\s*(\d+)/),
|
|
218
|
+
details: num(/div\.pI_x6G_detailsCol,[\s\S]{0,200}?z-index:\s*(\d+)/),
|
|
219
|
+
hamburger: num(/button\.dsh-ma-hamburger\s*\{[\s\S]{0,600}?z-index:\s*(\d+)/),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
class MutationObserverStub {
|
|
224
|
+
constructor(cb) { this.cb = cb; this.targets = []; MutationObserverStub.instances.push(this); }
|
|
225
|
+
observe(t, opts) { this.targets.push({ t, opts }); }
|
|
226
|
+
disconnect() {}
|
|
227
|
+
/** 手工触发:模拟官方改了 frame 的某个属性(并按 opts.attributeFilter/名字规则交给回调)。 */
|
|
228
|
+
fire(attributeName, targetEl) {
|
|
229
|
+
const t = targetEl || (this.targets[0] && this.targets[0].t);
|
|
230
|
+
this.cb([{ type: "attributes", attributeName, target: t }], this);
|
|
231
|
+
}
|
|
232
|
+
static reset() { MutationObserverStub.instances = []; }
|
|
233
|
+
}
|
|
234
|
+
MutationObserverStub.instances = [];
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 搭一个最小的官方 dsh web 窄屏环境:
|
|
238
|
+
* frame(grid 三列) = 侧栏 / 中央列(含输入框) / 第三列(详情 or rightbar) + overlayLayer(全屏)
|
|
239
|
+
* 输入框按官方真实结构:contenteditable + data-composer-input + role=textbox + aria-multiline。
|
|
240
|
+
*/
|
|
241
|
+
function harness({
|
|
242
|
+
// ⚠️ 用 null 表示"官方不写这个属性"(undefined 会命中默认值,测不出"属性缺失"的场景)
|
|
243
|
+
collapsedAttr = "true", detailsAttr = "true", rightbarAttr = null, variant = "rc1",
|
|
244
|
+
width = 390, height = 844, withComposer = true, withToggle = true,
|
|
245
|
+
} = {}) {
|
|
246
|
+
MutationObserverStub.reset();
|
|
247
|
+
const intervals = [];
|
|
248
|
+
const html = new El("html");
|
|
249
|
+
html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
250
|
+
+ "<title>DeepSeek Harness</title></head><body></body></html>";
|
|
251
|
+
const body = new El("body");
|
|
252
|
+
const frame = new El("div", { class: "pI_x6G_frame" });
|
|
253
|
+
if (collapsedAttr !== null) frame.setAttribute("data-sidebar-collapsed", collapsedAttr);
|
|
254
|
+
if (detailsAttr !== null) frame.setAttribute("data-details-collapsed", detailsAttr);
|
|
255
|
+
if (variant === "rc2" && rightbarAttr !== null) frame.setAttribute("data-rightbar-collapsed", rightbarAttr);
|
|
256
|
+
|
|
257
|
+
const sidebar = new El("div", { class: variant === "rc2" ? "pI_x6G_sidebarCol" : "pI_x6G_sidebarCol" });
|
|
258
|
+
const center = new El("div", { class: "pI_x6G_centerCol" });
|
|
259
|
+
const third = new El("div", { class: variant === "rc2" ? "pI_x6G_rightbarCol" : "pI_x6G_detailsCol" });
|
|
260
|
+
const overlay = new El("div", { class: "pI_x6G_overlayLayer", "data-shell-overlay": "true" });
|
|
261
|
+
const row = new El("div", { role: "treeitem", "aria-selected": "false", class: "hHd-Xa_sessionRow" });
|
|
262
|
+
// 抽屉里一条历史会话行的实测位置(侧栏 0..328,避开左上角的汉堡按钮)
|
|
263
|
+
row._rect = { left: 16, top: 200, right: 216, bottom: 240, width: 200, height: 40 };
|
|
264
|
+
sidebar.appendChild(row);
|
|
265
|
+
// 官方输入区(Lexical):contenteditable 富文本宿主
|
|
266
|
+
const composer = new El("div", {
|
|
267
|
+
contenteditable: "true", "data-composer-input": "true", role: "textbox", "aria-multiline": "true",
|
|
268
|
+
});
|
|
269
|
+
const composerChild = new El("span");
|
|
270
|
+
composer.appendChild(composerChild);
|
|
271
|
+
// 正文里的一行(工具行/消息行):用户点它 = "我要看详情"
|
|
272
|
+
const toolRow = new El("button", { class: "Sh0Q9G_toolRow" });
|
|
273
|
+
toolRow._rect = { left: 12, top: 300, right: 378, bottom: 340, width: 366, height: 40 };
|
|
274
|
+
center.appendChild(toolRow);
|
|
275
|
+
center.appendChild(composer);
|
|
276
|
+
frame.appendChild(sidebar);
|
|
277
|
+
frame.appendChild(center);
|
|
278
|
+
frame.appendChild(third);
|
|
279
|
+
frame.appendChild(overlay); // 官方 overlay 层恒在 frame 末尾(0.1.5 起)
|
|
280
|
+
|
|
281
|
+
// 抽屉/详情列的几何随 html 上的 class 变化(= STYLE 里那两条 transform 规则)
|
|
282
|
+
const rectFor = (el) => {
|
|
283
|
+
if (el === sidebar) return html.classList.contains("dsh-ma-sidebar-open") ? IN_LEFT : OFF_LEFT;
|
|
284
|
+
if (el === third) return html.classList.contains("dsh-ma-details-open") ? IN_RIGHT : OFF_RIGHT;
|
|
285
|
+
if (el === overlay) return { left: 0, right: width, top: 0, bottom: height, width, height };
|
|
286
|
+
if (el === frame) return { left: 0, right: width, top: 0, bottom: height, width, height };
|
|
287
|
+
return el._rect;
|
|
288
|
+
};
|
|
289
|
+
for (const el of [sidebar, center, third, overlay, frame, row, composer, composerChild, toolRow]) {
|
|
290
|
+
el.getBoundingClientRect = () => rectFor(el);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const toggle = new El("button");
|
|
294
|
+
toggle.setAttribute("aria-label", "打开侧边栏");
|
|
295
|
+
toggle._rect = { left: 8, top: 8, right: 48, bottom: 48, width: 40, height: 40 };
|
|
296
|
+
|
|
297
|
+
const doc = new El("#document");
|
|
298
|
+
doc.tagName = "#DOCUMENT";
|
|
299
|
+
doc.appendChild(html);
|
|
300
|
+
html.appendChild(body);
|
|
301
|
+
body.appendChild(frame);
|
|
302
|
+
// document 特有能力
|
|
303
|
+
doc.readyState = "complete";
|
|
304
|
+
doc.createElement = (t) => new El(t);
|
|
305
|
+
doc.createTextNode = (t) => ({ nodeValue: t });
|
|
306
|
+
doc.getElementById = () => null;
|
|
307
|
+
doc.documentElement = html;
|
|
308
|
+
doc.body = body;
|
|
309
|
+
const docQS = El.prototype.querySelector.bind(doc);
|
|
310
|
+
doc.querySelector = (sel) => {
|
|
311
|
+
const s = String(sel);
|
|
312
|
+
if (s.includes("aria-label=") && !s.includes("侧边栏") && !s.includes("sidebar")) return withToggle ? toggle : null;
|
|
313
|
+
if (s.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
|
|
314
|
+
return docQS(s);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
|
|
318
|
+
const win = {
|
|
319
|
+
innerWidth: width, innerHeight: height, devicePixelRatio: 2,
|
|
320
|
+
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]; } },
|
|
321
|
+
location: loc,
|
|
322
|
+
MutationObserver: MutationObserverStub,
|
|
323
|
+
ResizeObserver: class { observe() {} disconnect() {} },
|
|
324
|
+
requestAnimationFrame: (fn) => setImmediate(fn),
|
|
325
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
326
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
327
|
+
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
|
|
328
|
+
clearInterval: (id) => { if (id) intervals[id - 1] = null; },
|
|
329
|
+
getComputedStyle: (el) => ({
|
|
330
|
+
// 只模拟"点不点得动"最关键的那条:遮罩只有在 dsh-ma-scrim-on 时才 pointer-events:auto
|
|
331
|
+
pointerEvents: el === scrimRef.el
|
|
332
|
+
? (html.classList.contains("dsh-ma-scrim-on") ? "auto" : "none")
|
|
333
|
+
: "auto",
|
|
334
|
+
zIndex: "",
|
|
335
|
+
}),
|
|
336
|
+
addEventListener(...a) { doc.addEventListener(...a); },
|
|
337
|
+
removeEventListener(...a) { doc.removeEventListener(...a); },
|
|
338
|
+
};
|
|
339
|
+
const scrimRef = { el: null };
|
|
340
|
+
|
|
341
|
+
const ctx = vm.createContext({
|
|
342
|
+
window: win, document: doc, console,
|
|
343
|
+
navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
|
|
344
|
+
location: loc, localStorage: win.localStorage,
|
|
345
|
+
setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
|
346
|
+
setInterval: win.setInterval, clearInterval: win.clearInterval,
|
|
347
|
+
MutationObserver: MutationObserverStub, ResizeObserver: win.ResizeObserver,
|
|
348
|
+
requestAnimationFrame: win.requestAnimationFrame,
|
|
349
|
+
URL, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
|
|
350
|
+
HTMLElement: El,
|
|
351
|
+
});
|
|
352
|
+
ctx.globalThis = ctx;
|
|
353
|
+
ctx.self = win;
|
|
354
|
+
/** 手工 tick 一次已挂上的 setInterval(看门狗 800ms 那一支)。 */
|
|
355
|
+
const tickTimers = () => { for (const fn of intervals.slice()) { if (fn) fn(); } };
|
|
356
|
+
return { ctx, doc, html, body, frame, sidebar, center, third, overlay, row, toolRow, composer, composerChild, toggle, win, scrimRef, tickTimers, intervals };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 跑注入脚本(真实 SCRIPT,一字不改)。 */
|
|
360
|
+
function run(env, js) {
|
|
361
|
+
vm.runInContext(js, env.ctx, { timeout: 5000 });
|
|
362
|
+
const scrim = env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
363
|
+
if (scrim) env.scrimRef.el = scrim;
|
|
364
|
+
const ham = env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
365
|
+
if (ham) ham._rect = { left: 10, top: 10, right: 52, bottom: 52, width: 42, height: 42 };
|
|
366
|
+
return env;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
370
|
+
const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
371
|
+
|
|
372
|
+
// ── 命中测试:按 CSS 契约(z-index 高者在上;同级按 DOM 顺序)判断某点会点到谁 ──────────────
|
|
373
|
+
function hitTest(env, z, x, y) {
|
|
374
|
+
const html = env.html;
|
|
375
|
+
const candidates = [];
|
|
376
|
+
const push = (el, zi, order) => {
|
|
377
|
+
if (!el) return;
|
|
378
|
+
const r = el.getBoundingClientRect();
|
|
379
|
+
if (!(r.left <= x && x <= r.right && r.top <= y && y <= r.bottom)) return;
|
|
380
|
+
candidates.push({ el, zi, order });
|
|
381
|
+
};
|
|
382
|
+
const scrim = findScrim(env);
|
|
383
|
+
const scrimOn = html.classList.contains("dsh-ma-scrim-on");
|
|
384
|
+
const sidebarOpen = html.classList.contains("dsh-ma-sidebar-open");
|
|
385
|
+
const detailsOpen = html.classList.contains("dsh-ma-details-open");
|
|
386
|
+
push(env.frame, 0, 0);
|
|
387
|
+
push(env.center, 0, 1);
|
|
388
|
+
const sidebarZ = sidebarOpen ? z.sidebarOpen : z.sidebar;
|
|
389
|
+
push(env.sidebar, sidebarZ, 2);
|
|
390
|
+
push(env.row, sidebarZ, 6); // 抽屉里的行:子元素在父的层叠上下文里、画在父之上
|
|
391
|
+
push(env.third, z.details, 3);
|
|
392
|
+
if (scrimOn) push(scrim, z.scrim, 4); // 遮罩 DOM 在 frame 之后(body 里的注入元素)
|
|
393
|
+
push(findHamburger(env), z.hamburger, 5);
|
|
394
|
+
if (!scrimOn && scrim) { /* pointer-events:none → 不参与命中 */ }
|
|
395
|
+
if (!detailsOpen && env.third) { /* 详情列在屏外,rect 不包含测试点,自然不参与 */ }
|
|
396
|
+
candidates.sort((a, b) => (b.zi - a.zi) || (b.order - a.order));
|
|
397
|
+
return candidates[0] ? candidates[0].el : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
401
|
+
// Bug 1:微信语音转文字期间的 Enter 不得触发发送
|
|
402
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
403
|
+
|
|
404
|
+
/** 模拟官方输入区的键位图:挂在编辑器根(冒泡阶段)上,Enter ≈ 发送。 */
|
|
405
|
+
function installOfficialSend(env) {
|
|
406
|
+
const sent = [];
|
|
407
|
+
env.composer.addEventListener("keydown", (ev) => {
|
|
408
|
+
if (ev.key !== "Enter") return;
|
|
409
|
+
if (ev.shiftKey) return; // 官方:Shift+Enter 换行
|
|
410
|
+
if (ev.isComposing || ev.keyCode === 229) return; // 官方自带的 IME 守卫(真机实测会漏)
|
|
411
|
+
ev.preventDefault();
|
|
412
|
+
sent.push("send");
|
|
413
|
+
});
|
|
414
|
+
return sent;
|
|
415
|
+
}
|
|
416
|
+
const keydown = (env, init) => {
|
|
417
|
+
const ev = new Ev("keydown", init);
|
|
418
|
+
env.composerChild.dispatchEvent(ev);
|
|
419
|
+
return ev;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
test("IME:合成进行中(isComposing)的 Enter 不得触达官方发送逻辑", () => {
|
|
423
|
+
const { js } = injected();
|
|
424
|
+
const env = run(harness({ width: 390 }), js);
|
|
425
|
+
const sent = installOfficialSend(env);
|
|
426
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: true });
|
|
427
|
+
assert.equal(sent.length, 0, "合成中的 Enter 绝不能被当成发送");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("IME:只报 keyCode 229 的安卓输入法(key 仍是 Enter)也不得发送", () => {
|
|
431
|
+
const { js } = injected();
|
|
432
|
+
// 宽屏也测:228/229 这类"输入法确认"在任何宽度都不该被当成发送
|
|
433
|
+
const env = run(harness({ width: 1280, height: 900 }), js);
|
|
434
|
+
const sent = installOfficialSend(env);
|
|
435
|
+
keydown(env, { key: "Enter", keyCode: 229, isComposing: false });
|
|
436
|
+
assert.equal(sent.length, 0, "keyCode 229 = 输入法确认键(官方自己也这么判),不得发送");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("IME:compositionend 之后紧跟的 Enter(isComposing 已 false)不得发送 —— 微信语音转文字的时序", () => {
|
|
440
|
+
const { js } = injected();
|
|
441
|
+
const env = run(harness({ width: 390 }), js);
|
|
442
|
+
const sent = installOfficialSend(env);
|
|
443
|
+
// 模拟"自动整理文字":合成开始 → 合成结束 → 确认键(官方 10ms 窗口挡不住的那一下)
|
|
444
|
+
env.doc.dispatchEvent(new Ev("compositionstart", {}));
|
|
445
|
+
env.doc.dispatchEvent(new Ev("compositionend", {}));
|
|
446
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
447
|
+
assert.equal(sent.length, 0, "合成刚结束的 Enter 是输入法的确认键,不得发送(官方只留了 10ms 窗口,实测 43ms 就漏)");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("IME:合成结束很久之后的 Enter 不再算输入法确认(窗口只有几十毫秒)", async () => {
|
|
451
|
+
const { js } = injected();
|
|
452
|
+
const env = run(harness({ width: 1280, height: 900 }), js);
|
|
453
|
+
const sent = installOfficialSend(env);
|
|
454
|
+
env.doc.dispatchEvent(new Ev("compositionstart", {}));
|
|
455
|
+
env.doc.dispatchEvent(new Ev("compositionend", {}));
|
|
456
|
+
await new Promise((r) => setTimeout(r, 90)); // 超出 60ms 窗口
|
|
457
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
458
|
+
assert.equal(sent.length, 1, "合成结束 90ms 之后、宽屏上的 Enter 仍应是官方语义(发送)—— 窗口不能太长");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("IME:手机窄屏下 Enter 只换行不发送(发送交给发送按钮),且不 preventDefault", () => {
|
|
462
|
+
const { js } = injected();
|
|
463
|
+
const env = run(harness({ width: 390 }), js);
|
|
464
|
+
const sent = installOfficialSend(env);
|
|
465
|
+
const ev = keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
466
|
+
assert.equal(sent.length, 0, "窄屏下 Enter 不得发送(微信/Telegram 等手机 IM 的语义)");
|
|
467
|
+
assert.equal(ev.defaultPrevented, false,
|
|
468
|
+
"必须只 stopPropagation、不 preventDefault —— 否则换行也插不进去,用户按 Enter 像坏了");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("IME:桌面宽屏上普通 Enter 仍是发送(不破坏桌面习惯)", () => {
|
|
472
|
+
const { js } = injected();
|
|
473
|
+
const env = run(harness({ width: 1280, height: 900 }), js);
|
|
474
|
+
const sent = installOfficialSend(env);
|
|
475
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
476
|
+
assert.equal(sent.length, 1, "宽屏非 IME 的 Enter 必须保持官方语义");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("IME:组合键 Ctrl/Cmd+Enter(官方强制提交)不拦", () => {
|
|
480
|
+
const { js } = injected();
|
|
481
|
+
const env = run(harness({ width: 390 }), js);
|
|
482
|
+
const sent = installOfficialSend(env);
|
|
483
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false, ctrlKey: true });
|
|
484
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false, metaKey: true });
|
|
485
|
+
assert.equal(sent.length, 2, "Ctrl/Cmd+Enter 是官方「强制提交」语义,必须放行");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("IME:守卫只作用于输入框 —— 弹窗/表单里其它地方的 Enter 照常传播", () => {
|
|
489
|
+
const { js } = injected();
|
|
490
|
+
const env = run(harness({ width: 390 }), js);
|
|
491
|
+
const dialog = new El("div", { class: "dsh-ma-dialog" });
|
|
492
|
+
const dialogBtn = new El("button", { "aria-label": "确认" });
|
|
493
|
+
dialog.appendChild(dialogBtn);
|
|
494
|
+
env.body.appendChild(dialog);
|
|
495
|
+
let seen = 0;
|
|
496
|
+
env.doc.addEventListener("keydown", () => { seen += 1; }, true);
|
|
497
|
+
dialogBtn.dispatchEvent(new Ev("keydown", { key: "Enter", keyCode: 13, isComposing: false }));
|
|
498
|
+
dialogBtn.dispatchEvent(new Ev("keydown", { key: "Enter", keyCode: 13, isComposing: true }));
|
|
499
|
+
assert.equal(seen, 2, "非输入框的 Enter(弹窗确认/表单提交/快捷键)一律不得被吞");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("IME:非 Enter 的按键在合成期也不拦(不能伤到输入法自身)", () => {
|
|
503
|
+
const { js } = injected();
|
|
504
|
+
const env = run(harness({ width: 390 }), js);
|
|
505
|
+
const seen = [];
|
|
506
|
+
env.doc.addEventListener("keydown", (ev) => { seen.push(ev.key); }, true);
|
|
507
|
+
env.doc.dispatchEvent(new Ev("compositionstart", {}));
|
|
508
|
+
for (const k of ["a", "Process", "ArrowDown", "Backspace"]) {
|
|
509
|
+
const ev = new Ev("keydown", { key: k, keyCode: k === "Process" ? 229 : k.length === 1 ? k.toUpperCase().charCodeAt(0) : 0, isComposing: true });
|
|
510
|
+
env.composerChild.dispatchEvent(ev);
|
|
511
|
+
}
|
|
512
|
+
env.doc.dispatchEvent(new Ev("compositionend", {}));
|
|
513
|
+
assert.deepEqual(seen, ["a", "Process", "ArrowDown", "Backspace"], "合成期的其它按键必须原样放行");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
517
|
+
// Bug 2:抽屉点不动 + 右边白色框
|
|
518
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
519
|
+
|
|
520
|
+
test("白框:滑出屏外的第三列(rect 完全在视口右侧之外)绝不能被判成「展开的详情列」", () => {
|
|
521
|
+
const { js } = injected();
|
|
522
|
+
// 官方属性认不到(改名/未写)→ 旧实现退回几何判定:离屏列的 right=759 > 8 且 width 358 < 0.98vw
|
|
523
|
+
// → 判成展开 → 白框滑进来盖住整屏 + 遮罩常亮。这正是用户报的现场。
|
|
524
|
+
const env = run(harness({ detailsAttr: null, collapsedAttr: "true" }), js);
|
|
525
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
526
|
+
"离屏的第三列不得被判成展开的详情列(否则就是那个白色空框)");
|
|
527
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "没有任何抽屉在视口里 → 遮罩不得拦点击");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("白框:官方属性说「展开」但页面刚载入时不得自动滑入空的详情面板", () => {
|
|
531
|
+
const { js } = injected();
|
|
532
|
+
// 载入时官方就是展开的(宽屏/上次会话遗留)→ 旧实现立刻滑出白色 Details 面板盖住界面
|
|
533
|
+
const env = run(harness({ detailsAttr: null, rightbarAttr: null, variant: "rc2" }), js);
|
|
534
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "载入时的遗留展开态不得自动弹白框");
|
|
535
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "没有抽屉 → 遮罩不得拦点击");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("白框:官方 收起→展开 的实际切换(用户点了工具行)仍然要能滑入详情", () => {
|
|
539
|
+
const { js } = injected();
|
|
540
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
541
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
542
|
+
assert.ok(obs, "应挂了 frame 属性观察器");
|
|
543
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "前提:初始为收起");
|
|
544
|
+
env.toolRow.dispatchEvent(new Ev("click", {})); // 用户点正文里的工具行
|
|
545
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "官方此刻还说收起 → 不得弹面板");
|
|
546
|
+
env.frame.removeAttribute("data-details-collapsed"); // 官方展开详情(属性被移除)
|
|
547
|
+
obs.fire("data-details-collapsed");
|
|
548
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), true,
|
|
549
|
+
"用户点过正文 + 官方展开详情 → 必须能滑入(不能为了防白框把功能废掉)");
|
|
550
|
+
// 详情列真的滑进视口后,遮罩才允许拦住它背后 —— 正常行为不能被改坏
|
|
551
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
552
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "详情列真的在视口里时,遮罩应正常生效");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("白框:没有用户动作时官方自己翻转成展开(改名/重渲染)不得弹白框", () => {
|
|
556
|
+
const { js } = injected();
|
|
557
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
558
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
559
|
+
// 真机验证里的现场:官方中途把属性换成另一种写法(或直接移除),而用户什么都没点
|
|
560
|
+
env.frame.removeAttribute("data-details-collapsed");
|
|
561
|
+
obs.fire("data-details-collapsed");
|
|
562
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
563
|
+
"用户没点过正文 → 不得仅凭官方属性/时序变化就把空的详情面板推上屏(白框必须是「用户要的」才出现)");
|
|
564
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "同理:遮罩不得跟着拦点击");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("白框:详情面板显示后点空白(遮罩)能关掉,且不会自己弹回来", () => {
|
|
568
|
+
const { js } = injected();
|
|
569
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
570
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
571
|
+
env.toolRow.dispatchEvent(new Ev("click", {}));
|
|
572
|
+
env.frame.removeAttribute("data-details-collapsed");
|
|
573
|
+
obs.fire("data-details-collapsed");
|
|
574
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
575
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), true, "前提:用户点过正文后详情已显示");
|
|
576
|
+
findScrim(env).dispatchEvent(new Ev("click", {}));
|
|
577
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "点空白应关掉详情面板");
|
|
578
|
+
// 后续任何一次兜底 re-sync 都不得把用户明确关掉的面板又弹回来
|
|
579
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
580
|
+
obs.fire("data-rightbar-collapsed");
|
|
581
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
582
|
+
"用户点空白关掉之后,「要看详情」的意图必须被收回,不得自己弹回");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("白框:详情列在视口里时遮罩生效;官方收起来后遮罩立刻撤掉", () => {
|
|
586
|
+
const { js } = injected();
|
|
587
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
588
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
589
|
+
env.toolRow.dispatchEvent(new Ev("click", {}));
|
|
590
|
+
env.frame.removeAttribute("data-details-collapsed");
|
|
591
|
+
obs.fire("data-details-collapsed");
|
|
592
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
593
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:详情列展开且遮罩生效");
|
|
594
|
+
env.frame.setAttribute("data-details-collapsed", "true");
|
|
595
|
+
obs.fire("data-details-collapsed");
|
|
596
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
597
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "官方收起 → 详情 class 撤掉");
|
|
598
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "官方收起 → 遮罩必须一起撤掉");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("遮罩不变量:遮罩在拦点击但视口内没有任何可交互抽屉 → 看门狗立刻摘掉", () => {
|
|
602
|
+
const { js } = injected();
|
|
603
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
604
|
+
const ham = findHamburger(env);
|
|
605
|
+
ham.dispatchEvent(new Ev("click", {})); // 先正常打开抽屉 → 遮罩下闸 + 看门狗启动
|
|
606
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
607
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:抽屉展开、遮罩在拦点击");
|
|
608
|
+
// 制造"判定链已经错了"的现场:抽屉被判定成展开(class 还在),但它的几何已经滑出视口之外
|
|
609
|
+
//(真实世界里对应"官方改了结构/动画没跑完/容器被隐藏"这类让判定与实际不符的情况)
|
|
610
|
+
env.sidebar.getBoundingClientRect = () => OFF_LEFT;
|
|
611
|
+
env.third.getBoundingClientRect = () => OFF_RIGHT;
|
|
612
|
+
env.tickTimers(); // 看门狗 tick(800ms 那一支)
|
|
613
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
|
|
614
|
+
"没有任何抽屉在视口里却在下闸 → 看门狗必须立刻摘掉(用户不能被一张看不见的遮罩困住)");
|
|
615
|
+
assert.ok(ham, "汉堡按钮必须始终存在(唯一自救出口)");
|
|
616
|
+
assert.equal(ham.style.display, "", "汉堡按钮不得被隐藏");
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("遮罩不变量:抽屉真的展开时遮罩正常生效(不能把正常行为一起摘掉)", () => {
|
|
620
|
+
const { js } = injected();
|
|
621
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
622
|
+
const ham = findHamburger(env);
|
|
623
|
+
ham.dispatchEvent(new Ev("click", {}));
|
|
624
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "点汉堡应展开抽屉");
|
|
625
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
626
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "抽屉在视口里 → 遮罩应正常生效");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("抽屉可点:抽屉展开时抽屉内点击命中抽屉自身(不被遮罩/详情列吃掉)", () => {
|
|
630
|
+
const { js, css } = injected();
|
|
631
|
+
const z = zIndexes(css);
|
|
632
|
+
assert.ok(z.sidebarOpen > z.scrim,
|
|
633
|
+
`抽屉展开时的层级(${z.sidebarOpen})必须高于遮罩(${z.scrim}) —— 否则抽屉里所有点击都被遮罩吃掉`);
|
|
634
|
+
assert.ok(z.sidebarOpen > z.details,
|
|
635
|
+
`抽屉展开时的层级(${z.sidebarOpen})必须高于详情列(${z.details}) —— 详情列在 frame 里排在抽屉之后,同级会压住抽屉`);
|
|
636
|
+
assert.ok(z.hamburger > z.details && z.hamburger > z.sidebarOpen,
|
|
637
|
+
"汉堡按钮必须始终在最上层(判定怎么错都点得到 = 唯一自救出口)");
|
|
638
|
+
|
|
639
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
640
|
+
findHamburger(env).dispatchEvent(new Ev("click", {}));
|
|
641
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
642
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:抽屉展开、遮罩生效");
|
|
643
|
+
// 抽屉里的历史会话行中心点 → 必须命中的是抽屉(行本身),不是遮罩
|
|
644
|
+
const r = env.row.getBoundingClientRect();
|
|
645
|
+
const hit = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
|
|
646
|
+
assert.equal(hit, env.row, "抽屉展开时,抽屉内的点击必须落在抽屉内容上");
|
|
647
|
+
// 最坏情况:详情列也同时被判成展开 —— 抽屉仍然必须压得住(能在抽屉里点选)
|
|
648
|
+
env.html.classList.add("dsh-ma-details-open");
|
|
649
|
+
const hit2 = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
|
|
650
|
+
assert.equal(hit2, env.row, "即便详情列同时展开,抽屉内的点击也必须有效(不能把用户困住)");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("属性观察:官方换名字表达抽屉开合时,适配层仍能收到通知并同步", () => {
|
|
654
|
+
const { js } = injected();
|
|
655
|
+
const env = run(harness({ collapsedAttr: "true" }), js);
|
|
656
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
657
|
+
assert.ok(obs, "应挂了 frame 属性观察器");
|
|
658
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "前提:初始收起");
|
|
659
|
+
// 模拟官方未来改名:用 data-sidebar-open 表达"展开",并触发一次属性变更
|
|
660
|
+
env.frame.removeAttribute("data-sidebar-collapsed");
|
|
661
|
+
env.frame.setAttribute("data-sidebar-open", "true");
|
|
662
|
+
obs.fire("data-sidebar-open");
|
|
663
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true,
|
|
664
|
+
"官方改名的开合属性也必须能被观察到(旧的白名单 attributeFilter 会漏掉 → 官方开了抽屉适配层不知道)");
|
|
665
|
+
// 与之无关的属性变化不应触发同步
|
|
666
|
+
env.frame.setAttribute("data-unrelated-thing", "1");
|
|
667
|
+
env.frame.setAttribute("aria-busy", "true");
|
|
668
|
+
obs.fire("data-unrelated-thing"); // 与开合无关的属性:不得引起状态错乱
|
|
669
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "无关属性变化不得改变抽屉状态");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("属性兜底:开合属性完全认不到时默认收起(拿不准就选不会困住用户的一边)", () => {
|
|
673
|
+
const { js } = injected();
|
|
674
|
+
const env = run(harness({ collapsedAttr: null, detailsAttr: null }), js);
|
|
675
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false,
|
|
676
|
+
"官方开合属性都认不到时,不得默认展开抽屉(离屏抽屉会盖住内容,而收起态至少还能用汉堡打开)");
|
|
677
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "认不到属性时遮罩不得拦点击");
|
|
678
|
+
const ham = findHamburger(env);
|
|
679
|
+
assert.ok(ham, "此时汉堡按钮必须存在");
|
|
680
|
+
ham.dispatchEvent(new Ev("click", {}));
|
|
681
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "认不到属性也要能用汉堡按钮打开抽屉(可自救)");
|
|
682
|
+
});
|