Files
yu-zhi-ran/tests/test_browser.py
T
Yuzhiran Dev 233e23016c feat: 内容数据迁移至数据库,合规审查全链路打通
- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录
- 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布
- 新增 todayCount 筛选按钮,与系统概览统计数据一致
- 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮
- 统一 '优化' → '审查' 命名,消除前后端术语不一致
- 调度器创作完成后自动触发审查(生成 → 审查 → 待发布)
- 清理旧备份/调试文件、过期大纲和研究笔记
2026-05-13 17:33:56 +08:00

282 lines
9.6 KiB
Python

"""
宇之然平台 · 浏览器自动化测试
- 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())