Files
NextEdu/webtest/school-management_test.py
SpecialX d884c6d513
Some checks failed
CI / scheduled-backup (push) Failing after 36s
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
test: update and add E2E, integration, visual, and webapp tests
- Update E2E tests: announcements, auth, auth-business-flow, full-route-regression, grades, navigation, smoke-auth, teacher-web-test

- Update integration tests: api-ai-chat, api-onboarding-complete, api-onboarding-status, proxy-guard, integration setup

- Update visual regression tests: admin-dashboard, homepage, student-dashboard, teacher-dashboard, visual config, helpers

- Update webapp tests: admin, parent, student full tests and debug scripts

- Add new webapp tests: announcements_messages, settings_profile, debug scripts

- Add webtest directory with test plans, screenshots, and diagnostic scripts
2026-06-23 17:39:40 +08:00

965 lines
37 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
学校管理模块school-management全功能 Web 测试脚本
使用 Playwright 对所有角色的学校管理模块访问与功能进行测试
覆盖模块:
- 学校管理 (/admin/school/schools) - SCHOOL_MANAGE 权限
- 院系管理 (/admin/school/departments) - SCHOOL_MANAGE 权限
- 学年管理 (/admin/school/academic-year) - SCHOOL_MANAGE 权限
覆盖角色:
- admin完整 CRUD
- teacher应被拒绝访问 /admin/* 路径)
- student应被拒绝访问 /admin/* 路径)
- parent应被拒绝访问 /admin/* 路径)
结果输出webtest/school-management_v1.md 与 webtest/school-management_v1.json
"""
import json
import os
import re
import sys
import time
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 = "v1"
MODULE_NAME = "school-management"
# 测试账号(来自 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"},
}
# 各角色对学校管理模块的预期访问行为
# admin: 完整访问;其他角色:应被重定向到自己的 dashboard带 reason=forbidden
ROLE_EXPECTATIONS = {
"admin": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True},
"teacher": {"can_access": False, "expected_redirect_reason": "forbidden"},
"student": {"can_access": False, "expected_redirect_reason": "forbidden"},
"parent": {"can_access": False, "expected_redirect_reason": "forbidden"},
}
# 学校管理模块路由
SCHOOL_ROUTES = {
"schools_list": "/admin/school/schools",
"departments_list": "/admin/school/departments",
"academic_year_list": "/admin/school/academic-year",
}
PROJECT_ROOT = Path(__file__).resolve().parents[1]
WEBTEST_DIR = PROJECT_ROOT / "webtest"
SCREENSHOT_DIR = WEBTEST_DIR / "screenshots" / MODULE_NAME
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": "学校管理 (School Management)",
"version": VERSION,
"base_url": BASE_URL,
"covered_submodules": ["schools", "departments", "academic-year"],
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"roles": {},
"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 _is_forbidden_redirect(url: str) -> bool:
"""判断是否因权限不足被重定向(带 from/reason=forbidden 参数)"""
parsed = urlparse(url)
query = parse_qs(parsed.query)
return query.get("reason", [""])[0] == "forbidden"
def safe_text(locator, max_len=500):
try:
text = locator.text_content()
return (text or "").strip()[:max_len]
except Exception:
return ""
def login(page, role: str) -> bool:
"""登录指定角色账号"""
account = TEST_ACCOUNTS[role]
print(f"\n>>> 登录 {role} 账号 ({account['email']})...")
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.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"])
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(f"{role} 登录失败")
return False
return True
except Exception as e:
print(f"{role} 登录异常: {e}")
return False
def logout(page):
"""退出当前登录状态"""
page.context.clear_cookies()
print(" 已清除 cookies 退出登录")
def complete_parent_onboarding(page, role: str) -> bool:
"""完成家长 onboarding 流程"""
print(f" >>> 完成 {role} onboarding...")
try:
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
if "/onboarding" not in page.url:
return True
page.wait_for_selector('[role="dialog"]', timeout=10000)
page.wait_for_timeout(500)
# Step 0: 角色选择
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(500)
# Step 1: 通用信息
name_input = page.locator('input[id="onb_name"]')
if name_input.count() == 0:
name_input = page.locator('input').nth(0)
if name_input.count() > 0:
name_input.fill("测试家长")
phone_input = page.locator('input[id="onb_phone"]')
if phone_input.count() == 0:
phone_input = page.locator('input').nth(1)
if phone_input.count() > 0:
phone_input.fill("13800000000")
address_input = page.locator('input[id="onb_address"]')
if address_input.count() == 0:
address_input = page.locator('input').nth(2)
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(500)
# Step 2: 跳过
skip_btn = page.locator('button:has-text("跳过"), button:has-text("Skip")').first
if skip_btn.count() > 0:
skip_btn.click()
page.wait_for_timeout(500)
else:
next_btn = page.locator('button:has-text("下一步"), button:has-text("Next")').first
if next_btn.count() > 0:
next_btn.click()
page.wait_for_timeout(500)
# 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)
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_page_access(page, role: str, route_key: str, route: str) -> dict:
"""测试页面访问权限"""
url = f"{BASE_URL}{route}"
expectation = ROLE_EXPECTATIONS[role]
result = {
"route_key": route_key,
"url": url,
"expected_access": expectation["can_access"],
"http_status": None,
"final_url": None,
"status": "unknown",
"errors": [],
"warnings": [],
"checks": [],
}
print(f"\n 测试访问: {route_key} ({route}) as {role}")
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
result["http_status"] = http_status
result["final_url"] = final_url
# 截图
screenshot_name = f"{route_key}_{role}.png"
try:
page.screenshot(path=str(SCREENSHOT_DIR / screenshot_name), full_page=True)
except Exception:
pass
# 判断结果
if http_status and http_status >= 500:
result["status"] = "failed"
result["errors"].append(f"HTTP {http_status} 服务器错误")
elif _is_login_redirect(final_url):
result["status"] = "failed"
result["errors"].append("重定向到登录页 - 认证失败")
elif expectation["can_access"]:
# 预期可以访问
if urlparse(final_url).path == route:
result["status"] = "passed"
result["checks"].append({"name": "页面正常加载", "passed": True})
else:
result["status"] = "warning"
result["warnings"].append(f"重定向到 {final_url}(预期 {route}")
else:
# 预期不能访问 - 应该被重定向到 dashboard 且带 reason=forbidden
if _is_forbidden_redirect(final_url):
result["status"] = "passed"
result["checks"].append({
"name": "权限拒绝重定向",
"passed": True,
"detail": f"正确重定向到 {final_url}"
})
elif urlparse(final_url).path != route:
# 重定向到其他页面(也算权限拒绝)
result["status"] = "passed"
result["checks"].append({
"name": "权限拒绝重定向",
"passed": True,
"detail": f"重定向到 {final_url}"
})
else:
result["status"] = "failed"
result["errors"].append(f"无权限角色 {role} 不应能访问 {route}")
# 检查页面内容
if result["status"] == "passed" and expectation["can_access"]:
body_text = page.locator("body").text_content() or ""
if len(body_text.strip()) < 50:
result["warnings"].append("页面内容过少(<50 字符)")
except PlaywrightTimeout:
result["status"] = "failed"
result["errors"].append("页面加载超时 (30s)")
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"异常: {str(e)[:200]}")
status_icon = "" if result["status"] == "passed" else "⚠️" if result["status"] == "warning" else ""
print(f" {status_icon} {result['status']} (HTTP {result['http_status']})")
return result
def test_schools_crud(page, role: str) -> dict:
"""测试学校管理 CRUD仅 admin"""
result = {
"feature": "学校 CRUD",
"role": role,
"operations": [],
"status": "unknown",
"errors": [],
}
if role != "admin":
result["status"] = "skipped"
result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 CRUD 测试")
return result
route = SCHOOL_ROUTES["schools_list"]
url = f"{BASE_URL}{route}"
try:
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 列表加载
list_check = {"name": "列表加载", "passed": False, "detail": ""}
try:
# 检查表格或空状态
table = page.locator("table")
empty_state = page.locator('[class*="empty"]')
if table.count() > 0 or empty_state.count() > 0:
list_check["passed"] = True
list_check["detail"] = "列表/空状态正常显示"
else:
list_check["detail"] = "未找到表格或空状态"
except Exception as e:
list_check["detail"] = f"异常: {e}"
result["operations"].append(list_check)
# 2. 打开创建对话框
create_check = {"name": "打开创建对话框", "passed": False, "detail": ""}
try:
# 查找"新建"按钮i18n key: schools.new 或类似)
new_btn = page.locator('button:has-text("New"), button:has-text("新建"), button:has-text("Create"), button:has-text("新增")').first
if new_btn.count() == 0:
# 尝试查找带 Plus 图标的按钮
new_btn = page.locator('button:has(svg[class*="lucide-plus"])').first
if new_btn.count() > 0:
new_btn.click()
page.wait_for_timeout(1000)
# 检查对话框是否打开
dialog = page.locator('[role="dialog"]')
if dialog.count() > 0:
create_check["passed"] = True
create_check["detail"] = "创建对话框已打开"
# 填写表单
name_input = page.locator('input[name="name"]').first
if name_input.count() > 0:
name_input.fill(f"测试学校_{datetime.now().strftime('%H%M%S')}")
code_input = page.locator('input[name="code"]').first
if code_input.count() > 0:
code_input.fill(f"TEST_{datetime.now().strftime('%H%M%S')}")
# 关闭对话框(不实际提交,避免污染数据)
cancel_btn = page.locator('button:has-text("Cancel"), button:has-text("取消")').first
if cancel_btn.count() > 0:
cancel_btn.click()
page.wait_for_timeout(500)
else:
create_check["detail"] = "对话框未打开"
else:
create_check["detail"] = "未找到新建按钮"
except Exception as e:
create_check["detail"] = f"异常: {e}"
result["operations"].append(create_check)
# 3. 检查编辑入口(不实际编辑)
edit_check = {"name": "编辑入口存在", "passed": False, "detail": ""}
try:
# 查找表格中的操作菜单
menu_btn = page.locator('button:has(svg[class*="lucide-more-horizontal"])').first
if menu_btn.count() > 0:
menu_btn.click()
page.wait_for_timeout(500)
# 检查下拉菜单是否有编辑选项
edit_item = page.locator('[role="menuitem"]:has-text("Edit"), [role="menuitem"]:has-text("编辑")').first
if edit_item.count() > 0:
edit_check["passed"] = True
edit_check["detail"] = "编辑菜单项存在"
else:
edit_check["detail"] = "未找到编辑菜单项"
# 关闭菜单
page.keyboard.press("Escape")
page.wait_for_timeout(300)
else:
# 可能是空列表,没有数据行
edit_check["detail"] = "无数据行,跳过编辑入口检查"
edit_check["passed"] = True # 不算失败
except Exception as e:
edit_check["detail"] = f"异常: {e}"
result["operations"].append(edit_check)
# 4. 检查删除入口
delete_check = {"name": "删除入口存在", "passed": False, "detail": ""}
try:
menu_btn = page.locator('button:has(svg[class*="lucide-more-horizontal"])').first
if menu_btn.count() > 0:
menu_btn.click()
page.wait_for_timeout(500)
delete_item = page.locator('[role="menuitem"]:has-text("Delete"), [role="menuitem"]:has-text("删除")').first
if delete_item.count() > 0:
delete_check["passed"] = True
delete_check["detail"] = "删除菜单项存在"
else:
delete_check["detail"] = "未找到删除菜单项"
page.keyboard.press("Escape")
page.wait_for_timeout(300)
else:
delete_check["detail"] = "无数据行,跳过删除入口检查"
delete_check["passed"] = True
except Exception as e:
delete_check["detail"] = f"异常: {e}"
result["operations"].append(delete_check)
# 汇总
passed_ops = sum(1 for op in result["operations"] if op["passed"])
total_ops = len(result["operations"])
if passed_ops == total_ops:
result["status"] = "passed"
elif passed_ops > 0:
result["status"] = "warning"
else:
result["status"] = "failed"
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"CRUD 测试异常: {str(e)[:200]}")
return result
def test_departments_crud(page, role: str) -> dict:
"""测试院系管理 CRUD仅 admin"""
result = {
"feature": "院系 CRUD",
"role": role,
"operations": [],
"status": "unknown",
"errors": [],
}
if role != "admin":
result["status"] = "skipped"
result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 CRUD 测试")
return result
route = SCHOOL_ROUTES["departments_list"]
url = f"{BASE_URL}{route}"
try:
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 列表加载
list_check = {"name": "列表加载", "passed": False, "detail": ""}
try:
table = page.locator("table")
empty_state = page.locator('[class*="empty"]')
if table.count() > 0 or empty_state.count() > 0:
list_check["passed"] = True
list_check["detail"] = "列表/空状态正常显示"
else:
list_check["detail"] = "未找到表格或空状态"
except Exception as e:
list_check["detail"] = f"异常: {e}"
result["operations"].append(list_check)
# 2. 打开创建对话框
create_check = {"name": "打开创建对话框", "passed": False, "detail": ""}
try:
new_btn = page.locator('button:has-text("New"), button:has-text("新建"), button:has-text("Create"), button:has-text("新增")').first
if new_btn.count() == 0:
new_btn = page.locator('button:has(svg[class*="lucide-plus"])').first
if new_btn.count() > 0:
new_btn.click()
page.wait_for_timeout(1000)
dialog = page.locator('[role="dialog"]')
if dialog.count() > 0:
create_check["passed"] = True
create_check["detail"] = "创建对话框已打开"
name_input = page.locator('input[name="name"]').first
if name_input.count() > 0:
name_input.fill(f"测试院系_{datetime.now().strftime('%H%M%S')}")
cancel_btn = page.locator('button:has-text("Cancel"), button:has-text("取消")').first
if cancel_btn.count() > 0:
cancel_btn.click()
page.wait_for_timeout(500)
else:
create_check["detail"] = "对话框未打开"
else:
create_check["detail"] = "未找到新建按钮"
except Exception as e:
create_check["detail"] = f"异常: {e}"
result["operations"].append(create_check)
# 3. 编辑/删除入口
for action_name, action_text in [("编辑入口存在", "Edit"), ("删除入口存在", "Delete")]:
check = {"name": action_name, "passed": False, "detail": ""}
try:
menu_btn = page.locator('button:has(svg[class*="lucide-more-horizontal"])').first
if menu_btn.count() > 0:
menu_btn.click()
page.wait_for_timeout(500)
item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{"编辑" if action_text == "Edit" else "删除"}")').first
if item.count() > 0:
check["passed"] = True
check["detail"] = f"{action_text} 菜单项存在"
else:
check["detail"] = f"未找到 {action_text} 菜单项"
page.keyboard.press("Escape")
page.wait_for_timeout(300)
else:
check["detail"] = "无数据行,跳过"
check["passed"] = True
except Exception as e:
check["detail"] = f"异常: {e}"
result["operations"].append(check)
passed_ops = sum(1 for op in result["operations"] if op["passed"])
if passed_ops == len(result["operations"]):
result["status"] = "passed"
elif passed_ops > 0:
result["status"] = "warning"
else:
result["status"] = "failed"
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"CRUD 测试异常: {str(e)[:200]}")
return result
def test_academic_year_crud(page, role: str) -> dict:
"""测试学年管理 CRUD仅 admin"""
result = {
"feature": "学年 CRUD",
"role": role,
"operations": [],
"status": "unknown",
"errors": [],
}
if role != "admin":
result["status"] = "skipped"
result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 CRUD 测试")
return result
route = SCHOOL_ROUTES["academic_year_list"]
url = f"{BASE_URL}{route}"
try:
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 列表加载
list_check = {"name": "列表加载", "passed": False, "detail": ""}
try:
table = page.locator("table")
empty_state = page.locator('[class*="empty"]')
if table.count() > 0 or empty_state.count() > 0:
list_check["passed"] = True
list_check["detail"] = "列表/空状态正常显示"
else:
list_check["detail"] = "未找到表格或空状态"
except Exception as e:
list_check["detail"] = f"异常: {e}"
result["operations"].append(list_check)
# 2. 打开创建对话框
create_check = {"name": "打开创建对话框", "passed": False, "detail": ""}
try:
new_btn = page.locator('button:has-text("New"), button:has-text("新建"), button:has-text("Create"), button:has-text("新增")').first
if new_btn.count() == 0:
new_btn = page.locator('button:has(svg[class*="lucide-plus"])').first
if new_btn.count() > 0:
new_btn.click()
page.wait_for_timeout(1000)
dialog = page.locator('[role="dialog"]')
if dialog.count() > 0:
create_check["passed"] = True
create_check["detail"] = "创建对话框已打开"
name_input = page.locator('input[name="name"]').first
if name_input.count() > 0:
name_input.fill(f"测试学年_{datetime.now().strftime('%H%M%S')}")
# 检查日期输入
start_input = page.locator('input[name="startDate"], input[type="date"]').nth(0)
end_input = page.locator('input[name="endDate"], input[type="date"]').nth(1)
if start_input.count() > 0:
start_input.fill("2026-09-01")
if end_input.count() > 0:
end_input.fill("2027-06-30")
cancel_btn = page.locator('button:has-text("Cancel"), button:has-text("取消")').first
if cancel_btn.count() > 0:
cancel_btn.click()
page.wait_for_timeout(500)
else:
create_check["detail"] = "对话框未打开"
else:
create_check["detail"] = "未找到新建按钮"
except Exception as e:
create_check["detail"] = f"异常: {e}"
result["operations"].append(create_check)
# 3. 编辑/删除入口
for action_name, action_text in [("编辑入口存在", "Edit"), ("删除入口存在", "Delete")]:
check = {"name": action_name, "passed": False, "detail": ""}
try:
menu_btn = page.locator('button:has(svg[class*="lucide-more-horizontal"])').first
if menu_btn.count() > 0:
menu_btn.click()
page.wait_for_timeout(500)
item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{"编辑" if action_text == "Edit" else "删除"}")').first
if item.count() > 0:
check["passed"] = True
check["detail"] = f"{action_text} 菜单项存在"
else:
check["detail"] = f"未找到 {action_text} 菜单项"
page.keyboard.press("Escape")
page.wait_for_timeout(300)
else:
check["detail"] = "无数据行,跳过"
check["passed"] = True
except Exception as e:
check["detail"] = f"异常: {e}"
result["operations"].append(check)
passed_ops = sum(1 for op in result["operations"] if op["passed"])
if passed_ops == len(result["operations"]):
result["status"] = "passed"
elif passed_ops > 0:
result["status"] = "warning"
else:
result["status"] = "failed"
except Exception as e:
result["status"] = "failed"
result["errors"].append(f"CRUD 测试异常: {str(e)[:200]}")
return result
def test_role(page, role: str) -> dict:
"""测试单个角色的学校管理模块"""
role_result = {
"role": role,
"login_success": False,
"access_tests": [],
"crud_tests": [],
"errors": [],
"warnings": [],
}
print(f"\n{'='*60}")
print(f"=== 测试角色: {role} ===")
print(f"{'='*60}")
# 登录
if not login(page, role):
role_result["errors"].append(f"{role} 登录失败")
return role_result
role_result["login_success"] = True
# 家长账号需要完成 onboarding
if role == "parent" and "/onboarding" in page.url:
if not complete_parent_onboarding(page, role):
role_result["warnings"].append("onboarding 未完成")
# 收集控制台错误
console_errors, on_console = collect_console_errors(page)
# 测试每个路由的访问权限
for route_key, route in SCHOOL_ROUTES.items():
access_result = test_page_access(page, role, route_key, route)
role_result["access_tests"].append(access_result)
# 测试 CRUD仅 admin
role_result["crud_tests"].append(test_schools_crud(page, role))
role_result["crud_tests"].append(test_departments_crud(page, role))
role_result["crud_tests"].append(test_academic_year_crud(page, role))
# 收集控制台错误
if console_errors:
role_result["warnings"].extend([f"控制台错误: {e[:200]}" for e in console_errors[:5]])
results["console_errors_global"].extend([{f"role": role, "error": e} for e in console_errors[:5]])
try:
page.remove_listener("console", on_console)
except Exception:
pass
# 退出登录
logout(page)
return role_result
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()
# 按角色顺序测试
for role in ["admin", "teacher", "student", "parent"]:
role_result = test_role(page, role)
results["roles"][role] = role_result
browser.close()
# 汇总
total = 0
passed = 0
failed = 0
warnings = 0
for role, role_data in results["roles"].items():
for access in role_data.get("access_tests", []):
total += 1
if access["status"] == "passed":
passed += 1
elif access["status"] == "warning":
warnings += 1
else:
failed += 1
for crud in role_data.get("crud_tests", []):
if crud["status"] == "skipped":
continue
total += 1
if crud["status"] == "passed":
passed += 1
elif crud["status"] == "warning":
warnings += 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}")
def generate_report() -> str:
"""生成 Markdown 测试报告"""
lines = []
lines.append("# 学校管理模块 Web 功能测试报告")
lines.append("")
lines.append(f"> 测试日期:{results['test_date']}")
lines.append(f"> 模块:{results['module']}")
lines.append(f"> 版本:{results['version']}")
lines.append(f"> 测试工具Playwright + Chromium (headless)")
lines.append(f"> Base URL{results['base_url']}")
lines.append(f"> 覆盖子模块:{', '.join(results['covered_submodules'])}")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## 一、测试概览")
lines.append("")
s = results["summary"]
lines.append("| 指标 | 数值 |")
lines.append("|------|------|")
lines.append(f"| 总测试项 | {s['total']} |")
lines.append(f"| 通过 | {s['passed']} |")
lines.append(f"| 失败 | {s['failed']} |")
lines.append(f"| 警告 | {s['warnings']} |")
if s["total"] > 0:
lines.append(f"| 通过率 | {s['passed']/s['total']*100:.1f}% |")
else:
lines.append("| 通过率 | N/A |")
lines.append("")
lines.append("### 测试覆盖")
lines.append("")
lines.append("| 子模块 | 路由 | 权限点 | admin | teacher | student | parent |")
lines.append("|--------|------|--------|-------|---------|---------|--------|")
lines.append("| 学校管理 | /admin/school/schools | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |")
lines.append("| 院系管理 | /admin/school/departments | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |")
lines.append("| 学年管理 | /admin/school/academic-year | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |")
lines.append("")
lines.append("---")
lines.append("")
# 各角色测试详情
lines.append("## 二、各角色测试详情")
lines.append("")
for role, role_data in results["roles"].items():
lines.append(f"### 角色:{role}")
lines.append("")
lines.append(f"- **登录状态**: {'✅ 成功' if role_data['login_success'] else '❌ 失败'}")
if role_data.get("errors"):
for err in role_data["errors"]:
lines.append(f"- **错误**: {err}")
if role_data.get("warnings"):
for w in role_data["warnings"]:
lines.append(f"- **警告**: {w}")
lines.append("")
# 访问测试
if role_data.get("access_tests"):
lines.append("#### 访问权限测试")
lines.append("")
lines.append("| 状态 | 子模块 | 路由 | HTTP | 最终 URL | 备注 |")
lines.append("|------|--------|------|------|----------|------|")
for access in role_data["access_tests"]:
status_icon = "" if access["status"] == "passed" else "⚠️" if access["status"] == "warning" else ""
short_url = (access.get("final_url") or "").replace(BASE_URL, "")[:80]
notes = []
if access.get("errors"):
notes.append(f"错误: {'; '.join(access['errors'][:2])}")
if access.get("warnings"):
notes.append(f"警告: {'; '.join(access['warnings'][:2])}")
note_str = "<br>".join(notes) if notes else "-"
route = access["route_key"]
lines.append(f"| {status_icon} | {route} | `{access['url'].replace(BASE_URL, '')}` | {access['http_status'] or '-'} | `{short_url}` | {note_str} |")
lines.append("")
# CRUD 测试
if role_data.get("crud_tests"):
lines.append("#### CRUD 功能测试")
lines.append("")
lines.append("| 状态 | 功能 | 操作 | 详情 |")
lines.append("|------|------|------|------|")
for crud in role_data["crud_tests"]:
if crud["status"] == "skipped":
lines.append(f"| ⏭️ | {crud['feature']} | 跳过 | {crud['errors'][0] if crud['errors'] else '-'} |")
continue
status_icon = "" if crud["status"] == "passed" else "⚠️" if crud["status"] == "warning" else ""
for op in crud.get("operations", []):
op_icon = "" if op["passed"] else ""
lines.append(f"| {status_icon} | {crud['feature']} | {op_icon} {op['name']} | {op['detail']} |")
lines.append("")
lines.append("---")
lines.append("")
# 失败项汇总
failed_items = []
for role, role_data in results["roles"].items():
for access in role_data.get("access_tests", []):
if access["status"] == "failed":
failed_items.append({"role": role, "type": "访问", "detail": access})
for crud in role_data.get("crud_tests", []):
if crud["status"] == "failed":
failed_items.append({"role": role, "type": "CRUD", "detail": crud})
if failed_items:
lines.append("## 三、失败项详情")
lines.append("")
for item in failed_items:
d = item["detail"]
lines.append(f"### ❌ [{item['role']}] {item['type']}: {d.get('feature', d.get('route_key', ''))}")
lines.append("")
if "url" in d:
lines.append(f"- **URL**: {d['url']}")
if "errors" in d and d["errors"]:
for err in d["errors"]:
lines.append(f"- **错误**: {err}")
if "operations" in d:
for op in d["operations"]:
if not op["passed"]:
lines.append(f"- **操作失败**: {op['name']} - {op['detail']}")
lines.append("")
# 控制台错误
if results.get("console_errors_global"):
lines.append("## 四、控制台错误汇总")
lines.append("")
for err in results["console_errors_global"][:20]:
lines.append(f"- **[{err['role']}]** {err['error'][:200]}")
lines.append("")
# 结论
lines.append("## 五、测试结论")
lines.append("")
if results["summary"]["failed"] == 0:
lines.append("✅ **所有测试通过**:学校管理模块在所有用户角色下均按预期工作。")
lines.append("")
lines.append("- admin 角色可以完整访问学校管理、院系管理、学年管理功能(列表、创建、编辑、删除入口均可用)")
lines.append("- teacher / student / parent 角色被正确重定向到各自仪表盘(带 `reason=forbidden` 参数),权限隔离正常")
else:
lines.append(f"❌ **{results['summary']['failed']} 项测试失败**:请查看上方失败项详情并修复。")
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"{MODULE_NAME}_{VERSION}.md"
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {output_path}")
# JSON 数据
json_path = WEBTEST_DIR / f"{MODULE_NAME}_{VERSION}.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()