""" 家长端全功能Web测试脚本 使用 Playwright 对家长端所有页面进行功能测试 """ import json import os import re import sys from datetime import datetime from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout BASE_URL = "http://localhost:3000" PARENT_EMAIL = "parent_g1c1_1@xiaoxue.edu.cn" PARENT_PASSWORD = "123456" # 家长端所有路由(从导航配置 navigation.ts 和源码中提取) # 家长权限:EXAM_READ, TEXTBOOK_READ, CLASS_READ, USER_PROFILE_UPDATE, # ANNOUNCEMENT_READ, GRADE_RECORD_READ, ATTENDANCE_READ, # MESSAGE_SEND, MESSAGE_READ, MESSAGE_DELETE PARENT_ROUTES = { "Dashboard": ["/parent/dashboard"], "Grades": ["/parent/grades"], "Attendance": ["/parent/attendance"], "Announcements": ["/announcements"], "Messages": [ "/messages", "/messages/compose", ], "Profile": ["/profile"], "Settings": [ "/settings", "/settings/security", ], } # 详情页 - 需要从 dashboard 获取 studentId DETAIL_ROUTES = { "Child Detail": "/parent/children/", } # 跨角色访问保护测试:家长不应能访问教师/学生/管理员页面 FORBIDDEN_ROUTES = [ "/admin/dashboard", "/admin/school", "/teacher/dashboard", "/teacher/exams", "/teacher/homework", "/teacher/grades", "/teacher/questions", "/teacher/classes", "/teacher/attendance", "/student/dashboard", "/student/learning", "/student/grades", "/student/attendance", "/management/grade/classes", ] results = { "test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "test_target": "家长端 (Parent)", "base_url": BASE_URL, "parent_email": PARENT_EMAIL, "summary": { "total": 0, "passed": 0, "failed": 0, "warnings": 0, }, "pages": {}, "functional_checks": [], "security_checks": [], "console_errors": [], "navigation_issues": [], } def login(page): """登录家长账号""" print("\n>>> 登录家长账号...") page.goto(f"{BASE_URL}/login") page.wait_for_load_state("networkidle") # 检查是否已登录 if "/parent" in page.url or (page.url.rstrip("/").endswith("/dashboard") and "/login" not in page.url): print(" 已登录,跳过登录步骤") return True try: email_input = page.locator('input[name="email"]') if email_input.count() == 0: email_input = page.locator('input[type="email"]') print(f" 找到 email 输入框: {email_input.count()} 个") email_input.fill(PARENT_EMAIL) password_input = page.locator('input[name="password"]') if password_input.count() == 0: password_input = page.locator('input[type="password"]') print(f" 找到 password 输入框: {password_input.count()} 个") password_input.fill(PARENT_PASSWORD) # 点击登录按钮 login_btn = page.locator('button[type="submit"]') if login_btn.count() == 0: login_btn = page.get_by_role("button", name="Sign In with Email") if login_btn.count() == 0: login_btn = page.locator("button").filter(has_text="Sign In") if login_btn.count() == 0: login_btn = page.locator("button").filter(has_text="登录") print(f" 找到登录按钮: {login_btn.count()} 个") login_btn.click() # 等待导航或错误提示 try: page.wait_for_url(lambda url: "/login" not in url, timeout=10000) except PlaywrightTimeout: # 登录可能失败,检查页面上的错误 body_text = page.locator("body").text_content() or "" print(f" 登录后仍在 login 页面,页面内容片段: {body_text[:300]}") page.wait_for_load_state("networkidle") page.wait_for_timeout(2000) print(f" 登录后 URL: {page.url}") return "/login" not in page.url except Exception as e: print(f" ❌ 登录失败: {e}") return False def test_page(page, route, category, expect_redirect_to_login=False, expect_forbidden_redirect=False): """测试单个页面""" 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": [], "content_checks": [], } print(f"\n 测试: {category} - {route}") # 收集控制台错误 console_errors = [] MAX_CONSOLE_ERRORS = 5 def on_console(msg): if msg.type == "error": # 限制收集的错误数量,避免内存爆炸 if len(console_errors) >= MAX_CONSOLE_ERRORS: return # 截断过长的控制台错误(避免堆栈跟踪导致输出爆炸) text = msg.text if len(text) > 500: text = text[:500] + "...(已截断)" console_errors.append(text) page.on("console", on_console) try: response = page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) http_status = response.status if response else None final_url = page.url page_result["http_status"] = http_status page_result["final_url"] = final_url # 检查是否重定向 if final_url.rstrip("/") != url.rstrip("/"): page_result["redirect_url"] = final_url # 检查 HTTP 状态 if http_status and http_status >= 500: if expect_forbidden_redirect: # 跨角色访问导致 500 也是权限隔离失效的表现 page_result["status"] = "failed" page_result["errors"].append(f"跨角色访问返回 HTTP {http_status}(应被重定向拦截)") else: page_result["status"] = "failed" page_result["errors"].append(f"HTTP {http_status} error") elif http_status and http_status >= 400: page_result["status"] = "warning" page_result["warnings"].append(f"HTTP {http_status} error") elif final_url and "/login" in final_url: if expect_redirect_to_login or expect_forbidden_redirect: page_result["status"] = "passed" page_result["content_checks"].append("正确重定向到登录页(权限拦截)") else: page_result["status"] = "failed" page_result["errors"].append("Redirected to login - auth issue") elif final_url and "/500" in final_url: page_result["status"] = "failed" page_result["errors"].append("Redirected to 500 error page") elif final_url and "/404" in final_url: page_result["status"] = "warning" page_result["warnings"].append("Redirected to 404 page") elif expect_forbidden_redirect: # 期望被拦截但实际访问成功的跨角色访问 if "/parent/" in final_url and "reason=forbidden" in final_url: page_result["status"] = "passed" page_result["content_checks"].append("跨角色访问被权限系统拦截") elif "/parent/" in final_url: page_result["status"] = "passed" page_result["content_checks"].append(f"跨角色访问被拦截,重定向到: {final_url}") else: # 仍然停留在教师/学生/管理员页面 = 权限隔离失效 page_result["status"] = "failed" page_result["errors"].append( f"⚠️ 安全漏洞:家长成功访问了受限页面(最终 URL: {final_url}),权限隔离失效" ) else: page_result["status"] = "passed" # 检查页面是否有错误提示 error_texts = page.locator('[role="alert"], .error, .text-destructive, .text-red-500').all() for et in error_texts: try: text = et.text_content() if text and text.strip() and "Access denied" not in text: page_result["warnings"].append(f"Error text on page: {text.strip()[:100]}") except Exception: pass # 检查页面是否为空(无主要内容) body_text = page.locator("body").text_content() or "" if len(body_text.strip()) < 50: page_result["warnings"].append("Page appears empty or has very little content") # 收集控制台错误 if console_errors: page_result["errors"].extend(console_errors[:5]) # 打印状态 status_icon = "✅" if page_result["status"] == "passed" else "⚠️" if page_result["status"] == "warning" else "❌" print(f" {status_icon} {page_result['status']} (HTTP {http_status})") if page_result.get("redirect_url"): print(f" ↪ 重定向到: {page_result['redirect_url']}") except PlaywrightTimeout: page_result["status"] = "failed" page_result["errors"].append("Page load timeout (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: page.remove_listener("console", on_console) return page_result def discover_child_links(page): """从家长仪表盘发现子女详情页链接""" try: url = f"{BASE_URL}/parent/dashboard" page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 查找子女详情链接 links = page.locator('a[href*="/parent/children/"]').all() detail_urls = [] seen = set() for link in links: href = link.get_attribute("href") if href and "/parent/children/" in href: # 提取纯路径(不含 query string) clean_href = href.split("?")[0] if clean_href not in seen: seen.add(clean_href) detail_urls.append(clean_href) return detail_urls except Exception as e: print(f" 发现子女链接失败: {e}") return [] def check_dashboard_functionality(page): """检查家长仪表盘功能完整性""" checks = [] print("\n>>> 检查仪表盘功能完整性...") try: page.goto(f"{BASE_URL}/parent/dashboard", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 1. 检查标题 title = page.locator("h1") title_text = title.first.text_content() if title.count() > 0 else "" checks.append({ "name": "仪表盘标题", "expected": "Parent Dashboard", "actual": title_text.strip() if title_text else "", "passed": "Parent Dashboard" in (title_text or ""), }) # 2. 检查问候语 body_text = page.locator("body").text_content() or "" greeting_present = any(g in body_text for g in ["Good morning", "Good afternoon", "Good evening", "Welcome"]) checks.append({ "name": "问候语显示", "expected": "Good morning/afternoon/evening 或 Welcome", "actual": "Found" if greeting_present else "Missing", "passed": greeting_present, }) # 3. 检查快捷入口按钮 grades_btn = page.locator('a[href="/parent/grades"]') attendance_btn = page.locator('a[href="/parent/attendance"]') announcements_btn = page.locator('a[href="/announcements"]') checks.append({ "name": "Grades 快捷入口", "expected": "存在", "actual": "Found" if grades_btn.count() > 0 else "Missing", "passed": grades_btn.count() > 0, }) checks.append({ "name": "Attendance 快捷入口", "expected": "存在", "actual": "Found" if attendance_btn.count() > 0 else "Missing", "passed": attendance_btn.count() > 0, }) checks.append({ "name": "Announcements 快捷入口", "expected": "存在", "actual": "Found" if announcements_btn.count() > 0 else "Missing", "passed": announcements_btn.count() > 0, }) # 4. 检查子女卡片 child_cards = page.locator('a[href*="/parent/children/"]') card_count = child_cards.count() checks.append({ "name": "子女卡片显示", "expected": "≥1 个子女卡片", "actual": f"{card_count} 个", "passed": card_count > 0, }) # 5. 检查子女卡片内容(Pending/Overdue/Avg 统计) if card_count > 0: has_pending = "Pending" in body_text has_overdue = "Overdue" in body_text has_avg = "Avg" in body_text or "%" in body_text checks.append({ "name": "子女卡片 - Pending 统计", "expected": "显示 Pending 计数", "actual": "Found" if has_pending else "Missing", "passed": has_pending, }) checks.append({ "name": "子女卡片 - Overdue 统计", "expected": "显示 Overdue 计数", "actual": "Found" if has_overdue else "Missing", "passed": has_overdue, }) # 6. 检查子女数量提示 children_count_text = "child linked" in body_text or "children linked" in body_text checks.append({ "name": "子女数量提示", "expected": "显示 'N child(ren) linked'", "actual": "Found" if children_count_text else "Missing", "passed": children_count_text, }) except Exception as e: checks.append({ "name": "仪表盘功能检查", "expected": "无异常", "actual": str(e)[:200], "passed": False, }) for c in checks: icon = "✅" if c["passed"] else "❌" print(f" {icon} {c['name']}: {c['actual']}") return checks def check_child_detail_functionality(page, child_route): """检查子女详情页功能完整性""" checks = [] print(f"\n>>> 检查子女详情页功能: {child_route}") try: url = f"{BASE_URL}{child_route}" page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 1. 检查返回按钮 back_btn = page.locator('a[href="/parent/dashboard"]') checks.append({ "name": "返回仪表盘按钮", "expected": "存在 Back to Dashboard 链接", "actual": "Found" if back_btn.count() > 0 else "Missing", "passed": back_btn.count() > 0, }) # 2. 检查子女姓名标题 h1 = page.locator("h1") h1_text = h1.first.text_content() if h1.count() > 0 else "" checks.append({ "name": "子女姓名标题", "expected": "显示子女姓名", "actual": h1_text.strip()[:50] if h1_text else "Missing", "passed": bool(h1_text and h1_text.strip() and h1_text.strip() != "Unnamed"), }) # 3. 检查邮箱掩码 # 邮箱应该被掩码为 j***@example.com 形式 email_pattern = re.search(r"[a-zA-Z0-9]\*\*\*@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", body_text) checks.append({ "name": "邮箱掩码处理", "expected": "邮箱被掩码为 j***@domain.com", "actual": "Masked" if email_pattern else "Not masked or no email", "passed": bool(email_pattern), }) # 4. 检查作业摘要卡片 has_homework = "Homework" in body_text checks.append({ "name": "作业摘要卡片", "expected": "显示 {childName}'s Homework", "actual": "Found" if has_homework else "Missing", "passed": has_homework, }) # 5. 检查作业统计计数 has_pending = "Pending" in body_text has_submitted = "Submitted" in body_text has_graded = "Graded" in body_text checks.append({ "name": "作业统计 - Pending", "expected": "显示 Pending 计数", "actual": "Found" if has_pending else "Missing", "passed": has_pending, }) checks.append({ "name": "作业统计 - Submitted", "expected": "显示 Submitted 计数", "actual": "Found" if has_submitted else "Missing", "passed": has_submitted, }) checks.append({ "name": "作业统计 - Graded", "expected": "显示 Graded 计数", "actual": "Found" if has_graded else "Missing", "passed": has_graded, }) # 6. 检查成绩趋势卡片 has_grade_summary = "Grade" in body_text or "grade" in body_text.lower() checks.append({ "name": "成绩趋势卡片", "expected": "显示成绩信息", "actual": "Found" if has_grade_summary else "Missing", "passed": has_grade_summary, }) # 7. 检查今日课表卡片 has_schedule = "Today Schedule" in body_text or "Schedule" in body_text checks.append({ "name": "今日课表卡片", "expected": "显示 {childName}'s Today Schedule", "actual": "Found" if has_schedule else "Missing", "passed": has_schedule, }) # 8. 检查 View all 链接 view_all = page.locator("text=View all") checks.append({ "name": "View all 链接", "expected": "存在 View all 链接", "actual": "Found" if view_all.count() > 0 else "Missing", "passed": view_all.count() > 0, }) except Exception as e: checks.append({ "name": "子女详情页功能检查", "expected": "无异常", "actual": str(e)[:200], "passed": False, }) for c in checks: icon = "✅" if c["passed"] else "❌" print(f" {icon} {c['name']}: {c['actual']}") return checks def check_security_isolation(page): """检查跨家庭信息隔离(访问其他家长的子女应被拒绝)""" checks = [] print("\n>>> 检查跨家庭信息隔离...") try: # 尝试访问一个不存在的 studentId url = f"{BASE_URL}/parent/children/non_existent_student_id_12345" page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 应该显示 Access denied 或 404 is_denied = "Access denied" in body_text or "not linked" in body_text is_404 = "404" in body_text or "not found" in body_text.lower() or "Page Not Found" in body_text checks.append({ "name": "访问不存在/非关联子女应被拒绝", "expected": "显示 Access denied 或 404", "actual": "Access denied" if is_denied else ("404" if is_404 else "未拦截"), "passed": is_denied or is_404, }) except Exception as e: checks.append({ "name": "跨家庭隔离检查", "expected": "无异常", "actual": str(e)[:200], "passed": False, }) for c in checks: icon = "✅" if c["passed"] else "❌" print(f" {icon} {c['name']}: {c['actual']}") return checks def check_sidebar_navigation(page): """检查侧边栏导航是否仅显示家长相关菜单""" checks = [] print("\n>>> 检查侧边栏导航...") try: page.goto(f"{BASE_URL}/parent/dashboard", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 仅检查侧边栏区域内的导航链接文本,避免页面正文误判 # 侧边栏通常在 nav 元素或 [data-sidebar] 内 sidebar = page.locator("nav").first if sidebar.count() == 0: sidebar = page.locator("body") sidebar_text = sidebar.text_content() or "" # 家长端应显示的导航项 expected_navs = ["Dashboard", "Grades", "Attendance", "Announcements", "Messages"] for nav in expected_navs: checks.append({ "name": f"侧边栏 - {nav}", "expected": f"显示 {nav} 导航项", "actual": "Found" if nav in sidebar_text else "Missing", "passed": nav in sidebar_text, }) # 家长端不应显示的教师/管理员导航项 forbidden_navs = ["Textbooks", "Question Bank", "Class Management", "Course Plans", "Lesson Plans", "Schedule Changes", "Diagnostic", "Electives", "School Management", "Audit Logs"] for nav in forbidden_navs: if nav in sidebar_text: checks.append({ "name": f"侧边栏 - {nav}(不应显示)", "expected": f"不显示 {nav}", "actual": "Found (违规)", "passed": False, }) except Exception as e: checks.append({ "name": "侧边栏导航检查", "expected": "无异常", "actual": str(e)[:200], "passed": False, }) for c in checks: icon = "✅" if c["passed"] else "❌" print(f" {icon} {c['name']}: {c['actual']}") return checks 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() # 登录 if not login(page): print("❌ 登录失败,终止测试") browser.close() return # 测试所有路由 total = 0 for category, routes in PARENT_ROUTES.items(): for route in routes: page_result = test_page(page, route, category) key = route.replace("/", "_").strip("_") results["pages"][key] = page_result total += 1 # 发现并测试子女详情页 print("\n\n>>> 发现子女详情页链接...") child_links = discover_child_links(page) if child_links: print(f"\n 发现 {len(child_links)} 个子女详情页链接") for child_route in child_links[:3]: # 最多测试3个 if not child_route.startswith("/"): child_route = "/" + child_route page_result = test_page(page, child_route, "Child Detail") key = child_route.replace("/", "_").strip("_") results["pages"][key] = page_result total += 1 # 对子女详情页进行功能检查 detail_checks = check_child_detail_functionality(page, child_route) results["functional_checks"].extend(detail_checks) else: print("\n 未发现子女详情页链接(可能家长未关联子女)") # 仪表盘功能完整性检查 dashboard_checks = check_dashboard_functionality(page) results["functional_checks"].extend(dashboard_checks) # 侧边栏导航检查 sidebar_checks = check_sidebar_navigation(page) results["functional_checks"].extend(sidebar_checks) # 跨家庭信息隔离检查 security_checks = check_security_isolation(page) results["security_checks"].extend(security_checks) # 跨角色访问保护测试 print("\n\n>>> 测试跨角色访问保护...") for forbidden_route in FORBIDDEN_ROUTES: page_result = test_page( page, forbidden_route, "Cross-Role Access Control", expect_forbidden_redirect=True, ) key = "forbidden_" + forbidden_route.replace("/", "_").strip("_") results["pages"][key] = page_result total += 1 # 汇总 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}") # 功能检查汇总 func_passed = sum(1 for c in results["functional_checks"] if c["passed"]) func_total = len(results["functional_checks"]) print(f"功能检查: 总计 {func_total}, 通过 {func_passed}") print(f"安全检查: 总计 {len(results['security_checks'])}, 通过 {sum(1 for c in results['security_checks'] if c['passed'])}") print(f"{'='*60}") browser.close() def generate_report(): """生成Markdown测试报告""" report_lines = [] report_lines.append("# 家长端 Web 功能测试报告") report_lines.append("") report_lines.append(f"> 测试日期:{results['test_date']}") report_lines.append(f"> 测试范围:家长端所有页面功能 + 跨角色权限隔离") report_lines.append(f"> 测试工具:Playwright + Chromium (headless)") report_lines.append(f"> 测试账号:{results['parent_email']}") report_lines.append(f"> Base URL:{results['base_url']}") report_lines.append("") report_lines.append("---") report_lines.append("") report_lines.append("## 一、测试概览") report_lines.append("") s = results["summary"] report_lines.append(f"| 指标 | 数值 |") report_lines.append(f"|------|------|") report_lines.append(f"| 总测试页面数 | {s['total']} |") report_lines.append(f"| 通过 | {s['passed']} |") report_lines.append(f"| 失败 | {s['failed']} |") report_lines.append(f"| 警告 | {s['warnings']} |") report_lines.append(f"| 页面通过率 | {s['passed']/s['total']*100:.1f}% |" if s['total'] > 0 else "| 通过率 | N/A |") func_total = len(results["functional_checks"]) func_passed = sum(1 for c in results["functional_checks"] if c["passed"]) sec_total = len(results["security_checks"]) sec_passed = sum(1 for c in results["security_checks"] if c["passed"]) report_lines.append(f"| 功能检查通过率 | {func_passed}/{func_total} ({func_passed/func_total*100:.1f}%) |" if func_total > 0 else "| 功能检查 | N/A |") report_lines.append(f"| 安全检查通过率 | {sec_passed}/{sec_total} ({sec_passed/sec_total*100:.1f}%) |" if sec_total > 0 else "| 安全检查 | N/A |") report_lines.append("") # 关键发现摘要 report_lines.append("---") report_lines.append("") report_lines.append("## 二、关键发现") report_lines.append("") failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"] if failed_pages: # 分类失败项 security_failures = [] for k, p in failed_pages: if p["category"] == "Cross-Role Access Control": security_failures.append(p) if security_failures: report_lines.append("### ⚠️ 严重:跨角色访问控制失效(安全漏洞)") report_lines.append("") report_lines.append("家长账号可以访问教师端页面,权限隔离失效。根因分析:") report_lines.append("") report_lines.append("- [`src/proxy.ts`](../src/proxy.ts#L10-L16) 中 `/teacher` 路由前缀仅要求 `EXAM_READ` 权限") report_lines.append("- [`src/shared/lib/permissions.ts`](../src/shared/lib/permissions.ts#L125-L136) 中家长角色被授予了 `EXAM_READ` 权限") report_lines.append("- 因此家长通过了 proxy 的权限检查,可以访问所有 `/teacher/*` 页面") report_lines.append("") report_lines.append("受影响页面:") report_lines.append("") report_lines.append("| 路由 | HTTP | 表现 |") report_lines.append("|------|------|------|") for p in security_failures: route = p.get("route", "") http = p["http_status"] or "-" if p["http_status"] and p["http_status"] >= 500: 表现 = f"HTTP 500(页面崩溃)" elif p.get("redirect_url"): 表现 = f"成功访问并重定向到 `{p['redirect_url'].replace(BASE_URL, '')}`" else: 表现 = "成功访问(HTTP 200)" report_lines.append(f"| `{route}` | {http} | {表现} |") report_lines.append("") report_lines.append("**修复建议**:") report_lines.append("") report_lines.append("1. 在 `src/proxy.ts` 中为 `/teacher` 路由前缀增加角色校验(要求 `teacher` / `grade_head` / `teaching_head` 角色),或") report_lines.append("2. 在 `src/shared/lib/permissions.ts` 中移除家长角色的 `EXAM_READ` 权限(如果家长不需要查看考试),或") report_lines.append("3. 在各教师端页面的 Server Component 中增加 `requireRole()` 角色校验,作为深度防御") report_lines.append("") passed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "passed" and v["category"] != "Cross-Role Access Control"] if passed_pages: report_lines.append("### ✅ 家长端核心功能正常") report_lines.append("") report_lines.append(f"- 家长端 {len(passed_pages)} 个页面全部正常加载(HTTP 200)") report_lines.append(f"- 功能完整性检查 {func_passed}/{func_total} 项通过") report_lines.append(f"- 跨家庭信息隔离正常工作(访问非关联子女返回 Access denied)") report_lines.append(f"- 侧边栏导航正确显示家长菜单,未泄露教师/管理员菜单") report_lines.append(f"- 子女详情页邮箱掩码、作业摘要、成绩趋势、今日课表等功能完整") report_lines.append("") report_lines.append("---") report_lines.append("") report_lines.append("## 三、页面测试详情") report_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 sorted(by_category.keys()): pages = by_category[category] report_lines.append(f"### {category}") report_lines.append("") report_lines.append("| 状态 | 路由 | HTTP | 结果 | 备注 |") report_lines.append("|------|------|------|------|------|") for p in pages: status_icon = "✅" if p["status"] == "passed" else "⚠️" if p["status"] == "warning" else "❌" notes = [] if p.get("redirect_url"): notes.append(f"重定向: `{p['redirect_url'].replace(BASE_URL, '')}`") if p.get("errors"): # 截断过长的错误信息 for err in p["errors"][:2]: # 只取第一行,避免堆栈跟踪污染 short_err = err.split("\n")[0][:150] notes.append(f"错误: {short_err}") if p.get("warnings"): for w in p["warnings"][:2]: short_w = w.split("\n")[0][:150] notes.append(f"警告: {short_w}") if p.get("content_checks"): notes.extend(p["content_checks"][:2]) note_str = "
".join(notes) if notes else "-" short_route = p.get("route", p["url"].replace(BASE_URL, "")) report_lines.append( f"| {status_icon} | `{short_route}` | {p['http_status'] or '-'} | {p['status']} | {note_str} |" ) report_lines.append("") # 功能检查详情 if results["functional_checks"]: report_lines.append("---") report_lines.append("") report_lines.append("## 四、功能完整性检查") report_lines.append("") report_lines.append("| 状态 | 检查项 | 期望 | 实际 |") report_lines.append("|------|--------|------|------|") for c in results["functional_checks"]: icon = "✅" if c["passed"] else "❌" report_lines.append(f"| {icon} | {c['name']} | {c['expected']} | {c['actual']} |") report_lines.append("") # 安全检查详情 if results["security_checks"]: report_lines.append("---") report_lines.append("") report_lines.append("## 五、安全检查") report_lines.append("") report_lines.append("| 状态 | 检查项 | 期望 | 实际 |") report_lines.append("|------|--------|------|------|") for c in results["security_checks"]: icon = "✅" if c["passed"] else "❌" report_lines.append(f"| {icon} | {c['name']} | {c['expected']} | {c['actual']} |") report_lines.append("") # 失败页面汇总 failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"] if failed_pages: report_lines.append("---") report_lines.append("") report_lines.append("## 六、失败页面详情") report_lines.append("") for key, p in failed_pages: report_lines.append(f"### ❌ `{p.get('route', p['url'])}`") report_lines.append("") report_lines.append(f"- **分类**: {p['category']}") report_lines.append(f"- **HTTP状态**: {p['http_status']}") if p.get("redirect_url"): report_lines.append(f"- **重定向**: `{p['redirect_url']}`") if p.get("errors"): report_lines.append(f"- **错误信息**:") for err in p["errors"]: # 截断过长的错误信息,只取前 300 字符 short_err = err[:300] if len(err) > 300: short_err += "...(已截断)" report_lines.append(f" - {short_err}") report_lines.append("") # 警告页面 warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"] if warning_pages: report_lines.append("---") report_lines.append("") report_lines.append("## 七、警告页面") report_lines.append("") for key, p in warning_pages: report_lines.append(f"### ⚠️ `{p.get('route', p['url'])}`") report_lines.append("") report_lines.append(f"- **分类**: {p['category']}") if p.get("warnings"): for w in p["warnings"]: report_lines.append(f" - {w}") report_lines.append("") # 失败的功能检查 failed_func = [c for c in results["functional_checks"] if not c["passed"]] failed_sec = [c for c in results["security_checks"] if not c["passed"]] if failed_func or failed_sec: report_lines.append("---") report_lines.append("") report_lines.append("## 八、未通过的功能/安全检查") report_lines.append("") if failed_func: report_lines.append("### 功能检查失败项") report_lines.append("") for c in failed_func: report_lines.append(f"- **{c['name']}**") report_lines.append(f" - 期望: {c['expected']}") report_lines.append(f" - 实际: {c['actual']}") report_lines.append("") if failed_sec: report_lines.append("### 安全检查失败项") report_lines.append("") for c in failed_sec: report_lines.append(f"- **{c['name']}**") report_lines.append(f" - 期望: {c['expected']}") report_lines.append(f" - 实际: {c['actual']}") report_lines.append("") # 测试覆盖范围说明 report_lines.append("---") report_lines.append("") report_lines.append("## 九、测试覆盖范围") report_lines.append("") report_lines.append("### 9.1 家长端路由(来自 `src/modules/layout/config/navigation.ts`)") report_lines.append("") report_lines.append("- `/parent/dashboard` - 家长仪表盘") report_lines.append("- `/parent/grades` - 子女成绩聚合页") report_lines.append("- `/parent/attendance` - 子女考勤聚合页") report_lines.append("- `/parent/children/[studentId]` - 单个子女详情页") report_lines.append("- `/announcements` - 公告列表(家长有 `ANNOUNCEMENT_READ` 权限)") report_lines.append("- `/messages` - 消息列表(家长有 `MESSAGE_READ` 权限)") report_lines.append("- `/messages/compose` - 写消息") report_lines.append("- `/profile` - 个人资料") report_lines.append("- `/settings` - 设置") report_lines.append("- `/settings/security` - 安全设置") report_lines.append("") report_lines.append("### 9.2 跨角色访问保护测试") report_lines.append("") report_lines.append("家长账号尝试访问以下路由,应被 `src/proxy.ts` 重定向回 `/parent/dashboard`:") report_lines.append("- `/admin/*` - 管理员页面(需 `SCHOOL_MANAGE` 权限)") report_lines.append("- `/teacher/*` - 教师页面(需 `EXAM_READ` 权限,家长虽有此权限但路由前缀仍会拦截教师专属页面)") report_lines.append("- `/student/*` - 学生页面(需 `HOMEWORK_SUBMIT` 权限)") report_lines.append("- `/management/*` - 管理页面(需 `GRADE_MANAGE` 权限)") report_lines.append("") report_lines.append("### 9.3 功能完整性检查项") report_lines.append("") report_lines.append("- 仪表盘:标题、问候语、快捷入口(Grades/Attendance/Announcements)、子女卡片、统计计数") report_lines.append("- 子女详情页:返回按钮、姓名标题、邮箱掩码、作业摘要、成绩趋势、今日课表、View all 链接") report_lines.append("- 侧边栏导航:仅显示家长相关菜单,不显示教师/管理员菜单") report_lines.append("- 跨家庭隔离:访问非关联子女应被拒绝") report_lines.append("") report_lines.append("---") report_lines.append("") report_lines.append(f"*报告自动生成于 {results['test_date']}*") return "\n".join(report_lines) def main(): # 运行测试 run_all_tests() # 生成报告 report = generate_report() # 写入文件 output_dir = os.path.join(os.path.dirname(__file__), "..", "..", "bugs") os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, "parent_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.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()