233e23016c
- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录 - 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布 - 新增 todayCount 筛选按钮,与系统概览统计数据一致 - 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮 - 统一 '优化' → '审查' 命名,消除前后端术语不一致 - 调度器创作完成后自动触发审查(生成 → 审查 → 待发布) - 清理旧备份/调试文件、过期大纲和研究笔记
282 lines
9.6 KiB
Python
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())
|