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.
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python3
2
+ """真机化交互验证:用真实设备描述符 + 触摸点击,验三个工具按钮的**行为**(不只是存在)。
3
+
4
+ 判据(对照 docs/dsh-mobile-ui.md §3.3 / §6):
5
+ ⋯ → 必须弹出 [role=menu],菜单在视口内、有条目、且**不被祖先透明/遮罩吃掉**
6
+ ▤ → 输入区高度必须明显变化(折叠/展开)
7
+ ☰ → 抽屉必须打开(.dsh-mobile-drawer 可见)
8
+ 用法: python3 probe-actions.py <base-url> <token-file> [--device "Pixel 7"] [--title 片段]
9
+ """
10
+ import sys
11
+
12
+ from playwright.sync_api import sync_playwright
13
+
14
+ BASE = sys.argv[1]
15
+ TOKF = sys.argv[2]
16
+ DEVICE = sys.argv[sys.argv.index("--device") + 1] if "--device" in sys.argv else "Pixel 7"
17
+ TITLE = sys.argv[sys.argv.index("--title") + 1] if "--title" in sys.argv else None
18
+ TAG = sys.argv[sys.argv.index("--tag") + 1] if "--tag" in sys.argv else "act"
19
+ TOKEN = open(TOKF, encoding="utf-8").read().strip()
20
+
21
+ DRAWER = """
22
+ () => {
23
+ const vis = (el) => {
24
+ if (!el) return false;
25
+ const cs = getComputedStyle(el), r = el.getBoundingClientRect();
26
+ return cs.visibility !== "hidden" && parseFloat(cs.opacity || "1") > 0.5 && r.width > 1 && r.height > 1;
27
+ };
28
+ return { scrim: vis(document.querySelector(".dsh-mobile-scrim")),
29
+ drawer: vis(document.querySelector(".dsh-mobile-drawer")) };
30
+ }
31
+ """
32
+
33
+ MENU = """
34
+ () => {
35
+ const menus = [...document.querySelectorAll('[role=menu], [role=listbox]')].filter((m) => {
36
+ const r = m.getBoundingClientRect();
37
+ return r.width > 40 && r.height > 20;
38
+ });
39
+ if (!menus.length) return { count: 0 };
40
+ const m = menus[menus.length - 1];
41
+ const r = m.getBoundingClientRect();
42
+ // visibility 是继承属性:getComputedStyle(自身) 已含继承结果,只判它即可
43
+ //(逐级判祖先会把「祖先 hidden 但自己 visible」误报成不可见,2026-09-28 踩过)。
44
+ // opacity 是「组不透明度」、pointer-events 也是继承 ⇒ 这两项必须逐级。
45
+ const vis = getComputedStyle(m).visibility;
46
+ let op = 1, pe = "auto", node = m.parentElement;
47
+ while (node && node !== document.documentElement) {
48
+ const s = getComputedStyle(node);
49
+ op *= parseFloat(s.opacity || "1");
50
+ if (s.pointerEvents === "none") pe = "none";
51
+ node = node.parentElement;
52
+ }
53
+ const cx = Math.min(Math.max(r.left + r.width / 2, 1), window.innerWidth - 1);
54
+ const cy = Math.min(Math.max(r.top + r.height / 2, 1), window.innerHeight - 1);
55
+ const hit = document.elementFromPoint(cx, cy);
56
+ const items = m.querySelectorAll('[role=menuitem], button, a').length;
57
+ return {
58
+ count: menus.length,
59
+ rect: [r.left, r.top, r.width, r.height].map(Math.round),
60
+ inViewport: r.left >= -1 && r.right <= window.innerWidth + 1 && r.top >= -1 && r.bottom <= window.innerHeight + 1,
61
+ effOpacity: +op.toFixed(2), visibility: vis, pointerEvents: pe,
62
+ hitInside: !!(hit && m.contains(hit)),
63
+ items,
64
+ text: (m.innerText || "").replace(/\\n/g, " | ").slice(0, 90),
65
+ };
66
+ }
67
+ """
68
+
69
+ COMPOSER = """
70
+ () => {
71
+ const c = document.querySelector('[class*=composerSeat]') || document.querySelector('[class*=composer]');
72
+ if (!c) return null;
73
+ const r = c.getBoundingClientRect();
74
+ return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)];
75
+ }
76
+ """
77
+
78
+
79
+ def tap(page, sel):
80
+ box = page.evaluate("""(sel) => { const el = document.querySelector(sel); if (!el) return null;
81
+ const r = el.getBoundingClientRect(); return [r.left + r.width/2, r.top + r.height/2]; }""", sel)
82
+ if box is None:
83
+ return False
84
+ page.touchscreen.tap(box[0], box[1])
85
+ return True
86
+
87
+
88
+ fails = []
89
+ with sync_playwright() as p:
90
+ b = p.chromium.launch()
91
+ dev = p.devices.get(DEVICE) or p.devices["Pixel 5"]
92
+ ctx = b.new_context(**dev)
93
+ page = ctx.new_page()
94
+ page.goto(f"{BASE}/?token={TOKEN}", wait_until="domcontentloaded", timeout=60000)
95
+ page.wait_for_timeout(6000)
96
+ print(f"base={BASE} device={DEVICE} viewport={page.viewport_size}")
97
+
98
+ tap(page, "#dsh-mobile-menu-btn")
99
+ page.wait_for_timeout(1500)
100
+ rows = page.query_selector_all("[role=treeitem][class*=sessionRow]") or page.query_selector_all("[role=treeitem]")
101
+ picked = title = None
102
+ for row in rows[:12]:
103
+ t = (row.inner_text() or "").strip().replace("\n", " ")
104
+ if TITLE and TITLE in t:
105
+ picked, title = row, t[:28]
106
+ break
107
+ if not TITLE and t and "新会话" not in t:
108
+ picked, title = row, t[:28]
109
+ break
110
+ if picked is None:
111
+ print("找不到会话"); sys.exit(2)
112
+ picked.click()
113
+ page.wait_for_timeout(1500)
114
+ for _ in range(6):
115
+ if page.evaluate(DRAWER)["drawer"] is False and page.evaluate(DRAWER)["scrim"] is False:
116
+ break
117
+ page.touchscreen.tap(page.viewport_size["width"] - 30, 500)
118
+ page.wait_for_timeout(900)
119
+ print(f"会话:{title}\n")
120
+
121
+ # ── ⋯ 更多操作 ──
122
+ print("── ⋯ (dsh-mobile-more-proxy) ──")
123
+ tap(page, "#dsh-mobile-more-proxy")
124
+ page.wait_for_timeout(1500)
125
+ m = page.evaluate(MENU)
126
+ if not m.get("count"):
127
+ print(" ✗ 点击后没有出现 [role=menu]")
128
+ fails.append("⋯ 点击无菜单")
129
+ else:
130
+ print(f" 菜单 rect={m['rect']} items={m['items']} inViewport={m['inViewport']} "
131
+ f"op={m['effOpacity']} vis={m['visibility']} pe={m['pointerEvents']} hitInside={m['hitInside']}")
132
+ print(f" 文本: {m['text']}")
133
+ if not m["inViewport"]:
134
+ fails.append(f"⋯ 菜单出视口 {m['rect']}")
135
+ if m["effOpacity"] < 0.9 or m["visibility"] != "visible" or m["pointerEvents"] == "none":
136
+ fails.append(f"⋯ 菜单不可见(op={m['effOpacity']} vis={m['visibility']} pe={m['pointerEvents']})")
137
+ if not m["hitInside"]:
138
+ fails.append("⋯ 菜单中心点不到(被盖)")
139
+ if m["items"] == 0:
140
+ fails.append("⋯ 菜单没有条目")
141
+ page.screenshot(path=f"/tmp/{TAG}-menu.png")
142
+ page.keyboard.press("Escape")
143
+ page.wait_for_timeout(800)
144
+
145
+ # ── ▤ 折叠输入区 ──
146
+ print("\n── ▤ (dsh-mobile-fold-composer) ──")
147
+ c0 = page.evaluate(COMPOSER)
148
+ tap(page, "#dsh-mobile-fold-composer")
149
+ page.wait_for_timeout(1200)
150
+ c1 = page.evaluate(COMPOSER)
151
+ print(f" 输入区高度 {c0[3] if c0 else None} → {c1[3] if c1 else None}")
152
+ if c0 and c1 and abs(c0[3] - c1[3]) < 20:
153
+ fails.append(f"▤ 点击后输入区高度没变化({c0[3]} → {c1[3]})")
154
+ page.screenshot(path=f"/tmp/{TAG}-fold.png")
155
+ tap(page, "#dsh-mobile-fold-composer") # 复原
156
+ page.wait_for_timeout(1000)
157
+
158
+ # ── ☰ 打开抽屉 ──
159
+ print("\n── ☰ (dsh-mobile-tab-menu) ──")
160
+ before = page.evaluate(DRAWER)
161
+ tap(page, "#dsh-mobile-tab-menu")
162
+ page.wait_for_timeout(1500)
163
+ after = page.evaluate(DRAWER)
164
+ print(f" 点击前 {before} → 点击后 {after}")
165
+ if not after["drawer"]:
166
+ fails.append(f"☰ 点击后抽屉没打开({after})")
167
+ page.screenshot(path=f"/tmp/{TAG}-drawer.png")
168
+
169
+ print()
170
+ if fails:
171
+ print(f"❌ {len(fails)} 项异常:")
172
+ for f in fails:
173
+ print(" - " + f)
174
+ sys.exit(1)
175
+ print("✅ 三个按钮行为均正常")
176
+ ctx.close(); b.close()
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ """PC(桌面视口)验证:mobile-ui 插件**绝不能**触碰右侧栏。
3
+
4
+ 背景(2026-09-28 事故):非移动端分支误调 closeRightbar() ⇒ 它点上游「收起右侧边栏」,
5
+ 每次 sync(MutationObserver)都把用户的右侧栏收起 ⇒ 用户看到「不停打开/关闭右侧栏」。
6
+
7
+ 判据:连续采样 N 秒,右侧栏的宽度 / grid 列 / 开关按钮 state 必须**保持不变**,
8
+ 且 body 不得出现 dsh-mobile-rightbar(插件的移动端浮层 class)。
9
+
10
+ 用法: python3 probe-pc-rightbar.py <base-url> <token-file> [--title 片段] [--seconds 8]
11
+ """
12
+ import sys
13
+ import time
14
+
15
+ from playwright.sync_api import sync_playwright
16
+
17
+ BASE, TOKF = sys.argv[1], sys.argv[2]
18
+ TITLE = sys.argv[sys.argv.index("--title") + 1] if "--title" in sys.argv else None
19
+ SECONDS = int(sys.argv[sys.argv.index("--seconds") + 1]) if "--seconds" in sys.argv else 8
20
+ TOKEN = open(TOKF, encoding="utf-8").read().strip()
21
+
22
+ STATE = """
23
+ () => {
24
+ const frame = [...document.querySelectorAll("div")].find((e) => {
25
+ const cs = getComputedStyle(e);
26
+ return cs.display === "grid" && e.children.length >= 3;
27
+ });
28
+ const rb = document.querySelector("[class*=rightbarCol]");
29
+ const r = rb ? rb.getBoundingClientRect() : null;
30
+ return {
31
+ vw: window.innerWidth,
32
+ grid: frame ? getComputedStyle(frame).gridTemplateColumns : null,
33
+ gridInline: frame ? (frame.style.gridTemplateColumns || "(none)") : null,
34
+ rb: r ? [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)] : null,
35
+ openBtn: !!document.querySelector('button[aria-label="打开右侧边栏"]'),
36
+ closeBtn: !!document.querySelector('button[aria-label="收起右侧边栏"]'),
37
+ mobileCls: document.body.classList.contains("dsh-mobile-ui"),
38
+ overlayCls: document.body.classList.contains("dsh-mobile-rightbar"),
39
+ };
40
+ }
41
+ """
42
+
43
+ fails = []
44
+ with sync_playwright() as p:
45
+ b = p.chromium.launch()
46
+ # 桌面视口:1376x900(不 is_mobile,不 has_touch —— 与真 PC 一致)
47
+ ctx = b.new_context(viewport={"width": 1376, "height": 900})
48
+ page = ctx.new_page()
49
+ page.goto(f"{BASE}/?token={TOKEN}", wait_until="domcontentloaded", timeout=60000)
50
+ page.wait_for_timeout(6000)
51
+
52
+ # PC 端:左侧栏直接可见,点第一个会话
53
+ rows = page.query_selector_all("[role=treeitem][class*=sessionRow]") or page.query_selector_all("[role=treeitem]")
54
+ picked = t = None
55
+ for row in rows[:12]:
56
+ s = (row.inner_text() or "").strip().replace("\n", " ")
57
+ if TITLE and TITLE in s:
58
+ picked, t = row, s[:26]; break
59
+ if not TITLE and s and "新会话" not in s:
60
+ picked, t = row, s[:26]; break
61
+ if picked is None:
62
+ print("找不到会话"); sys.exit(2)
63
+ picked.click()
64
+ page.wait_for_timeout(3000)
65
+ print(f"base={BASE} 会话={t}")
66
+
67
+ d0 = page.evaluate(STATE)
68
+ print(f"视口宽={d0['vw']}(桌面档) mobile class={d0['mobileCls']}")
69
+ # 确保右侧栏处于「打开」状态(PC 上它就是上游原生三列之一)
70
+ if d0["openBtn"]:
71
+ page.evaluate("() => document.querySelector('button[aria-label=\"打开右侧边栏\"]').click()")
72
+ page.wait_for_timeout(1500)
73
+ d0 = page.evaluate(STATE)
74
+ print("(先手动打开右侧栏,模拟用户在用的状态)")
75
+
76
+ print(f"\n初始: grid={d0['grid']} inline={d0['gridInline']} rb={d0['rb']} "
77
+ f"开按钮={d0['openBtn']} 收按钮={d0['closeBtn']}")
78
+ base_rb, base_grid, base_inline = d0["rb"], d0["grid"], d0["gridInline"]
79
+
80
+ print(f"\n连续采样 {SECONDS} 秒(每次 1s):")
81
+ samples = []
82
+ for i in range(SECONDS):
83
+ time.sleep(1)
84
+ d = page.evaluate(STATE)
85
+ samples.append(d)
86
+ print(f" t={i+1}s rb={d['rb']} grid={d['grid']} inline={d['gridInline']} "
87
+ f"overlayCls={d['overlayCls']} 开={d['openBtn']} 收={d['closeBtn']}")
88
+
89
+ changed = [s for s in samples if s["rb"] != base_rb or s["grid"] != base_grid or s["gridInline"] != base_inline]
90
+ if changed:
91
+ fails.append(f"右侧栏状态在 {len(changed)}/{SECONDS} 次采样中发生了变化(应为恒定)")
92
+ if any(s["overlayCls"] for s in samples):
93
+ fails.append("PC 端出现了 dsh-mobile-rightbar 浮层 class")
94
+ if any(s["mobileCls"] for s in samples):
95
+ fails.append("PC 端出现了 dsh-mobile-ui class")
96
+
97
+ print()
98
+ if fails:
99
+ print(f"❌ {len(fails)} 项异常:")
100
+ for f in fails:
101
+ print(" - " + f)
102
+ sys.exit(1)
103
+ print(f"✅ PC 端右侧栏在 {SECONDS} 秒内保持不变(插件未触碰上游右侧栏)")
104
+ page.screenshot(path="/tmp/pc-rightbar.png")
105
+ ctx.close(); b.close()
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """验证右侧边栏(工作区文件)入口按钮:点开 → 面板全屏可见可点 → 关闭。
3
+
4
+ 用法: python3 probe-rightbar.py <base-url> <token-file> [--title 片段]
5
+ """
6
+ import sys
7
+
8
+ from playwright.sync_api import sync_playwright
9
+
10
+ BASE, TOKF = sys.argv[1], sys.argv[2]
11
+ TITLE = sys.argv[sys.argv.index("--title") + 1] if "--title" in sys.argv else None
12
+ TAG = sys.argv[sys.argv.index("--tag") + 1] if "--tag" in sys.argv else "rb"
13
+ TOKEN = open(TOKF, encoding="utf-8").read().strip()
14
+
15
+ STATE = """
16
+ () => {
17
+ const rb = document.querySelector("[class*=rightbarCol]");
18
+ const r = rb ? rb.getBoundingClientRect() : null;
19
+ let op = 1, pe = "auto", node = rb ? rb.parentElement : null;
20
+ while (node && node !== document.documentElement) {
21
+ const s = getComputedStyle(node);
22
+ op *= parseFloat(s.opacity || "1");
23
+ if (s.pointerEvents === "none") pe = "none";
24
+ node = node.parentElement;
25
+ }
26
+ const hit = r ? document.elementFromPoint(Math.min(Math.max(r.left + r.width/2, 1), innerWidth-1),
27
+ Math.min(Math.max(r.top + r.height/2, 1), innerHeight-1)) : null;
28
+ return {
29
+ cls: document.body.className.includes("dsh-mobile-rightbar"),
30
+ rect: r ? [r.left, r.top, r.width, r.height].map(Math.round) : null,
31
+ pos: rb ? getComputedStyle(rb).position : null,
32
+ vis: rb ? getComputedStyle(rb).visibility : null,
33
+ effOpacity: +op.toFixed(2), pointerEvents: pe,
34
+ inViewport: !!(r && r.left >= -1 && r.top >= -1 && r.right <= innerWidth+1 && r.bottom <= innerHeight+1),
35
+ /* ⚠️ 收起态的 rightbarCol 是 [0, innerHeight, w, 0]:top/bottom 都等于视口高,
36
+ 单看 inViewport 会误判成「可见」(2026-09-28 踩过)⇒ 必须叠加尺寸判据。 */
37
+ visible: !!(r && r.width > 100 && r.height > 100 && r.top < innerHeight),
38
+ text: rb ? (rb.innerText || "").trim().replace(/\\n+/g, " | ").slice(0, 120) : null,
39
+ hitInside: !!(hit && rb && rb.contains(hit)),
40
+ clickables: rb ? rb.querySelectorAll("button, [role=button], [role=treeitem]").length : 0,
41
+ btnOn: !!document.querySelector("#dsh-mobile-rightbar-toggle.dsh-mobile-on"),
42
+ };
43
+ }
44
+ """
45
+
46
+
47
+ def tap(page, sel):
48
+ box = page.evaluate("""(sel) => { const el = document.querySelector(sel); if (!el) return null;
49
+ const r = el.getBoundingClientRect();
50
+ return (r.width > 0 && r.height > 0) ? [r.left + r.width/2, r.top + r.height/2] : null; }""", sel)
51
+ if box is None:
52
+ return False
53
+ page.touchscreen.tap(box[0], box[1])
54
+ return True
55
+
56
+
57
+ fails = []
58
+ with sync_playwright() as p:
59
+ b = p.chromium.launch()
60
+ ctx = b.new_context(**p.devices.get("Pixel 7", p.devices["Pixel 5"]))
61
+ page = ctx.new_page()
62
+ page.goto(f"{BASE}/?token={TOKEN}", wait_until="domcontentloaded", timeout=60000)
63
+ page.wait_for_timeout(6000)
64
+ tap(page, "#dsh-mobile-menu-btn")
65
+ page.wait_for_timeout(1500)
66
+ rows = page.query_selector_all("[role=treeitem][class*=sessionRow]") or page.query_selector_all("[role=treeitem]")
67
+ picked = t = None
68
+ for row in rows[:12]:
69
+ s = (row.inner_text() or "").strip().replace("\n", " ")
70
+ if TITLE and TITLE in s:
71
+ picked, t = row, s[:26]; break
72
+ if not TITLE and s and "新会话" not in s:
73
+ picked, t = row, s[:26]; break
74
+ picked.click()
75
+ page.wait_for_timeout(1500)
76
+ vw = page.viewport_size["width"]
77
+ for _ in range(6):
78
+ st = page.evaluate("() => ({s: !!document.querySelector('.dsh-mobile-scrim'), d: !!document.querySelector('.dsh-mobile-drawer')})")
79
+ if not st["s"] and not st["d"]:
80
+ break
81
+ page.touchscreen.tap(vw - 30, 500)
82
+ page.wait_for_timeout(800)
83
+ for _ in range(20):
84
+ page.wait_for_timeout(800)
85
+ if page.evaluate("() => !!document.querySelector('#dsh-mobile-rightbar-toggle')"):
86
+ break
87
+ print(f"base={BASE} 会话={t} 视口={page.viewport_size}\n")
88
+
89
+ d0 = page.evaluate(STATE)
90
+ print(f"初始: 按钮激活={d0['btnOn']} 面板 rect={d0['rect']}")
91
+
92
+ if not tap(page, "#dsh-mobile-rightbar-toggle"):
93
+ print("✗ 找不到 #dsh-mobile-rightbar-toggle 按钮"); sys.exit(2)
94
+ page.wait_for_timeout(2200)
95
+ d1 = page.evaluate(STATE)
96
+ print("\n点击「工作区文件」按钮后:")
97
+ for k in ("cls", "rect", "pos", "vis", "effOpacity", "pointerEvents", "inViewport", "visible", "hitInside", "clickables", "btnOn"):
98
+ print(f" {k:12s} = {d1.get(k)}")
99
+ print(f" text = {d1.get('text')!r}")
100
+ if not d1["visible"]:
101
+ fails.append(f"面板未全屏可见 rect={d1['rect']}")
102
+ if d1["vis"] != "visible" or d1["effOpacity"] < 0.9 or d1["pointerEvents"] == "none":
103
+ fails.append(f"面板不可见 vis={d1['vis']} op={d1['effOpacity']} pe={d1['pointerEvents']}")
104
+ if not d1["hitInside"]:
105
+ fails.append("面板中心点不到(被覆盖)")
106
+ if d1["clickables"] < 3:
107
+ fails.append(f"面板内可点元素过少({d1['clickables']})")
108
+ # ⚠️ 面板首屏内容随上游版本不同:0.1.7 是「开始/工作区文件/新建终端」菜单,
109
+ # 0.1.5 直接是文件树 ⇒ 只要求有实质内容,别锁死文案。
110
+ if len((d1["text"] or "").strip()) < 10:
111
+ fails.append(f"面板内容为空或过短:{d1['text']!r}")
112
+ page.screenshot(path=f"/tmp/{TAG}-open.png")
113
+
114
+ # 用上游自带按钮关闭
115
+ closed = page.evaluate("""() => {
116
+ const b = document.querySelector('button[aria-label="收起右侧边栏"]');
117
+ if (!b) return false;
118
+ b.click(); return true;
119
+ }""")
120
+ page.wait_for_timeout(1200)
121
+ d2 = page.evaluate(STATE)
122
+ print(f"\n点上游「收起右侧边栏」({closed}) 后: 按钮激活={d2['btnOn']} 面板 rect={d2['rect']} visible={d2['visible']}")
123
+ if d2["visible"]:
124
+ fails.append(f"关闭后面板仍全屏可见 rect={d2['rect']}")
125
+ if d2["btnOn"]:
126
+ fails.append("关闭后工具按钮仍处于激活态")
127
+
128
+ # 再打开一次;随后**用面板内上游自带的收起按钮**关闭
129
+ # (面板是全屏浮层、z-index 远高于工具组 ⇒ 打开期间工具按钮被盖住,关闭入口在面板右上角)
130
+ tap(page, "#dsh-mobile-rightbar-toggle")
131
+ page.wait_for_timeout(1800)
132
+ d3 = page.evaluate(STATE)
133
+ print(f"再次打开: visible={d3['visible']}")
134
+ if not d3["visible"]:
135
+ fails.append("工具按钮第二次打开失败")
136
+ covered = page.evaluate("""() => {
137
+ const b = document.querySelector('#dsh-mobile-rightbar-toggle');
138
+ if (!b) return null;
139
+ const r = b.getBoundingClientRect();
140
+ const hit = document.elementFromPoint(r.left + r.width/2, r.top + r.height/2);
141
+ return !(hit && (hit === b || b.contains(hit)));
142
+ }""")
143
+ print(f"打开期间工具组被面板覆盖: {covered}(预期 true —— 关闭走面板内按钮)")
144
+ closed2 = page.evaluate("""() => {
145
+ const b = document.querySelector('button[aria-label="收起右侧边栏"]');
146
+ if (!b) return false;
147
+ b.click(); return true;
148
+ }""")
149
+ page.wait_for_timeout(1300)
150
+ d4 = page.evaluate(STATE)
151
+ print(f"从面板内收起({closed2}): visible={d4['visible']} 按钮激活={d4['btnOn']}")
152
+ if d4["visible"]:
153
+ fails.append("面板内收起按钮没能关闭")
154
+ if d4["btnOn"]:
155
+ fails.append("关闭后工具按钮仍处激活态")
156
+
157
+ print()
158
+ if fails:
159
+ print(f"❌ {len(fails)} 项异常:")
160
+ for f in fails:
161
+ print(" - " + f)
162
+ sys.exit(1)
163
+ print("✅ 右侧边栏入口:可打开、全屏可见可点、能关闭")
164
+ ctx.close(); b.close()
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """验证「用语义锚点 [class*=headerUtilities] [role=menu] 替代 hash」能否把 ⋯ 菜单拉回视口。
3
+
4
+ 在浏览器里注入候选 CSS(不改插件文件),对比注入前后菜单 rect。
5
+ """
6
+ import sys
7
+
8
+ from playwright.sync_api import sync_playwright
9
+
10
+ BASE, TOKF = sys.argv[1], sys.argv[2]
11
+ TITLE = sys.argv[sys.argv.index("--title") + 1] if "--title" in sys.argv else None
12
+ TOKEN = open(TOKF, encoding="utf-8").read().strip()
13
+
14
+ CANDIDATE = """
15
+ body.dsh-mobile-ui [class*=headerUtilities] [role=menu]{
16
+ position:fixed !important; left:8px !important; right:8px !important; width:auto !important;
17
+ min-width:0 !important; max-width:none !important; max-height:calc(100vh - 150px) !important;
18
+ overflow:auto !important; top:122px !important;
19
+ visibility:visible !important; pointer-events:auto !important;
20
+ }
21
+ """
22
+
23
+ MEASURE = """
24
+ () => {
25
+ const menus = [...document.querySelectorAll('[role=menu]')].filter((m) => {
26
+ const r = m.getBoundingClientRect();
27
+ return r.width > 40 && r.height > 20;
28
+ });
29
+ if (!menus.length) return { none: true };
30
+ const m = menus[menus.length - 1];
31
+ const r = m.getBoundingClientRect();
32
+ const cs = getComputedStyle(m);
33
+ // ⚠️ visibility 是继承属性:getComputedStyle(元素) 已含继承结果,
34
+ // 只看自身即可。逐级判祖先会把「祖先 hidden 但自己 visible」误报成不可见
35
+ // (2026-09-28 实测踩过:候选 CSS 明明生效却被判失败)。
36
+ const visSelf = cs.visibility;
37
+ let op = 1, pe = "auto", hiddenAncestor = null, node = m.parentElement;
38
+ while (node && node !== document.documentElement) {
39
+ const s = getComputedStyle(node);
40
+ op *= parseFloat(s.opacity || "1"); // opacity 是组不透明度,必须逐级相乘
41
+ if (s.visibility === "hidden") hiddenAncestor = (node.className || node.tagName).toString().slice(0, 50);
42
+ if (s.pointerEvents === "none") pe = "none"; // pointer-events 继承
43
+ node = node.parentElement;
44
+ }
45
+ const cx = Math.min(Math.max(r.left + r.width/2, 1), window.innerWidth - 1);
46
+ const cy = Math.min(Math.max(r.top + r.height/2, 1), window.innerHeight - 1);
47
+ const hit = document.elementFromPoint(cx, cy);
48
+ return {
49
+ cls: (m.className || "").toString().slice(0, 70),
50
+ rect: [r.left, r.top, r.width, r.height].map(Math.round),
51
+ pos: cs.position, left: cs.left, right: cs.right,
52
+ inViewport: r.left >= -1 && r.right <= window.innerWidth + 1 && r.top >= -1 && r.bottom <= window.innerHeight + 1,
53
+ effOpacity: +op.toFixed(2), visibility: visSelf, hiddenAncestor, pointerEvents: pe,
54
+ hitInside: !!(hit && m.contains(hit)),
55
+ items: m.querySelectorAll('[role=menuitem], button, a').length,
56
+ };
57
+ }
58
+ """
59
+
60
+
61
+ def open_session(page, vw):
62
+ page.goto(f"{BASE}/?token={TOKEN}", wait_until="domcontentloaded", timeout=60000)
63
+ page.wait_for_timeout(6000)
64
+ page.touchscreen.tap(*page.evaluate("() => { const r = document.querySelector('#dsh-mobile-menu-btn').getBoundingClientRect(); return [r.left+r.width/2, r.top+r.height/2]; }"))
65
+ page.wait_for_timeout(1500)
66
+ rows = page.query_selector_all("[role=treeitem][class*=sessionRow]") or page.query_selector_all("[role=treeitem]")
67
+ picked = t = None
68
+ for row in rows[:12]:
69
+ s = (row.inner_text() or "").strip().replace("\n", " ")
70
+ if TITLE and TITLE in s:
71
+ picked, t = row, s[:26]; break
72
+ if not TITLE and s and "新会话" not in s:
73
+ picked, t = row, s[:26]; break
74
+ picked.click()
75
+ page.wait_for_timeout(1500)
76
+ for _ in range(6):
77
+ st = page.evaluate("() => ({s: !!document.querySelector('.dsh-mobile-scrim'), d: !!document.querySelector('.dsh-mobile-drawer')})")
78
+ if not st["s"] and not st["d"]:
79
+ break
80
+ page.touchscreen.tap(vw - 30, 500)
81
+ page.wait_for_timeout(800)
82
+ for _ in range(20):
83
+ page.wait_for_timeout(800)
84
+ if page.evaluate("() => !!document.querySelector('#dsh-mobile-more-proxy')"):
85
+ break
86
+ return t
87
+
88
+
89
+ def tap_more(page):
90
+ page.touchscreen.tap(*page.evaluate("""() => { const el = document.querySelector('#dsh-mobile-more-proxy');
91
+ const r = el.getBoundingClientRect(); return [r.left + r.width/2, r.top + r.height/2]; }"""))
92
+ page.wait_for_timeout(1600)
93
+
94
+
95
+ with sync_playwright() as p:
96
+ b = p.chromium.launch()
97
+ ctx = b.new_context(**p.devices.get("Pixel 7", p.devices["Pixel 5"]))
98
+ page = ctx.new_page()
99
+ vw = page.viewport_size["width"]
100
+ title = open_session(page, vw)
101
+ print(f"base={BASE} 会话={title} 视口宽={vw}\n")
102
+
103
+ tap_more(page)
104
+ before = page.evaluate(MEASURE)
105
+ print("【注入前】")
106
+ for k in ("cls", "rect", "pos", "left", "right", "inViewport", "effOpacity", "visibility", "hiddenAt", "pointerEvents", "hitInside", "items"):
107
+ print(f" {k:12s} = {before.get(k)}")
108
+ page.keyboard.press("Escape")
109
+ page.wait_for_timeout(800)
110
+
111
+ page.add_style_tag(content=CANDIDATE)
112
+ page.wait_for_timeout(400)
113
+ tap_more(page)
114
+ after = page.evaluate(MEASURE)
115
+ print("\n【注入候选 CSS 后】")
116
+ for k in ("cls", "rect", "pos", "left", "right", "inViewport", "effOpacity", "visibility", "hiddenAt", "pointerEvents", "hitInside", "items"):
117
+ print(f" {k:12s} = {after.get(k)}")
118
+
119
+ ok = (after.get("inViewport") and after.get("effOpacity", 0) >= 0.9
120
+ and after.get("visibility") == "visible" and after.get("pointerEvents") != "none"
121
+ and after.get("hitInside") and after.get("items"))
122
+ print("\n" + ("✅ 候选 CSS 有效:菜单回到视口内且可见可点" if ok else "❌ 候选 CSS 不足以修复"))
123
+ page.screenshot(path="/tmp/menu-after.png")
124
+ ctx.close(); b.close()
125
+ sys.exit(0 if ok else 1)