dsh-mobile-ui 0.2.8 → 0.2.9
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/client.js +90 -6
- package/package.json +1 -1
- package/tests/dump-header.py +120 -0
- package/tests/dump-menu.py +96 -0
- package/tests/dump-rightbar.py +95 -0
- package/tests/live-probe.py +345 -164
- package/tests/probe-actions.py +176 -0
- package/tests/probe-pc-rightbar.py +105 -0
- package/tests/probe-rightbar.py +164 -0
- package/tests/try-menu-fix.py +125 -0
- package/tests/try-rightbar.py +132 -0
- package/tests/width-scan.py +179 -0
package/tests/live-probe.py
CHANGED
|
@@ -30,6 +30,13 @@ import sys
|
|
|
30
30
|
MENUS = "() => document.querySelectorAll('[class*=ZKlsPq_menu]').length"
|
|
31
31
|
ROWS = """() => [...document.querySelectorAll('[class*=ZKlsPq_row]')]
|
|
32
32
|
.map(e => { const r = e.getBoundingClientRect(); return [Math.round(r.x + 40), Math.round(r.y + 20)]; })"""
|
|
33
|
+
# 「更多操作」菜单的**语义锚**(2026-09-28 修):
|
|
34
|
+
# ⚠️ 别再写死 `_list_1nxmc_` —— 0.1.7 漂移成 `_list_gzo7u_`,且**失效是静默的**
|
|
35
|
+
# (选择器不命中 ⇒ 菜单看着"没打开",报出来的却是"跑出视口/没有条目",指错方向)。
|
|
36
|
+
# 两版实测都成立的锚:菜单祖先里始终有 `wSkVaW_headerUtilities`
|
|
37
|
+
# (祖先链 menu → span._root_*_1 → div → div.wSkVaW_headerUtilities → titleRow → header)。
|
|
38
|
+
MORE_MENU = "[class*=headerUtilities] [role=menu]"
|
|
39
|
+
|
|
33
40
|
GEOM = """() => {
|
|
34
41
|
const sw = [...document.querySelectorAll('button[class*=ZKlsPq_trigger]')].pop();
|
|
35
42
|
const crumbs = document.querySelector('[class*=wSkVaW_crumbs]') || document.querySelector('[class*=crumbs]');
|
|
@@ -72,8 +79,106 @@ SESSION_ROWS_JS = "[...document.querySelectorAll('[role=treeitem][class*=session
|
|
|
72
79
|
SESSION_LABELS = """(needle) => %s
|
|
73
80
|
.map(r => (r.textContent || '').trim())
|
|
74
81
|
.filter(t => !needle || t.includes(needle))""" % SESSION_ROWS_JS
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
# ⚠️ 这里曾有个 SESSION_CLICK(JS `el.click()`)—— **已删除、勿再加回**:它在会话行上
|
|
83
|
+
# 时灵时不灵(2026-09-28 实测连点 5 次只有 2 次真跳转),会把「没点动」伪装成
|
|
84
|
+
# 「已进入会话」,于是后面所有断言都在测首页 ⇒ 报出一串**指向错误方向**的假回归。
|
|
85
|
+
# 正确做法 = `click_session()`(真实 tap,走用户路径)。
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tap_sel(page, sel):
|
|
89
|
+
"""按选择器点元素中心;不存在或尺寸为 0 返回 False。
|
|
90
|
+
|
|
91
|
+
⚠️ 别再用硬编码坐标:抽屉入口的位置/归属随 dsh 版本漂移过(0.1.5 是右上角悬浮汉堡,
|
|
92
|
+
0.1.7 header 压矮后同一坐标落空)—— 2026-09-28 实测 `tap(w-33, 69)` 在 0.1.7 上
|
|
93
|
+
压根没打开抽屉,⑤ 于是恒红,看起来像回归、其实是探针自己点空了。
|
|
94
|
+
"""
|
|
95
|
+
box = page.evaluate("""(sel) => { const el = document.querySelector(sel);
|
|
96
|
+
if (!el) return null;
|
|
97
|
+
const r = el.getBoundingClientRect();
|
|
98
|
+
return (r.width > 0 && r.height > 0) ? [r.left + r.width / 2, r.top + r.height / 2] : null; }""", sel)
|
|
99
|
+
if box is None:
|
|
100
|
+
return False
|
|
101
|
+
page.touchscreen.tap(box[0], box[1])
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def open_drawer(page):
|
|
106
|
+
"""开抽屉:会话页点 tabs 行的 ☰,回退首页右上角悬浮汉堡。返回点到的选择器或 None。"""
|
|
107
|
+
for sel in ("#dsh-mobile-tab-menu", "#dsh-mobile-menu-btn"):
|
|
108
|
+
if tap_sel(page, sel):
|
|
109
|
+
return sel
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def close_drawer(page, w):
|
|
114
|
+
"""关抽屉:点右侧空白。
|
|
115
|
+
|
|
116
|
+
抽屉开着时插件会插一层 `#dsh-mobile-scrim.dsh-mobile-visible` 吃点击(它**在** DOM 里),
|
|
117
|
+
但**不能点它的中心** —— 抽屉宽 320px、屏幕 390px,中心落在抽屉自身上;所以要偏右缘点。
|
|
118
|
+
"""
|
|
119
|
+
page.touchscreen.tap(w - 30, 500)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ensure_closed(page):
|
|
123
|
+
"""抽屉若开着就关掉;已关则**什么都不点**(在已关时乱点会误触页面内容)。"""
|
|
124
|
+
w = page.viewport_size["width"]
|
|
125
|
+
if not page.evaluate(DRAWER).startswith("none"):
|
|
126
|
+
close_drawer(page, w)
|
|
127
|
+
page.wait_for_timeout(700)
|
|
128
|
+
return page.evaluate(DRAWER)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def open_drawer_confirmed(page, tries=3, poll=6):
|
|
132
|
+
"""点开抽屉并**轮询**确认,返回 (入口选择器, display:width)。
|
|
133
|
+
|
|
134
|
+
为什么不「点一下、睡固定时长、再读一次」:2026-09-28 实测同一版本会偶发读到 none
|
|
135
|
+
(采样竞态)⇒ 单次采样会让断言时红时绿、把真回归淹没在假阳性里。
|
|
136
|
+
"""
|
|
137
|
+
w = page.viewport_size["width"]
|
|
138
|
+
state, used = "none:0", None
|
|
139
|
+
for _ in range(tries):
|
|
140
|
+
used = open_drawer(page)
|
|
141
|
+
if used is None:
|
|
142
|
+
return None, state
|
|
143
|
+
for _ in range(poll):
|
|
144
|
+
state = page.evaluate(DRAWER)
|
|
145
|
+
if not state.startswith("none"):
|
|
146
|
+
return used, state
|
|
147
|
+
page.wait_for_timeout(500)
|
|
148
|
+
close_drawer(page, w) # 没开成 ⇒ 归零重试
|
|
149
|
+
page.wait_for_timeout(600)
|
|
150
|
+
return used, state
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def click_session(page, label):
|
|
154
|
+
"""按标题点开会话 —— 用**真实 tap**(点行中心),不要用 JS `el.click()`。
|
|
155
|
+
|
|
156
|
+
⚠️ 2026-09-28 实测:`el.click()` 在会话行上**时灵时不灵**(连点 5 次只有 2 次真跳转),
|
|
157
|
+
而症状极隐蔽 —— 探针以为"已进入会话",其实还停在原页面,于是 ③b~③e 全都在测错对象
|
|
158
|
+
(这正是"探针报绿却什么都没测到"的成因之一)。tap 走用户的真实路径,可靠。
|
|
159
|
+
行可能被滚出抽屉可视区 ⇒ 先 scrollIntoView,再确认中心确实落在视口内。
|
|
160
|
+
"""
|
|
161
|
+
box = page.evaluate("""(label) => {
|
|
162
|
+
const r = [...document.querySelectorAll('[role=treeitem][class*=sessionRow]')]
|
|
163
|
+
.find(e => (e.textContent || '').trim() === label);
|
|
164
|
+
if (!r) return null;
|
|
165
|
+
r.scrollIntoView({block: 'center'});
|
|
166
|
+
const b = r.getBoundingClientRect();
|
|
167
|
+
const cx = b.left + b.width / 2, cy = b.top + b.height / 2;
|
|
168
|
+
if (cx < 2 || cy < 2 || cx > innerWidth - 2 || cy > innerHeight - 2) return null;
|
|
169
|
+
return [cx, cy]; }""", label)
|
|
170
|
+
if box is None:
|
|
171
|
+
return False
|
|
172
|
+
page.touchscreen.tap(box[0], box[1])
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def on_session_page(page):
|
|
177
|
+
"""当前是否是**会话页**(而不是首页/启动页):会话页才有 header 与 tabs 行工具组。"""
|
|
178
|
+
return page.evaluate("""() => ({ header: !!document.querySelector('header'),
|
|
179
|
+
tools: document.querySelectorAll('#dsh-mobile-tab-tools button').length,
|
|
180
|
+
ham: (() => { const h = document.getElementById('dsh-mobile-menu-btn');
|
|
181
|
+
return h ? getComputedStyle(h).display : null; })() })""")
|
|
77
182
|
|
|
78
183
|
|
|
79
184
|
def run(args) -> int:
|
|
@@ -86,7 +191,7 @@ def run(args) -> int:
|
|
|
86
191
|
token = token_file.read_text(encoding="utf-8").strip()
|
|
87
192
|
w, h = (int(x) for x in args.viewport.lower().split("x"))
|
|
88
193
|
base = args.base_url.rstrip("/")
|
|
89
|
-
fails
|
|
194
|
+
fails, skips = [], []
|
|
90
195
|
|
|
91
196
|
with sync_playwright() as p:
|
|
92
197
|
browser = p.chromium.launch()
|
|
@@ -97,30 +202,39 @@ def run(args) -> int:
|
|
|
97
202
|
page.wait_for_timeout(3000)
|
|
98
203
|
|
|
99
204
|
# ① 逐个试开会话,直到找到一个**有子代理**的(列表里多数会话没有切换器)
|
|
100
|
-
page
|
|
205
|
+
open_drawer(page)
|
|
101
206
|
page.wait_for_timeout(1300)
|
|
102
207
|
labels = page.evaluate(SESSION_LABELS, args.session or "")
|
|
103
|
-
page
|
|
208
|
+
ensure_closed(page) # 幂等关抽屉(已关则不点,免得误触)
|
|
104
209
|
page.wait_for_timeout(1000)
|
|
105
210
|
if not labels:
|
|
106
211
|
print(f"❌ 会话列表里没有可打开的会话(--session={args.session!r})")
|
|
107
212
|
return 2
|
|
108
213
|
geom, opened = {"err": "未找到含子代理的会话"}, None
|
|
109
214
|
for i in range(args.session_tries):
|
|
110
|
-
page
|
|
215
|
+
open_drawer(page) # 开抽屉(按元素点,勿硬编码坐标)
|
|
111
216
|
page.wait_for_timeout(1200)
|
|
112
217
|
# ⚠️ 每次都要重新读列表:点开其它工作区的会话会切换工作区,会话列表随之整批变化
|
|
113
218
|
# (按索引点会点错行 —— 必须"读当下这一行的文本 → 再按该文本点")
|
|
114
219
|
rows_now = page.evaluate(SESSION_LABELS, args.session or "")
|
|
220
|
+
# ⚠️⚠️ 每个出口都必须先关抽屉!上面刚 open_drawer 过,直接 break/continue 会把
|
|
221
|
+
# **开着的抽屉**留给后面的断言:抽屉宽 320px,③b 点的「后台任务」在 x≈146..270
|
|
222
|
+
# ⇒ 那一下实际点在**抽屉里的行**上(点到「新会话/工作区」),页面被带成空会话视图,
|
|
223
|
+
# 于是后面报出「工具组不全 / ⋯ 按钮不能开合」等一串**指向错误方向**的假回归。
|
|
224
|
+
# (2026-09-28 实测:探针全绿的假象与这次假回归都源于此。)
|
|
115
225
|
if i >= len(rows_now):
|
|
226
|
+
ensure_closed(page)
|
|
116
227
|
break
|
|
117
228
|
label = rows_now[i]
|
|
118
|
-
clicked = page
|
|
229
|
+
clicked = click_session(page, label)
|
|
119
230
|
if not clicked:
|
|
231
|
+
ensure_closed(page)
|
|
120
232
|
continue
|
|
121
233
|
page.wait_for_timeout(1500)
|
|
122
|
-
|
|
123
|
-
|
|
234
|
+
# ⚠️ 只在抽屉确实开着时才点 scrim:无条件点会在**已关**时误触会话页内容
|
|
235
|
+
# (2026-09-28 实测把页面带到别的视图 ⇒ 后面断言全测错对象、报一堆假回归)。
|
|
236
|
+
ensure_closed(page)
|
|
237
|
+
page.wait_for_timeout(400)
|
|
124
238
|
# 会话历史可能很大(实测 1.1MB,首次渲染要数秒)⇒ 轮询等切换器出现,别固定睡眠
|
|
125
239
|
geom, opened = {"err": "未找到含子代理的会话"}, label
|
|
126
240
|
for _ in range(12):
|
|
@@ -133,11 +247,53 @@ def run(args) -> int:
|
|
|
133
247
|
print(f" 跳过(无子代理切换器):{label[:40]}")
|
|
134
248
|
if opened:
|
|
135
249
|
print(f"打开会话:{opened[:40]}")
|
|
250
|
+
ensure_closed(page) # 兜底:绝不能带着开着的抽屉去跑后面的断言
|
|
251
|
+
page.wait_for_timeout(500)
|
|
252
|
+
|
|
253
|
+
# ⚠️ 先确认**真的进了会话页**再往下断言:会话行没点动时,后面 ③b~③e 会拿首页去量,
|
|
254
|
+
# 报出来的是"工具组不全 / ⋯ 按钮不能开合"之类**指向错误方向**的假回归。
|
|
255
|
+
st = on_session_page(page)
|
|
256
|
+
for _ in range(2): # 自愈:重新开抽屉→点会话→关抽屉
|
|
257
|
+
if st["header"] and st["tools"]:
|
|
258
|
+
break
|
|
259
|
+
print(f"⚠️ 当前不在会话页(header={st['header']} tabs工具={st['tools']})⇒ 重进一次")
|
|
260
|
+
open_drawer(page)
|
|
261
|
+
page.wait_for_timeout(1200)
|
|
262
|
+
rows_retry = page.evaluate(SESSION_LABELS, args.session or "")
|
|
263
|
+
if rows_retry:
|
|
264
|
+
click_session(page, rows_retry[0])
|
|
265
|
+
page.wait_for_timeout(2000)
|
|
266
|
+
ensure_closed(page)
|
|
267
|
+
page.wait_for_timeout(3500)
|
|
268
|
+
st = on_session_page(page)
|
|
269
|
+
if not st["header"] or st["tools"] == 0:
|
|
270
|
+
print(f"❌ 重进也没能到会话页(header={st['header']} tabs工具={st['tools']} "
|
|
271
|
+
f"悬浮汉堡={st['ham']})—— 会话行点了没反应?")
|
|
272
|
+
print(" ⇒ 后面所有断言都会测错对象,直接中止(这是探针自身故障,不是插件回归)")
|
|
273
|
+
fails.append("探针没能进入会话页(会话行点击失效)⇒ 断言未执行,先修探针")
|
|
274
|
+
browser.close()
|
|
275
|
+
print()
|
|
276
|
+
print(f"❌ 真实页面回归:{len(fails)} 项不达标")
|
|
277
|
+
for f in fails:
|
|
278
|
+
print(f" - {f}")
|
|
279
|
+
return 1
|
|
136
280
|
|
|
137
281
|
# ② 头部切换器几何
|
|
138
282
|
print(f"切换器:{json.dumps(geom, ensure_ascii=False)}")
|
|
139
283
|
if geom.get("err"):
|
|
140
|
-
|
|
284
|
+
# 「试开的会话都没有子代理」= 本次**覆盖不到**,记 skip(否则每次跑都红、真回归被淹没)。
|
|
285
|
+
# ⚠️ 但本条**无法区分**两种成因:①本会话真的没有子代理(常态)
|
|
286
|
+
# ②上游把触发点改了(改名/换标签)—— 症状完全相同,都是"找不到切换器"。
|
|
287
|
+
# ② 的静态判据已核过(2026-09-28,0.1.5 与 0.1.7 的 dsh-client-ui-subagent
|
|
288
|
+
# bundle 逐句对照,三处契约一致):触发点是
|
|
289
|
+
# jsxs("button", {className: …trigger|…switcherTrigger, "aria-expanded": open,
|
|
290
|
+
# onKeyDown: ArrowDown → changeOpen(true)})
|
|
291
|
+
# 关闭在 root 的 navigate:Escape → changeOpen(false, true)。
|
|
292
|
+
# ⇒ 复验命令与结论见 docs/dsh-mobile-ui.md §3.1;要**真覆盖**需含子代理的会话
|
|
293
|
+
# (本机历史上 0 个:179 会话头部 delegationDepth 全 0、正文 0 个 parentSessionId)
|
|
294
|
+
# ⇒ python3 tests/live-probe.py --session "<含子代理的会话标题>"
|
|
295
|
+
skips.append(f"子代理切换器断言未覆盖(本会话无子代理;**也可能是上游改了触发点**,"
|
|
296
|
+
f"判据见 docs/dsh-mobile-ui.md §3.1):{geom['err']}")
|
|
141
297
|
else:
|
|
142
298
|
if geom["clipped"]:
|
|
143
299
|
fails.append(f"切换器被 crumbs 裁掉(宽仅 {geom['w']}px,right 超出 crumbs)")
|
|
@@ -146,10 +302,16 @@ def run(args) -> int:
|
|
|
146
302
|
if not geom["clickable"]:
|
|
147
303
|
fails.append("切换器中心点不到它(被其它元素覆盖)")
|
|
148
304
|
|
|
149
|
-
# ③ toggle:连点 4 次应为 开/关/开/关
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
305
|
+
# ③ 子代理切换器 toggle:连点 4 次应为 开/关/开/关
|
|
306
|
+
# ⚠️ 这一段**必须**有子代理会话(header 才渲染 ZKlsPq_trigger)。
|
|
307
|
+
# ③b~③e 与子代理无关,**已从本 guard 里摘出去** —— 2026-09-28 实测发现:
|
|
308
|
+
# 本机 179 个会话 delegationDepth 全为 0 ⇒ 永远找不到切换器 ⇒ 旧写法下
|
|
309
|
+
# 「⋯ 按钮 / 412px 隐形死区 / header 瘦身 / 折叠输入区」这一整组**静默跳过**,
|
|
310
|
+
# 而那正是用户报过故障的区域(探针全绿却什么都没测)。
|
|
311
|
+
sw = page.evaluate("""() => { const b = [...document.querySelectorAll('button[class*=ZKlsPq_trigger]')].pop();
|
|
312
|
+
if (!b) return null; const r = b.getBoundingClientRect();
|
|
313
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }""")
|
|
314
|
+
if not geom.get("err") and sw:
|
|
153
315
|
seq = []
|
|
154
316
|
for _ in range(4):
|
|
155
317
|
page.touchscreen.tap(sw["x"], sw["y"])
|
|
@@ -159,155 +321,165 @@ def run(args) -> int:
|
|
|
159
321
|
if seq != [1, 0, 1, 0]:
|
|
160
322
|
fails.append(f"点击无法临时开合:{seq}(期望 [1,0,1,0])")
|
|
161
323
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
324
|
+
# ③b 顶部 popover 不许出视口(2026-09-14 用户报「点后台任务/终端/session 日志显示不全」:
|
|
325
|
+
# 上游按桌面宽度做左/右对齐 —— 后台任务菜单 right 溢出 207px、「更多操作」菜单 left 溢出 90px)
|
|
326
|
+
for name, trig_sel, menu_sel in (
|
|
327
|
+
("后台任务菜单", "button[class*=QsffPG_trigger]", "[class*=QsffPG_menu]"),
|
|
328
|
+
# 「更多操作」的入口是插件注入的**代理按钮**:上游按钮已被移出可点层
|
|
329
|
+
# (position:absolute; opacity:0; pointer-events:none —— 否则它会算出 0×0 的菜单,
|
|
330
|
+
# 见 client.js 里 headerUtilities 段注释),点它才算走用户的真实路径
|
|
331
|
+
("更多操作菜单", "#dsh-mobile-more-proxy", MORE_MENU),
|
|
332
|
+
):
|
|
333
|
+
pos = page.evaluate(
|
|
334
|
+
"""(s) => { const b = document.querySelector(s); if (!b) return null;
|
|
335
|
+
const r = b.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }""", trig_sel)
|
|
336
|
+
if not pos:
|
|
337
|
+
if trig_sel == "#dsh-mobile-more-proxy":
|
|
338
|
+
# 插件的 ⋯ 代理按钮在会话页本该存在 ⇒ 缺失=工具组没挂上,是**真回归**
|
|
339
|
+
fails.append("「更多操作」代理按钮不存在(#dsh-mobile-more-proxy 缺失,工具组没挂上?)")
|
|
340
|
+
else:
|
|
341
|
+
# 上游**按需渲染**:该会话没有后台任务时压根不画这个按钮
|
|
342
|
+
# (2026-09-28 实测:本机该会话有「23 个后台任务」⇒ 有按钮;
|
|
343
|
+
# devbox 同一会话没有 ⇒ 无按钮。查过两版 bundle,QsffPG 这个 hash **没漂移**,
|
|
344
|
+
# 别把"没有按钮"误判成"上游改结构了"。)
|
|
345
|
+
skips.append(f"{name}未覆盖:该会话没有后台任务,上游不渲染该按钮")
|
|
346
|
+
print(f" [{name}] 入口不存在(该会话没有后台任务)⇒ 记 skip")
|
|
347
|
+
continue
|
|
348
|
+
page.touchscreen.tap(pos[0], pos[1])
|
|
349
|
+
page.wait_for_timeout(1500)
|
|
350
|
+
g = page.evaluate(POPOVER, menu_sel)
|
|
351
|
+
print(f" [{name}] {json.dumps(g, ensure_ascii=False)}")
|
|
352
|
+
if not g or not g.get("ok"):
|
|
353
|
+
fails.append(f"{name}跑出视口({g})—— 上游对齐按桌面宽度算,插件的浮层约束失效?")
|
|
354
|
+
elif g.get("items", 0) < 1:
|
|
355
|
+
fails.append(f"{name}打开后没有任何条目({g})")
|
|
356
|
+
# ⚠️⚠️ 光有几何和条目不算数:必须真能看见、真能点到
|
|
357
|
+
# (2026-09-15 血泪:菜单 rect 全对、条目也在,但祖先 wSkVaW_headerUtilities 的
|
|
358
|
+
# opacity:0 把整棵子树变透明 + pointer-events:none 被继承 ⇒ 用户「点了没反应」,
|
|
359
|
+
# 而只断言 left/right/items 的旧版探针一路绿灯)
|
|
360
|
+
elif g.get("visibility") == "hidden" or g.get("effectiveOpacity", 1) < 0.9 \
|
|
361
|
+
or g.get("pointerEvents") == "none" or not g.get("hitSelf"):
|
|
362
|
+
fails.append(
|
|
363
|
+
f"{name}看得见却点不到(visibility={g.get('visibility')} "
|
|
364
|
+
f"有效opacity={g.get('effectiveOpacity')} pointer-events={g.get('pointerEvents')} "
|
|
365
|
+
f"中心命中={g.get('hitTop')})—— 祖先 opacity:0 的组透明 / pointer-events 继承泄漏?")
|
|
366
|
+
page.keyboard.press("Escape")
|
|
367
|
+
page.wait_for_timeout(800)
|
|
197
368
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
369
|
+
# ③c header 瘦身 + 折叠输入区(2026-09-14:用户反馈「标题栏三行有点乱 / 正文显得窄」)
|
|
370
|
+
lay = page.evaluate("""() => {
|
|
371
|
+
const hdr = document.querySelector('header'); const hb = hdr ? hdr.getBoundingClientRect().bottom : 0;
|
|
372
|
+
const seat = document.querySelector('[class*=composerSeat]');
|
|
373
|
+
const seatVis = seat && getComputedStyle(seat).display !== 'none' && seat.getBoundingClientRect().height > 0;
|
|
374
|
+
const bottom = seatVis ? seat.getBoundingClientRect().top : window.innerHeight;
|
|
375
|
+
const tools = [...document.querySelectorAll('#dsh-mobile-tab-tools button')].map(b => b.id);
|
|
376
|
+
const hamburger = document.getElementById('dsh-mobile-menu-btn');
|
|
377
|
+
return { headerH: Math.round(hb), readPct: Math.round((bottom - hb) / window.innerHeight * 100),
|
|
378
|
+
tools: tools, hamburgerHidden: hamburger ? getComputedStyle(hamburger).display === 'none' : null }; }""")
|
|
379
|
+
print(f" [布局] header={lay['headerH']}px 正文占比={lay['readPct']}% 工具={lay['tools']}")
|
|
380
|
+
# 行数回涨(三行 header ⇒ 138px)或工具组缺失都算回归
|
|
381
|
+
if lay["headerH"] > 120:
|
|
382
|
+
fails.append(f"header 又变高了({lay['headerH']}px > 120px,是不是行数回涨了?)")
|
|
383
|
+
if len(lay["tools"]) < 3:
|
|
384
|
+
fails.append(f"tabs 行工具组不全:{lay['tools']}(应含 more-proxy / fold-composer / tab-menu)")
|
|
385
|
+
if lay.get("hamburgerHidden") is not True:
|
|
386
|
+
fails.append("会话页的原悬浮汉堡没隐藏(会压住第二行/tabs 行)")
|
|
387
|
+
# 折叠切换:正文占比应显著上升,且按钮状态跟着翻
|
|
388
|
+
fold = page.evaluate("""() => { const b = document.getElementById('dsh-mobile-fold-composer'); if (!b) return null;
|
|
389
|
+
const r = b.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }""")
|
|
390
|
+
if fold:
|
|
391
|
+
page.touchscreen.tap(fold[0], fold[1])
|
|
392
|
+
page.wait_for_timeout(1200)
|
|
393
|
+
folded = page.evaluate("""() => {
|
|
201
394
|
const seat = document.querySelector('[class*=composerSeat]');
|
|
202
395
|
const seatVis = seat && getComputedStyle(seat).display !== 'none' && seat.getBoundingClientRect().height > 0;
|
|
396
|
+
const hb = document.querySelector('header').getBoundingClientRect().bottom;
|
|
203
397
|
const bottom = seatVis ? seat.getBoundingClientRect().top : window.innerHeight;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if
|
|
213
|
-
fails.append(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const hb = document.querySelector('header').getBoundingClientRect().bottom;
|
|
226
|
-
const bottom = seatVis ? seat.getBoundingClientRect().top : window.innerHeight;
|
|
227
|
-
return { hidden: document.body.classList.contains('dsh-mobile-composer-hidden'),
|
|
228
|
-
readPct: Math.round((bottom - hb) / window.innerHeight * 100) }; }""")
|
|
229
|
-
print(f" [折叠] hidden={folded['hidden']} 正文占比={folded['readPct']}%")
|
|
230
|
-
if not folded["hidden"] or folded["readPct"] < lay["readPct"] + 15:
|
|
231
|
-
fails.append(f"折叠输入区没生效({lay['readPct']}% → {folded['readPct']}%)")
|
|
232
|
-
page.touchscreen.tap(fold[0], fold[1]) # 复原,别影响后续断言
|
|
398
|
+
return { hidden: document.body.classList.contains('dsh-mobile-composer-hidden'),
|
|
399
|
+
readPct: Math.round((bottom - hb) / window.innerHeight * 100) }; }""")
|
|
400
|
+
print(f" [折叠] hidden={folded['hidden']} 正文占比={folded['readPct']}%")
|
|
401
|
+
if not folded["hidden"] or folded["readPct"] < lay["readPct"] + 15:
|
|
402
|
+
fails.append(f"折叠输入区没生效({lay['readPct']}% → {folded['readPct']}%)")
|
|
403
|
+
page.touchscreen.tap(fold[0], fold[1]) # 复原,别影响后续断言
|
|
404
|
+
page.wait_for_timeout(1000)
|
|
405
|
+
back = page.evaluate("() => document.body.classList.contains('dsh-mobile-composer-hidden')")
|
|
406
|
+
if back:
|
|
407
|
+
fails.append("再次点击没能恢复输入区")
|
|
408
|
+
|
|
409
|
+
# ③d 工具按钮必须真的能用:⋯ 代理按钮开→关一次就翻状态
|
|
410
|
+
# (曾因「上游 pointerdown 先关 / 我们的 click 又开」的竞态,命中率只有 2/6 ——
|
|
411
|
+
# 用户报「三个点按钮点击没反应」;折叠按钮也曾因 30×26 目标过小被报「不灵敏」)
|
|
412
|
+
menus_js = "() => document.querySelectorAll('%s').length" % MORE_MENU
|
|
413
|
+
mp = page.evaluate("""() => { const b = document.getElementById('dsh-mobile-more-proxy'); if (!b) return null;
|
|
414
|
+
const r = b.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }""")
|
|
415
|
+
if mp:
|
|
416
|
+
seq = []
|
|
417
|
+
for _ in range(2):
|
|
418
|
+
page.touchscreen.tap(mp[0], mp[1])
|
|
233
419
|
page.wait_for_timeout(1000)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
#
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const r = b.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }""")
|
|
244
|
-
if mp:
|
|
245
|
-
seq = []
|
|
246
|
-
for _ in range(2):
|
|
247
|
-
page.touchscreen.tap(mp[0], mp[1])
|
|
248
|
-
page.wait_for_timeout(1000)
|
|
249
|
-
seq.append(page.evaluate(menus_js))
|
|
250
|
-
print(f" [⋯ 代理按钮] 开合序列={seq}(期望 [1,0])")
|
|
251
|
-
if seq != [1, 0]:
|
|
252
|
-
fails.append(f"「更多操作」代理按钮不能开合:{seq}(上游 pointerdown 与转发 click 的竞态又回来了?)")
|
|
253
|
-
# 按钮尺寸:触摸目标不得小于 32px(插件规范 44,实测 30×26 时用户报"不灵敏")
|
|
254
|
-
size = page.evaluate("""() => { const b = document.getElementById('dsh-mobile-more-proxy');
|
|
255
|
-
if (!b) return null; const r = b.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; }""")
|
|
256
|
-
if size and (size[0] < 32 or size[1] < 30):
|
|
257
|
-
fails.append(f"工具按钮触摸目标过小:{size}")
|
|
258
|
-
|
|
259
|
-
# ③e ⚠️⚠️ 宽度相关的「隐形死区」回归(2026-09-14 用户第三次报「三个点没反应」的真根因)
|
|
260
|
-
# 上游右侧栏 resize 把手 pI_x6G_handle 是 absolute;top:0;bottom:0;width:8px;
|
|
261
|
-
# z-index:11;pointer-events:auto —— 右侧栏折叠后 rightbarCol 缩成 height:0,
|
|
262
|
-
# 但把手**不跟着消失**,仍以 8px 宽、贯穿整个视口高度钉在固定 x(实测 276..284)。
|
|
263
|
-
# 工具组是右对齐的(⋯ 中心 = 视口宽 - 134)⇒ 它只在 **410–417px** 这段视口里
|
|
264
|
-
# 正好压住 ⋯ 的中心:「390px 测着全好、用户手机却点不动」就是这么来的。
|
|
265
|
-
# ⇒ 断言**必须扫一批宽度**,单宽度探针天然漏检;同时确认把手已不可命中
|
|
266
|
-
# (插件 0.2.7 起 display:none,另给工具组 position:relative;z-index:30 做第二道保险)。
|
|
267
|
-
HITTEST = """() => {
|
|
268
|
-
const h = document.querySelector('[class*=pI_x6G_handle]');
|
|
269
|
-
const hs = h ? getComputedStyle(h) : null;
|
|
270
|
-
return { handle: h ? { display: hs.display, pe: hs.pointerEvents } : null,
|
|
271
|
-
buttons: [...document.querySelectorAll('#dsh-mobile-tab-tools button')].map(b => {
|
|
272
|
-
const r = b.getBoundingClientRect();
|
|
273
|
-
const t = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
274
|
-
return { id: b.id, self: !!(t && (t === b || b.contains(t))),
|
|
275
|
-
top: t ? (t.id || String(t.className || '').split(' ').filter(Boolean).pop() || t.tagName) : null }; }) }; }"""
|
|
276
|
-
dead, covered, probe412 = [], [], None
|
|
277
|
-
for vw in (320, 360, 375, 390, 400, 410, 412, 415, 418, 428, 460, 768):
|
|
278
|
-
page.set_viewport_size({"width": vw, "height": h})
|
|
279
|
-
page.wait_for_timeout(320)
|
|
280
|
-
r = page.evaluate(HITTEST)
|
|
281
|
-
if r["handle"] and r["handle"]["display"] != "none":
|
|
282
|
-
dead.append(vw)
|
|
283
|
-
miss = [f"{x['id']}←{x['top']}" for x in r["buttons"] if not x["self"]]
|
|
284
|
-
if miss:
|
|
285
|
-
covered.append(f"{vw}px:{','.join(miss)}")
|
|
286
|
-
if vw == 412: # 用户实测踩中的那一段宽度,做端到端点击验证
|
|
287
|
-
c412 = page.evaluate("""() => { const b = document.getElementById('dsh-mobile-more-proxy');
|
|
288
|
-
if (!b) return null; const r = b.getBoundingClientRect();
|
|
289
|
-
return [r.x + r.width / 2, r.y + r.height / 2]; }""")
|
|
290
|
-
seq2 = []
|
|
291
|
-
if c412:
|
|
292
|
-
for _ in range(2):
|
|
293
|
-
page.touchscreen.tap(c412[0], c412[1])
|
|
294
|
-
page.wait_for_timeout(900)
|
|
295
|
-
seq2.append(page.evaluate(menus_js))
|
|
296
|
-
if seq2[1]: # 别把菜单留着干扰后续断言
|
|
297
|
-
page.touchscreen.tap(c412[0], c412[1])
|
|
298
|
-
page.wait_for_timeout(700)
|
|
299
|
-
probe412 = seq2
|
|
300
|
-
page.set_viewport_size({"width": w, "height": h})
|
|
301
|
-
page.wait_for_timeout(500)
|
|
302
|
-
print(f" [宽度扫描 12 档] 把手残留={dead or '无'} 按钮被覆盖={covered or '无'} 412px ⋯开合={probe412}(期望 [1,0])")
|
|
303
|
-
if dead:
|
|
304
|
-
fails.append(f"右侧栏拖拽把手又在这些宽度残留成隐形死区:{dead}px"
|
|
305
|
-
f"(pI_x6G_handle 覆盖整列 ⇒ 该列 tap 全被吃掉)")
|
|
306
|
-
if covered:
|
|
307
|
-
fails.append(f"tabs 行工具按钮被别的层盖住(点不到):{covered}")
|
|
308
|
-
if probe412 is not None and probe412 != [1, 0]:
|
|
309
|
-
fails.append(f"412px(用户手机宽度)下 ⋯ 按钮仍不能开合:{probe412}")
|
|
420
|
+
seq.append(page.evaluate(menus_js))
|
|
421
|
+
print(f" [⋯ 代理按钮] 开合序列={seq}(期望 [1,0])")
|
|
422
|
+
if seq != [1, 0]:
|
|
423
|
+
fails.append(f"「更多操作」代理按钮不能开合:{seq}(上游 pointerdown 与转发 click 的竞态又回来了?)")
|
|
424
|
+
# 按钮尺寸:触摸目标不得小于 32px(插件规范 44,实测 30×26 时用户报"不灵敏")
|
|
425
|
+
size = page.evaluate("""() => { const b = document.getElementById('dsh-mobile-more-proxy');
|
|
426
|
+
if (!b) return null; const r = b.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; }""")
|
|
427
|
+
if size and (size[0] < 32 or size[1] < 30):
|
|
428
|
+
fails.append(f"工具按钮触摸目标过小:{size}")
|
|
310
429
|
|
|
430
|
+
# ③e ⚠️⚠️ 宽度相关的「隐形死区」回归(2026-09-14 用户第三次报「三个点没反应」的真根因)
|
|
431
|
+
# 上游右侧栏 resize 把手 pI_x6G_handle 是 absolute;top:0;bottom:0;width:8px;
|
|
432
|
+
# z-index:11;pointer-events:auto —— 右侧栏折叠后 rightbarCol 缩成 height:0,
|
|
433
|
+
# 但把手**不跟着消失**,仍以 8px 宽、贯穿整个视口高度钉在固定 x(实测 276..284)。
|
|
434
|
+
# 工具组是右对齐的(⋯ 中心 = 视口宽 - 134)⇒ 它只在 **410–417px** 这段视口里
|
|
435
|
+
# 正好压住 ⋯ 的中心:「390px 测着全好、用户手机却点不动」就是这么来的。
|
|
436
|
+
# ⇒ 断言**必须扫一批宽度**,单宽度探针天然漏检;同时确认把手已不可命中
|
|
437
|
+
# (插件 0.2.7 起 display:none,另给工具组 position:relative;z-index:30 做第二道保险)。
|
|
438
|
+
HITTEST = """() => {
|
|
439
|
+
const h = document.querySelector('[class*=pI_x6G_handle]');
|
|
440
|
+
const hs = h ? getComputedStyle(h) : null;
|
|
441
|
+
return { handle: h ? { display: hs.display, pe: hs.pointerEvents } : null,
|
|
442
|
+
buttons: [...document.querySelectorAll('#dsh-mobile-tab-tools button')].map(b => {
|
|
443
|
+
const r = b.getBoundingClientRect();
|
|
444
|
+
const t = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
445
|
+
return { id: b.id, self: !!(t && (t === b || b.contains(t))),
|
|
446
|
+
top: t ? (t.id || String(t.className || '').split(' ').filter(Boolean).pop() || t.tagName) : null }; }) }; }"""
|
|
447
|
+
dead, covered, probe412 = [], [], None
|
|
448
|
+
for vw in (320, 360, 375, 390, 400, 410, 412, 415, 418, 428, 460, 768):
|
|
449
|
+
page.set_viewport_size({"width": vw, "height": h})
|
|
450
|
+
page.wait_for_timeout(320)
|
|
451
|
+
r = page.evaluate(HITTEST)
|
|
452
|
+
if r["handle"] and r["handle"]["display"] != "none":
|
|
453
|
+
dead.append(vw)
|
|
454
|
+
miss = [f"{x['id']}←{x['top']}" for x in r["buttons"] if not x["self"]]
|
|
455
|
+
if miss:
|
|
456
|
+
covered.append(f"{vw}px:{','.join(miss)}")
|
|
457
|
+
if vw == 412: # 用户实测踩中的那一段宽度,做端到端点击验证
|
|
458
|
+
c412 = page.evaluate("""() => { const b = document.getElementById('dsh-mobile-more-proxy');
|
|
459
|
+
if (!b) return null; const r = b.getBoundingClientRect();
|
|
460
|
+
return [r.x + r.width / 2, r.y + r.height / 2]; }""")
|
|
461
|
+
seq2 = []
|
|
462
|
+
if c412:
|
|
463
|
+
for _ in range(2):
|
|
464
|
+
page.touchscreen.tap(c412[0], c412[1])
|
|
465
|
+
page.wait_for_timeout(900)
|
|
466
|
+
seq2.append(page.evaluate(menus_js))
|
|
467
|
+
if seq2[1]: # 别把菜单留着干扰后续断言
|
|
468
|
+
page.touchscreen.tap(c412[0], c412[1])
|
|
469
|
+
page.wait_for_timeout(700)
|
|
470
|
+
probe412 = seq2
|
|
471
|
+
page.set_viewport_size({"width": w, "height": h})
|
|
472
|
+
page.wait_for_timeout(500)
|
|
473
|
+
print(f" [宽度扫描 12 档] 把手残留={dead or '无'} 按钮被覆盖={covered or '无'} 412px ⋯开合={probe412}(期望 [1,0])")
|
|
474
|
+
if dead:
|
|
475
|
+
fails.append(f"右侧栏拖拽把手又在这些宽度残留成隐形死区:{dead}px"
|
|
476
|
+
f"(pI_x6G_handle 覆盖整列 ⇒ 该列 tap 全被吃掉)")
|
|
477
|
+
if covered:
|
|
478
|
+
fails.append(f"tabs 行工具按钮被别的层盖住(点不到):{covered}")
|
|
479
|
+
if probe412 is not None and probe412 != [1, 0]:
|
|
480
|
+
fails.append(f"412px(用户手机宽度)下 ⋯ 按钮仍不能开合:{probe412}")
|
|
481
|
+
# ④ 需切换器(没有子代理的会话跑不了)
|
|
482
|
+
if not geom.get("err") and sw:
|
|
311
483
|
# ④ 菜单行可进入子代理(面包屑应变成两段)
|
|
312
484
|
page.touchscreen.tap(sw["x"], sw["y"])
|
|
313
485
|
page.wait_for_timeout(800)
|
|
@@ -324,19 +496,28 @@ def run(args) -> int:
|
|
|
324
496
|
fails.append(f"点子代理行没有进入子代理会话(面包屑未变化:{after!r})")
|
|
325
497
|
|
|
326
498
|
# ⑤ 抽屉回归(补丁不得吞掉其它点击)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
499
|
+
# ⚠️ 抽屉开着时会盖一层 #dsh-mobile-scrim 罩住 ☰,此时点 ☰ 实际落在 scrim 上
|
|
500
|
+
# = **关**抽屉 ⇒ 必须先归零再断言(否则报"抽屉打不开"是假阳性)。
|
|
501
|
+
pre = ensure_closed(page)
|
|
502
|
+
used, drawer_open = open_drawer_confirmed(page)
|
|
503
|
+
close_drawer(page, w)
|
|
331
504
|
page.wait_for_timeout(1000)
|
|
332
505
|
drawer_closed = page.evaluate(DRAWER)
|
|
333
|
-
print(f"
|
|
334
|
-
if
|
|
335
|
-
fails.append(
|
|
506
|
+
print(f"抽屉:前置={pre} 开={drawer_open} 关={drawer_closed}(入口 {used})")
|
|
507
|
+
if used is None:
|
|
508
|
+
fails.append("抽屉入口按钮不可点(#dsh-mobile-tab-menu / #dsh-mobile-menu-btn 都没有)")
|
|
509
|
+
elif drawer_open.startswith("none"):
|
|
510
|
+
fails.append(f"抽屉打不开(开={drawer_open},入口 {used})")
|
|
511
|
+
elif not drawer_closed.startswith("none"):
|
|
512
|
+
fails.append(f"抽屉关不上(关={drawer_closed})")
|
|
336
513
|
|
|
337
514
|
browser.close()
|
|
338
515
|
|
|
339
516
|
print()
|
|
517
|
+
if skips:
|
|
518
|
+
print(f"⚠️ {len(skips)} 项未覆盖(**不算回归**;要覆盖请用 --session 指定含子代理的会话):")
|
|
519
|
+
for x in skips:
|
|
520
|
+
print(f" - {x}")
|
|
340
521
|
if fails:
|
|
341
522
|
print(f"❌ 真实页面回归:{len(fails)} 项不达标")
|
|
342
523
|
for f in fails:
|