Files
NextEdu/webtest/dashboard_test.py
SpecialX d884c6d513
Some checks failed
CI / scheduled-backup (push) Failing after 36s
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
test: update and add E2E, integration, visual, and webapp tests
- Update E2E tests: announcements, auth, auth-business-flow, full-route-regression, grades, navigation, smoke-auth, teacher-web-test

- Update integration tests: api-ai-chat, api-onboarding-complete, api-onboarding-status, proxy-guard, integration setup

- Update visual regression tests: admin-dashboard, homepage, student-dashboard, teacher-dashboard, visual config, helpers

- Update webapp tests: admin, parent, student full tests and debug scripts

- Add new webapp tests: announcements_messages, settings_profile, debug scripts

- Add webtest directory with test plans, screenshots, and diagnostic scripts
2026-06-23 17:39:40 +08:00

894 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
仪表盘模块全功能 Web 测试脚本
使用 Playwright 对所有角色的仪表盘进行功能测试
覆盖admin / teacher / student / parent 四种角色 + /dashboard 重定向逻辑
结果输出webtest/dashboard_0.1.0.md 与 webtest/dashboard_0.1.0.json
"""
import json
import os
import re
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
VERSION = "0.1.0"
# 测试账号(来自 scripts/seed.ts
TEST_ACCOUNTS = {
"admin": {"email": "admin@xiaoxue.edu.cn", "password": "123456", "expected_path": "/admin/dashboard"},
"teacher": {"email": "t_chinese_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/teacher/dashboard"},
"student": {"email": "student_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/student/dashboard"},
"parent": {"email": "parent_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/parent/dashboard"},
}
# 各角色仪表盘预期包含的关键元素(基于源码分析)
DASHBOARD_EXPECTED_ELEMENTS = {
"admin": {
"title_keys": ["管理员", "Admin", "仪表盘", "Dashboard"],
"stat_cards": 4, # users, classes, homeworkPublished, toGrade
"quick_action_cards": 6, # importUsers, newAnnouncement, approveSchedule, autoSchedule, fileManagement, attendanceOverview
"charts": 2, # userGrowthTrend, homeworkSubmissionTrend
"info_cards": 3, # userRoles, content, homeworkActivity
"table_present": True, # recentUsers table
"quick_links": ["/admin/users/import", "/admin/announcements", "/admin/scheduling/changes",
"/admin/scheduling/auto", "/admin/files", "/admin/attendance", "/admin/users"],
},
"teacher": {
"title_keys": ["教师", "Teacher", "仪表盘", "Dashboard"],
"stat_cards": 4, # toGrade, activeAssignments, averageScore, submissionRate
"todo_items": 3, # toGrade, todayAttendance, activeAssignments
"charts": 1, # gradeTrends
"schedule_present": True,
"submissions_list": True,
"homework_card": True,
"classes_card": True,
"quick_links": ["/teacher/homework/submissions", "/teacher/attendance/sheet", "/teacher/homework/assignments"],
},
"student": {
"title_keys": ["学生", "Student", "仪表盘", "Dashboard"],
"stat_cards": 5, # enrolledClassCount, dueSoonCount, overdueCount, gradedCount, ranking
"assignments_card": True,
"grades_card": True,
"schedule_card": True,
},
"parent": {
"title_keys": ["家长", "Parent", "仪表盘", "Dashboard"],
"quick_entries": 4, # grades, attendance, announcements, leaveRequest
"children_cards": True,
"quick_links": ["/parent/grades", "/parent/attendance", "/announcements", "/parent/leave"],
},
}
# 跨角色访问保护测试:每个角色不应能访问其他角色的仪表盘
CROSS_ROLE_FORBIDDEN = {
"admin": ["/teacher/dashboard", "/student/dashboard", "/parent/dashboard"],
"teacher": ["/admin/dashboard", "/student/dashboard", "/parent/dashboard"],
"student": ["/admin/dashboard", "/teacher/dashboard", "/parent/dashboard"],
"parent": ["/admin/dashboard", "/teacher/dashboard", "/student/dashboard"],
}
PROJECT_ROOT = Path(__file__).resolve().parents[1]
WEBTEST_DIR = PROJECT_ROOT / "webtest"
SCREENSHOT_DIR = WEBTEST_DIR / "screenshots"
WEBTEST_DIR.mkdir(parents=True, exist_ok=True)
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"module": "仪表盘 (Dashboard)",
"version": VERSION,
"base_url": BASE_URL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"roles": {},
"redirect_tests": [],
"cross_role_tests": [],
"console_errors_global": [],
}
def _is_login_redirect(url: str) -> bool:
"""精确判断 URL 是否为登录页重定向"""
parsed = urlparse(url)
path = parsed.path.rstrip("/")
if path.endswith("/login"):
return True
query = parse_qs(parsed.query)
callback = query.get("callbackUrl", [""])[0]
if callback and "/login" in callback:
return True
return False
def login(page, role: str) -> bool:
"""登录指定角色账号"""
account = TEST_ACCOUNTS[role]
print(f"\n>>> 登录 {role} 账号 ({account['email']})...")
# 先导航到空白页,确保之前的页面状态被清除
try:
page.goto("about:blank", timeout=5000)
except Exception:
pass
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
# 检查是否已登录
current_path = urlparse(page.url).path
if current_path.startswith(account["expected_path"]) or current_path.endswith("/dashboard"):
print(f" 已登录,跳过登录步骤 (URL: {page.url})")
return True
try:
# 等待登录表单加载
email_input = page.locator('input[name="email"]')
if email_input.count() == 0:
email_input = page.locator('input[type="email"]')
email_input.wait_for(state="visible", timeout=15000)
email_input.fill(account["email"])
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
password_input.fill(account["password"])
# 登录表单使用 signIn({redirect: false}) + router.push()
# 这是客户端导航,不会触发完整的页面加载
# 因此使用 wait_for_url 等待 URL 变化
# 找到提交按钮表单内第一个按钮shadcn Button 在 form 内默认 type=submit
login_btn = page.locator("form button").first
# 点击提交按钮
login_btn.click()
# 等待 URL 离开 /login 页面(登录成功会 router.push 到 /dashboard
try:
page.wait_for_url(
lambda url: "/login" not in url,
timeout=20000,
)
except PlaywrightTimeout:
# URL 没有变化,说明登录失败
debug_path = SCREENSHOT_DIR / f"login_fail_{role}.png"
page.screenshot(path=str(debug_path))
# 获取页面错误提示
body_text = page.locator("body").text_content() or ""
print(f"{role} 登录失败URL 未变化,截图: {debug_path.name}")
print(f" 页面文本片段: {body_text[:200]}")
return False
# 等待客户端路由稳定
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
print(f" 登录后 URL: {page.url}")
if "/login" in page.url:
debug_path = SCREENSHOT_DIR / f"login_fail_{role}.png"
page.screenshot(path=str(debug_path))
print(f"{role} 登录失败(仍在登录页,截图: {debug_path.name}")
return False
return True
except Exception as e:
print(f"{role} 登录异常: {e}")
try:
debug_path = SCREENSHOT_DIR / f"login_error_{role}.png"
page.screenshot(path=str(debug_path))
except Exception:
pass
return False
def logout(page):
"""退出当前登录状态(通过清除 cookies 并访问首页)"""
page.context.clear_cookies()
print(" 已清除 cookies 退出登录")
def complete_parent_onboarding(page, role: str) -> bool:
"""完成家长 onboarding 流程(家长账号未预置 onboardedAt"""
print(f" >>> 完成 {role} onboarding...")
try:
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
if "/onboarding" not in page.url:
print(f" 不在 onboarding 页面 (URL: {page.url})")
return True
# 已知学生数据(来自 seed.ts
# S_G1C1_1: email=student_g1c1_1@xiaoxue.edu.cn, birthDate=2018-01-15
child_email = "student_g1c1_1@xiaoxue.edu.cn"
child_birth = "2018-01-15"
child_phone_suffix = "0001"
# Step 0: 角色确认 - 点击下一步
page.wait_for_selector('button', timeout=10000)
page.wait_for_timeout(500)
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(800)
# Step 1: 基础信息 - 填写姓名和电话
name_input = page.locator('input[id="onb_name"]')
if name_input.count() > 0:
name_input.fill("测试家长")
phone_input = page.locator('input[id="onb_phone"]')
if phone_input.count() > 0:
phone_input.fill("13800000000")
address_input = page.locator('input[id="onb_address"]')
if address_input.count() > 0:
address_input.fill("测试地址")
# 点击下一步
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(800)
# Step 2: 家长绑定子女
email_input = page.locator('input[id="onb_child_email_0"]')
if email_input.count() > 0:
email_input.fill(child_email)
birth_input = page.locator('input[id="onb_child_birth_0"]')
if birth_input.count() > 0:
birth_input.fill(child_birth)
phone_suffix_input = page.locator('input[id="onb_child_phone_0"]')
if phone_suffix_input.count() > 0:
phone_suffix_input.fill(child_phone_suffix)
# 点击下一步(绑定可能失败,但 onboarding 仍会标记完成)
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(800)
# Step 3: 完成
finish_btn = page.locator('button:has-text("完成"), button:has-text("Finish")').first
if finish_btn.count() > 0:
finish_btn.click()
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
print(f" onboarding 完成后 URL: {page.url}")
return "/onboarding" not in page.url
except Exception as e:
print(f" onboarding 异常: {e}")
return False
def collect_console_errors(page):
"""附加控制台错误收集器"""
errors = []
def on_console(msg):
if msg.type == "error":
errors.append(msg.text)
page.on("console", on_console)
return errors, on_console
def test_role_dashboard(page, role: str) -> dict:
"""测试单个角色的仪表盘"""
expected_path = TEST_ACCOUNTS[role]["expected_path"]
url = f"{BASE_URL}{expected_path}"
expected = DASHBOARD_EXPECTED_ELEMENTS[role]
role_result = {
"role": role,
"url": url,
"login_success": False,
"page_status": "unknown",
"http_status": None,
"final_url": None,
"checks": [],
"console_errors": [],
"screenshots": [],
"errors": [],
"warnings": [],
}
print(f"\n=== 测试 {role} 仪表盘 ===")
# 登录
if not login(page, role):
role_result["errors"].append(f"{role} 登录失败")
role_result["page_status"] = "failed"
return role_result
role_result["login_success"] = True
# 家长账号需要完成 onboardingseed 数据未预置 onboardedAt
if role == "parent" and "/onboarding" in page.url:
onboarding_ok = complete_parent_onboarding(page, role)
if not onboarding_ok:
role_result["warnings"].append("onboarding 未完成,尝试直接访问仪表盘")
# 收集控制台错误
console_errors, on_console = collect_console_errors(page)
try:
response = page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
http_status = response.status if response else None
final_url = page.url
role_result["http_status"] = http_status
role_result["final_url"] = final_url
# 截图
screenshot_name = f"dashboard_{role}.png"
screenshot_path = SCREENSHOT_DIR / screenshot_name
page.screenshot(path=str(screenshot_path), full_page=True)
role_result["screenshots"].append(screenshot_name)
# HTTP 状态检查
if http_status and http_status >= 500:
role_result["page_status"] = "failed"
role_result["errors"].append(f"HTTP {http_status} 服务器错误")
elif http_status and http_status >= 400:
role_result["page_status"] = "failed"
role_result["errors"].append(f"HTTP {http_status} 客户端错误")
elif _is_login_redirect(final_url):
role_result["page_status"] = "failed"
role_result["errors"].append("重定向到登录页 - 认证失败")
elif urlparse(final_url).path != expected_path:
role_result["page_status"] = "warning"
role_result["warnings"].append(f"重定向到 {final_url}(预期 {expected_path}")
else:
role_result["page_status"] = "passed"
# 检查页面标题
body_text = page.locator("body").text_content() or ""
if len(body_text.strip()) < 50:
role_result["warnings"].append("页面内容过少(<50 字符)")
role_result["checks"].append({"name": "页面内容非空", "passed": False, "detail": f"内容长度 {len(body_text.strip())}"})
else:
role_result["checks"].append({"name": "页面内容非空", "passed": True, "detail": f"内容长度 {len(body_text.strip())}"})
# 检查标题关键词
title_matched = any(key in body_text for key in expected["title_keys"])
role_result["checks"].append({
"name": "标题关键词匹配",
"passed": title_matched,
"detail": f"预期关键词: {expected['title_keys']}"
})
if not title_matched:
role_result["warnings"].append(f"未找到标题关键词: {expected['title_keys']}")
# 检查 StatCard 数量
if "stat_cards" in expected:
stat_cards = page.locator('[class*="stat-card"], [data-stat-card]').count()
# 也尝试其他常见模式
if stat_cards == 0:
stat_cards = page.locator('div:has(> div.tabular-nums)').count()
role_result["checks"].append({
"name": f"统计卡片数量 (预期 {expected['stat_cards']})",
"passed": stat_cards >= expected["stat_cards"],
"detail": f"实际: {stat_cards}"
})
# 检查快捷操作卡片admin
if role == "admin":
quick_actions = page.locator('a[href^="/admin/"]').count()
role_result["checks"].append({
"name": f"快捷操作链接 (预期 >= {len(expected['quick_links'])})",
"passed": quick_actions >= len(expected["quick_links"]),
"detail": f"实际: {quick_actions}"
})
# 检查表格
has_table = page.locator('table').count() > 0
role_result["checks"].append({
"name": "最近用户表格存在",
"passed": has_table == expected["table_present"],
"detail": f"表格存在: {has_table}"
})
# 检查图表recharts- 图表在有数据时渲染,无数据时显示 EmptyState
charts = page.locator('.recharts-wrapper, [data-recharts]').count()
# 也检查是否有空状态提示(无数据时的替代显示)
has_empty_state = (
"暂无数据" in body_text
or "No data" in body_text
or "暂无" in body_text
)
role_result["checks"].append({
"name": f"图表或空状态 (预期图表 {expected['charts']})",
"passed": charts >= expected["charts"] or has_empty_state,
"detail": f"图表: {charts}, 空状态: {has_empty_state}"
})
# 检查教师仪表盘特有元素
if role == "teacher":
# 待办卡片
todo_section = page.locator('section, aside, div').filter(has_text="待办")
role_result["checks"].append({
"name": "待办卡片存在",
"passed": todo_section.count() > 0 or "待办" in body_text or "Todo" in body_text,
"detail": f"待办区域: {todo_section.count()}"
})
# 检查快捷链接
for link in expected["quick_links"]:
link_exists = page.locator(f'a[href="{link}"]').count() > 0
role_result["checks"].append({
"name": f"快捷链接存在: {link}",
"passed": link_exists,
"detail": ""
})
# 检查学生仪表盘特有元素
if role == "student":
# 检查今日课表
schedule_present = "今日课表" in body_text or "Today" in body_text or "schedule" in body_text.lower()
role_result["checks"].append({
"name": "今日课表卡片",
"passed": schedule_present,
"detail": ""
})
# 检查即将到来的作业
assignments_present = "作业" in body_text or "Assignment" in body_text
role_result["checks"].append({
"name": "作业卡片",
"passed": assignments_present,
"detail": ""
})
# 检查家长仪表盘特有元素
if role == "parent":
# 检查快捷入口
for link in expected["quick_links"]:
link_exists = page.locator(f'a[href="{link}"]').count() > 0
role_result["checks"].append({
"name": f"快捷入口链接: {link}",
"passed": link_exists,
"detail": ""
})
# 检查孩子卡片
children_cards = page.locator('[class*="child-card"], [data-child-card]').count()
# 也检查是否有孩子姓名相关内容
has_children_content = "孩子" in body_text or "Child" in body_text or "子女" in body_text
role_result["checks"].append({
"name": "孩子卡片区域",
"passed": has_children_content,
"detail": f"检测到孩子相关内容: {has_children_content}"
})
# 收集控制台错误
role_result["console_errors"] = console_errors[:10]
if console_errors:
role_result["warnings"].append(f"控制台错误 {len(console_errors)}")
# 检查页面错误提示
error_alerts = page.locator('[role="alert"]').all()
for alert in error_alerts:
try:
text = alert.text_content()
if text and text.strip() and "error" in text.lower():
role_result["warnings"].append(f"页面错误提示: {text.strip()[:100]}")
except Exception:
pass
status_icon = "" if role_result["page_status"] == "passed" else "⚠️" if role_result["page_status"] == "warning" else ""
print(f" {status_icon} {role_result['page_status']} (HTTP {http_status})")
# 打印检查结果
passed_checks = sum(1 for c in role_result["checks"] if c["passed"])
total_checks = len(role_result["checks"])
print(f" 检查项: {passed_checks}/{total_checks} 通过")
except PlaywrightTimeout:
role_result["page_status"] = "failed"
role_result["errors"].append("页面加载超时 (30s)")
print(f"{role} 页面加载超时")
except Exception as e:
role_result["page_status"] = "failed"
role_result["errors"].append(f"异常: {str(e)[:200]}")
print(f"{role} 异常: {str(e)[:100]}")
finally:
try:
page.remove_listener("console", on_console)
except Exception:
pass
return role_result
def test_dashboard_redirect(page, role: str) -> dict:
"""测试 /dashboard 通用路由的重定向逻辑"""
account = TEST_ACCOUNTS[role]
expected_path = account["expected_path"]
result = {
"role": role,
"url": f"{BASE_URL}/dashboard",
"expected_redirect": expected_path,
"final_url": None,
"passed": False,
"error": None,
}
print(f"\n=== 测试 /dashboard 重定向 ({role}) ===")
try:
response = page.goto(f"{BASE_URL}/dashboard", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
final_url = page.url
final_path = urlparse(final_url).path
result["final_url"] = final_url
if final_path == expected_path:
result["passed"] = True
print(f" ✅ 重定向正确: {final_path}")
elif _is_login_redirect(final_url):
result["error"] = "重定向到登录页"
print(f" ❌ 重定向到登录页")
else:
result["error"] = f"重定向到 {final_path}(预期 {expected_path}"
print(f" ❌ 重定向错误: {final_path}(预期 {expected_path}")
except Exception as e:
result["error"] = f"异常: {str(e)[:200]}"
print(f" ❌ 异常: {str(e)[:100]}")
return result
def test_cross_role_access(page, role: str) -> list:
"""测试跨角色访问保护"""
forbidden_routes = CROSS_ROLE_FORBIDDEN[role]
test_results = []
print(f"\n=== 测试 {role} 跨角色访问保护 ===")
for route in forbidden_routes:
result = {
"role": role,
"forbidden_route": route,
"final_url": None,
"passed": False,
"error": None,
}
try:
page.goto(f"{BASE_URL}{route}", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(800)
final_url = page.url
final_path = urlparse(final_url).path
result["final_url"] = final_url
# 检查页面内容是否显示权限错误PermissionDeniedError
body_text = page.locator("body").text_content() or ""
has_permission_error = (
"权限不足" in body_text
or "PermissionDenied" in body_text
or "Something went wrong" in body_text
or "loadFailed" in body_text
or "发生错误" in body_text
)
# 通过:被拒绝访问(重定向回自己的仪表盘、登录页、或返回 403/401
if _is_login_redirect(final_url):
result["passed"] = True
result["error"] = "重定向到登录页(拒绝访问)"
print(f"{route} -> 登录页(拒绝)")
elif final_path.startswith(TEST_ACCOUNTS[role]["expected_path"]):
result["passed"] = True
result["error"] = f"重定向回 {final_path}(拒绝访问)"
print(f"{route} -> {final_path}(拒绝)")
elif final_path == route and has_permission_error:
# URL 未变但页面显示权限错误,说明 Server Action 拒绝了访问
result["passed"] = True
result["error"] = "页面显示权限错误Server Action 拒绝)"
print(f"{route} -> 权限错误页(拒绝)")
elif final_path == route:
# URL 未变且无权限错误提示,则未通过
result["passed"] = False
result["error"] = "成功访问禁止路由(权限漏洞)"
print(f"{route} -> 直接访问(权限漏洞)")
elif "/403" in final_url or "/401" in final_url or "/unauthorized" in final_url:
result["passed"] = True
result["error"] = "重定向到未授权页"
print(f"{route} -> 未授权页(拒绝)")
else:
# 重定向到其他页面也算通过(说明权限校验生效)
result["passed"] = True
result["error"] = f"重定向到 {final_path}(拒绝访问)"
print(f"{route} -> {final_path}(拒绝)")
except PlaywrightTimeout:
result["error"] = "页面加载超时"
result["passed"] = True # 超时可能是因为重定向链
print(f" ⚠️ {route} -> 超时")
# 恢复页面状态:导航到空白页,避免影响后续测试
try:
page.goto("about:blank", timeout=5000)
except Exception:
pass
except Exception as e:
result["error"] = f"异常: {str(e)[:200]}"
result["passed"] = True # 异常通常意味着访问被拒绝
print(f" ⚠️ {route} -> 异常")
# 恢复页面状态
try:
page.goto("about:blank", timeout=5000)
except Exception:
pass
test_results.append(result)
return test_results
def run_all_tests():
"""运行所有测试"""
global results
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()
# 测试每个角色的仪表盘
for role in ["admin", "teacher", "student", "parent"]:
# 退出之前的登录
logout(page)
# 测试角色仪表盘
role_result = test_role_dashboard(page, role)
results["roles"][role] = role_result
# 测试 /dashboard 重定向(同一登录状态下)
redirect_result = test_dashboard_redirect(page, role)
results["redirect_tests"].append(redirect_result)
# 测试跨角色访问保护
cross_role_results = test_cross_role_access(page, role)
results["cross_role_tests"].extend(cross_role_results)
# 汇总
total = 0
passed = 0
failed = 0
warnings = 0
# 角色仪表盘测试
for role, r in results["roles"].items():
total += 1
if r["page_status"] == "passed":
passed += 1
elif r["page_status"] == "warning":
warnings += 1
else:
failed += 1
# 重定向测试
for r in results["redirect_tests"]:
total += 1
if r["passed"]:
passed += 1
else:
failed += 1
# 跨角色访问测试
for r in results["cross_role_tests"]:
total += 1
if r["passed"]:
passed += 1
else:
failed += 1
results["summary"]["total"] = total
results["summary"]["passed"] = passed
results["summary"]["failed"] = failed
results["summary"]["warnings"] = warnings
print(f"\n{'='*60}")
print(f"测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}")
print(f"{'='*60}")
browser.close()
def generate_report() -> str:
"""生成 Markdown 测试报告"""
lines = []
lines.append(f"# 仪表盘模块 Web 功能测试报告")
lines.append("")
lines.append(f"> 模块:仪表盘 (Dashboard)")
lines.append(f"> 版本:{results['version']}")
lines.append(f"> 测试日期:{results['test_date']}")
lines.append(f"> 测试工具Playwright + Chromium (headless)")
lines.append(f"> Base URL{results['base_url']}")
lines.append(f"> 测试范围admin / teacher / student / parent 四角色仪表盘 + /dashboard 重定向 + 跨角色访问保护")
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['failed']} |")
lines.append(f"| 警告 | {s['warnings']} |")
lines.append(f"| 通过率 | {pass_rate:.1f}% |")
lines.append("")
lines.append("---")
lines.append("")
# 测试账号
lines.append("## 二、测试账号")
lines.append("")
lines.append("| 角色 | 邮箱 | 预期仪表盘路径 |")
lines.append("|------|------|----------------|")
for role, acc in TEST_ACCOUNTS.items():
lines.append(f"| {role} | `{acc['email']}` | `{acc['expected_path']}` |")
lines.append("")
lines.append("---")
lines.append("")
# 各角色仪表盘测试详情
lines.append("## 三、各角色仪表盘测试详情")
lines.append("")
for role in ["admin", "teacher", "student", "parent"]:
r = results["roles"].get(role, {})
if not r:
continue
status_icon = "" if r.get("page_status") == "passed" else "⚠️" if r.get("page_status") == "warning" else ""
lines.append(f"### {status_icon} {role.upper()} 仪表盘 (`{r.get('url', '')}`)")
lines.append("")
lines.append(f"- **登录**: {'✅ 成功' if r.get('login_success') else '❌ 失败'}")
lines.append(f"- **HTTP 状态**: {r.get('http_status', '-')}")
lines.append(f"- **最终 URL**: `{r.get('final_url', '-')}`")
lines.append(f"- **页面状态**: {r.get('page_status', '-')}")
if r.get("errors"):
lines.append(f"- **错误**:")
for err in r["errors"]:
lines.append(f" - {err}")
if r.get("warnings"):
lines.append(f"- **警告**:")
for w in r["warnings"]:
lines.append(f" - {w}")
if r.get("console_errors"):
lines.append(f"- **控制台错误** (前 5 条):")
for ce in r["console_errors"][:5]:
lines.append(f" - `{ce[:200]}`")
if r.get("screenshots"):
lines.append(f"- **截图**: {', '.join(r['screenshots'])}")
lines.append("")
# 检查项明细
if r.get("checks"):
lines.append("#### 检查项明细")
lines.append("")
lines.append("| 状态 | 检查项 | 详情 |")
lines.append("|------|--------|------|")
for c in r["checks"]:
icon = "" if c["passed"] else ""
lines.append(f"| {icon} | {c['name']} | {c.get('detail', '')} |")
lines.append("")
lines.append("---")
lines.append("")
# /dashboard 重定向测试
lines.append("## 四、/dashboard 通用路由重定向测试")
lines.append("")
lines.append("| 角色 | 预期重定向 | 实际 URL | 结果 | 错误 |")
lines.append("|------|-----------|----------|------|------|")
for r in results["redirect_tests"]:
icon = "" if r["passed"] else ""
actual_path = urlparse(r.get("final_url", "")).path or "-"
lines.append(f"| {r['role']} | `{r['expected_redirect']}` | `{actual_path}` | {icon} {'通过' if r['passed'] else '失败'} | {r.get('error', '-') or '-'} |")
lines.append("")
lines.append("---")
lines.append("")
# 跨角色访问保护测试
lines.append("## 五、跨角色访问保护测试")
lines.append("")
lines.append("| 角色 | 禁止路由 | 实际 URL | 结果 | 说明 |")
lines.append("|------|----------|----------|------|------|")
for r in results["cross_role_tests"]:
icon = "" if r["passed"] else ""
actual_path = urlparse(r.get("final_url", "")).path or "-"
lines.append(f"| {r['role']} | `{r['forbidden_route']}` | `{actual_path}` | {icon} {'拒绝' if r['passed'] else '通过'} | {r.get('error', '-') or '-'} |")
lines.append("")
lines.append("---")
lines.append("")
# 失败项汇总
failed_items = []
for role, r in results["roles"].items():
if r.get("page_status") == "failed":
failed_items.append(("角色仪表盘", role, r.get("url", ""), r.get("errors", [])))
for r in results["redirect_tests"]:
if not r["passed"]:
failed_items.append(("重定向测试", r["role"], r.get("url", ""), [r.get("error", "")]))
for r in results["cross_role_tests"]:
if not r["passed"]:
failed_items.append(("跨角色保护", r["role"], r.get("forbidden_route", ""), [r.get("error", "")]))
if failed_items:
lines.append("## 六、失败项汇总")
lines.append("")
lines.append("| 类别 | 角色 | URL | 错误 |")
lines.append("|------|------|-----|------|")
for cat, role, url, errs in failed_items:
err_str = "; ".join(errs[:2]) if errs else "-"
lines.append(f"| {cat} | {role} | `{url}` | {err_str} |")
lines.append("")
lines.append("---")
lines.append("")
# 结论与建议
lines.append("## 七、测试结论与改进建议")
lines.append("")
if s["failed"] == 0:
lines.append("✅ **仪表盘模块所有测试通过**,各角色仪表盘功能正常,权限保护有效。")
else:
lines.append(f"❌ **{s['failed']} 项测试失败**,需修复以下问题:")
lines.append("")
for cat, role, url, errs in failed_items:
lines.append(f"- **{cat} - {role}** (`{url}`): {'; '.join(errs[:2]) if errs else '未知错误'}")
lines.append("")
lines.append("### 改进建议")
lines.append("")
lines.append("1. **认证与权限**:失败页面中若出现重定向至 /login需检查会话过期策略与权限校验逻辑。")
lines.append("2. **HTTP 5xx 错误**:服务端错误需检查 Server Action 数据访问层与数据库连接。")
lines.append("3. **页面内容为空**:检查数据查询条件与渲染逻辑,确认数据源是否返回预期结果。")
lines.append("4. **控制台错误**:浏览器控制台报错需检查前端组件渲染与 API 调用。")
lines.append("5. **跨角色访问**:若出现权限漏洞,需检查 `requirePermission()` 调用与角色-权限映射。")
lines.append("")
lines.append("---")
lines.append("")
lines.append(f"*报告自动生成于 {results['test_date']}*")
return "\n".join(lines)
def main():
run_all_tests()
report = generate_report()
output_path = WEBTEST_DIR / f"dashboard_{VERSION}.md"
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {output_path}")
json_path = str(output_path).replace(".md", ".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()