""" 仪表盘模块全功能 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()