""" 宇之然平台 · 浏览器自动化测试 - PC 视口 (1280x720) - H5 视口 (375x667) 覆盖:登录、页面加载、导航、数据渲染、移动端响应式 """ import asyncio import json import re import sys from datetime import datetime from pathlib import Path from playwright.async_api import async_playwright, expect BASE_URL = "http://localhost:8001" REPORT_DIR = Path("tests/reports") REPORT_DIR.mkdir(parents=True, exist_ok=True) CREDENTIALS = {"username": "admin", "password": "admin123"} PAGES = [ {"path": "/", "name": "仪表盘", "id_hint": "#app"}, {"path": "/topics.html", "name": "选题管理"}, {"path": "/calendar.html", "name": "内容日历"}, {"path": "/metrics.html", "name": "数据分析"}, {"path": "/assets.html", "name": "素材库"}, {"path": "/tasks.html", "name": "创作任务"}, {"path": "/platforms.html", "name": "平台配置"}, {"path": "/admin.html", "name": "系统管理"}, ] class Tally: def __init__(self): self.passed = [] self.failed = [] self.skipped = [] def ok(self, case: str, detail=""): self.passed.append((case, detail)) print(f" ✅ {case}") def fail(self, case: str, detail=""): self.failed.append((case, detail)) print(f" ❌ {case} — {detail}") def skip(self, case: str, detail=""): self.skipped.append((case, detail)) print(f" ⏭ {case} — {detail}") @property def total(self): return len(self.passed) + len(self.failed) + len(self.skipped) def snap_path(name: str, view: str) -> str: ts = datetime.now().strftime("%H%M%S") return str(REPORT_DIR / f"{view}_{name}_{ts}.png") async def login(page, tally: Tally): await page.goto(f"{BASE_URL}/login.html", wait_until="networkidle") await page.wait_for_timeout(500) try: inputs = page.locator("input.form-input") count = await inputs.count() if count >= 2: await inputs.nth(0).fill(CREDENTIALS["username"]) await inputs.nth(1).fill(CREDENTIALS["password"]) else: await page.fill("input[type=text]", CREDENTIALS["username"]) await page.fill("input[type=password]", CREDENTIALS["password"]) await page.click("button.login-btn") await page.wait_for_timeout(3000) current = page.url if "login" not in current.lower(): tally.ok("登录", "成功跳转至主页") return True else: err = await page.text_content("body") has_success = "登录成功" in (err or "") if has_success: await page.wait_for_timeout(3000) current = page.url if "login" not in current.lower(): tally.ok("登录", "成功跳转(延迟后)") return True tally.fail("登录", f"仍停留在登录页") return False except Exception as e: tally.fail("登录", str(e)) return False async def check_page_load(page, tally: Tally, page_info: dict): name = page_info["name"] path = page_info["path"] try: resp = await page.goto(f"{BASE_URL}{path}", wait_until="networkidle", timeout=15000) await page.wait_for_timeout(1000) status = resp.status if resp else 0 if status >= 400: text = await page.text_content("body") or "" tally.fail(f"{name} 加载", f"HTTP {status}") return content = await page.text_content("body") or "" has_vue_error = "Vue警告" in content or "Failed to mount" in content or "Error" in content[:500] if has_vue_error: tally.fail(f"{name} 加载", "Vue 渲染异常") return has_app = bool(re.search(r"导航|宇之然|dashboard|选题|内容", content[:200])) if has_app: tally.ok(f"{name} 加载", f"HTTP {status}") else: tally.fail(f"{name} 加载", "页面内容异常") except Exception as e: tally.fail(f"{name} 加载", str(e)) async def check_api_data(page, tally: Tally, view: str): token = await page.evaluate("localStorage.getItem('authToken')") headers = {"Authorization": f"Bearer {token}"} if token else {} routes = { "选题": ("/api/topics", None), "日历": ("/api/calendar?year=2026&month=5", None), "数据": ("/api/metrics/dashboard", None), "任务": ("/api/tasks", None), "素材": ("/api/assets", None), } for label, (path, _) in routes.items(): try: resp = await page.request.get(f"{BASE_URL}{path}", headers=headers) body = await resp.json() if resp.ok else {} if resp.ok: tally.ok(f"{label} API ({view})", f"HTTP {resp.status}") elif resp.status == 404: tally.skip(f"{label} API ({view})", "路由不存在") elif resp.status == 401: tally.fail(f"{label} API ({view})", "无权限") else: tally.fail(f"{label} API ({view})", f"HTTP {resp.status}") except Exception as e: tally.fail(f"{label} API ({view})", str(e)) async def check_navigation(page, tally: Tally, view: str, is_mobile: bool): try: await page.goto(f"{BASE_URL}/", wait_until="networkidle") await page.wait_for_timeout(800) nav_links = [ ("选题", "topics.html"), ("日历", "calendar.html"), ("数据", "metrics.html"), ] found = 0 for label, target in nav_links: link = page.locator(f"a[href*='{target}'], [onclick*='{target}'], *:has-text('{label}')").first if await link.is_visible(): found += 1 if not is_mobile: nav_links_extra = [ ("素材", "assets.html"), ("任务", "tasks.html"), ("平台", "platforms.html"), ] for label, target in nav_links_extra: link = page.locator(f"a[href*='{target}'], [onclick*='{target}'], *:has-text('{label}')").first if await link.is_visible(): found += 1 if found >= (2 if is_mobile else 3): tally.ok(f"导航系统 ({view})", f"发现 {found} 个导航链接") else: tally.fail(f"导航系统 ({view})", f"仅发现 {found} 个导航链接") except Exception as e: tally.fail(f"导航系统 ({view})", str(e)) async def check_mobile_layout(page, tally: Tally): """H5 专项:底部导航、内容撑满、无横向滚动""" await page.goto(f"{BASE_URL}/", wait_until="networkidle") await page.wait_for_timeout(1000) has_h_scroll = await page.evaluate("document.body.scrollWidth > document.documentElement.clientWidth + 5") if has_h_scroll: tally.fail("H5 无横向滚动", "存在横向滚动条") else: tally.ok("H5 无横向滚动") bottom_nav = page.locator(".bottom-nav, .mobile-nav, nav:below(main), [class*=bottom], [class*=mobile]").first try: if await bottom_nav.is_visible(): tally.ok("H5 底部导航", "底部导航可见") else: await page.screenshot(path=snap_path("h5_layout", "h5")) tally.skip("H5 底部导航", "未检测到典型底部导航,需人工确认") except Exception: tally.skip("H5 底部导航", "未找到底部导航元素") async def run_test(device_name: str, viewport, is_mobile: bool): tally = Tally() ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"\n{'='*60}") print(f" {device_name} 测试 @ {ts}") print(f"{'='*60}") async with async_playwright() as p: browser = await p.chromium.launch(headless=True, channel="chrome", args=["--no-sandbox"]) context = await browser.new_context( viewport=viewport, is_mobile=is_mobile, device_scale_factor=2 if is_mobile else 1, ) page = await context.new_page() logged_in = await login(page, tally) if not logged_in: await browser.close() return tally for pg in PAGES: await check_page_load(page, tally, pg) await check_navigation(page, tally, device_name, is_mobile) await check_api_data(page, tally, device_name) if is_mobile: await check_mobile_layout(page, tally) await page.screenshot(path=snap_path("final", device_name.lower().replace(" ", "_"))) await browser.close() return tally async def main(): print(f"{'='*60}") print(f" 宇之然平台 · 浏览器自动化测试") print(f" {BASE_URL}") print(f" 报告: {REPORT_DIR.resolve()}") print(f"{'='*60}") pc_tally = await run_test("PC (1280x720)", {"width": 1280, "height": 720}, is_mobile=False) h5_tally = await run_test("H5 (375x667)", {"width": 375, "height": 667}, is_mobile=True) print(f"\n{'='*60}") print(f" 测试汇总") print(f"{'='*60}") for label, t in [("PC", pc_tally), ("H5", h5_tally)]: print(f" {label}: ✅ {len(t.passed)} ❌ {len(t.failed)} ⏭ {len(t.skipped)} / 总计 {t.total}") print(f"{'='*60}") overall_fail = len(pc_tally.failed) + len(h5_tally.failed) if overall_fail > 0: print(f"\n❌ {overall_fail} 个失败用例:") for label, t in [("PC", pc_tally), ("H5", h5_tally)]: for case, detail in t.failed: print(f" [{label}] {case}: {detail}") sys.exit(1) else: print(f"\n✅ 全部通过!") sys.exit(0) if __name__ == "__main__": asyncio.run(main())