Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
767 lines
29 KiB
Python
767 lines
29 KiB
Python
"""
|
||
教师端全功能 Web 测试脚本(综合版)
|
||
使用 Playwright 对教师端所有页面与核心交互进行功能测试
|
||
结果输出:bugs/teacher_web_test.md
|
||
"""
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
|
||
|
||
BASE_URL = "http://localhost:3000"
|
||
TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
|
||
TEACHER_PASSWORD = "123456"
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||
BUGS_DIR = PROJECT_ROOT / "bugs"
|
||
SCREENSHOT_DIR = PROJECT_ROOT / "tests" / "webapp" / "screenshots"
|
||
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 教师端所有路由(依据 src/modules/layout/config/navigation.ts 与源码目录)
|
||
TEACHER_ROUTES = {
|
||
"Dashboard": ["/teacher/dashboard"],
|
||
"Textbooks": ["/teacher/textbooks"],
|
||
"Exams": [
|
||
"/teacher/exams",
|
||
"/teacher/exams/all",
|
||
"/teacher/exams/create",
|
||
],
|
||
"Homework": [
|
||
"/teacher/homework",
|
||
"/teacher/homework/assignments",
|
||
"/teacher/homework/assignments/create",
|
||
"/teacher/homework/submissions",
|
||
],
|
||
"Grades": [
|
||
"/teacher/grades",
|
||
"/teacher/grades/entry",
|
||
"/teacher/grades/stats",
|
||
"/teacher/grades/analytics",
|
||
],
|
||
"Question Bank": ["/teacher/questions"],
|
||
"Class Management": [
|
||
"/teacher/classes",
|
||
"/teacher/classes/my",
|
||
"/teacher/classes/students",
|
||
"/teacher/classes/schedule",
|
||
],
|
||
"Course Plans": ["/teacher/course-plans"],
|
||
"Lesson Plans": [
|
||
"/teacher/lesson-plans",
|
||
"/teacher/lesson-plans/new",
|
||
],
|
||
"Attendance": [
|
||
"/teacher/attendance",
|
||
"/teacher/attendance/sheet",
|
||
"/teacher/attendance/stats",
|
||
],
|
||
"Schedule Changes": ["/teacher/schedule-changes"],
|
||
"Diagnostic": ["/teacher/diagnostic"],
|
||
"Electives": ["/teacher/elective"],
|
||
"Management": [
|
||
"/management/grade/classes",
|
||
"/management/grade/insights",
|
||
],
|
||
"Announcements": ["/announcements"],
|
||
"Messages": ["/messages", "/messages/compose"],
|
||
"Profile & Settings": ["/profile", "/settings", "/settings/security"],
|
||
}
|
||
|
||
# 详情页发现规则:(列表页, 链接前缀)
|
||
DETAIL_DISCOVERIES = {
|
||
"Textbook Detail": ("/teacher/textbooks", "/teacher/textbooks/"),
|
||
"Class Detail": ("/teacher/classes/my", "/teacher/classes/my/"),
|
||
"Course Plan Detail": ("/teacher/course-plans", "/teacher/course-plans/"),
|
||
"Lesson Plan Edit": ("/teacher/lesson-plans", "/teacher/lesson-plans/"),
|
||
"Exam Detail": ("/teacher/exams/all", "/teacher/exams/"),
|
||
"Homework Assignment Detail": ("/teacher/homework/assignments", "/teacher/homework/assignments/"),
|
||
}
|
||
|
||
results = {
|
||
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"test_target": "教师端 (Teacher)",
|
||
"base_url": BASE_URL,
|
||
"teacher_email": TEACHER_EMAIL,
|
||
"summary": {
|
||
"total": 0,
|
||
"passed": 0,
|
||
"failed": 0,
|
||
"warnings": 0,
|
||
},
|
||
"pages": {},
|
||
"interactions": [],
|
||
"console_errors_global": [],
|
||
"navigation_issues": [],
|
||
}
|
||
|
||
|
||
# ============ 工具函数 ============
|
||
|
||
def safe_text(locator, max_len=200):
|
||
try:
|
||
text = locator.text_content()
|
||
return (text or "").strip()[:max_len]
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def login(page):
|
||
"""登录教师账号"""
|
||
print("\n>>> 登录教师账号...")
|
||
page.goto(f"{BASE_URL}/login", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
|
||
if "/login" not in page.url:
|
||
print(f" 已登录,当前 URL: {page.url}")
|
||
return True
|
||
|
||
try:
|
||
page.locator('input[name="email"]').fill(TEACHER_EMAIL)
|
||
page.locator('input[name="password"]').fill(TEACHER_PASSWORD)
|
||
page.get_by_role("button", name=re.compile(r"Sign In with Email", re.I)).click()
|
||
|
||
page.wait_for_load_state("networkidle", timeout=20000)
|
||
page.wait_for_timeout(2000)
|
||
|
||
if "/login" in page.url:
|
||
print(f" ❌ 登录后仍在登录页: {page.url}")
|
||
return False
|
||
|
||
print(f" ✅ 登录成功,跳转至: {page.url}")
|
||
return True
|
||
except Exception as e:
|
||
print(f" ❌ 登录异常: {e}")
|
||
return False
|
||
|
||
|
||
def test_page(page, route, category, take_screenshot=False):
|
||
"""测试单个页面:HTTP 状态、重定向、控制台错误、空内容检测"""
|
||
url = f"{BASE_URL}{route}"
|
||
page_result = {
|
||
"url": url,
|
||
"route": route,
|
||
"category": category,
|
||
"status": "unknown",
|
||
"http_status": None,
|
||
"final_url": None,
|
||
"redirect_url": None,
|
||
"errors": [],
|
||
"warnings": [],
|
||
"console_errors": [],
|
||
"title": "",
|
||
"body_length": 0,
|
||
"screenshot": None,
|
||
}
|
||
|
||
print(f"\n 测试: {category} - {route}")
|
||
|
||
console_errors = []
|
||
|
||
def on_console(msg):
|
||
if msg.type == "error":
|
||
text = msg.text
|
||
# 忽略已知的浏览器扩展/第三方噪音
|
||
if "favicon" in text.lower() or "Download the React DevTools" in text:
|
||
return
|
||
console_errors.append(text)
|
||
results["console_errors_global"].append({"route": route, "error": text})
|
||
|
||
def on_pageerror(err):
|
||
page_result["errors"].append(f"PageError: {str(err)[:200]}")
|
||
|
||
page.on("console", on_console)
|
||
page.on("pageerror", on_pageerror)
|
||
|
||
try:
|
||
response = page.goto(url, timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(800)
|
||
|
||
http_status = response.status if response else None
|
||
final_url = page.url
|
||
|
||
page_result["http_status"] = http_status
|
||
page_result["final_url"] = final_url
|
||
page_result["title"] = safe_text(page.locator("title"))
|
||
body_text = safe_text(page.locator("body"), 5000)
|
||
page_result["body_length"] = len(body_text)
|
||
|
||
if final_url.rstrip("/") != url.rstrip("/"):
|
||
page_result["redirect_url"] = final_url
|
||
|
||
# 状态判定
|
||
if http_status and http_status >= 500:
|
||
page_result["status"] = "failed"
|
||
page_result["errors"].append(f"HTTP {http_status} 服务器错误")
|
||
elif http_status and http_status >= 400:
|
||
page_result["status"] = "warning"
|
||
page_result["warnings"].append(f"HTTP {http_status} 客户端错误")
|
||
elif final_url and "/login" in final_url:
|
||
page_result["status"] = "failed"
|
||
page_result["errors"].append("重定向到登录页(认证失败或会话过期)")
|
||
elif final_url and "/500" in final_url:
|
||
page_result["status"] = "failed"
|
||
page_result["errors"].append("重定向到 500 错误页")
|
||
elif final_url and "/404" in final_url:
|
||
page_result["status"] = "warning"
|
||
page_result["warnings"].append("重定向到 404 页面")
|
||
elif page_result["body_length"] < 50:
|
||
page_result["status"] = "warning"
|
||
page_result["warnings"].append("页面内容过少(可能渲染失败)")
|
||
else:
|
||
page_result["status"] = "passed"
|
||
|
||
# 检查页面错误提示
|
||
error_locs = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600').all()
|
||
for et in error_locs[:3]:
|
||
text = safe_text(et, 150)
|
||
if text and "error" not in text.lower()[:0]: # 保留所有 alert 文本
|
||
page_result["warnings"].append(f"页面告警文本: {text}")
|
||
|
||
# 收集控制台错误
|
||
if console_errors:
|
||
page_result["console_errors"] = console_errors[:5]
|
||
if page_result["status"] == "passed":
|
||
page_result["status"] = "warning"
|
||
page_result["warnings"].append(f"控制台错误 {len(console_errors)} 条")
|
||
|
||
# 截图
|
||
if take_screenshot or page_result["status"] != "passed":
|
||
safe_name = re.sub(r"[^\w\-]", "_", route.strip("/"))
|
||
shot_path = SCREENSHOT_DIR / f"{safe_name}.png"
|
||
try:
|
||
page.screenshot(path=str(shot_path), full_page=True)
|
||
page_result["screenshot"] = str(shot_path.relative_to(PROJECT_ROOT))
|
||
except Exception as e:
|
||
page_result["warnings"].append(f"截图失败: {e}")
|
||
|
||
status_icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(page_result["status"], "❓")
|
||
print(f" {status_icon} {page_result['status']} (HTTP {http_status}) -> {final_url}")
|
||
|
||
except PlaywrightTimeout:
|
||
page_result["status"] = "failed"
|
||
page_result["errors"].append("页面加载超时 (30s)")
|
||
print(f" ❌ TIMEOUT")
|
||
except Exception as e:
|
||
page_result["status"] = "failed"
|
||
page_result["errors"].append(str(e)[:200])
|
||
print(f" ❌ ERROR: {str(e)[:100]}")
|
||
finally:
|
||
try:
|
||
page.remove_listener("console", on_console)
|
||
page.remove_listener("pageerror", on_pageerror)
|
||
except Exception:
|
||
pass
|
||
|
||
return page_result
|
||
|
||
|
||
def discover_detail_links(page, list_route, link_pattern):
|
||
"""从列表页发现详情页链接"""
|
||
try:
|
||
url = f"{BASE_URL}{list_route}"
|
||
page.goto(url, timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(800)
|
||
|
||
links = page.locator(f'a[href*="{link_pattern}"]').all()
|
||
detail_urls = []
|
||
seen = set()
|
||
for link in links[:5]:
|
||
try:
|
||
href = link.get_attribute("href")
|
||
except Exception:
|
||
continue
|
||
if not href or href == list_route:
|
||
continue
|
||
if link_pattern in href and href not in seen:
|
||
seen.add(href)
|
||
detail_urls.append(href)
|
||
return detail_urls[:2]
|
||
except Exception as e:
|
||
print(f" 发现详情页链接失败 ({list_route}): {e}")
|
||
return []
|
||
|
||
|
||
def test_interactions(page):
|
||
"""测试关键交互功能"""
|
||
interactions = []
|
||
|
||
# 1. 仪表盘快捷操作按钮
|
||
print("\n>>> 测试交互: 仪表盘快捷操作")
|
||
try:
|
||
page.goto(f"{BASE_URL}/teacher/dashboard", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(1000)
|
||
|
||
# 检查仪表盘是否有快捷操作按钮
|
||
quick_action_btns = page.locator('a[href*="/teacher/"], button').all()
|
||
clickable_count = 0
|
||
for btn in quick_action_btns[:10]:
|
||
try:
|
||
if btn.is_visible():
|
||
clickable_count += 1
|
||
except Exception:
|
||
continue
|
||
interactions.append({
|
||
"name": "仪表盘快捷操作可见性",
|
||
"status": "passed" if clickable_count > 0 else "warning",
|
||
"detail": f"可见可点击元素 {clickable_count} 个",
|
||
})
|
||
print(f" ✅ 仪表盘可见元素 {clickable_count} 个")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "仪表盘快捷操作可见性",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 仪表盘交互测试失败: {e}")
|
||
|
||
# 2. 教材详情页 - 检查章节侧边栏
|
||
print("\n>>> 测试交互: 教材详情页章节侧边栏")
|
||
try:
|
||
page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(800)
|
||
|
||
textbook_links = page.locator('a[href*="/teacher/textbooks/"]').all()
|
||
if textbook_links:
|
||
first_href = textbook_links[0].get_attribute("href")
|
||
if first_href:
|
||
if not first_href.startswith("/"):
|
||
first_href = "/" + first_href
|
||
page.goto(f"{BASE_URL}{first_href}", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(1000)
|
||
|
||
# 检查章节列表
|
||
chapter_locs = page.locator('[data-testid*="chapter"], [class*="chapter"], li, a').all()
|
||
interactions.append({
|
||
"name": "教材详情页加载",
|
||
"status": "passed",
|
||
"detail": f"教材 {first_href} 加载成功,发现 {len(chapter_locs)} 个潜在章节元素",
|
||
})
|
||
print(f" ✅ 教材详情页加载成功")
|
||
else:
|
||
interactions.append({
|
||
"name": "教材详情页加载",
|
||
"status": "warning",
|
||
"detail": "未发现教材链接(可能数据库无教材数据)",
|
||
})
|
||
print(f" ⚠️ 未发现教材链接")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "教材详情页加载",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 教材详情页测试失败: {e}")
|
||
|
||
# 3. 创建考试页面 - 表单元素检查
|
||
print("\n>>> 测试交互: 创建考试表单")
|
||
try:
|
||
page.goto(f"{BASE_URL}/teacher/exams/create", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(1000)
|
||
|
||
form_inputs = page.locator('input, textarea, select, button[type="submit"]').all()
|
||
interactions.append({
|
||
"name": "创建考试表单元素",
|
||
"status": "passed" if len(form_inputs) > 0 else "warning",
|
||
"detail": f"发现 {len(form_inputs)} 个表单元素",
|
||
})
|
||
print(f" ✅ 创建考试表单 {len(form_inputs)} 个元素")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "创建考试表单元素",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 创建考试表单测试失败: {e}")
|
||
|
||
# 4. 题库页面 - 筛选与表格
|
||
print("\n>>> 测试交互: 题库页面表格")
|
||
try:
|
||
page.goto(f"{BASE_URL}/teacher/questions", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(1000)
|
||
|
||
table_rows = page.locator('tbody tr, [role="row"]').all()
|
||
filter_inputs = page.locator('input[placeholder*="搜索"], input[placeholder*="筛选"], select').all()
|
||
interactions.append({
|
||
"name": "题库表格与筛选",
|
||
"status": "passed" if len(table_rows) >= 0 else "warning",
|
||
"detail": f"表格行 {len(table_rows)} 个,筛选器 {len(filter_inputs)} 个",
|
||
})
|
||
print(f" ✅ 题库表格行 {len(table_rows)} 个")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "题库表格与筛选",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 题库表格测试失败: {e}")
|
||
|
||
# 5. 作业创建页面
|
||
print("\n>>> 测试交互: 创建作业表单")
|
||
try:
|
||
page.goto(f"{BASE_URL}/teacher/homework/assignments/create", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(1000)
|
||
|
||
form_elements = page.locator('input, textarea, select, button').all()
|
||
interactions.append({
|
||
"name": "创建作业表单",
|
||
"status": "passed" if len(form_elements) > 2 else "warning",
|
||
"detail": f"发现 {len(form_elements)} 个表单元素",
|
||
})
|
||
print(f" ✅ 创建作业表单 {len(form_elements)} 个元素")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "创建作业表单",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 创建作业表单测试失败: {e}")
|
||
|
||
# 6. 备课新建页面
|
||
print("\n>>> 测试交互: 新建备课")
|
||
try:
|
||
page.goto(f"{BASE_URL}/teacher/lesson-plans/new", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(1000)
|
||
|
||
form_elements = page.locator('input, textarea, select, button, [contenteditable="true"]').all()
|
||
interactions.append({
|
||
"name": "新建备课表单",
|
||
"status": "passed" if len(form_elements) > 0 else "warning",
|
||
"detail": f"发现 {len(form_elements)} 个表单/编辑元素",
|
||
})
|
||
print(f" ✅ 新建备课 {len(form_elements)} 个元素")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "新建备课表单",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 新建备课测试失败: {e}")
|
||
|
||
# 7. 侧边栏导航
|
||
print("\n>>> 测试交互: 侧边栏导航")
|
||
try:
|
||
sidebar_links = page.locator('nav a, aside a, [data-sidebar] a').all()
|
||
interactions.append({
|
||
"name": "侧边栏导航链接",
|
||
"status": "passed" if len(sidebar_links) > 3 else "warning",
|
||
"detail": f"发现 {len(sidebar_links)} 个侧边栏链接",
|
||
})
|
||
print(f" ✅ 侧边栏 {len(sidebar_links)} 个链接")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "侧边栏导航链接",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 侧边栏测试失败: {e}")
|
||
|
||
# 8. 消息撰写
|
||
print("\n>>> 测试交互: 消息撰写页")
|
||
try:
|
||
page.goto(f"{BASE_URL}/messages/compose", timeout=30000)
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
page.wait_for_timeout(800)
|
||
|
||
form_elements = page.locator('input, textarea, select, button').all()
|
||
interactions.append({
|
||
"name": "消息撰写表单",
|
||
"status": "passed" if len(form_elements) > 0 else "warning",
|
||
"detail": f"发现 {len(form_elements)} 个表单元素",
|
||
})
|
||
print(f" ✅ 消息撰写 {len(form_elements)} 个元素")
|
||
except Exception as e:
|
||
interactions.append({
|
||
"name": "消息撰写表单",
|
||
"status": "failed",
|
||
"detail": str(e)[:200],
|
||
})
|
||
print(f" ❌ 消息撰写测试失败: {e}")
|
||
|
||
return interactions
|
||
|
||
|
||
# ============ 主流程 ============
|
||
|
||
def run_all_tests():
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch(headless=True)
|
||
context = browser.new_context(
|
||
viewport={"width": 1440, "height": 900},
|
||
locale="zh-CN",
|
||
ignore_https_errors=True,
|
||
)
|
||
page = context.new_page()
|
||
|
||
if not login(page):
|
||
print("❌ 登录失败,终止测试")
|
||
browser.close()
|
||
return
|
||
|
||
total = 0
|
||
# 测试所有主路由
|
||
for category, routes in TEACHER_ROUTES.items():
|
||
for route in routes:
|
||
page_result = test_page(page, route, category, take_screenshot=False)
|
||
key = route.replace("/", "_").strip("_")
|
||
results["pages"][key] = page_result
|
||
total += 1
|
||
|
||
# 发现并测试详情页
|
||
print("\n\n>>> 发现详情页链接...")
|
||
for name, (list_url, pattern) in DETAIL_DISCOVERIES.items():
|
||
detail_urls = discover_detail_links(page, list_url, pattern)
|
||
if detail_urls:
|
||
print(f"\n {name}: 发现 {len(detail_urls)} 个详情页")
|
||
for detail_url in detail_urls[:1]: # 每类只测第一个
|
||
route = detail_url if detail_url.startswith("/") else "/" + detail_url
|
||
page_result = test_page(page, route, f"{name}")
|
||
key = route.replace("/", "_").strip("_")
|
||
results["pages"][key] = page_result
|
||
total += 1
|
||
else:
|
||
print(f"\n {name}: 未发现详情页链接(可能无数据)")
|
||
results["navigation_issues"].append({
|
||
"category": name,
|
||
"list_url": list_url,
|
||
"issue": "未发现详情页链接",
|
||
})
|
||
|
||
# 交互测试
|
||
print("\n\n>>> 开始交互功能测试...")
|
||
results["interactions"] = test_interactions(page)
|
||
|
||
# 汇总
|
||
passed = sum(1 for p in results["pages"].values() if p["status"] == "passed")
|
||
failed = sum(1 for p in results["pages"].values() if p["status"] == "failed")
|
||
warnings = sum(1 for p in results["pages"].values() if p["status"] == "warning")
|
||
|
||
results["summary"]["total"] = total
|
||
results["summary"]["passed"] = passed
|
||
results["summary"]["failed"] = failed
|
||
results["summary"]["warnings"] = warnings
|
||
|
||
print(f"\n\n{'='*60}")
|
||
print(f"测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}")
|
||
print(f"{'='*60}")
|
||
|
||
browser.close()
|
||
|
||
|
||
def generate_report():
|
||
"""生成 Markdown 测试报告"""
|
||
lines = []
|
||
lines.append("# 教师端 Web 功能测试报告")
|
||
lines.append("")
|
||
lines.append(f"> 测试日期:{results['test_date']}")
|
||
lines.append(f"> 测试范围:教师端所有页面与核心交互功能")
|
||
lines.append(f"> 测试工具:Playwright + Chromium (headless)")
|
||
lines.append(f"> 测试账号:`{results['teacher_email']}`")
|
||
lines.append(f"> 基础 URL:`{results['base_url']}`")
|
||
lines.append(f"> 测试依据:`src/modules/layout/config/navigation.ts`、`src/app/(dashboard)/teacher/`")
|
||
lines.append("")
|
||
lines.append("---")
|
||
lines.append("")
|
||
|
||
# 一、测试概览
|
||
lines.append("## 一、测试概览")
|
||
lines.append("")
|
||
s = results["summary"]
|
||
pass_rate = (s["passed"] / s["total"] * 100) if s["total"] > 0 else 0
|
||
lines.append("| 指标 | 数值 |")
|
||
lines.append("|------|------|")
|
||
lines.append(f"| 总测试页面数 | {s['total']} |")
|
||
lines.append(f"| 通过 ✅ | {s['passed']} |")
|
||
lines.append(f"| 警告 ⚠️ | {s['warnings']} |")
|
||
lines.append(f"| 失败 ❌ | {s['failed']} |")
|
||
lines.append(f"| 通过率 | {pass_rate:.1f}% |")
|
||
lines.append(f"| 交互测试数 | {len(results['interactions'])} |")
|
||
lines.append(f"| 全局控制台错误 | {len(results['console_errors_global'])} |")
|
||
lines.append("")
|
||
lines.append("---")
|
||
lines.append("")
|
||
|
||
# 二、页面测试详情
|
||
lines.append("## 二、页面测试详情(按模块分组)")
|
||
lines.append("")
|
||
|
||
by_category = {}
|
||
for key, page_result in results["pages"].items():
|
||
cat = page_result.get("category", "Other")
|
||
by_category.setdefault(cat, []).append(page_result)
|
||
|
||
for category in by_category.keys():
|
||
pages = by_category[category]
|
||
lines.append(f"### {category}")
|
||
lines.append("")
|
||
lines.append("| 状态 | 路由 | HTTP | 最终 URL | 备注 |")
|
||
lines.append("|------|------|------|----------|------|")
|
||
|
||
for p in pages:
|
||
status_icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(p["status"], "❓")
|
||
notes = []
|
||
if p.get("redirect_url"):
|
||
notes.append(f"重定向: `{p['redirect_url']}`")
|
||
if p.get("errors"):
|
||
notes.append(f"错误: {'; '.join(p['errors'][:2])}")
|
||
if p.get("warnings"):
|
||
notes.append(f"警告: {'; '.join(p['warnings'][:2])}")
|
||
if p.get("console_errors"):
|
||
notes.append(f"控制台错误: {len(p['console_errors'])} 条")
|
||
note_str = "<br>".join(notes) if notes else "-"
|
||
|
||
short_url = p["route"]
|
||
final_url = (p.get("final_url") or "").replace(BASE_URL, "") or short_url
|
||
lines.append(
|
||
f"| {status_icon} | `{short_url}` | {p['http_status'] or '-'} | `{final_url}` | {note_str} |"
|
||
)
|
||
|
||
lines.append("")
|
||
|
||
# 三、交互测试详情
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 三、交互功能测试详情")
|
||
lines.append("")
|
||
if results["interactions"]:
|
||
lines.append("| 状态 | 交互项 | 详情 |")
|
||
lines.append("|------|--------|------|")
|
||
for it in results["interactions"]:
|
||
status_icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(it["status"], "❓")
|
||
lines.append(f"| {status_icon} | {it['name']} | {it['detail']} |")
|
||
lines.append("")
|
||
else:
|
||
lines.append("无交互测试数据。")
|
||
lines.append("")
|
||
|
||
# 四、失败页面详情
|
||
failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"]
|
||
if failed_pages:
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 四、失败页面详情(需修复)")
|
||
lines.append("")
|
||
for key, p in failed_pages:
|
||
lines.append(f"### ❌ `{p['route']}`")
|
||
lines.append("")
|
||
lines.append(f"- **分类**: {p['category']}")
|
||
lines.append(f"- **HTTP 状态**: {p['http_status']}")
|
||
lines.append(f"- **最终 URL**: `{p.get('final_url', '-')}`")
|
||
if p.get("redirect_url"):
|
||
lines.append(f"- **重定向到**: `{p['redirect_url']}`")
|
||
if p.get("errors"):
|
||
lines.append(f"- **错误信息**:")
|
||
for err in p["errors"]:
|
||
lines.append(f" - {err}")
|
||
if p.get("console_errors"):
|
||
lines.append(f"- **控制台错误**:")
|
||
for ce in p["console_errors"]:
|
||
lines.append(f" - `{ce}`")
|
||
if p.get("screenshot"):
|
||
lines.append(f"- **截图**: `{p['screenshot']}`")
|
||
lines.append("")
|
||
|
||
# 五、警告页面详情
|
||
warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"]
|
||
if warning_pages:
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 五、警告页面详情(建议复查)")
|
||
lines.append("")
|
||
for key, p in warning_pages:
|
||
lines.append(f"### ⚠️ `{p['route']}`")
|
||
lines.append("")
|
||
lines.append(f"- **分类**: {p['category']}")
|
||
lines.append(f"- **HTTP 状态**: {p['http_status']}")
|
||
if p.get("warnings"):
|
||
for w in p["warnings"]:
|
||
lines.append(f" - {w}")
|
||
if p.get("console_errors"):
|
||
lines.append(f"- **控制台错误**:")
|
||
for ce in p["console_errors"]:
|
||
lines.append(f" - `{ce}`")
|
||
lines.append("")
|
||
|
||
# 六、全局控制台错误
|
||
if results["console_errors_global"]:
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 六、全局控制台错误汇总")
|
||
lines.append("")
|
||
lines.append("| 路由 | 错误信息 |")
|
||
lines.append("|------|----------|")
|
||
for ce in results["console_errors_global"][:30]:
|
||
err_text = ce["error"][:200].replace("|", "\\|")
|
||
lines.append(f"| `{ce['route']}` | `{err_text}` |")
|
||
lines.append("")
|
||
|
||
# 七、导航问题
|
||
if results["navigation_issues"]:
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 七、导航发现问题")
|
||
lines.append("")
|
||
for ni in results["navigation_issues"]:
|
||
lines.append(f"- **{ni['category']}** (`{ni['list_url']}`): {ni['issue']}")
|
||
lines.append("")
|
||
|
||
# 八、测试结论与建议
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 八、测试结论与建议")
|
||
lines.append("")
|
||
if s["failed"] == 0 and s["warnings"] == 0:
|
||
lines.append("✅ **教师端所有页面与交互功能测试全部通过**,未发现严重问题。")
|
||
elif s["failed"] == 0:
|
||
lines.append(f"⚠️ **教师端测试未发现失败页面**,但有 {s['warnings']} 个警告需复查。")
|
||
else:
|
||
lines.append(f"❌ **教师端测试发现 {s['failed']} 个失败页面**,需优先修复。")
|
||
lines.append("")
|
||
lines.append("### 建议后续动作")
|
||
lines.append("")
|
||
lines.append("1. 优先修复「失败页面详情」中列出的所有 P0 问题(HTTP 5xx、重定向到登录页等)")
|
||
lines.append("2. 复查「警告页面详情」中的页面,确认是否为数据缺失或非关键告警")
|
||
lines.append("3. 控制台错误如涉及 Next.js 运行时或服务端异常,应排查 Server Action 与 data-access 层")
|
||
lines.append("4. 对于未发现详情页链接的模块,建议先在种子数据中补充对应记录再回归测试")
|
||
lines.append("")
|
||
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append(f"*报告自动生成于 {results['test_date']} by webapp-testing skill*")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def main():
|
||
run_all_tests()
|
||
|
||
report = generate_report()
|
||
|
||
BUGS_DIR.mkdir(parents=True, exist_ok=True)
|
||
output_path = BUGS_DIR / "teacher_web_test.md"
|
||
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
f.write(report)
|
||
print(f"\n📄 报告已写入: {output_path}")
|
||
|
||
# 同时输出 JSON 便于调试
|
||
json_path = output_path.with_suffix(".json")
|
||
with open(json_path, "w", encoding="utf-8") as f:
|
||
json.dump(results, f, ensure_ascii=False, indent=2, default=str)
|
||
print(f"📄 JSON 数据已写入: {json_path}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|