"""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""" 往来查询
提示
""", 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()