""" 学生端全功能Web测试脚本 使用 Playwright 对学生端所有页面进行功能测试 """ import json import os from datetime import datetime from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout BASE_URL = "http://localhost:3000" STUDENT_EMAIL = "student_g1c1_1@xiaoxue.edu.cn" STUDENT_PASSWORD = "123456" # 学生端所有路由(来自 src/modules/layout/config/navigation.ts 及 src/app/(dashboard)/student/ 目录) STUDENT_ROUTES = { "Dashboard": ["/student/dashboard"], "My Learning - Courses": ["/student/learning/courses"], "My Learning - Assignments": ["/student/learning/assignments"], "My Learning - Textbooks": ["/student/learning/textbooks"], "Schedule": ["/student/schedule"], "My Grades": ["/student/grades"], "Attendance": ["/student/attendance"], "Diagnostic": ["/student/diagnostic"], "Electives": ["/student/elective"], "Announcements": ["/announcements"], "Messages": [ "/messages", "/messages/compose", ], "Profile": ["/profile"], "Settings": [ "/settings", "/settings/security", ], "Common Dashboard": ["/dashboard"], } # 详情页发现规则:从列表页提取详情链接 DETAIL_DISCOVERIES = { "Assignment Detail": ("/student/learning/assignments", "/student/learning/assignments/"), "Textbook Detail": ("/student/learning/textbooks", "/student/learning/textbooks/"), } results = { "test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "test_target": "学生端 (Student)", "base_url": BASE_URL, "student_email": STUDENT_EMAIL, "summary": { "total": 0, "passed": 0, "failed": 0, "warnings": 0, }, "pages": {}, "console_errors": [], "navigation_issues": [], } def login(page): """登录学生账号""" print("\n>>> 登录学生账号...") page.goto(f"{BASE_URL}/login", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) # 检查是否已登录 if "/student" in page.url or page.url.rstrip("/").endswith("/dashboard"): print(" 已登录,跳过登录步骤") return True try: email_input = page.locator('input[name="email"]') if email_input.count() == 0: email_input = page.locator('input[type="email"]') email_input.fill(STUDENT_EMAIL) password_input = page.locator('input[name="password"]') if password_input.count() == 0: password_input = page.locator('input[type="password"]') password_input.fill(STUDENT_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="登录") login_btn.click() page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) print(f" 登录后 URL: {page.url}") # 验证登录成功 if "/login" in page.url: print(" ❌ 登录后仍在登录页") return False return True except Exception as e: print(f" ❌ 登录失败: {e}") return False def test_page(page, route, category): """测试单个页面""" url = f"{BASE_URL}{route}" page_result = { "url": url, "category": category, "status": "unknown", "http_status": None, "redirect_url": None, "final_url": None, "errors": [], "warnings": [], "title": None, "content_length": 0, } print(f"\n 测试: {category} - {route}") # 收集控制台错误 console_errors = [] def on_console(msg): if msg.type == "error": console_errors.append(msg.text) page.on("console", on_console) # 收集页面错误 def on_page_error(err): console_errors.append(f"[PAGE_ERROR] {err}") page.on("pageerror", on_page_error) 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 # 获取页面标题 try: page_result["title"] = page.title() except Exception: pass # 检查是否重定向 if final_url.rstrip("/") != url.rstrip("/"): page_result["redirect_url"] = final_url # 检查 HTTP 状态 if http_status and http_status >= 500: page_result["status"] = "failed" page_result["errors"].append(f"HTTP {http_status} 服务器错误") elif http_status and http_status >= 400: page_result["status"] = "warning" page_result["warnings"].append(f"HTTP {http_status} 客户端错误") elif final_url and "/login" in final_url: page_result["status"] = "failed" page_result["errors"].append("重定向到登录页 - 认证失败") elif final_url and "/500" in final_url: page_result["status"] = "failed" page_result["errors"].append("重定向到 500 错误页") elif final_url and "/404" in final_url: page_result["status"] = "warning" page_result["warnings"].append("重定向到 404 页面") else: page_result["status"] = "passed" # 检查页面是否有错误提示 try: error_elements = page.locator('[role="alert"], .error, .text-destructive, .text-red-500').all() for et in error_elements[:3]: try: text = et.text_content() if text and text.strip(): page_result["warnings"].append(f"页面错误提示: {text.strip()[:150]}") except Exception: pass except Exception: pass # 检查页面是否为空(无主要内容) body_text = page.locator("body").text_content() or "" page_result["content_length"] = len(body_text.strip()) if len(body_text.strip()) < 50: page_result["warnings"].append("页面内容过少,可能为空") # 收集控制台错误 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})") except PlaywrightTimeout: page_result["status"] = "failed" page_result["errors"].append("页面加载超时 (30s)") print(f" ❌ TIMEOUT") except Exception as e: page_result["status"] = "failed" page_result["errors"].append(str(e)[:200]) print(f" ❌ ERROR: {str(e)[:100]}") finally: try: page.remove_listener("console", on_console) page.remove_listener("pageerror", on_page_error) except Exception: pass return page_result def discover_detail_links(page, list_route, link_pattern): """从列表页发现详情页链接""" try: url = f"{BASE_URL}{list_route}" page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) # 查找匹配的链接 links = page.locator(f'a[href*="{link_pattern}"]').all() detail_urls = [] seen = set() for link in links[:5]: # 最多取5个 href = link.get_attribute("href") if href and link_pattern in href and href != list_route: # 标准化URL if not href.startswith("/"): href = "/" + href # 排除列表页本身 if href.rstrip("/") == list_route.rstrip("/"): continue if href not in seen: seen.add(href) detail_urls.append(href) return detail_urls except Exception as e: print(f" 发现详情页链接失败: {e}") return [] 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 STUDENT_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>>> 发现详情页链接...") for name, (list_url, pattern) in DETAIL_DISCOVERIES.items(): detail_urls = discover_detail_links(page, list_url, pattern) if detail_urls: print(f"\n {name} 详情页: {len(detail_urls)} 个") for detail_url in detail_urls[:2]: route = detail_url if not route.startswith("/"): route = "/" + route page_result = test_page(page, route, f"{name}") key = route.replace("/", "_").strip("_") results["pages"][key] = page_result total += 1 else: print(f"\n {name}: 未发现详情页链接") # 汇总 passed = sum(1 for pg in results["pages"].values() if pg["status"] == "passed") failed = sum(1 for pg in results["pages"].values() if pg["status"] == "failed") warnings = sum(1 for pg in results["pages"].values() if pg["status"] == "warning") results["summary"]["total"] = total results["summary"]["passed"] = passed results["summary"]["failed"] = failed results["summary"]["warnings"] = warnings print(f"\n\n{'='*60}") print(f"测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}") print(f"{'='*60}") browser.close() def generate_report(): """生成Markdown测试报告""" 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['student_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("| 指标 | 数值 |") report_lines.append("|------|------|") 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 |") 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("| 状态 | URL | HTTP状态 | 结果 | 备注 |") report_lines.append("|------|-----|----------|------|------|") for pg in pages: status_icon = "✅" if pg["status"] == "passed" else "⚠️" if pg["status"] == "warning" else "❌" notes = [] if pg.get("redirect_url"): notes.append(f"重定向到: `{pg['redirect_url']}`") if pg.get("errors"): # 截断并替换换行符,避免破坏 markdown 表格 err_short = "; ".join(pg["errors"][:2]).replace("\n", " ").replace("\r", " ") if len(err_short) > 200: err_short = err_short[:200] + "..." notes.append(f"错误: {err_short}") if pg.get("warnings"): warn_short = "; ".join(pg["warnings"][:2]).replace("\n", " ").replace("\r", " ") if len(warn_short) > 200: warn_short = warn_short[:200] + "..." notes.append(f"警告: {warn_short}") note_str = "
".join(notes) if notes else "-" # 简化URL显示 short_url = pg["url"].replace(BASE_URL, "") report_lines.append( f"| {status_icon} | `{short_url}` | {pg['http_status'] or '-'} | {pg['status']} | {note_str} |" ) 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, pg in failed_pages: report_lines.append(f"### ❌ `{pg['url']}`") report_lines.append("") report_lines.append(f"- **分类**: {pg['category']}") report_lines.append(f"- **HTTP状态**: {pg['http_status']}") if pg.get("redirect_url"): report_lines.append(f"- **重定向**: `{pg['redirect_url']}`") if pg.get("errors"): report_lines.append(f"- **错误信息**:") for err in pg["errors"]: report_lines.append(f" - {err}") report_lines.append("") # 警告页面 warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"] next_section_num = 4 if warning_pages: report_lines.append("---") report_lines.append("") report_lines.append(f"## 四、警告页面") report_lines.append("") for key, pg in warning_pages: report_lines.append(f"### ⚠️ `{pg['url']}`") report_lines.append("") report_lines.append(f"- **分类**: {pg['category']}") if pg.get("warnings"): for w in pg["warnings"]: report_lines.append(f" - {w}") report_lines.append("") next_section_num = 5 report_lines.append("---") report_lines.append("") report_lines.append(f"## {'四' if next_section_num == 4 else '五'}、发现的问题分析") report_lines.append("") report_lines.append("根据测试结果,发现以下问题:") report_lines.append("") # 收集所有有错误的页面(包括 passed 但有错误的) issue_num = 1 for key, pg in results["pages"].items(): if pg.get("errors"): report_lines.append(f"### 问题 {issue_num}: {pg['category']} - `{pg['url'].replace(BASE_URL, '')}`") report_lines.append("") report_lines.append(f"- **状态**: {pg['status']} (HTTP {pg['http_status']})") report_lines.append(f"- **错误信息**:") for err in pg["errors"]: # 截断过长的错误信息 err_clean = err.replace("\n", " ").replace("\r", " ") if len(err_clean) > 300: err_clean = err_clean[:300] + "..." report_lines.append(f" - {err_clean}") report_lines.append("") issue_num += 1 report_lines.append("---") report_lines.append("") report_lines.append(f"## {'五' if next_section_num == 4 else '六'}、测试覆盖范围") report_lines.append("") report_lines.append("本次测试覆盖学生端以下功能模块:") report_lines.append("") report_lines.append("| 模块 | 路由 | 说明 |") report_lines.append("|------|------|------|") report_lines.append("| Dashboard | `/student/dashboard` | 学生仪表盘 |") report_lines.append("| My Learning - Courses | `/student/learning/courses` | 我的课程 |") report_lines.append("| My Learning - Assignments | `/student/learning/assignments` | 作业列表 |") report_lines.append("| My Learning - Assignment Detail | `/student/learning/assignments/[id]` | 作业详情/作答 |") report_lines.append("| My Learning - Textbooks | `/student/learning/textbooks` | 教材列表 |") report_lines.append("| My Learning - Textbook Detail | `/student/learning/textbooks/[id]` | 教材阅读 |") report_lines.append("| Schedule | `/student/schedule` | 课表 |") report_lines.append("| My Grades | `/student/grades` | 我的成绩 |") report_lines.append("| Attendance | `/student/attendance` | 考勤 |") report_lines.append("| Diagnostic | `/student/diagnostic` | 学情诊断 |") report_lines.append("| Electives | `/student/elective` | 选课中心 |") report_lines.append("| Announcements | `/announcements` | 公告 |") report_lines.append("| Messages | `/messages` | 消息列表 |") report_lines.append("| Messages - Compose | `/messages/compose` | 写消息 |") report_lines.append("| Profile | `/profile` | 个人资料 |") report_lines.append("| Settings | `/settings` | 设置 |") report_lines.append("| Settings - Security | `/settings/security` | 安全设置 |") report_lines.append("| Common Dashboard | `/dashboard` | 通用仪表盘(角色跳转) |") 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, "student_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()