- 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
894 lines
35 KiB
Python
894 lines
35 KiB
Python
"""
|
||
仪表盘模块全功能 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
|
||
|
||
# 家长账号需要完成 onboarding(seed 数据未预置 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()
|