≤720px 时 toast-region 因 top/left/right/bottom 铺满视口,空白处不再拦截导航和表单;Toast 本体仍可点。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
412 lines
17 KiB
Python
412 lines
17 KiB
Python
"""HEL-360: 手机端 Toast 覆盖层不得拦截页面点击。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
from bank_importer import auth, master_data
|
|
from bank_importer.db import connect, migrate
|
|
|
|
import server
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
WEB = ROOT / "web"
|
|
ADMIN_PASSWORD = "AdminPass123"
|
|
CASHIER_PASSWORD = "CashierA123"
|
|
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError: # pragma: no cover
|
|
sync_playwright = None
|
|
|
|
|
|
def _prepare_chrome_libs() -> Path | None:
|
|
candidates = [ROOT / ".chrome-libs" / "lib"]
|
|
for lib_dir in candidates:
|
|
if (lib_dir / "libatk-1.0.so.0").exists():
|
|
current = os.environ.get("LD_LIBRARY_PATH", "")
|
|
prefix = str(lib_dir)
|
|
if prefix not in current.split(":"):
|
|
os.environ["LD_LIBRARY_PATH"] = (
|
|
f"{prefix}:{current}" if current else prefix
|
|
)
|
|
return lib_dir
|
|
return None
|
|
|
|
|
|
def _chromium_available() -> bool:
|
|
if not sync_playwright:
|
|
return False
|
|
_prepare_chrome_libs()
|
|
try:
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
|
browser.close()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _css_rule(css: str, selector: str) -> str:
|
|
match = re.search(rf"{re.escape(selector)} \{{([^}}]*)\}}", css)
|
|
if not match:
|
|
raise AssertionError(f"missing rule {selector}")
|
|
return match.group(1)
|
|
|
|
|
|
def _mouse_click_center(page, selector: str) -> tuple[float, float]:
|
|
loc = page.locator(selector).first
|
|
loc.wait_for(state="visible", timeout=8000)
|
|
loc.scroll_into_view_if_needed()
|
|
page.wait_for_function(
|
|
"""(sel) => {
|
|
const el = document.querySelector(sel);
|
|
if (!el) return false;
|
|
const r = el.getBoundingClientRect();
|
|
return r.width > 2 && r.height > 2 && r.right > 0 && r.bottom > 0
|
|
&& r.left < window.innerWidth && r.top < window.innerHeight;
|
|
}""",
|
|
arg=selector,
|
|
timeout=4000,
|
|
)
|
|
box = loc.bounding_box()
|
|
assert box, f"{selector} has no box"
|
|
x = box["x"] + box["width"] / 2
|
|
y = box["y"] + box["height"] / 2
|
|
page.mouse.click(x, y)
|
|
return x, y
|
|
|
|
|
|
def _hit(page, x: float, y: float) -> dict:
|
|
return page.evaluate(
|
|
"""({x, y}) => {
|
|
const el = document.elementFromPoint(x, y);
|
|
const region = document.getElementById('toastRegion');
|
|
const toast = el && el.closest ? el.closest('.toast') : null;
|
|
return {
|
|
tag: el ? el.tagName : null,
|
|
id: el ? el.id : null,
|
|
className: el && el.className ? String(el.className) : '',
|
|
inRegion: !!(region && el && region.contains(el)),
|
|
inToast: !!toast,
|
|
regionPe: region ? getComputedStyle(region).pointerEvents : null,
|
|
toastPe: toast ? getComputedStyle(toast).pointerEvents : null,
|
|
};
|
|
}""",
|
|
{"x": x, "y": y},
|
|
)
|
|
|
|
|
|
class Hel360SourceContractTests(unittest.TestCase):
|
|
def test_toast_region_passes_clicks_to_toast_only(self) -> None:
|
|
css = (WEB / "design-system.css").read_text(encoding="utf-8")
|
|
region = _css_rule(css, ".toast-region")
|
|
toast = _css_rule(css, ".toast")
|
|
self.assertIn("pointer-events: none", region)
|
|
self.assertIn("pointer-events: auto", toast)
|
|
self.assertIn("z-index: var(--z-toast)", region)
|
|
mobile = css[css.index("@media (max-width: 720px) {\n .toast-region") :]
|
|
mobile_block = mobile[: mobile.index("\n}")]
|
|
self.assertIn("left: 12px", mobile_block)
|
|
self.assertIn("bottom: 12px", mobile_block)
|
|
self.assertIn("right: 12px", mobile_block)
|
|
self.assertNotIn("pointer-events: none !important", css)
|
|
self.assertNotIn("z-index: -1", region)
|
|
self.assertNotIn("z-index: 0", region)
|
|
|
|
|
|
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针")
|
|
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针")
|
|
class Hel360CssPointerProbeTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
|
|
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
cls.port = cls.httpd.server_address[1]
|
|
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
|
cls.thread.start()
|
|
cls.base = f"http://127.0.0.1:{cls.port}"
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.httpd.shutdown()
|
|
cls.httpd.server_close()
|
|
|
|
def _open(self, page, *, night: bool, width: int, height: int = 640) -> None:
|
|
page.set_viewport_size({"width": width, "height": height})
|
|
theme = "night" if night else "day"
|
|
page.set_content(
|
|
f"""<!doctype html>
|
|
<html lang="zh-CN" class="v-fusion" data-theme="{theme}">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<link rel="stylesheet" href="{self.base}/design-system.css"/>
|
|
</head>
|
|
<body class="is-app" data-theme="{theme}" style="margin:0;min-height:100vh">
|
|
<button type="button" id="page-btn" style="position:fixed;left:50%;top:58%;transform:translate(-50%,-50%);z-index:1">页面按钮</button>
|
|
<a id="page-nav" href="#pair" style="position:fixed;left:16px;top:88px;z-index:1">往来查询</a>
|
|
<div class="tabs" style="position:fixed;left:16px;top:124px;z-index:1;width:220px">
|
|
<button type="button" id="page-tab">页签</button>
|
|
</div>
|
|
<input class="input" id="page-date" type="date" value="2026-09-01" style="position:fixed;left:16px;top:176px;z-index:1;width:160px" />
|
|
<div class="toast-region" id="toastRegion" aria-live="polite">
|
|
<div class="toast info" id="the-toast" role="status">
|
|
<span class="t-dot"></span>
|
|
<div class="t-body">
|
|
<div class="t-title">提示</div>
|
|
<button type="button" id="toast-copy">复制</button>
|
|
<button type="button" id="toast-close">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
window.__clicks = [];
|
|
["page-btn","page-nav","page-tab","page-date","toast-copy","toast-close"].forEach((id) => {{
|
|
document.getElementById(id).addEventListener("click", () => window.__clicks.push(id));
|
|
}});
|
|
</script>
|
|
</body></html>""",
|
|
wait_until="domcontentloaded",
|
|
)
|
|
page.wait_for_selector("#toastRegion")
|
|
|
|
def test_empty_overlay_does_not_eat_real_clicks(self) -> None:
|
|
cases = [
|
|
(360, False, 640),
|
|
(390, True, 640),
|
|
(820, False, 900),
|
|
(1440, True, 900),
|
|
]
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
|
page = browser.new_page()
|
|
try:
|
|
for width, night, height in cases:
|
|
with self.subTest(width=width, night=night):
|
|
self._open(page, night=night, width=width, height=height)
|
|
metrics = page.evaluate(
|
|
"""() => {
|
|
const tr = document.getElementById('toastRegion');
|
|
const toast = document.getElementById('the-toast');
|
|
const r = tr.getBoundingClientRect();
|
|
return {
|
|
regionPe: getComputedStyle(tr).pointerEvents,
|
|
toastPe: getComputedStyle(toast).pointerEvents,
|
|
height: r.height,
|
|
width: r.width,
|
|
top: r.top,
|
|
viewportH: window.innerHeight,
|
|
};
|
|
}"""
|
|
)
|
|
self.assertEqual("none", metrics["regionPe"], metrics)
|
|
self.assertEqual("auto", metrics["toastPe"], metrics)
|
|
if width <= 720:
|
|
self.assertGreater(metrics["height"], metrics["viewportH"] - 40, metrics)
|
|
|
|
page.evaluate("() => { window.__clicks = []; }")
|
|
_mouse_click_center(page, "#page-btn")
|
|
_mouse_click_center(page, "#page-nav")
|
|
_mouse_click_center(page, "#page-tab")
|
|
_mouse_click_center(page, "#page-date")
|
|
clicks = page.evaluate("() => window.__clicks.slice()")
|
|
self.assertEqual(
|
|
["page-btn", "page-nav", "page-tab", "page-date"],
|
|
clicks,
|
|
f"width={width} night={night} clicks={clicks}",
|
|
)
|
|
|
|
cx, cy = _mouse_click_center(page, "#toast-copy")
|
|
hit = _hit(page, cx, cy)
|
|
self.assertTrue(hit["inToast"], hit)
|
|
_mouse_click_center(page, "#toast-close")
|
|
toast_clicks = page.evaluate("() => window.__clicks.slice(-2)")
|
|
self.assertEqual(["toast-copy", "toast-close"], toast_clicks)
|
|
|
|
mid_x = width / 2
|
|
mid_y = height * 0.58
|
|
mid = _hit(page, mid_x, mid_y)
|
|
self.assertFalse(mid["inRegion"] and not mid["inToast"], mid)
|
|
self.assertEqual("none", mid["regionPe"], mid)
|
|
finally:
|
|
browser.close()
|
|
|
|
|
|
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
|
|
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
|
|
class Hel360LiveAppClickTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
_prepare_chrome_libs()
|
|
cls.temp_dir = tempfile.TemporaryDirectory()
|
|
root = Path(cls.temp_dir.name)
|
|
cls.db_path = root / "app.db"
|
|
cls.storage = root / "files"
|
|
cls.storage.mkdir()
|
|
cls._old_db = server.DB_PATH
|
|
cls._old_storage = server.STORAGE_DIR
|
|
server.DB_PATH = cls.db_path
|
|
server.STORAGE_DIR = cls.storage
|
|
connection = connect(cls.db_path)
|
|
migrate(connection)
|
|
auth.create_user(
|
|
connection, "group-admin", ADMIN_PASSWORD, "admin", must_change_password=False
|
|
)
|
|
company_id = master_data.create_company(connection, "甲公司", None, None, None)
|
|
auth.create_user(
|
|
connection,
|
|
"cashier-a",
|
|
CASHIER_PASSWORD,
|
|
"company",
|
|
company_id,
|
|
must_change_password=False,
|
|
)
|
|
connection.close()
|
|
|
|
class QuietHandler(server.AppHandler):
|
|
def log_message(self, *args) -> None:
|
|
pass
|
|
|
|
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
|
cls.port = cls.httpd.server_address[1]
|
|
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
|
cls.thread.start()
|
|
cls.base = f"http://127.0.0.1:{cls.port}"
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.httpd.shutdown()
|
|
cls.httpd.server_close()
|
|
server.DB_PATH = cls._old_db
|
|
server.STORAGE_DIR = cls._old_storage
|
|
cls.temp_dir.cleanup()
|
|
|
|
def _login(self, page, *, portal: str) -> None:
|
|
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
|
|
username = "group-admin" if portal == "admin" else "cashier-a"
|
|
password = ADMIN_PASSWORD if portal == "admin" else CASHIER_PASSWORD
|
|
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
|
|
page.fill("#account", username)
|
|
page.fill("#password", password)
|
|
page.locator('button[type="submit"]').click()
|
|
expect = "admin.html" if portal == "admin" else "company.html"
|
|
page.wait_for_url(f"**/{expect}", timeout=15000)
|
|
page.wait_for_selector(".side-nav", timeout=10000)
|
|
|
|
def _set_theme(self, page, night: bool) -> None:
|
|
theme = "night" if night else "day"
|
|
page.evaluate(
|
|
"""(theme) => {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
document.body.setAttribute('data-theme', theme);
|
|
try { localStorage.setItem('jinniu-theme', theme); } catch (e) {}
|
|
}""",
|
|
theme,
|
|
)
|
|
|
|
def _goto_view(self, page, view: str) -> None:
|
|
_mouse_click_center(page, f'.side-nav a[data-view="{view}"]')
|
|
page.wait_for_selector(f'.app-view[data-page="{view}"].is-active', timeout=4000)
|
|
|
|
def _probe_toast_passthrough(self, page, width: int, height: int) -> None:
|
|
page.evaluate(
|
|
"""() => {
|
|
window.__pageHits = 0;
|
|
const btn = document.querySelector('.topbar .icon-button, .menu-button, .btn');
|
|
if (btn) btn.addEventListener('click', () => { window.__pageHits += 1; }, { once: true });
|
|
if (typeof showToast === 'function') showToast('HEL-360', '点击穿透', 'info');
|
|
}"""
|
|
)
|
|
page.wait_for_selector("#toastRegion .toast", timeout=4000)
|
|
toast_box = page.locator("#toastRegion .toast").first.bounding_box()
|
|
self.assertIsNotNone(toast_box)
|
|
page.mouse.click(
|
|
toast_box["x"] + toast_box["width"] / 2,
|
|
toast_box["y"] + toast_box["height"] / 2,
|
|
)
|
|
toast_hit = _hit(
|
|
page,
|
|
toast_box["x"] + toast_box["width"] / 2,
|
|
toast_box["y"] + toast_box["height"] / 2,
|
|
)
|
|
self.assertTrue(toast_hit["inToast"], toast_hit)
|
|
self.assertEqual("auto", toast_hit["toastPe"], toast_hit)
|
|
|
|
empty_x = width / 2
|
|
empty_y = height * 0.62
|
|
empty = _hit(page, empty_x, empty_y)
|
|
self.assertEqual("none", empty["regionPe"], empty)
|
|
self.assertFalse(empty["inToast"], empty)
|
|
self.assertFalse(empty["inRegion"], empty)
|
|
page.mouse.click(empty_x, empty_y)
|
|
after = _hit(page, empty_x, empty_y)
|
|
self.assertFalse(after["inRegion"], after)
|
|
|
|
def test_real_clicks_reach_admin_and_company_controls(self) -> None:
|
|
cases = [
|
|
("admin", 360, 640, False),
|
|
("admin", 390, 640, True),
|
|
("company", 360, 640, True),
|
|
("company", 390, 640, False),
|
|
("admin", 820, 900, False),
|
|
("admin", 1440, 900, True),
|
|
]
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(
|
|
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
|
|
)
|
|
try:
|
|
for portal, width, height, night in cases:
|
|
with self.subTest(portal=portal, width=width, night=night):
|
|
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
|
page = context.new_page()
|
|
try:
|
|
self._login(page, portal=portal)
|
|
self._set_theme(page, night)
|
|
page.set_viewport_size({"width": width, "height": height})
|
|
page.wait_for_timeout(200)
|
|
region = page.evaluate(
|
|
"""() => {
|
|
const tr = document.getElementById('toastRegion');
|
|
const cs = getComputedStyle(tr);
|
|
const r = tr.getBoundingClientRect();
|
|
return { pe: cs.pointerEvents, height: r.height, vw: window.innerWidth };
|
|
}"""
|
|
)
|
|
self.assertEqual("none", region["pe"], region)
|
|
|
|
if portal == "admin":
|
|
self._goto_view(page, "audit")
|
|
tab_sel = '#auditTabs button[data-audit-filter="断档"]'
|
|
_mouse_click_center(page, tab_sel)
|
|
self.assertEqual("true", page.locator(tab_sel).get_attribute("aria-pressed"))
|
|
self._goto_view(page, "pair")
|
|
date_sel = "#pairEnd"
|
|
else:
|
|
self._goto_view(page, "reconcile")
|
|
_mouse_click_center(page, "#tab-subject")
|
|
self.assertTrue(page.locator("#panel-subject").is_visible())
|
|
self._goto_view(page, "flows")
|
|
date_sel = "#flowStart"
|
|
page.locator(date_sel).scroll_into_view_if_needed()
|
|
_mouse_click_center(page, date_sel)
|
|
page.wait_for_selector("#ds-datepicker:not([hidden])", timeout=4000)
|
|
page.keyboard.press("Escape")
|
|
self._probe_toast_passthrough(page, width, height)
|
|
finally:
|
|
context.close()
|
|
finally:
|
|
browser.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|