""" 教师端全功能 Web 测试脚本(综合版) 使用 Playwright 对教师端所有页面与核心交互进行功能测试 结果输出:bugs/teacher_web_test.md """ import json import os import re import sys from datetime import datetime from pathlib import Path from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout BASE_URL = "http://localhost:3000" TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn" TEACHER_PASSWORD = "123456" PROJECT_ROOT = Path(__file__).resolve().parents[2] BUGS_DIR = PROJECT_ROOT / "bugs" SCREENSHOT_DIR = PROJECT_ROOT / "tests" / "webapp" / "screenshots" SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) # 教师端所有路由(依据 src/modules/layout/config/navigation.ts 与源码目录) TEACHER_ROUTES = { "Dashboard": ["/teacher/dashboard"], "Textbooks": ["/teacher/textbooks"], "Exams": [ "/teacher/exams", "/teacher/exams/all", "/teacher/exams/create", ], "Homework": [ "/teacher/homework", "/teacher/homework/assignments", "/teacher/homework/assignments/create", "/teacher/homework/submissions", ], "Grades": [ "/teacher/grades", "/teacher/grades/entry", "/teacher/grades/stats", "/teacher/grades/analytics", ], "Question Bank": ["/teacher/questions"], "Class Management": [ "/teacher/classes", "/teacher/classes/my", "/teacher/classes/students", "/teacher/classes/schedule", ], "Course Plans": ["/teacher/course-plans"], "Lesson Plans": [ "/teacher/lesson-plans", "/teacher/lesson-plans/new", ], "Attendance": [ "/teacher/attendance", "/teacher/attendance/sheet", "/teacher/attendance/stats", ], "Schedule Changes": ["/teacher/schedule-changes"], "Diagnostic": ["/teacher/diagnostic"], "Electives": ["/teacher/elective"], "Management": [ "/management/grade/classes", "/management/grade/insights", ], "Announcements": ["/announcements"], "Messages": ["/messages", "/messages/compose"], "Profile & Settings": ["/profile", "/settings", "/settings/security"], } # 详情页发现规则:(列表页, 链接前缀) DETAIL_DISCOVERIES = { "Textbook Detail": ("/teacher/textbooks", "/teacher/textbooks/"), "Class Detail": ("/teacher/classes/my", "/teacher/classes/my/"), "Course Plan Detail": ("/teacher/course-plans", "/teacher/course-plans/"), "Lesson Plan Edit": ("/teacher/lesson-plans", "/teacher/lesson-plans/"), "Exam Detail": ("/teacher/exams/all", "/teacher/exams/"), "Homework Assignment Detail": ("/teacher/homework/assignments", "/teacher/homework/assignments/"), } results = { "test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "test_target": "教师端 (Teacher)", "base_url": BASE_URL, "teacher_email": TEACHER_EMAIL, "summary": { "total": 0, "passed": 0, "failed": 0, "warnings": 0, }, "pages": {}, "interactions": [], "console_errors_global": [], "navigation_issues": [], } # ============ 工具函数 ============ def safe_text(locator, max_len=200): try: text = locator.text_content() return (text or "").strip()[:max_len] except Exception: return "" def login(page): """登录教师账号""" print("\n>>> 登录教师账号...") page.goto(f"{BASE_URL}/login", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) if "/login" not in page.url: print(f" 已登录,当前 URL: {page.url}") return True try: page.locator('input[name="email"]').fill(TEACHER_EMAIL) page.locator('input[name="password"]').fill(TEACHER_PASSWORD) page.get_by_role("button", name=re.compile(r"Sign In with Email", re.I)).click() page.wait_for_load_state("networkidle", timeout=20000) page.wait_for_timeout(2000) if "/login" in page.url: print(f" ❌ 登录后仍在登录页: {page.url}") return False print(f" ✅ 登录成功,跳转至: {page.url}") return True except Exception as e: print(f" ❌ 登录异常: {e}") return False def test_page(page, route, category, take_screenshot=False): """测试单个页面:HTTP 状态、重定向、控制台错误、空内容检测""" 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": [], "console_errors": [], "title": "", "body_length": 0, "screenshot": None, } print(f"\n 测试: {category} - {route}") console_errors = [] def on_console(msg): if msg.type == "error": text = msg.text # 忽略已知的浏览器扩展/第三方噪音 if "favicon" in text.lower() or "Download the React DevTools" in text: return console_errors.append(text) results["console_errors_global"].append({"route": route, "error": text}) def on_pageerror(err): page_result["errors"].append(f"PageError: {str(err)[:200]}") page.on("console", on_console) page.on("pageerror", on_pageerror) try: response = page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(800) http_status = response.status if response else None final_url = page.url page_result["http_status"] = http_status page_result["final_url"] = final_url page_result["title"] = safe_text(page.locator("title")) body_text = safe_text(page.locator("body"), 5000) page_result["body_length"] = len(body_text) if final_url.rstrip("/") != url.rstrip("/"): page_result["redirect_url"] = final_url # 状态判定 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 页面") elif page_result["body_length"] < 50: page_result["status"] = "warning" page_result["warnings"].append("页面内容过少(可能渲染失败)") else: page_result["status"] = "passed" # 检查页面错误提示 error_locs = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600').all() for et in error_locs[:3]: text = safe_text(et, 150) if text and "error" not in text.lower()[:0]: # 保留所有 alert 文本 page_result["warnings"].append(f"页面告警文本: {text}") # 收集控制台错误 if console_errors: page_result["console_errors"] = console_errors[:5] if page_result["status"] == "passed": page_result["status"] = "warning" page_result["warnings"].append(f"控制台错误 {len(console_errors)} 条") # 截图 if take_screenshot or page_result["status"] != "passed": safe_name = re.sub(r"[^\w\-]", "_", route.strip("/")) shot_path = SCREENSHOT_DIR / f"{safe_name}.png" try: page.screenshot(path=str(shot_path), full_page=True) page_result["screenshot"] = str(shot_path.relative_to(PROJECT_ROOT)) except Exception as e: page_result["warnings"].append(f"截图失败: {e}") status_icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(page_result["status"], "❓") print(f" {status_icon} {page_result['status']} (HTTP {http_status}) -> {final_url}") 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_pageerror) 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(800) links = page.locator(f'a[href*="{link_pattern}"]').all() detail_urls = [] seen = set() for link in links[:5]: try: href = link.get_attribute("href") except Exception: continue if not href or href == list_route: continue if link_pattern in href and href not in seen: seen.add(href) detail_urls.append(href) return detail_urls[:2] except Exception as e: print(f" 发现详情页链接失败 ({list_route}): {e}") return [] def test_interactions(page): """测试关键交互功能""" interactions = [] # 1. 仪表盘快捷操作按钮 print("\n>>> 测试交互: 仪表盘快捷操作") try: page.goto(f"{BASE_URL}/teacher/dashboard", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) # 检查仪表盘是否有快捷操作按钮 quick_action_btns = page.locator('a[href*="/teacher/"], button').all() clickable_count = 0 for btn in quick_action_btns[:10]: try: if btn.is_visible(): clickable_count += 1 except Exception: continue interactions.append({ "name": "仪表盘快捷操作可见性", "status": "passed" if clickable_count > 0 else "warning", "detail": f"可见可点击元素 {clickable_count} 个", }) print(f" ✅ 仪表盘可见元素 {clickable_count} 个") except Exception as e: interactions.append({ "name": "仪表盘快捷操作可见性", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 仪表盘交互测试失败: {e}") # 2. 教材详情页 - 检查章节侧边栏 print("\n>>> 测试交互: 教材详情页章节侧边栏") try: page.goto(f"{BASE_URL}/teacher/textbooks", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(800) textbook_links = page.locator('a[href*="/teacher/textbooks/"]').all() if textbook_links: first_href = textbook_links[0].get_attribute("href") if first_href: if not first_href.startswith("/"): first_href = "/" + first_href page.goto(f"{BASE_URL}{first_href}", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) # 检查章节列表 chapter_locs = page.locator('[data-testid*="chapter"], [class*="chapter"], li, a').all() interactions.append({ "name": "教材详情页加载", "status": "passed", "detail": f"教材 {first_href} 加载成功,发现 {len(chapter_locs)} 个潜在章节元素", }) print(f" ✅ 教材详情页加载成功") else: interactions.append({ "name": "教材详情页加载", "status": "warning", "detail": "未发现教材链接(可能数据库无教材数据)", }) print(f" ⚠️ 未发现教材链接") except Exception as e: interactions.append({ "name": "教材详情页加载", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 教材详情页测试失败: {e}") # 3. 创建考试页面 - 表单元素检查 print("\n>>> 测试交互: 创建考试表单") try: page.goto(f"{BASE_URL}/teacher/exams/create", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) form_inputs = page.locator('input, textarea, select, button[type="submit"]').all() interactions.append({ "name": "创建考试表单元素", "status": "passed" if len(form_inputs) > 0 else "warning", "detail": f"发现 {len(form_inputs)} 个表单元素", }) print(f" ✅ 创建考试表单 {len(form_inputs)} 个元素") except Exception as e: interactions.append({ "name": "创建考试表单元素", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 创建考试表单测试失败: {e}") # 4. 题库页面 - 筛选与表格 print("\n>>> 测试交互: 题库页面表格") try: page.goto(f"{BASE_URL}/teacher/questions", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) table_rows = page.locator('tbody tr, [role="row"]').all() filter_inputs = page.locator('input[placeholder*="搜索"], input[placeholder*="筛选"], select').all() interactions.append({ "name": "题库表格与筛选", "status": "passed" if len(table_rows) >= 0 else "warning", "detail": f"表格行 {len(table_rows)} 个,筛选器 {len(filter_inputs)} 个", }) print(f" ✅ 题库表格行 {len(table_rows)} 个") except Exception as e: interactions.append({ "name": "题库表格与筛选", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 题库表格测试失败: {e}") # 5. 作业创建页面 print("\n>>> 测试交互: 创建作业表单") try: page.goto(f"{BASE_URL}/teacher/homework/assignments/create", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) form_elements = page.locator('input, textarea, select, button').all() interactions.append({ "name": "创建作业表单", "status": "passed" if len(form_elements) > 2 else "warning", "detail": f"发现 {len(form_elements)} 个表单元素", }) print(f" ✅ 创建作业表单 {len(form_elements)} 个元素") except Exception as e: interactions.append({ "name": "创建作业表单", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 创建作业表单测试失败: {e}") # 6. 备课新建页面 print("\n>>> 测试交互: 新建备课") try: page.goto(f"{BASE_URL}/teacher/lesson-plans/new", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) form_elements = page.locator('input, textarea, select, button, [contenteditable="true"]').all() interactions.append({ "name": "新建备课表单", "status": "passed" if len(form_elements) > 0 else "warning", "detail": f"发现 {len(form_elements)} 个表单/编辑元素", }) print(f" ✅ 新建备课 {len(form_elements)} 个元素") except Exception as e: interactions.append({ "name": "新建备课表单", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 新建备课测试失败: {e}") # 7. 侧边栏导航 print("\n>>> 测试交互: 侧边栏导航") try: sidebar_links = page.locator('nav a, aside a, [data-sidebar] a').all() interactions.append({ "name": "侧边栏导航链接", "status": "passed" if len(sidebar_links) > 3 else "warning", "detail": f"发现 {len(sidebar_links)} 个侧边栏链接", }) print(f" ✅ 侧边栏 {len(sidebar_links)} 个链接") except Exception as e: interactions.append({ "name": "侧边栏导航链接", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 侧边栏测试失败: {e}") # 8. 消息撰写 print("\n>>> 测试交互: 消息撰写页") try: page.goto(f"{BASE_URL}/messages/compose", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(800) form_elements = page.locator('input, textarea, select, button').all() interactions.append({ "name": "消息撰写表单", "status": "passed" if len(form_elements) > 0 else "warning", "detail": f"发现 {len(form_elements)} 个表单元素", }) print(f" ✅ 消息撰写 {len(form_elements)} 个元素") except Exception as e: interactions.append({ "name": "消息撰写表单", "status": "failed", "detail": str(e)[:200], }) print(f" ❌ 消息撰写测试失败: {e}") return interactions # ============ 主流程 ============ def run_all_tests(): 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 TEACHER_ROUTES.items(): for route in routes: page_result = test_page(page, route, category, take_screenshot=False) 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[:1]: # 每类只测第一个 route = detail_url if detail_url.startswith("/") else "/" + detail_url page_result = test_page(page, route, f"{name}") key = route.replace("/", "_").strip("_") results["pages"][key] = page_result total += 1 else: print(f"\n {name}: 未发现详情页链接(可能无数据)") results["navigation_issues"].append({ "category": name, "list_url": list_url, "issue": "未发现详情页链接", }) # 交互测试 print("\n\n>>> 开始交互功能测试...") results["interactions"] = test_interactions(page) # 汇总 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}") browser.close() def generate_report(): """生成 Markdown 测试报告""" lines = [] lines.append("# 教师端 Web 功能测试报告") lines.append("") lines.append(f"> 测试日期:{results['test_date']}") lines.append(f"> 测试范围:教师端所有页面与核心交互功能") lines.append(f"> 测试工具:Playwright + Chromium (headless)") lines.append(f"> 测试账号:`{results['teacher_email']}`") lines.append(f"> 基础 URL:`{results['base_url']}`") lines.append(f"> 测试依据:`src/modules/layout/config/navigation.ts`、`src/app/(dashboard)/teacher/`") 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['warnings']} |") lines.append(f"| 失败 ❌ | {s['failed']} |") lines.append(f"| 通过率 | {pass_rate:.1f}% |") lines.append(f"| 交互测试数 | {len(results['interactions'])} |") lines.append(f"| 全局控制台错误 | {len(results['console_errors_global'])} |") lines.append("") lines.append("---") lines.append("") # 二、页面测试详情 lines.append("## 二、页面测试详情(按模块分组)") 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 by_category.keys(): pages = by_category[category] lines.append(f"### {category}") lines.append("") lines.append("| 状态 | 路由 | HTTP | 最终 URL | 备注 |") lines.append("|------|------|------|----------|------|") for p in pages: status_icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(p["status"], "❓") notes = [] if p.get("redirect_url"): notes.append(f"重定向: `{p['redirect_url']}`") if p.get("errors"): notes.append(f"错误: {'; '.join(p['errors'][:2])}") if p.get("warnings"): notes.append(f"警告: {'; '.join(p['warnings'][:2])}") if p.get("console_errors"): notes.append(f"控制台错误: {len(p['console_errors'])} 条") note_str = "
".join(notes) if notes else "-" short_url = p["route"] final_url = (p.get("final_url") or "").replace(BASE_URL, "") or short_url lines.append( f"| {status_icon} | `{short_url}` | {p['http_status'] or '-'} | `{final_url}` | {note_str} |" ) lines.append("") # 三、交互测试详情 lines.append("---") lines.append("") lines.append("## 三、交互功能测试详情") lines.append("") if results["interactions"]: lines.append("| 状态 | 交互项 | 详情 |") lines.append("|------|--------|------|") for it in results["interactions"]: status_icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(it["status"], "❓") lines.append(f"| {status_icon} | {it['name']} | {it['detail']} |") lines.append("") else: lines.append("无交互测试数据。") lines.append("") # 四、失败页面详情 failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"] if failed_pages: lines.append("---") lines.append("") lines.append("## 四、失败页面详情(需修复)") lines.append("") for key, p in failed_pages: lines.append(f"### ❌ `{p['route']}`") lines.append("") lines.append(f"- **分类**: {p['category']}") lines.append(f"- **HTTP 状态**: {p['http_status']}") lines.append(f"- **最终 URL**: `{p.get('final_url', '-')}`") if p.get("redirect_url"): lines.append(f"- **重定向到**: `{p['redirect_url']}`") if p.get("errors"): lines.append(f"- **错误信息**:") for err in p["errors"]: lines.append(f" - {err}") if p.get("console_errors"): lines.append(f"- **控制台错误**:") for ce in p["console_errors"]: lines.append(f" - `{ce}`") if p.get("screenshot"): lines.append(f"- **截图**: `{p['screenshot']}`") lines.append("") # 五、警告页面详情 warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"] if warning_pages: lines.append("---") lines.append("") lines.append("## 五、警告页面详情(建议复查)") lines.append("") for key, p in warning_pages: lines.append(f"### ⚠️ `{p['route']}`") lines.append("") lines.append(f"- **分类**: {p['category']}") lines.append(f"- **HTTP 状态**: {p['http_status']}") if p.get("warnings"): for w in p["warnings"]: lines.append(f" - {w}") if p.get("console_errors"): lines.append(f"- **控制台错误**:") for ce in p["console_errors"]: lines.append(f" - `{ce}`") lines.append("") # 六、全局控制台错误 if results["console_errors_global"]: lines.append("---") lines.append("") lines.append("## 六、全局控制台错误汇总") lines.append("") lines.append("| 路由 | 错误信息 |") lines.append("|------|----------|") for ce in results["console_errors_global"][:30]: err_text = ce["error"][:200].replace("|", "\\|") lines.append(f"| `{ce['route']}` | `{err_text}` |") lines.append("") # 七、导航问题 if results["navigation_issues"]: lines.append("---") lines.append("") lines.append("## 七、导航发现问题") lines.append("") for ni in results["navigation_issues"]: lines.append(f"- **{ni['category']}** (`{ni['list_url']}`): {ni['issue']}") lines.append("") # 八、测试结论与建议 lines.append("---") lines.append("") lines.append("## 八、测试结论与建议") lines.append("") if s["failed"] == 0 and s["warnings"] == 0: lines.append("✅ **教师端所有页面与交互功能测试全部通过**,未发现严重问题。") elif s["failed"] == 0: lines.append(f"⚠️ **教师端测试未发现失败页面**,但有 {s['warnings']} 个警告需复查。") else: lines.append(f"❌ **教师端测试发现 {s['failed']} 个失败页面**,需优先修复。") lines.append("") lines.append("### 建议后续动作") lines.append("") lines.append("1. 优先修复「失败页面详情」中列出的所有 P0 问题(HTTP 5xx、重定向到登录页等)") lines.append("2. 复查「警告页面详情」中的页面,确认是否为数据缺失或非关键告警") lines.append("3. 控制台错误如涉及 Next.js 运行时或服务端异常,应排查 Server Action 与 data-access 层") lines.append("4. 对于未发现详情页链接的模块,建议先在种子数据中补充对应记录再回归测试") lines.append("") lines.append("---") lines.append("") lines.append(f"*报告自动生成于 {results['test_date']} by webapp-testing skill*") return "\n".join(lines) def main(): run_all_tests() report = generate_report() BUGS_DIR.mkdir(parents=True, exist_ok=True) output_path = BUGS_DIR / "teacher_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.with_suffix(".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()