@mrrisega/dsh-remote 0.6.9-beta.1 → 0.6.9-beta.3
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/mobile-adapter.mjs +367 -37
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +832 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +898 -240
- package/packages/dsh-remote-web/lib/index.js +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
|
@@ -0,0 +1,832 @@
|
|
|
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
|
+
// 抽屉规则都带 :not(.dsh-ma-details) 兜底(防止一列被同时标成抽屉与详情列)
|
|
217
|
+
sidebar: num(/div\.pI_x6G_sidebarCol(?::not\([^)]*\))?,\s*div\.dsh-ma-sidebar(?::not\([^)]*\))?\s*\{[\s\S]{0,500}?z-index:\s*(\d+)/),
|
|
218
|
+
sidebarOpen: num(/html\.dsh-ma-sidebar-open\s+div\.pI_x6G_sidebarCol(?::not\([^)]*\))?,[\s\S]{0,200}?\{\s*z-index:\s*(\d+)/),
|
|
219
|
+
details: num(/div\.pI_x6G_detailsCol,[\s\S]{0,300}?z-index:\s*(\d+)/),
|
|
220
|
+
hamburger: num(/button\.dsh-ma-hamburger\s*\{[\s\S]{0,700}?z-index:\s*(\d+)/),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
class MutationObserverStub {
|
|
225
|
+
constructor(cb) { this.cb = cb; this.targets = []; MutationObserverStub.instances.push(this); }
|
|
226
|
+
observe(t, opts) { this.targets.push({ t, opts }); }
|
|
227
|
+
disconnect() {}
|
|
228
|
+
/** 手工触发:模拟官方改了 frame 的某个属性(并按 opts.attributeFilter/名字规则交给回调)。 */
|
|
229
|
+
fire(attributeName, targetEl) {
|
|
230
|
+
const t = targetEl || (this.targets[0] && this.targets[0].t);
|
|
231
|
+
this.cb([{ type: "attributes", attributeName, target: t }], this);
|
|
232
|
+
}
|
|
233
|
+
static reset() { MutationObserverStub.instances = []; }
|
|
234
|
+
}
|
|
235
|
+
MutationObserverStub.instances = [];
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 搭一个最小的官方 dsh web 窄屏环境:
|
|
239
|
+
* frame(grid 三列) = 侧栏 / 中央列(含输入框) / 第三列(详情 or rightbar) + overlayLayer(全屏)
|
|
240
|
+
* 输入框按官方真实结构:contenteditable + data-composer-input + role=textbox + aria-multiline。
|
|
241
|
+
*/
|
|
242
|
+
function harness({
|
|
243
|
+
// ⚠️ 用 null 表示"官方不写这个属性"(undefined 会命中默认值,测不出"属性缺失"的场景)
|
|
244
|
+
collapsedAttr = "true", detailsAttr = "true", rightbarAttr = null, variant = "rc1",
|
|
245
|
+
width = 390, height = 844, withComposer = true, withToggle = true,
|
|
246
|
+
} = {}) {
|
|
247
|
+
MutationObserverStub.reset();
|
|
248
|
+
const intervals = [];
|
|
249
|
+
const html = new El("html");
|
|
250
|
+
html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
251
|
+
+ "<title>DeepSeek Harness</title></head><body></body></html>";
|
|
252
|
+
const body = new El("body");
|
|
253
|
+
const frame = new El("div", { class: "pI_x6G_frame" });
|
|
254
|
+
if (collapsedAttr !== null) frame.setAttribute("data-sidebar-collapsed", collapsedAttr);
|
|
255
|
+
if (detailsAttr !== null) frame.setAttribute("data-details-collapsed", detailsAttr);
|
|
256
|
+
if (variant === "rc2" && rightbarAttr !== null) frame.setAttribute("data-rightbar-collapsed", rightbarAttr);
|
|
257
|
+
|
|
258
|
+
const sidebar = new El("div", { class: variant === "rc2" ? "pI_x6G_sidebarCol" : "pI_x6G_sidebarCol" });
|
|
259
|
+
const center = new El("div", { class: "pI_x6G_centerCol" });
|
|
260
|
+
const third = new El("div", { class: variant === "rc2" ? "pI_x6G_rightbarCol" : "pI_x6G_detailsCol" });
|
|
261
|
+
const overlay = new El("div", { class: "pI_x6G_overlayLayer", "data-shell-overlay": "true" });
|
|
262
|
+
const row = new El("div", { role: "treeitem", "aria-selected": "false", class: "hHd-Xa_sessionRow YDXeBa_sessionRow" });
|
|
263
|
+
// 抽屉里一条历史会话行的实测位置(侧栏 0..328,避开左上角的汉堡按钮)
|
|
264
|
+
row._rect = { left: 16, top: 200, right: 216, bottom: 240, width: 200, height: 40 };
|
|
265
|
+
sidebar.appendChild(row);
|
|
266
|
+
/* 工作区行(官方真实结构,2026-09-19 真机 outerHTML 取证):
|
|
267
|
+
<div class="YDXeBa_projectRow" role="treeitem" aria-expanded="false" draggable="true">
|
|
268
|
+
<span class="YDXeBa_slot YDXeBa_folder">…<svg>
|
|
269
|
+
<span class="YDXeBa_slot YDXeBa_chevron">…<svg>
|
|
270
|
+
<span class="YDXeBa_projectText"><span class="YDXeBa_title">myFreeWork</span></span>
|
|
271
|
+
<span class="YDXeBa_rowActions">
|
|
272
|
+
<button class="YDXeBa_iconButton" aria-label="Workspace actions for myFreeWork">…</button>
|
|
273
|
+
<button class="YDXeBa_iconButton" aria-label="New session in myFreeWork">…</button>
|
|
274
|
+
官方源码里这一行的 onClick 是 onToggle(setGroupExpanded),点它 = 展开/折叠该工作区的会话列表。 */
|
|
275
|
+
const wsRow = new El("div", { class: "YDXeBa_projectRow", role: "treeitem", "aria-expanded": "false", draggable: "true" });
|
|
276
|
+
wsRow._rect = { left: 12, top: 100, right: 268, bottom: 134, width: 256, height: 34 };
|
|
277
|
+
const wsTitle = new El("span", { class: "YDXeBa_title" });
|
|
278
|
+
wsTitle.textContent = "myFreeWork";
|
|
279
|
+
const wsActions = new El("span", { class: "YDXeBa_rowActions" });
|
|
280
|
+
const wsMenuBtn = new El("button", { class: "YDXeBa_iconButton", "aria-label": "Workspace actions for myFreeWork" });
|
|
281
|
+
const wsNewBtn = new El("button", { class: "YDXeBa_iconButton", "aria-label": "New session in myFreeWork" });
|
|
282
|
+
wsActions.appendChild(wsMenuBtn); wsActions.appendChild(wsNewBtn);
|
|
283
|
+
wsRow.appendChild(wsTitle); wsRow.appendChild(wsActions);
|
|
284
|
+
sidebar.appendChild(wsRow);
|
|
285
|
+
// 侧栏里的其它入口:Task Board(导航)与非交互区(纯文本/滚动槽)
|
|
286
|
+
const taskBoard = new El("button", { class: "_7D6uKa_entry", "aria-label": "Task Board", "data-dsh-taskboard-entry": "" });
|
|
287
|
+
const gutter = new El("div", { class: "bhn1Oq_listArea" });
|
|
288
|
+
const gutterText = new El("span");
|
|
289
|
+
gutter.appendChild(gutterText);
|
|
290
|
+
sidebar.appendChild(taskBoard);
|
|
291
|
+
sidebar.appendChild(gutter);
|
|
292
|
+
// 官方输入区(Lexical):contenteditable 富文本宿主
|
|
293
|
+
const composer = new El("div", {
|
|
294
|
+
contenteditable: "true", "data-composer-input": "true", role: "textbox", "aria-multiline": "true",
|
|
295
|
+
});
|
|
296
|
+
const composerChild = new El("span");
|
|
297
|
+
composer.appendChild(composerChild);
|
|
298
|
+
// 正文里的一行(工具行/消息行):用户点它 = "我要看详情"
|
|
299
|
+
const toolRow = new El("button", { class: "Sh0Q9G_toolRow" });
|
|
300
|
+
toolRow._rect = { left: 12, top: 300, right: 378, bottom: 340, width: 366, height: 40 };
|
|
301
|
+
center.appendChild(toolRow);
|
|
302
|
+
center.appendChild(composer);
|
|
303
|
+
frame.appendChild(sidebar);
|
|
304
|
+
frame.appendChild(center);
|
|
305
|
+
frame.appendChild(third);
|
|
306
|
+
frame.appendChild(overlay); // 官方 overlay 层恒在 frame 末尾(0.1.5 起)
|
|
307
|
+
|
|
308
|
+
// 抽屉/详情列的几何随 html 上的 class 变化(= STYLE 里那两条 transform 规则)
|
|
309
|
+
const rectFor = (el) => {
|
|
310
|
+
if (el === sidebar) return html.classList.contains("dsh-ma-sidebar-open") ? IN_LEFT : OFF_LEFT;
|
|
311
|
+
if (el === third) return html.classList.contains("dsh-ma-details-open") ? IN_RIGHT : OFF_RIGHT;
|
|
312
|
+
if (el === overlay) return { left: 0, right: width, top: 0, bottom: height, width, height };
|
|
313
|
+
if (el === frame) return { left: 0, right: width, top: 0, bottom: height, width, height };
|
|
314
|
+
return el._rect;
|
|
315
|
+
};
|
|
316
|
+
for (const el of [sidebar, center, third, overlay, frame, row, composer, composerChild, toolRow, wsRow, wsTitle, wsNewBtn, wsMenuBtn, taskBoard, gutterText]) {
|
|
317
|
+
el.getBoundingClientRect = () => rectFor(el);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const toggle = new El("button");
|
|
321
|
+
toggle.setAttribute("aria-label", "打开侧边栏");
|
|
322
|
+
toggle._rect = { left: 8, top: 8, right: 48, bottom: 48, width: 40, height: 40 };
|
|
323
|
+
|
|
324
|
+
const doc = new El("#document");
|
|
325
|
+
doc.tagName = "#DOCUMENT";
|
|
326
|
+
doc.appendChild(html);
|
|
327
|
+
html.appendChild(body);
|
|
328
|
+
body.appendChild(frame);
|
|
329
|
+
// document 特有能力
|
|
330
|
+
doc.readyState = "complete";
|
|
331
|
+
doc.createElement = (t) => new El(t);
|
|
332
|
+
doc.createTextNode = (t) => ({ nodeValue: t });
|
|
333
|
+
doc.getElementById = () => null;
|
|
334
|
+
doc.documentElement = html;
|
|
335
|
+
doc.body = body;
|
|
336
|
+
const docQS = El.prototype.querySelector.bind(doc);
|
|
337
|
+
doc.querySelector = (sel) => {
|
|
338
|
+
const s = String(sel);
|
|
339
|
+
if (s.includes("aria-label=") && !s.includes("侧边栏") && !s.includes("sidebar")) return withToggle ? toggle : null;
|
|
340
|
+
if (s.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
|
|
341
|
+
return docQS(s);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
|
|
345
|
+
const win = {
|
|
346
|
+
innerWidth: width, innerHeight: height, devicePixelRatio: 2,
|
|
347
|
+
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]; } },
|
|
348
|
+
location: loc,
|
|
349
|
+
MutationObserver: MutationObserverStub,
|
|
350
|
+
ResizeObserver: class { observe() {} disconnect() {} },
|
|
351
|
+
requestAnimationFrame: (fn) => setImmediate(fn),
|
|
352
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
353
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
354
|
+
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
|
|
355
|
+
clearInterval: (id) => { if (id) intervals[id - 1] = null; },
|
|
356
|
+
getComputedStyle: (el) => ({
|
|
357
|
+
// 只模拟"点不点得动"最关键的那条:遮罩只有在 dsh-ma-scrim-on 时才 pointer-events:auto
|
|
358
|
+
pointerEvents: el === scrimRef.el
|
|
359
|
+
? (html.classList.contains("dsh-ma-scrim-on") ? "auto" : "none")
|
|
360
|
+
: "auto",
|
|
361
|
+
zIndex: "",
|
|
362
|
+
}),
|
|
363
|
+
addEventListener(...a) { doc.addEventListener(...a); },
|
|
364
|
+
removeEventListener(...a) { doc.removeEventListener(...a); },
|
|
365
|
+
};
|
|
366
|
+
const scrimRef = { el: null };
|
|
367
|
+
|
|
368
|
+
const ctx = vm.createContext({
|
|
369
|
+
window: win, document: doc, console,
|
|
370
|
+
navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
|
|
371
|
+
location: loc, localStorage: win.localStorage,
|
|
372
|
+
setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
|
373
|
+
setInterval: win.setInterval, clearInterval: win.clearInterval,
|
|
374
|
+
MutationObserver: MutationObserverStub, ResizeObserver: win.ResizeObserver,
|
|
375
|
+
requestAnimationFrame: win.requestAnimationFrame,
|
|
376
|
+
URL, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
|
|
377
|
+
HTMLElement: El,
|
|
378
|
+
});
|
|
379
|
+
ctx.globalThis = ctx;
|
|
380
|
+
ctx.self = win;
|
|
381
|
+
/** 手工 tick 一次已挂上的 setInterval(看门狗 800ms 那一支)。 */
|
|
382
|
+
const tickTimers = () => { for (const fn of intervals.slice()) { if (fn) fn(); } };
|
|
383
|
+
return { ctx, doc, html, body, frame, sidebar, center, third, overlay, row, toolRow, composer, composerChild, toggle,
|
|
384
|
+
wsRow, wsTitle, wsNewBtn, wsMenuBtn, taskBoard, gutterText, win, scrimRef, tickTimers, intervals };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 跑注入脚本(真实 SCRIPT,一字不改)。 */
|
|
388
|
+
function run(env, js) {
|
|
389
|
+
vm.runInContext(js, env.ctx, { timeout: 5000 });
|
|
390
|
+
const scrim = env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
391
|
+
if (scrim) env.scrimRef.el = scrim;
|
|
392
|
+
const ham = env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
393
|
+
if (ham) ham._rect = { left: 10, top: 10, right: 52, bottom: 52, width: 42, height: 42 };
|
|
394
|
+
return env;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
398
|
+
const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
399
|
+
|
|
400
|
+
// ── 命中测试:按 CSS 契约(z-index 高者在上;同级按 DOM 顺序)判断某点会点到谁 ──────────────
|
|
401
|
+
function hitTest(env, z, x, y) {
|
|
402
|
+
const html = env.html;
|
|
403
|
+
const candidates = [];
|
|
404
|
+
const push = (el, zi, order) => {
|
|
405
|
+
if (!el) return;
|
|
406
|
+
const r = el.getBoundingClientRect();
|
|
407
|
+
if (!(r.left <= x && x <= r.right && r.top <= y && y <= r.bottom)) return;
|
|
408
|
+
candidates.push({ el, zi, order });
|
|
409
|
+
};
|
|
410
|
+
const scrim = findScrim(env);
|
|
411
|
+
const scrimOn = html.classList.contains("dsh-ma-scrim-on");
|
|
412
|
+
const sidebarOpen = html.classList.contains("dsh-ma-sidebar-open");
|
|
413
|
+
const detailsOpen = html.classList.contains("dsh-ma-details-open");
|
|
414
|
+
push(env.frame, 0, 0);
|
|
415
|
+
push(env.center, 0, 1);
|
|
416
|
+
const sidebarZ = sidebarOpen ? z.sidebarOpen : z.sidebar;
|
|
417
|
+
push(env.sidebar, sidebarZ, 2);
|
|
418
|
+
push(env.row, sidebarZ, 6); // 抽屉里的行:子元素在父的层叠上下文里、画在父之上
|
|
419
|
+
push(env.third, z.details, 3);
|
|
420
|
+
if (scrimOn) push(scrim, z.scrim, 4); // 遮罩 DOM 在 frame 之后(body 里的注入元素)
|
|
421
|
+
push(findHamburger(env), z.hamburger, 5);
|
|
422
|
+
if (!scrimOn && scrim) { /* pointer-events:none → 不参与命中 */ }
|
|
423
|
+
if (!detailsOpen && env.third) { /* 详情列在屏外,rect 不包含测试点,自然不参与 */ }
|
|
424
|
+
candidates.sort((a, b) => (b.zi - a.zi) || (b.order - a.order));
|
|
425
|
+
return candidates[0] ? candidates[0].el : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
429
|
+
// Bug 1:微信语音转文字期间的 Enter 不得触发发送
|
|
430
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
431
|
+
|
|
432
|
+
/** 模拟官方输入区的键位图:挂在编辑器根(冒泡阶段)上,Enter ≈ 发送。 */
|
|
433
|
+
function installOfficialSend(env) {
|
|
434
|
+
const sent = [];
|
|
435
|
+
env.composer.addEventListener("keydown", (ev) => {
|
|
436
|
+
if (ev.key !== "Enter") return;
|
|
437
|
+
if (ev.shiftKey) return; // 官方:Shift+Enter 换行
|
|
438
|
+
if (ev.isComposing || ev.keyCode === 229) return; // 官方自带的 IME 守卫(真机实测会漏)
|
|
439
|
+
ev.preventDefault();
|
|
440
|
+
sent.push("send");
|
|
441
|
+
});
|
|
442
|
+
return sent;
|
|
443
|
+
}
|
|
444
|
+
const keydown = (env, init) => {
|
|
445
|
+
const ev = new Ev("keydown", init);
|
|
446
|
+
env.composerChild.dispatchEvent(ev);
|
|
447
|
+
return ev;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
test("IME:合成进行中(isComposing)的 Enter 不得触达官方发送逻辑", () => {
|
|
451
|
+
const { js } = injected();
|
|
452
|
+
const env = run(harness({ width: 390 }), js);
|
|
453
|
+
const sent = installOfficialSend(env);
|
|
454
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: true });
|
|
455
|
+
assert.equal(sent.length, 0, "合成中的 Enter 绝不能被当成发送");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("IME:只报 keyCode 229 的安卓输入法(key 仍是 Enter)也不得发送", () => {
|
|
459
|
+
const { js } = injected();
|
|
460
|
+
// 宽屏也测:228/229 这类"输入法确认"在任何宽度都不该被当成发送
|
|
461
|
+
const env = run(harness({ width: 1280, height: 900 }), js);
|
|
462
|
+
const sent = installOfficialSend(env);
|
|
463
|
+
keydown(env, { key: "Enter", keyCode: 229, isComposing: false });
|
|
464
|
+
assert.equal(sent.length, 0, "keyCode 229 = 输入法确认键(官方自己也这么判),不得发送");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("IME:compositionend 之后紧跟的 Enter(isComposing 已 false)不得发送 —— 微信语音转文字的时序", () => {
|
|
468
|
+
const { js } = injected();
|
|
469
|
+
const env = run(harness({ width: 390 }), js);
|
|
470
|
+
const sent = installOfficialSend(env);
|
|
471
|
+
// 模拟"自动整理文字":合成开始 → 合成结束 → 确认键(官方 10ms 窗口挡不住的那一下)
|
|
472
|
+
env.doc.dispatchEvent(new Ev("compositionstart", {}));
|
|
473
|
+
env.doc.dispatchEvent(new Ev("compositionend", {}));
|
|
474
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
475
|
+
assert.equal(sent.length, 0, "合成刚结束的 Enter 是输入法的确认键,不得发送(官方只留了 10ms 窗口,实测 43ms 就漏)");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("IME:合成结束很久之后的 Enter 不再算输入法确认(窗口只有几十毫秒)", async () => {
|
|
479
|
+
const { js } = injected();
|
|
480
|
+
const env = run(harness({ width: 1280, height: 900 }), js);
|
|
481
|
+
const sent = installOfficialSend(env);
|
|
482
|
+
env.doc.dispatchEvent(new Ev("compositionstart", {}));
|
|
483
|
+
env.doc.dispatchEvent(new Ev("compositionend", {}));
|
|
484
|
+
await new Promise((r) => setTimeout(r, 90)); // 超出 60ms 窗口
|
|
485
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
486
|
+
assert.equal(sent.length, 1, "合成结束 90ms 之后、宽屏上的 Enter 仍应是官方语义(发送)—— 窗口不能太长");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("IME:手机窄屏下 Enter 只换行不发送(发送交给发送按钮),且不 preventDefault", () => {
|
|
490
|
+
const { js } = injected();
|
|
491
|
+
const env = run(harness({ width: 390 }), js);
|
|
492
|
+
const sent = installOfficialSend(env);
|
|
493
|
+
const ev = keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
494
|
+
assert.equal(sent.length, 0, "窄屏下 Enter 不得发送(微信/Telegram 等手机 IM 的语义)");
|
|
495
|
+
assert.equal(ev.defaultPrevented, false,
|
|
496
|
+
"必须只 stopPropagation、不 preventDefault —— 否则换行也插不进去,用户按 Enter 像坏了");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("IME:桌面宽屏上普通 Enter 仍是发送(不破坏桌面习惯)", () => {
|
|
500
|
+
const { js } = injected();
|
|
501
|
+
const env = run(harness({ width: 1280, height: 900 }), js);
|
|
502
|
+
const sent = installOfficialSend(env);
|
|
503
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false });
|
|
504
|
+
assert.equal(sent.length, 1, "宽屏非 IME 的 Enter 必须保持官方语义");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("IME:组合键 Ctrl/Cmd+Enter(官方强制提交)不拦", () => {
|
|
508
|
+
const { js } = injected();
|
|
509
|
+
const env = run(harness({ width: 390 }), js);
|
|
510
|
+
const sent = installOfficialSend(env);
|
|
511
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false, ctrlKey: true });
|
|
512
|
+
keydown(env, { key: "Enter", keyCode: 13, isComposing: false, metaKey: true });
|
|
513
|
+
assert.equal(sent.length, 2, "Ctrl/Cmd+Enter 是官方「强制提交」语义,必须放行");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("IME:守卫只作用于输入框 —— 弹窗/表单里其它地方的 Enter 照常传播", () => {
|
|
517
|
+
const { js } = injected();
|
|
518
|
+
const env = run(harness({ width: 390 }), js);
|
|
519
|
+
const dialog = new El("div", { class: "dsh-ma-dialog" });
|
|
520
|
+
const dialogBtn = new El("button", { "aria-label": "确认" });
|
|
521
|
+
dialog.appendChild(dialogBtn);
|
|
522
|
+
env.body.appendChild(dialog);
|
|
523
|
+
let seen = 0;
|
|
524
|
+
env.doc.addEventListener("keydown", () => { seen += 1; }, true);
|
|
525
|
+
dialogBtn.dispatchEvent(new Ev("keydown", { key: "Enter", keyCode: 13, isComposing: false }));
|
|
526
|
+
dialogBtn.dispatchEvent(new Ev("keydown", { key: "Enter", keyCode: 13, isComposing: true }));
|
|
527
|
+
assert.equal(seen, 2, "非输入框的 Enter(弹窗确认/表单提交/快捷键)一律不得被吞");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("IME:非 Enter 的按键在合成期也不拦(不能伤到输入法自身)", () => {
|
|
531
|
+
const { js } = injected();
|
|
532
|
+
const env = run(harness({ width: 390 }), js);
|
|
533
|
+
const seen = [];
|
|
534
|
+
env.doc.addEventListener("keydown", (ev) => { seen.push(ev.key); }, true);
|
|
535
|
+
env.doc.dispatchEvent(new Ev("compositionstart", {}));
|
|
536
|
+
for (const k of ["a", "Process", "ArrowDown", "Backspace"]) {
|
|
537
|
+
const ev = new Ev("keydown", { key: k, keyCode: k === "Process" ? 229 : k.length === 1 ? k.toUpperCase().charCodeAt(0) : 0, isComposing: true });
|
|
538
|
+
env.composerChild.dispatchEvent(ev);
|
|
539
|
+
}
|
|
540
|
+
env.doc.dispatchEvent(new Ev("compositionend", {}));
|
|
541
|
+
assert.deepEqual(seen, ["a", "Process", "ArrowDown", "Backspace"], "合成期的其它按键必须原样放行");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
545
|
+
// Bug 2:抽屉点不动 + 右边白色框
|
|
546
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
547
|
+
|
|
548
|
+
test("白框:滑出屏外的第三列(rect 完全在视口右侧之外)绝不能被判成「展开的详情列」", () => {
|
|
549
|
+
const { js } = injected();
|
|
550
|
+
// 官方属性认不到(改名/未写)→ 旧实现退回几何判定:离屏列的 right=759 > 8 且 width 358 < 0.98vw
|
|
551
|
+
// → 判成展开 → 白框滑进来盖住整屏 + 遮罩常亮。这正是用户报的现场。
|
|
552
|
+
const env = run(harness({ detailsAttr: null, collapsedAttr: "true" }), js);
|
|
553
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
554
|
+
"离屏的第三列不得被判成展开的详情列(否则就是那个白色空框)");
|
|
555
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "没有任何抽屉在视口里 → 遮罩不得拦点击");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("白框:官方属性说「展开」但页面刚载入时不得自动滑入空的详情面板", () => {
|
|
559
|
+
const { js } = injected();
|
|
560
|
+
// 载入时官方就是展开的(宽屏/上次会话遗留)→ 旧实现立刻滑出白色 Details 面板盖住界面
|
|
561
|
+
const env = run(harness({ detailsAttr: null, rightbarAttr: null, variant: "rc2" }), js);
|
|
562
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "载入时的遗留展开态不得自动弹白框");
|
|
563
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "没有抽屉 → 遮罩不得拦点击");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("白框:官方 收起→展开 的实际切换(用户点了工具行)仍然要能滑入详情", () => {
|
|
567
|
+
const { js } = injected();
|
|
568
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
569
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
570
|
+
assert.ok(obs, "应挂了 frame 属性观察器");
|
|
571
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "前提:初始为收起");
|
|
572
|
+
env.toolRow.dispatchEvent(new Ev("click", {})); // 用户点正文里的工具行
|
|
573
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "官方此刻还说收起 → 不得弹面板");
|
|
574
|
+
env.frame.removeAttribute("data-details-collapsed"); // 官方展开详情(属性被移除)
|
|
575
|
+
obs.fire("data-details-collapsed");
|
|
576
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), true,
|
|
577
|
+
"用户点过正文 + 官方展开详情 → 必须能滑入(不能为了防白框把功能废掉)");
|
|
578
|
+
// 详情列真的滑进视口后,遮罩才允许拦住它背后 —— 正常行为不能被改坏
|
|
579
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
580
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "详情列真的在视口里时,遮罩应正常生效");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("白框:没有用户动作时官方自己翻转成展开(改名/重渲染)不得弹白框", () => {
|
|
584
|
+
const { js } = injected();
|
|
585
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
586
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
587
|
+
// 真机验证里的现场:官方中途把属性换成另一种写法(或直接移除),而用户什么都没点
|
|
588
|
+
env.frame.removeAttribute("data-details-collapsed");
|
|
589
|
+
obs.fire("data-details-collapsed");
|
|
590
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
591
|
+
"用户没点过正文 → 不得仅凭官方属性/时序变化就把空的详情面板推上屏(白框必须是「用户要的」才出现)");
|
|
592
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "同理:遮罩不得跟着拦点击");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("白框:详情面板显示后点空白(遮罩)能关掉,且不会自己弹回来", () => {
|
|
596
|
+
const { js } = injected();
|
|
597
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
598
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
599
|
+
env.toolRow.dispatchEvent(new Ev("click", {}));
|
|
600
|
+
env.frame.removeAttribute("data-details-collapsed");
|
|
601
|
+
obs.fire("data-details-collapsed");
|
|
602
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
603
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), true, "前提:用户点过正文后详情已显示");
|
|
604
|
+
findScrim(env).dispatchEvent(new Ev("click", {}));
|
|
605
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "点空白应关掉详情面板");
|
|
606
|
+
// 后续任何一次兜底 re-sync 都不得把用户明确关掉的面板又弹回来
|
|
607
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
608
|
+
obs.fire("data-rightbar-collapsed");
|
|
609
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
610
|
+
"用户点空白关掉之后,「要看详情」的意图必须被收回,不得自己弹回");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("白框:详情列在视口里时遮罩生效;官方收起来后遮罩立刻撤掉", () => {
|
|
614
|
+
const { js } = injected();
|
|
615
|
+
const env = run(harness({ detailsAttr: "true" }), js);
|
|
616
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
617
|
+
env.toolRow.dispatchEvent(new Ev("click", {}));
|
|
618
|
+
env.frame.removeAttribute("data-details-collapsed");
|
|
619
|
+
obs.fire("data-details-collapsed");
|
|
620
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
621
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:详情列展开且遮罩生效");
|
|
622
|
+
env.frame.setAttribute("data-details-collapsed", "true");
|
|
623
|
+
obs.fire("data-details-collapsed");
|
|
624
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
625
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "官方收起 → 详情 class 撤掉");
|
|
626
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "官方收起 → 遮罩必须一起撤掉");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("遮罩不变量:遮罩在拦点击但视口内没有任何可交互抽屉 → 看门狗立刻摘掉", () => {
|
|
630
|
+
const { js } = injected();
|
|
631
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
632
|
+
const ham = findHamburger(env);
|
|
633
|
+
ham.dispatchEvent(new Ev("click", {})); // 先正常打开抽屉 → 遮罩下闸 + 看门狗启动
|
|
634
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
635
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:抽屉展开、遮罩在拦点击");
|
|
636
|
+
// 制造"判定链已经错了"的现场:抽屉被判定成展开(class 还在),但它的几何已经滑出视口之外
|
|
637
|
+
//(真实世界里对应"官方改了结构/动画没跑完/容器被隐藏"这类让判定与实际不符的情况)
|
|
638
|
+
env.sidebar.getBoundingClientRect = () => OFF_LEFT;
|
|
639
|
+
env.third.getBoundingClientRect = () => OFF_RIGHT;
|
|
640
|
+
env.tickTimers(); // 看门狗 tick(800ms 那一支)
|
|
641
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
|
|
642
|
+
"没有任何抽屉在视口里却在下闸 → 看门狗必须立刻摘掉(用户不能被一张看不见的遮罩困住)");
|
|
643
|
+
assert.ok(ham, "汉堡按钮必须始终存在(唯一自救出口)");
|
|
644
|
+
assert.equal(ham.style.display, "", "汉堡按钮不得被隐藏");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("遮罩不变量:抽屉真的展开时遮罩正常生效(不能把正常行为一起摘掉)", () => {
|
|
648
|
+
const { js } = injected();
|
|
649
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
650
|
+
const ham = findHamburger(env);
|
|
651
|
+
ham.dispatchEvent(new Ev("click", {}));
|
|
652
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "点汉堡应展开抽屉");
|
|
653
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
654
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "抽屉在视口里 → 遮罩应正常生效");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("抽屉可点:抽屉展开时抽屉内点击命中抽屉自身(不被遮罩/详情列吃掉)", () => {
|
|
658
|
+
const { js, css } = injected();
|
|
659
|
+
const z = zIndexes(css);
|
|
660
|
+
assert.ok(z.sidebarOpen > z.scrim,
|
|
661
|
+
`抽屉展开时的层级(${z.sidebarOpen})必须高于遮罩(${z.scrim}) —— 否则抽屉里所有点击都被遮罩吃掉`);
|
|
662
|
+
assert.ok(z.sidebarOpen > z.details,
|
|
663
|
+
`抽屉展开时的层级(${z.sidebarOpen})必须高于详情列(${z.details}) —— 详情列在 frame 里排在抽屉之后,同级会压住抽屉`);
|
|
664
|
+
assert.ok(z.hamburger > z.details && z.hamburger > z.sidebarOpen,
|
|
665
|
+
"汉堡按钮必须始终在最上层(判定怎么错都点得到 = 唯一自救出口)");
|
|
666
|
+
|
|
667
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
668
|
+
findHamburger(env).dispatchEvent(new Ev("click", {}));
|
|
669
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
670
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:抽屉展开、遮罩生效");
|
|
671
|
+
// 抽屉里的历史会话行中心点 → 必须命中的是抽屉(行本身),不是遮罩
|
|
672
|
+
const r = env.row.getBoundingClientRect();
|
|
673
|
+
const hit = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
|
|
674
|
+
assert.equal(hit, env.row, "抽屉展开时,抽屉内的点击必须落在抽屉内容上");
|
|
675
|
+
// 最坏情况:详情列也同时被判成展开 —— 抽屉仍然必须压得住(能在抽屉里点选)
|
|
676
|
+
env.html.classList.add("dsh-ma-details-open");
|
|
677
|
+
const hit2 = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
|
|
678
|
+
assert.equal(hit2, env.row, "即便详情列同时展开,抽屉内的点击也必须有效(不能把用户困住)");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("列识别:官方 0.1.5 第三列里的「Collapse right sidebar」按钮不得让它被当成抽屉列", () => {
|
|
682
|
+
const { js, css } = injected();
|
|
683
|
+
// 0.1.5-rc.2 真机取证:rightbar 列里有 <button class="P3OORG_iconButton" aria-label="Collapse right sidebar">
|
|
684
|
+
// 旧代码用 [aria-label*="sidebar" i] 宽松包含匹配 → rightbar 同时挂上 dsh-ma-sidebar + dsh-ma-details
|
|
685
|
+
// → 抽屉一展开,rightbar 也被 transform:none 拉到 left:0 盖住抽屉(实测 4 次启动 3 次命中)。
|
|
686
|
+
const env = harness({ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: "true" });
|
|
687
|
+
const rb = env.third; // harness variant rc2 → class="pI_x6G_rightbarCol"(官方 0.1.5-rc.2 的真实类名)
|
|
688
|
+
const collapseBtn = new El("button", { class: "P3OORG_iconButton", "aria-label": "Collapse right sidebar" });
|
|
689
|
+
rb.appendChild(collapseBtn);
|
|
690
|
+
run(env, js);
|
|
691
|
+
assert.equal(rb._cls.has("dsh-ma-details"), true, "第三列仍应被认成详情列");
|
|
692
|
+
assert.equal(rb._cls.has("dsh-ma-sidebar"), false,
|
|
693
|
+
"第三列绝不能同时被标成抽屉列(一列两个身份 → 抽屉被自己盖住,点哪都没反应)");
|
|
694
|
+
assert.equal(env.sidebar._cls.has("dsh-ma-sidebar"), true, "真正的侧栏列必须仍是抽屉列");
|
|
695
|
+
// CSS 兜底:抽屉的定位/层级规则都排除了"同时也是详情列"的元素
|
|
696
|
+
assert.match(css, /div\.dsh-ma-sidebar:not\(\.dsh-ma-details\)/,
|
|
697
|
+
"抽屉样式必须带 :not(.dsh-ma-details) 兜底(万一又标重,详情列的样式也要赢)");
|
|
698
|
+
// 抽屉仍然能开,且抽屉内点击可达
|
|
699
|
+
findHamburger(env).dispatchEvent(new Ev("click", {}));
|
|
700
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
701
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "抽屉应能正常展开");
|
|
702
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "抽屉在视口里 → 遮罩正常生效");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("属性观察:官方换名字表达抽屉开合时,适配层仍能收到通知并同步", () => {
|
|
706
|
+
const { js } = injected();
|
|
707
|
+
const env = run(harness({ collapsedAttr: "true" }), js);
|
|
708
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
709
|
+
assert.ok(obs, "应挂了 frame 属性观察器");
|
|
710
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "前提:初始收起");
|
|
711
|
+
// 模拟官方未来改名:用 data-sidebar-open 表达"展开",并触发一次属性变更
|
|
712
|
+
env.frame.removeAttribute("data-sidebar-collapsed");
|
|
713
|
+
env.frame.setAttribute("data-sidebar-open", "true");
|
|
714
|
+
obs.fire("data-sidebar-open");
|
|
715
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true,
|
|
716
|
+
"官方改名的开合属性也必须能被观察到(旧的白名单 attributeFilter 会漏掉 → 官方开了抽屉适配层不知道)");
|
|
717
|
+
// 与之无关的属性变化不应触发同步
|
|
718
|
+
env.frame.setAttribute("data-unrelated-thing", "1");
|
|
719
|
+
env.frame.setAttribute("aria-busy", "true");
|
|
720
|
+
obs.fire("data-unrelated-thing"); // 与开合无关的属性:不得引起状态错乱
|
|
721
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "无关属性变化不得改变抽屉状态");
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("属性兜底:开合属性完全认不到时默认收起(拿不准就选不会困住用户的一边)", () => {
|
|
725
|
+
const { js } = injected();
|
|
726
|
+
const env = run(harness({ collapsedAttr: null, detailsAttr: null }), js);
|
|
727
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false,
|
|
728
|
+
"官方开合属性都认不到时,不得默认展开抽屉(离屏抽屉会盖住内容,而收起态至少还能用汉堡打开)");
|
|
729
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "认不到属性时遮罩不得拦点击");
|
|
730
|
+
const ham = findHamburger(env);
|
|
731
|
+
assert.ok(ham, "此时汉堡按钮必须存在");
|
|
732
|
+
ham.dispatchEvent(new Ev("click", {}));
|
|
733
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "认不到属性也要能用汉堡按钮打开抽屉(可自救)");
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
737
|
+
// 回归(0.6.9-beta.2 用户实测):「选择工作区之后,左边那个抽屉它自己没有收回去」
|
|
738
|
+
//
|
|
739
|
+
// 根因:自动收抽屉的委托用**官方属性**(expanded())当闸门,而官方表达"展开"的方式恰恰是
|
|
740
|
+
// **把 data-sidebar-collapsed 移除** —— 抽屉一打开,expanded() 就变成 false
|
|
741
|
+
// (属性名认不到时更恒为 false),委托第一行直接 return,所有自动收起全部失效。
|
|
742
|
+
// 真机取证(390×844,抽屉已展开):frame 上 "(无 data-sidebar-collapsed)"。
|
|
743
|
+
// 修法:闸门改用适配层自己的确定状态(dsh-ma-sidebar-open || sidebarInView())。
|
|
744
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
745
|
+
|
|
746
|
+
/** 模拟"官方两个开合属性都认不到"(比 0.1.5 改名更极端;也是回归用例最关键的现场)。 */
|
|
747
|
+
function openDrawerWithoutAttrs(env) {
|
|
748
|
+
env.frame.removeAttribute("data-sidebar-collapsed");
|
|
749
|
+
env.frame.removeAttribute("data-sidebar-open");
|
|
750
|
+
findHamburger(env).dispatchEvent(new Ev("click", {}));
|
|
751
|
+
}
|
|
752
|
+
const toggleClicks = (env) => { let n = 0; env.toggle.addEventListener("click", () => { n += 1; }); return () => n; };
|
|
753
|
+
const waitTick = () => new Promise((r) => setTimeout(r, 30));
|
|
754
|
+
|
|
755
|
+
test("自动收抽屉:官方开合属性认不到时,点会话行**仍然**收起(本次回归的主用例)", async () => {
|
|
756
|
+
const { js } = injected();
|
|
757
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
758
|
+
const clicks = toggleClicks(env);
|
|
759
|
+
openDrawerWithoutAttrs(env);
|
|
760
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "前提:抽屉已展开");
|
|
761
|
+
assert.equal(env.frame.hasAttribute("data-sidebar-collapsed"), false, "前提:官方属性认不到(真机就是「展开时移除该属性」)");
|
|
762
|
+
env.row.dispatchEvent(new Ev("click", {}));
|
|
763
|
+
await waitTick();
|
|
764
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false,
|
|
765
|
+
"属性认不到也必须能自动收起(闸门要用适配层自己的状态,不能用官方属性的猜测)");
|
|
766
|
+
assert.ok(clicks() >= 1, "并应点一次官方 toggle(让官方状态与视觉一致)");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("自动收抽屉:点工作区行里的「New session in <工作区>」也收起(= 抽屉里的「选择工作区」入口)", async () => {
|
|
770
|
+
const { js } = injected();
|
|
771
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
772
|
+
openDrawerWithoutAttrs(env);
|
|
773
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "前提:抽屉已展开");
|
|
774
|
+
env.wsNewBtn.dispatchEvent(new Ev("click", {}));
|
|
775
|
+
await waitTick();
|
|
776
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false,
|
|
777
|
+
"在该工作区里新建会话 = 选定了工作区并进入 → 抽屉必须收起(官方 aria-label: New session in <工作区>)");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("自动收抽屉:点工作区行本身(展开/折叠 disclosure)**不**收起", async () => {
|
|
781
|
+
const { js } = injected();
|
|
782
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
783
|
+
openDrawerWithoutAttrs(env);
|
|
784
|
+
env.wsTitle.dispatchEvent(new Ev("click", {})); // 点在标题上 = 官方 onToggle(展开该工作区)
|
|
785
|
+
await waitTick();
|
|
786
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true,
|
|
787
|
+
"工作区行是 disclosure(官方源码 onClick: onToggle → setGroupExpanded;真机实测 aria-expanded false→true、会话行 6→11 条),"
|
|
788
|
+
+ "点它只是展开列表给用户看,收起抽屉会导致「展开完看不到」");
|
|
789
|
+
env.wsMenuBtn.dispatchEvent(new Ev("click", {})); // 行内「⋯」菜单
|
|
790
|
+
await waitTick();
|
|
791
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "行内「⋯」菜单也是就地操作,不得收起抽屉");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("自动收抽屉:点侧栏里的非交互区域(滚动槽/纯文本)不收起", async () => {
|
|
795
|
+
const { js } = injected();
|
|
796
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
797
|
+
openDrawerWithoutAttrs(env);
|
|
798
|
+
env.gutterText.dispatchEvent(new Ev("click", {}));
|
|
799
|
+
env.sidebar.dispatchEvent(new Ev("click", {}));
|
|
800
|
+
await waitTick();
|
|
801
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true,
|
|
802
|
+
"点侧栏空白/滚动槽不得收起(不能变成「点侧栏里任何东西都收」)");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("自动收抽屉:点 Task Board 入口收起(导航类)", async () => {
|
|
806
|
+
const { js } = injected();
|
|
807
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
808
|
+
openDrawerWithoutAttrs(env);
|
|
809
|
+
env.taskBoard.dispatchEvent(new Ev("click", {}));
|
|
810
|
+
await waitTick();
|
|
811
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "Task Board 是切换主视图的导航入口 → 应收起抽屉");
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("自动收抽屉:抽屉没开时点会话行不得产生任何副作用(不误点官方 toggle)", async () => {
|
|
815
|
+
const { js } = injected();
|
|
816
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
817
|
+
const clicks = toggleClicks(env);
|
|
818
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "前提:抽屉是收起的");
|
|
819
|
+
env.row.dispatchEvent(new Ev("click", {}));
|
|
820
|
+
await waitTick();
|
|
821
|
+
assert.equal(clicks(), 0, "抽屉没开就不该去点官方 toggle(那会把抽屉打开/状态搞乱)");
|
|
822
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "也不得因此打开抽屉");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("自动收抽屉:桌面宽屏(>820)不收起", async () => {
|
|
826
|
+
const { js } = injected();
|
|
827
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true", width: 1280, height: 900 }), js);
|
|
828
|
+
const clicks = toggleClicks(env);
|
|
829
|
+
env.row.dispatchEvent(new Ev("click", {}));
|
|
830
|
+
await waitTick();
|
|
831
|
+
assert.equal(clicks(), 0, "宽屏没有离屏抽屉这回事 —— 窄屏门必须继续有效");
|
|
832
|
+
});
|