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
This commit is contained in:
909
webtest/grade-management_test.py
Normal file
909
webtest/grade-management_test.py
Normal file
@@ -0,0 +1,909 @@
|
||||
"""
|
||||
年级管理模块(grade-management)全功能 Web 测试脚本
|
||||
使用 Playwright 对所有角色的年级管理模块访问与功能进行测试
|
||||
|
||||
覆盖模块:
|
||||
- 年级管理 (/admin/school/grades) - SCHOOL_MANAGE 权限(admin)
|
||||
- 年级洞察 (/admin/school/grades/insights) - SCHOOL_MANAGE 权限(admin)
|
||||
- 年级班级管理 (/management/grade/classes) - GRADE_MANAGE 权限(grade_head/teaching_head)
|
||||
- 年级洞察 (/management/grade/insights) - GRADE_RECORD_READ 权限(teacher/grade_head/teaching_head)
|
||||
|
||||
覆盖角色:
|
||||
- admin(完整 CRUD)
|
||||
- teacher(同时也是 grade_head,可访问 /management/* 但不能访问 /admin/school/grades)
|
||||
- student(应被拒绝访问)
|
||||
- parent(应被拒绝访问)
|
||||
|
||||
结果输出:webtest/grade-management_v1.md 与 webtest/grade-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 = "grade-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: 同时拥有 SCHOOL_MANAGE 和 GRADE_MANAGE,可访问所有年级管理路由
|
||||
# teacher (同时是 grade_head): 可访问 /management/* (GRADE_MANAGE),但不能访问 /admin/school/grades
|
||||
# student/parent: 都不能访问
|
||||
ROLE_EXPECTATIONS = {
|
||||
"admin": {
|
||||
"admin_grades": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True},
|
||||
"admin_insights": {"can_access": True},
|
||||
"management_classes": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True},
|
||||
"management_insights": {"can_access": True},
|
||||
},
|
||||
"teacher": {
|
||||
# teacher 也是 grade_head(T_C1 是一年级组长),有 GRADE_MANAGE 权限
|
||||
"admin_grades": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"admin_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"management_classes": {"can_access": True, "can_create": True, "can_edit": True, "can_delete": True},
|
||||
"management_insights": {"can_access": True},
|
||||
},
|
||||
"student": {
|
||||
"admin_grades": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"admin_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"management_classes": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"management_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
},
|
||||
"parent": {
|
||||
"admin_grades": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"admin_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"management_classes": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
"management_insights": {"can_access": False, "expected_redirect_reason": "forbidden"},
|
||||
},
|
||||
}
|
||||
|
||||
# 年级管理模块路由
|
||||
GRADE_ROUTES = {
|
||||
"admin_grades": "/admin/school/grades",
|
||||
"admin_insights": "/admin/school/grades/insights",
|
||||
"management_classes": "/management/grade/classes",
|
||||
"management_insights": "/management/grade/insights",
|
||||
}
|
||||
|
||||
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": "年级管理 (Grade Management)",
|
||||
"version": VERSION,
|
||||
"base_url": BASE_URL,
|
||||
"covered_submodules": ["admin-grades", "admin-insights", "management-classes", "management-insights"],
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"warnings": 0,
|
||||
},
|
||||
"roles": {},
|
||||
"console_errors_global": [],
|
||||
}
|
||||
|
||||
|
||||
def _is_login_redirect(url: str) -> bool:
|
||||
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:
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query)
|
||||
return query.get("reason", [""])[0] == "forbidden"
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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][route_key]
|
||||
|
||||
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:
|
||||
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_grades_crud(page, role: str) -> dict:
|
||||
"""测试年级管理 CRUD(仅 admin 在 /admin/school/grades)"""
|
||||
result = {
|
||||
"feature": "年级 CRUD (admin)",
|
||||
"role": role,
|
||||
"operations": [],
|
||||
"status": "unknown",
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
if role != "admin":
|
||||
result["status"] = "skipped"
|
||||
result["errors"].append(f"{role} 无 SCHOOL_MANAGE 权限,跳过 admin 年级 CRUD 测试")
|
||||
return result
|
||||
|
||||
route = GRADE_ROUTES["admin_grades"]
|
||||
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. 筛选器检查
|
||||
filter_check = {"name": "筛选器存在", "passed": False, "detail": ""}
|
||||
try:
|
||||
# 年级管理页面有搜索、学校筛选、组长筛选、排序
|
||||
select_count = page.locator('button[role="combobox"]').count()
|
||||
search_input = page.locator('input[placeholder*="搜索"], input[placeholder*="search"]').count()
|
||||
if select_count > 0 or search_input > 0:
|
||||
filter_check["passed"] = True
|
||||
filter_check["detail"] = f"找到筛选器(select: {select_count}, search: {search_input})"
|
||||
else:
|
||||
filter_check["detail"] = "未找到筛选器"
|
||||
except Exception as e:
|
||||
filter_check["detail"] = f"异常: {e}"
|
||||
result["operations"].append(filter_check)
|
||||
|
||||
# 3. 打开创建对话框
|
||||
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)
|
||||
|
||||
# 4. 编辑/删除入口
|
||||
for action_name, action_text in [("编辑入口存在", "Edit"), ("删除入口存在", "Delete"), ("洞察入口存在", "Insights")]:
|
||||
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)
|
||||
cn_text = "编辑" if action_text == "Edit" else "删除" if action_text == "Delete" else "洞察"
|
||||
item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{cn_text}")').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_management_classes_crud(page, role: str) -> dict:
|
||||
"""测试年级班级管理 CRUD(admin 和 teacher/grade_head 在 /management/grade/classes)"""
|
||||
result = {
|
||||
"feature": "年级班级 CRUD (grade_head)",
|
||||
"role": role,
|
||||
"operations": [],
|
||||
"status": "unknown",
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
if role not in ("admin", "teacher"):
|
||||
result["status"] = "skipped"
|
||||
result["errors"].append(f"{role} 无 GRADE_MANAGE 权限,跳过 management 班级 CRUD 测试")
|
||||
return result
|
||||
|
||||
route = GRADE_ROUTES["management_classes"]
|
||||
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)
|
||||
cn_text = "编辑" if action_text == "Edit" else "删除"
|
||||
item = page.locator(f'[role="menuitem"]:has-text("{action_text}"), [role="menuitem"]:has-text("{cn_text}")').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_insights_page(page, role: str, route_key: str) -> dict:
|
||||
"""测试洞察页面(年级洞察)"""
|
||||
result = {
|
||||
"feature": f"洞察页面 ({route_key})",
|
||||
"role": role,
|
||||
"operations": [],
|
||||
"status": "unknown",
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
expectation = ROLE_EXPECTATIONS[role][route_key]
|
||||
if not expectation["can_access"]:
|
||||
result["status"] = "skipped"
|
||||
result["errors"].append(f"{role} 无访问权限,跳过洞察页面测试")
|
||||
return result
|
||||
|
||||
route = GRADE_ROUTES[route_key]
|
||||
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. 页面加载
|
||||
load_check = {"name": "页面加载", "passed": False, "detail": ""}
|
||||
try:
|
||||
body_text = page.locator("body").text_content() or ""
|
||||
if len(body_text.strip()) > 50:
|
||||
load_check["passed"] = True
|
||||
load_check["detail"] = f"页面内容长度 {len(body_text.strip())}"
|
||||
else:
|
||||
load_check["detail"] = "页面内容过少"
|
||||
except Exception as e:
|
||||
load_check["detail"] = f"异常: {e}"
|
||||
result["operations"].append(load_check)
|
||||
|
||||
# 2. 筛选器存在
|
||||
filter_check = {"name": "年级筛选器存在", "passed": False, "detail": ""}
|
||||
try:
|
||||
select = page.locator('select[name="gradeId"]')
|
||||
if select.count() > 0:
|
||||
filter_check["passed"] = True
|
||||
filter_check["detail"] = "找到年级筛选 select"
|
||||
else:
|
||||
filter_check["detail"] = "未找到年级筛选 select"
|
||||
except Exception as e:
|
||||
filter_check["detail"] = f"异常: {e}"
|
||||
result["operations"].append(filter_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"洞察页面测试异常: {str(e)[:200]}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def test_role(page, role: str) -> dict:
|
||||
role_result = {
|
||||
"role": role,
|
||||
"login_success": False,
|
||||
"access_tests": [],
|
||||
"crud_tests": [],
|
||||
"insights_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
|
||||
|
||||
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 GRADE_ROUTES.items():
|
||||
access_result = test_page_access(page, role, route_key, route)
|
||||
role_result["access_tests"].append(access_result)
|
||||
|
||||
# 测试 CRUD
|
||||
role_result["crud_tests"].append(test_grades_crud(page, role))
|
||||
role_result["crud_tests"].append(test_management_classes_crud(page, role))
|
||||
|
||||
# 测试洞察页面
|
||||
role_result["insights_tests"].append(test_insights_page(page, role, "admin_insights"))
|
||||
role_result["insights_tests"].append(test_insights_page(page, role, "management_insights"))
|
||||
|
||||
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
|
||||
for ins in role_data.get("insights_tests", []):
|
||||
if ins["status"] == "skipped":
|
||||
continue
|
||||
total += 1
|
||||
if ins["status"] == "passed":
|
||||
passed += 1
|
||||
elif ins["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:
|
||||
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(grade_head) | student | parent |")
|
||||
lines.append("|--------|------|--------|-------|---------------------|---------|--------|")
|
||||
lines.append("| 年级管理(admin) | /admin/school/grades | SCHOOL_MANAGE | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |")
|
||||
lines.append("| 年级洞察(admin) | /admin/school/grades/insights | SCHOOL_MANAGE | ✅ 查看 | ❌ 拒绝 | ❌ 拒绝 | ❌ 拒绝 |")
|
||||
lines.append("| 年级班级管理 | /management/grade/classes | GRADE_MANAGE | ✅ 完整CRUD | ✅ 完整CRUD | ❌ 拒绝 | ❌ 拒绝 |")
|
||||
lines.append("| 年级洞察(teacher) | /management/grade/insights | GRADE_RECORD_READ | ✅ 查看 | ✅ 查看 | ❌ 拒绝 | ❌ 拒绝 |")
|
||||
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("")
|
||||
|
||||
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("")
|
||||
|
||||
if role_data.get("insights_tests"):
|
||||
lines.append("#### 洞察页面测试")
|
||||
lines.append("")
|
||||
lines.append("| 状态 | 功能 | 操作 | 详情 |")
|
||||
lines.append("|------|------|------|------|")
|
||||
for ins in role_data["insights_tests"]:
|
||||
if ins["status"] == "skipped":
|
||||
lines.append(f"| ⏭️ | {ins['feature']} | 跳过 | {ins['errors'][0] if ins['errors'] else '-'} |")
|
||||
continue
|
||||
status_icon = "✅" if ins["status"] == "passed" else "⚠️" if ins["status"] == "warning" else "❌"
|
||||
for op in ins.get("operations", []):
|
||||
op_icon = "✅" if op["passed"] else "❌"
|
||||
lines.append(f"| {status_icon} | {ins['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})
|
||||
for ins in role_data.get("insights_tests", []):
|
||||
if ins["status"] == "failed":
|
||||
failed_items.append({"role": role, "type": "洞察", "detail": ins})
|
||||
|
||||
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 角色可以完整访问 /admin/school/grades(年级 CRUD + 洞察)")
|
||||
lines.append("- teacher(兼 grade_head)角色可以访问 /management/grade/classes 和 /management/grade/insights")
|
||||
lines.append("- 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_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()
|
||||
Reference in New Issue
Block a user