feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
462
tests/webapp/admin_full_test.py
Normal file
462
tests/webapp/admin_full_test.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
管理员端全功能Web测试脚本
|
||||
使用 Playwright 对 admin 端所有页面进行功能测试
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
|
||||
|
||||
BASE_URL = "http://127.0.0.1:3000"
|
||||
ADMIN_EMAIL = "admin@xiaoxue.edu.cn"
|
||||
ADMIN_PASSWORD = "123456"
|
||||
|
||||
# 管理员端所有路由(从导航配置和源码中提取)
|
||||
ADMIN_ROUTES = {
|
||||
"Dashboard": ["/admin/dashboard"],
|
||||
"School Management": [
|
||||
"/admin/school",
|
||||
"/admin/school/schools",
|
||||
"/admin/school/grades",
|
||||
"/admin/school/grades/insights",
|
||||
"/admin/school/departments",
|
||||
"/admin/school/classes",
|
||||
"/admin/school/academic-year",
|
||||
],
|
||||
"Course Plans": [
|
||||
"/admin/course-plans",
|
||||
"/admin/course-plans/create",
|
||||
],
|
||||
"Users": [
|
||||
"/admin/users/import",
|
||||
],
|
||||
"Scheduling": [
|
||||
"/admin/scheduling/rules",
|
||||
"/admin/scheduling/auto",
|
||||
"/admin/scheduling/changes",
|
||||
],
|
||||
"Audit Logs": [
|
||||
"/admin/audit-logs",
|
||||
"/admin/audit-logs/login-logs",
|
||||
"/admin/audit-logs/data-changes",
|
||||
],
|
||||
"Announcements": [
|
||||
"/admin/announcements",
|
||||
],
|
||||
"Electives": [
|
||||
"/admin/elective",
|
||||
"/admin/elective/create",
|
||||
],
|
||||
"Attendance": [
|
||||
"/admin/attendance",
|
||||
],
|
||||
"Files": [
|
||||
"/admin/files",
|
||||
],
|
||||
"Messages": ["/messages"],
|
||||
"Settings": ["/settings"],
|
||||
"Profile": ["/profile"],
|
||||
"Announcements (Public)": ["/announcements"],
|
||||
}
|
||||
|
||||
# 详细页面 - 需要从列表页导航获取ID
|
||||
DETAIL_DISCOVERIES = {
|
||||
"Announcement Detail": ("/admin/announcements", "/admin/announcements/"),
|
||||
"Course Plan Detail": ("/admin/course-plans", "/admin/course-plans/"),
|
||||
"Elective Edit": ("/admin/elective", "/admin/elective/"),
|
||||
}
|
||||
|
||||
results = {
|
||||
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"test_target": "管理员端 (Admin)",
|
||||
"base_url": BASE_URL,
|
||||
"admin_email": ADMIN_EMAIL,
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"warnings": 0,
|
||||
},
|
||||
"pages": {},
|
||||
"console_errors": [],
|
||||
"navigation_issues": [],
|
||||
}
|
||||
|
||||
|
||||
def _is_login_redirect(url: str) -> bool:
|
||||
"""精确判断 URL 是否为登录页重定向(避免误判包含 'login' 字样的路径,如 login-logs)"""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.rstrip("/")
|
||||
# 精确匹配 /login 或 /login/ 结尾
|
||||
if path.endswith("/login"):
|
||||
return True
|
||||
# 检查 callbackUrl 参数中是否包含 /login(NextAuth 重定向模式)
|
||||
query = parse_qs(parsed.query)
|
||||
callback = query.get("callbackUrl", [""])[0]
|
||||
if callback and "/login" in callback:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def login(page):
|
||||
"""登录管理员账号"""
|
||||
print("\n>>> 登录管理员账号...")
|
||||
page.goto(f"{BASE_URL}/login")
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# 检查是否已登录
|
||||
if "/admin" 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(ADMIN_EMAIL)
|
||||
|
||||
password_input = page.locator('input[name="password"]')
|
||||
if password_input.count() == 0:
|
||||
password_input = page.locator('input[type="password"]')
|
||||
password_input.fill(ADMIN_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")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
print(f" 登录后 URL: {page.url}")
|
||||
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,
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
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 _is_login_redirect(final_url):
|
||||
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")
|
||||
else:
|
||||
page_result["status"] = "passed"
|
||||
|
||||
# 检查页面是否有错误提示(排除日志表格中的 errorMessage 显示)
|
||||
error_texts = page.locator('[role="alert"], .error').all()
|
||||
for et in error_texts:
|
||||
try:
|
||||
text = et.text_content()
|
||||
if text and text.strip():
|
||||
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})")
|
||||
|
||||
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_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 = []
|
||||
for link in links[:3]: # 最多取3个
|
||||
href = link.get_attribute("href")
|
||||
if href and href != list_route and link_pattern in href:
|
||||
detail_urls.append(href)
|
||||
return detail_urls
|
||||
except Exception:
|
||||
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 ADMIN_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 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测试报告"""
|
||||
report_lines = []
|
||||
report_lines.append(f"# 管理员端 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['admin_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 |")
|
||||
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 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']}")
|
||||
if p.get("errors"):
|
||||
notes.append(f"错误: {'; '.join(p['errors'][:2])}")
|
||||
if p.get("warnings"):
|
||||
notes.append(f"警告: {'; '.join(p['warnings'][:2])}")
|
||||
note_str = "<br>".join(notes) if notes else "-"
|
||||
|
||||
# 简化URL显示
|
||||
short_url = p["url"].replace(BASE_URL, "")
|
||||
report_lines.append(
|
||||
f"| {status_icon} | `{short_url}` | {p['http_status'] or '-'} | {p['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, p in failed_pages:
|
||||
report_lines.append(f"### ❌ `{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"]:
|
||||
report_lines.append(f" - {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['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("")
|
||||
|
||||
report_lines.append("---")
|
||||
report_lines.append("")
|
||||
report_lines.append("## 五、改进建议")
|
||||
report_lines.append("")
|
||||
report_lines.append("1. **认证与权限**:失败页面中若出现重定向至 /login,需检查会话过期策略与权限校验逻辑。")
|
||||
report_lines.append("2. **HTTP 5xx 错误**:服务端错误需检查 Server Action 数据访问层与数据库连接。")
|
||||
report_lines.append("3. **HTTP 4xx 错误**:客户端请求错误需检查路由参数与权限点映射。")
|
||||
report_lines.append("4. **页面内容为空**:检查数据查询条件与渲染逻辑,确认数据源是否返回预期结果。")
|
||||
report_lines.append("5. **控制台错误**:浏览器控制台报错需检查前端组件渲染与 API 调用。")
|
||||
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, "admin_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()
|
||||
Reference in New Issue
Block a user