test: update and add E2E, integration, visual, and webapp tests
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

- 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
This commit is contained in:
SpecialX
2026-06-23 17:39:40 +08:00
parent f40ce0f560
commit d884c6d513
183 changed files with 19006 additions and 0 deletions

View File

@@ -0,0 +1,964 @@
"""
学校管理模块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()