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

主要变更:

- 新增 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:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View File

@@ -0,0 +1,416 @@
/**
* 教师端全功能 Web 测试
* 测试范围:教师端所有页面路由
*/
import { expect, test } from "@playwright/test"
import fs from "node:fs"
import path from "node:path"
const TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
const TEACHER_PASSWORD = "123456"
// 教师端所有路由
const TEACHER_ROUTES: { category: string; routes: string[] }[] = [
{
category: "Dashboard",
routes: ["/teacher/dashboard"],
},
{
category: "Textbooks",
routes: ["/teacher/textbooks"],
},
{
category: "Exams",
routes: [
"/teacher/exams",
"/teacher/exams/all",
"/teacher/exams/create",
],
},
{
category: "Homework",
routes: [
"/teacher/homework",
"/teacher/homework/assignments",
"/teacher/homework/assignments/create",
"/teacher/homework/submissions",
],
},
{
category: "Grades",
routes: [
"/teacher/grades",
"/teacher/grades/entry",
"/teacher/grades/stats",
"/teacher/grades/analytics",
],
},
{
category: "Question Bank",
routes: ["/teacher/questions"],
},
{
category: "Class Management",
routes: [
"/teacher/classes",
"/teacher/classes/my",
"/teacher/classes/students",
"/teacher/classes/schedule",
],
},
{
category: "Course Plans",
routes: ["/teacher/course-plans"],
},
{
category: "Lesson Plans",
routes: [
"/teacher/lesson-plans",
"/teacher/lesson-plans/new",
],
},
{
category: "Attendance",
routes: [
"/teacher/attendance",
"/teacher/attendance/sheet",
"/teacher/attendance/stats",
],
},
{
category: "Schedule Changes",
routes: ["/teacher/schedule-changes"],
},
{
category: "Diagnostic",
routes: ["/teacher/diagnostic"],
},
{
category: "Electives",
routes: ["/teacher/elective"],
},
{
category: "Management",
routes: [
"/management/grade/classes",
"/management/grade/insights",
],
},
{
category: "Announcements",
routes: ["/announcements"],
},
{
category: "Messages",
routes: ["/messages"],
},
]
// 需要登录后从列表页抓取链接的详情页模式
const DETAIL_PATTERNS: { category: string; listRoute: string; linkPattern: string }[] = [
{ category: "Textbooks Detail", listRoute: "/teacher/textbooks", linkPattern: "/teacher/textbooks/" },
{ category: "Classes Detail", listRoute: "/teacher/classes/my", linkPattern: "/teacher/classes/my/" },
{ category: "Course Plans Detail", listRoute: "/teacher/course-plans", linkPattern: "/teacher/course-plans/" },
{ category: "Lesson Plans Detail", listRoute: "/teacher/lesson-plans", linkPattern: "/teacher/lesson-plans/" },
]
interface TestResult {
url: string
category: string
status: "passed" | "failed" | "warning"
httpStatus: number | null
finalUrl: string
errors: string[]
warnings: string[]
}
const allResults: TestResult[] = []
function addResult(result: TestResult): void {
allResults.push(result)
}
function generateReport(): string {
const lines: string[] = []
const passed = allResults.filter((r) => r.status === "passed").length
const failed = allResults.filter((r) => r.status === "failed").length
const warnings = allResults.filter((r) => r.status === "warning").length
const total = allResults.length
lines.push("# 教师端 Web 功能测试报告 (v2)")
lines.push("")
lines.push(`> 测试日期:${new Date().toISOString().replace("T", " ").slice(0, 19)}`)
lines.push("> 测试范围:所有教师端页面功能")
lines.push("> 测试工具Playwright + Chromium")
lines.push(`> 测试账号:${TEACHER_EMAIL}`)
lines.push("")
lines.push("---")
lines.push("")
lines.push("## 一、测试概览")
lines.push("")
lines.push("| 指标 | 数值 |")
lines.push("|------|------|")
lines.push(`| 总测试页面数 | ${total} |`)
lines.push(`| ✅ 通过 | ${passed} |`)
lines.push(`| ❌ 失败 | ${failed} |`)
lines.push(`| ⚠️ 警告 | ${warnings} |`)
lines.push(`| 通过率 | ${total > 0 ? ((passed / total) * 100).toFixed(1) : "N/A"}% |`)
lines.push("")
lines.push("---")
lines.push("")
lines.push("## 二、页面测试详情")
lines.push("")
// 按类别分组
const byCategory = new Map<string, TestResult[]>()
for (const r of allResults) {
const cat = r.category
if (!byCategory.has(cat)) byCategory.set(cat, [])
byCategory.get(cat)!.push(r)
}
for (const [category, results] of byCategory) {
lines.push(`### ${category}`)
lines.push("")
lines.push("| 页面 | HTTP状态 | 结果 | 备注 |")
lines.push("|------|----------|------|------|")
for (const r of results) {
const icon = r.status === "passed" ? "✅" : r.status === "warning" ? "⚠️" : "❌"
const notes: string[] = []
if (r.finalUrl !== r.url) notes.push(`重定向: ${r.finalUrl}`)
if (r.errors.length > 0) notes.push(`错误: ${r.errors.slice(0, 2).join("; ")}`)
if (r.warnings.length > 0) notes.push(`警告: ${r.warnings.slice(0, 2).join("; ")}`)
const noteStr = notes.length > 0 ? notes.join("<br>") : "-"
lines.push(`| ${icon} \`${r.url}\` | ${r.httpStatus ?? "-"} | ${r.status} | ${noteStr} |`)
}
lines.push("")
}
// 失败详情
const failedResults = allResults.filter((r) => r.status === "failed")
if (failedResults.length > 0) {
lines.push("---")
lines.push("")
lines.push("## 三、失败页面详情")
lines.push("")
for (const r of failedResults) {
lines.push(`### ❌ \`${r.url}\``)
lines.push("")
lines.push(`- **分类**: ${r.category}`)
lines.push(`- **HTTP状态**: ${r.httpStatus ?? "-"}`)
if (r.finalUrl !== r.url) lines.push(`- **重定向**: ${r.finalUrl}`)
if (r.errors.length > 0) {
lines.push("- **错误信息**:")
for (const e of r.errors) lines.push(` - ${e}`)
}
lines.push("")
}
}
// 警告详情
const warningResults = allResults.filter((r) => r.status === "warning")
if (warningResults.length > 0) {
lines.push("---")
lines.push("")
lines.push("## 四、警告页面")
lines.push("")
for (const r of warningResults) {
lines.push(`### ⚠️ \`${r.url}\``)
lines.push("")
lines.push(`- **分类**: ${r.category}`)
if (r.warnings.length > 0) {
for (const w of r.warnings) lines.push(` - ${w}`)
}
lines.push("")
}
}
lines.push("---")
lines.push("")
lines.push(`*报告自动生成于 ${new Date().toISOString().replace("T", " ").slice(0, 19)}*`)
return lines.join("\n")
}
test.describe("Teacher Full Web Test", () => {
test.setTimeout(300000) // 5 minutes total
test("login and test all teacher pages", async ({ page }) => {
// ====== Step 1: Login ======
console.log("\n>>> 登录教师账号...")
await page.goto("/login")
await page.waitForLoadState("networkidle")
// 填表登录
const emailInput = page.locator('input[name="email"]')
if ((await emailInput.count()) === 0) {
await page.locator('input[type="email"]').first().waitFor({ state: "visible", timeout: 5000 })
}
await page.locator('input[type="email"], input[name="email"]').first().fill(TEACHER_EMAIL)
await page.locator('input[type="password"], input[name="password"]').first().fill(TEACHER_PASSWORD)
// 点击登录
const submitBtn = page.locator('button[type="submit"]')
if ((await submitBtn.count()) > 0) {
await submitBtn.first().click()
} else {
await page.getByRole("button", { name: /Sign In|登录/i }).click()
}
await page.waitForLoadState("networkidle")
await page.waitForTimeout(2000)
console.log(`登录后 URL: ${page.url()}`)
expect(page.url()).not.toContain("/login")
// ====== Step 2: Test all routes ======
for (const { category, routes } of TEACHER_ROUTES) {
for (const route of routes) {
console.log(`\n 测试: ${category} - ${route}`)
const result = await testSinglePage(page, route, category)
addResult(result)
}
}
// ====== Step 3: Discover and test detail pages ======
console.log("\n\n>>> 发现详情页链接...")
for (const { category, listRoute, linkPattern } of DETAIL_PATTERNS) {
const detailUrls = await discoverDetailLinks(page, listRoute, linkPattern)
if (detailUrls.length > 0) {
console.log(`\n ${category}: ${detailUrls.length} 个详情页`)
for (const detailUrl of detailUrls.slice(0, 2)) {
const result = await testSinglePage(page, detailUrl, `${category}`)
addResult(result)
}
} else {
console.log(`\n ${category}: 未发现详情页链接`)
}
}
// ====== Step 4: Generate report ======
const report = generateReport()
const bugsDir = path.resolve(__dirname, "..", "..", "bugs")
fs.mkdirSync(bugsDir, { recursive: true })
const outputPath = path.join(bugsDir, "教师端_web_test2.md")
fs.writeFileSync(outputPath, report, "utf-8")
console.log(`\n\n${"=".repeat(60)}`)
const passed = allResults.filter((r) => r.status === "passed").length
const failed = allResults.filter((r) => r.status === "failed").length
console.log(`测试完成: 总计 ${allResults.length}, 通过 ${passed}, 失败 ${failed}`)
console.log(`报告已写入: ${outputPath}`)
console.log(`${"=".repeat(60)}`)
})
})
async function testSinglePage(
page: import("@playwright/test").Page,
route: string,
category: string,
): Promise<TestResult> {
const result: TestResult = {
url: route,
category,
status: "unknown",
httpStatus: null,
finalUrl: route,
errors: [],
warnings: [],
}
const consoleErrors: string[] = []
const onConsole = (msg: import("@playwright/test").ConsoleMessage) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text())
}
}
page.on("console", onConsole)
try {
const response = await page.goto(route, { timeout: 25000 })
await page.waitForLoadState("networkidle", { timeout: 15000 })
await page.waitForTimeout(500)
result.httpStatus = response?.status() ?? null
result.finalUrl = page.url()
const httpStatus = result.httpStatus
const finalUrl = result.finalUrl
if (httpStatus && httpStatus >= 500) {
result.status = "failed"
result.errors.push(`HTTP ${httpStatus} error`)
} else if (httpStatus && httpStatus >= 400) {
result.status = "warning"
result.warnings.push(`HTTP ${httpStatus} error`)
} else if (finalUrl.includes("/login")) {
result.status = "failed"
result.errors.push("Redirected to login - auth issue")
} else if (finalUrl.includes("/error") || finalUrl.includes("/500")) {
result.status = "failed"
result.errors.push("Redirected to error page")
} else {
result.status = "passed"
}
// 检查页面错误提示
const errorElements = page.locator('[role="alert"], .text-destructive, .text-red-500')
const errorCount = await errorElements.count()
if (errorCount > 0) {
for (let i = 0; i < Math.min(errorCount, 3); i++) {
const text = await errorElements.nth(i).textContent()
if (text && text.trim()) {
result.warnings.push(`Error text: ${text.trim().slice(0, 100)}`)
}
}
}
// 检查页面是否为空
const bodyText = (await page.locator("body").textContent()) ?? ""
if (bodyText.trim().length < 50) {
result.warnings.push("Page appears empty")
}
if (consoleErrors.length > 0) {
result.errors.push(...consoleErrors.slice(0, 5))
}
const icon = result.status === "passed" ? "✅" : result.status === "warning" ? "⚠️" : "❌"
console.log(` ${icon} ${result.status} (HTTP ${result.httpStatus})`)
} catch (e) {
result.status = "failed"
result.errors.push(`Exception: ${String(e).slice(0, 200)}`)
console.log(` ❌ ERROR: ${String(e).slice(0, 100)}`)
} finally {
page.removeListener("console", onConsole)
}
return result
}
async function discoverDetailLinks(
page: import("@playwright/test").Page,
listRoute: string,
linkPattern: string,
): Promise<string[]> {
try {
await page.goto(listRoute, { timeout: 25000 })
await page.waitForLoadState("networkidle", { timeout: 15000 })
await page.waitForTimeout(500)
const links = page.locator(`a[href*="${linkPattern}"]`)
const count = await links.count()
const detailUrls: string[] = []
for (let i = 0; i < Math.min(count, 3); i++) {
const href = await links.nth(i).getAttribute("href")
if (href && href !== listRoute && href.includes(linkPattern)) {
detailUrls.push(href)
}
}
return detailUrls
} catch {
return []
}
}

View 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 参数中是否包含 /loginNextAuth 重定向模式)
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()

View File

@@ -0,0 +1,30 @@
require('dotenv').config({ path: '.env' });
const { drizzle } = require('drizzle-orm/mysql2');
const mysql = require('mysql2/promise');
const schema = require('./src/shared/db/schema');
const relations = require('./src/shared/db/relations');
(async () => {
console.log('DATABASE_URL:', process.env.DATABASE_URL ? 'set' : 'not set');
const pool = mysql.createPool({
uri: process.env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
});
const db = drizzle(pool, { schema: { ...schema, ...relations }, mode: 'default' });
try {
console.log('Trying homeworkAssignments.findMany with sourceExam relation...');
const data = await db.query.homeworkAssignments.findMany({
where: require('drizzle-orm').eq(schema.homeworkAssignments.creatorId, 'user_T_C1'),
with: { sourceExam: true },
});
console.log('OK rows:', data.length);
console.log('First row:', JSON.stringify(data[0], null, 2).substring(0, 500));
} catch (e) {
console.log('ERR:', e.message);
console.log('Stack:', e.stack?.substring(0, 500));
}
await pool.end();
})();

View File

@@ -0,0 +1,989 @@
"""
家长端全功能Web测试脚本
使用 Playwright 对家长端所有页面进行功能测试
"""
import json
import os
import re
import sys
from datetime import datetime
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
PARENT_EMAIL = "parent_g1c1_1@xiaoxue.edu.cn"
PARENT_PASSWORD = "123456"
# 家长端所有路由(从导航配置 navigation.ts 和源码中提取)
# 家长权限EXAM_READ, TEXTBOOK_READ, CLASS_READ, USER_PROFILE_UPDATE,
# ANNOUNCEMENT_READ, GRADE_RECORD_READ, ATTENDANCE_READ,
# MESSAGE_SEND, MESSAGE_READ, MESSAGE_DELETE
PARENT_ROUTES = {
"Dashboard": ["/parent/dashboard"],
"Grades": ["/parent/grades"],
"Attendance": ["/parent/attendance"],
"Announcements": ["/announcements"],
"Messages": [
"/messages",
"/messages/compose",
],
"Profile": ["/profile"],
"Settings": [
"/settings",
"/settings/security",
],
}
# 详情页 - 需要从 dashboard 获取 studentId
DETAIL_ROUTES = {
"Child Detail": "/parent/children/",
}
# 跨角色访问保护测试:家长不应能访问教师/学生/管理员页面
FORBIDDEN_ROUTES = [
"/admin/dashboard",
"/admin/school",
"/teacher/dashboard",
"/teacher/exams",
"/teacher/homework",
"/teacher/grades",
"/teacher/questions",
"/teacher/classes",
"/teacher/attendance",
"/student/dashboard",
"/student/learning",
"/student/grades",
"/student/attendance",
"/management/grade/classes",
]
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"test_target": "家长端 (Parent)",
"base_url": BASE_URL,
"parent_email": PARENT_EMAIL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"pages": {},
"functional_checks": [],
"security_checks": [],
"console_errors": [],
"navigation_issues": [],
}
def login(page):
"""登录家长账号"""
print("\n>>> 登录家长账号...")
page.goto(f"{BASE_URL}/login")
page.wait_for_load_state("networkidle")
# 检查是否已登录
if "/parent" in page.url or (page.url.rstrip("/").endswith("/dashboard") and "/login" not in page.url):
print(" 已登录,跳过登录步骤")
return True
try:
email_input = page.locator('input[name="email"]')
if email_input.count() == 0:
email_input = page.locator('input[type="email"]')
print(f" 找到 email 输入框: {email_input.count()}")
email_input.fill(PARENT_EMAIL)
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
print(f" 找到 password 输入框: {password_input.count()}")
password_input.fill(PARENT_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="登录")
print(f" 找到登录按钮: {login_btn.count()}")
login_btn.click()
# 等待导航或错误提示
try:
page.wait_for_url(lambda url: "/login" not in url, timeout=10000)
except PlaywrightTimeout:
# 登录可能失败,检查页面上的错误
body_text = page.locator("body").text_content() or ""
print(f" 登录后仍在 login 页面,页面内容片段: {body_text[:300]}")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(2000)
print(f" 登录后 URL: {page.url}")
return "/login" not in page.url
except Exception as e:
print(f" ❌ 登录失败: {e}")
return False
def test_page(page, route, category, expect_redirect_to_login=False, expect_forbidden_redirect=False):
"""测试单个页面"""
url = f"{BASE_URL}{route}"
page_result = {
"url": url,
"route": route,
"category": category,
"status": "unknown",
"http_status": None,
"final_url": None,
"redirect_url": None,
"errors": [],
"warnings": [],
"content_checks": [],
}
print(f"\n 测试: {category} - {route}")
# 收集控制台错误
console_errors = []
MAX_CONSOLE_ERRORS = 5
def on_console(msg):
if msg.type == "error":
# 限制收集的错误数量,避免内存爆炸
if len(console_errors) >= MAX_CONSOLE_ERRORS:
return
# 截断过长的控制台错误(避免堆栈跟踪导致输出爆炸)
text = msg.text
if len(text) > 500:
text = text[:500] + "...(已截断)"
console_errors.append(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:
if expect_forbidden_redirect:
# 跨角色访问导致 500 也是权限隔离失效的表现
page_result["status"] = "failed"
page_result["errors"].append(f"跨角色访问返回 HTTP {http_status}(应被重定向拦截)")
else:
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 "/login" in final_url:
if expect_redirect_to_login or expect_forbidden_redirect:
page_result["status"] = "passed"
page_result["content_checks"].append("正确重定向到登录页(权限拦截)")
else:
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")
elif expect_forbidden_redirect:
# 期望被拦截但实际访问成功的跨角色访问
if "/parent/" in final_url and "reason=forbidden" in final_url:
page_result["status"] = "passed"
page_result["content_checks"].append("跨角色访问被权限系统拦截")
elif "/parent/" in final_url:
page_result["status"] = "passed"
page_result["content_checks"].append(f"跨角色访问被拦截,重定向到: {final_url}")
else:
# 仍然停留在教师/学生/管理员页面 = 权限隔离失效
page_result["status"] = "failed"
page_result["errors"].append(
f"⚠️ 安全漏洞:家长成功访问了受限页面(最终 URL: {final_url}),权限隔离失效"
)
else:
page_result["status"] = "passed"
# 检查页面是否有错误提示
error_texts = page.locator('[role="alert"], .error, .text-destructive, .text-red-500').all()
for et in error_texts:
try:
text = et.text_content()
if text and text.strip() and "Access denied" not in text:
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})")
if page_result.get("redirect_url"):
print(f" ↪ 重定向到: {page_result['redirect_url']}")
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_child_links(page):
"""从家长仪表盘发现子女详情页链接"""
try:
url = f"{BASE_URL}/parent/dashboard"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 查找子女详情链接
links = page.locator('a[href*="/parent/children/"]').all()
detail_urls = []
seen = set()
for link in links:
href = link.get_attribute("href")
if href and "/parent/children/" in href:
# 提取纯路径(不含 query string
clean_href = href.split("?")[0]
if clean_href not in seen:
seen.add(clean_href)
detail_urls.append(clean_href)
return detail_urls
except Exception as e:
print(f" 发现子女链接失败: {e}")
return []
def check_dashboard_functionality(page):
"""检查家长仪表盘功能完整性"""
checks = []
print("\n>>> 检查仪表盘功能完整性...")
try:
page.goto(f"{BASE_URL}/parent/dashboard", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 1. 检查标题
title = page.locator("h1")
title_text = title.first.text_content() if title.count() > 0 else ""
checks.append({
"name": "仪表盘标题",
"expected": "Parent Dashboard",
"actual": title_text.strip() if title_text else "",
"passed": "Parent Dashboard" in (title_text or ""),
})
# 2. 检查问候语
body_text = page.locator("body").text_content() or ""
greeting_present = any(g in body_text for g in ["Good morning", "Good afternoon", "Good evening", "Welcome"])
checks.append({
"name": "问候语显示",
"expected": "Good morning/afternoon/evening 或 Welcome",
"actual": "Found" if greeting_present else "Missing",
"passed": greeting_present,
})
# 3. 检查快捷入口按钮
grades_btn = page.locator('a[href="/parent/grades"]')
attendance_btn = page.locator('a[href="/parent/attendance"]')
announcements_btn = page.locator('a[href="/announcements"]')
checks.append({
"name": "Grades 快捷入口",
"expected": "存在",
"actual": "Found" if grades_btn.count() > 0 else "Missing",
"passed": grades_btn.count() > 0,
})
checks.append({
"name": "Attendance 快捷入口",
"expected": "存在",
"actual": "Found" if attendance_btn.count() > 0 else "Missing",
"passed": attendance_btn.count() > 0,
})
checks.append({
"name": "Announcements 快捷入口",
"expected": "存在",
"actual": "Found" if announcements_btn.count() > 0 else "Missing",
"passed": announcements_btn.count() > 0,
})
# 4. 检查子女卡片
child_cards = page.locator('a[href*="/parent/children/"]')
card_count = child_cards.count()
checks.append({
"name": "子女卡片显示",
"expected": "≥1 个子女卡片",
"actual": f"{card_count}",
"passed": card_count > 0,
})
# 5. 检查子女卡片内容Pending/Overdue/Avg 统计)
if card_count > 0:
has_pending = "Pending" in body_text
has_overdue = "Overdue" in body_text
has_avg = "Avg" in body_text or "%" in body_text
checks.append({
"name": "子女卡片 - Pending 统计",
"expected": "显示 Pending 计数",
"actual": "Found" if has_pending else "Missing",
"passed": has_pending,
})
checks.append({
"name": "子女卡片 - Overdue 统计",
"expected": "显示 Overdue 计数",
"actual": "Found" if has_overdue else "Missing",
"passed": has_overdue,
})
# 6. 检查子女数量提示
children_count_text = "child linked" in body_text or "children linked" in body_text
checks.append({
"name": "子女数量提示",
"expected": "显示 'N child(ren) linked'",
"actual": "Found" if children_count_text else "Missing",
"passed": children_count_text,
})
except Exception as e:
checks.append({
"name": "仪表盘功能检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
def check_child_detail_functionality(page, child_route):
"""检查子女详情页功能完整性"""
checks = []
print(f"\n>>> 检查子女详情页功能: {child_route}")
try:
url = f"{BASE_URL}{child_route}"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 1. 检查返回按钮
back_btn = page.locator('a[href="/parent/dashboard"]')
checks.append({
"name": "返回仪表盘按钮",
"expected": "存在 Back to Dashboard 链接",
"actual": "Found" if back_btn.count() > 0 else "Missing",
"passed": back_btn.count() > 0,
})
# 2. 检查子女姓名标题
h1 = page.locator("h1")
h1_text = h1.first.text_content() if h1.count() > 0 else ""
checks.append({
"name": "子女姓名标题",
"expected": "显示子女姓名",
"actual": h1_text.strip()[:50] if h1_text else "Missing",
"passed": bool(h1_text and h1_text.strip() and h1_text.strip() != "Unnamed"),
})
# 3. 检查邮箱掩码
# 邮箱应该被掩码为 j***@example.com 形式
email_pattern = re.search(r"[a-zA-Z0-9]\*\*\*@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", body_text)
checks.append({
"name": "邮箱掩码处理",
"expected": "邮箱被掩码为 j***@domain.com",
"actual": "Masked" if email_pattern else "Not masked or no email",
"passed": bool(email_pattern),
})
# 4. 检查作业摘要卡片
has_homework = "Homework" in body_text
checks.append({
"name": "作业摘要卡片",
"expected": "显示 {childName}'s Homework",
"actual": "Found" if has_homework else "Missing",
"passed": has_homework,
})
# 5. 检查作业统计计数
has_pending = "Pending" in body_text
has_submitted = "Submitted" in body_text
has_graded = "Graded" in body_text
checks.append({
"name": "作业统计 - Pending",
"expected": "显示 Pending 计数",
"actual": "Found" if has_pending else "Missing",
"passed": has_pending,
})
checks.append({
"name": "作业统计 - Submitted",
"expected": "显示 Submitted 计数",
"actual": "Found" if has_submitted else "Missing",
"passed": has_submitted,
})
checks.append({
"name": "作业统计 - Graded",
"expected": "显示 Graded 计数",
"actual": "Found" if has_graded else "Missing",
"passed": has_graded,
})
# 6. 检查成绩趋势卡片
has_grade_summary = "Grade" in body_text or "grade" in body_text.lower()
checks.append({
"name": "成绩趋势卡片",
"expected": "显示成绩信息",
"actual": "Found" if has_grade_summary else "Missing",
"passed": has_grade_summary,
})
# 7. 检查今日课表卡片
has_schedule = "Today Schedule" in body_text or "Schedule" in body_text
checks.append({
"name": "今日课表卡片",
"expected": "显示 {childName}'s Today Schedule",
"actual": "Found" if has_schedule else "Missing",
"passed": has_schedule,
})
# 8. 检查 View all 链接
view_all = page.locator("text=View all")
checks.append({
"name": "View all 链接",
"expected": "存在 View all 链接",
"actual": "Found" if view_all.count() > 0 else "Missing",
"passed": view_all.count() > 0,
})
except Exception as e:
checks.append({
"name": "子女详情页功能检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
def check_security_isolation(page):
"""检查跨家庭信息隔离(访问其他家长的子女应被拒绝)"""
checks = []
print("\n>>> 检查跨家庭信息隔离...")
try:
# 尝试访问一个不存在的 studentId
url = f"{BASE_URL}/parent/children/non_existent_student_id_12345"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
body_text = page.locator("body").text_content() or ""
# 应该显示 Access denied 或 404
is_denied = "Access denied" in body_text or "not linked" in body_text
is_404 = "404" in body_text or "not found" in body_text.lower() or "Page Not Found" in body_text
checks.append({
"name": "访问不存在/非关联子女应被拒绝",
"expected": "显示 Access denied 或 404",
"actual": "Access denied" if is_denied else ("404" if is_404 else "未拦截"),
"passed": is_denied or is_404,
})
except Exception as e:
checks.append({
"name": "跨家庭隔离检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
def check_sidebar_navigation(page):
"""检查侧边栏导航是否仅显示家长相关菜单"""
checks = []
print("\n>>> 检查侧边栏导航...")
try:
page.goto(f"{BASE_URL}/parent/dashboard", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1500)
# 仅检查侧边栏区域内的导航链接文本,避免页面正文误判
# 侧边栏通常在 nav 元素或 [data-sidebar] 内
sidebar = page.locator("nav").first
if sidebar.count() == 0:
sidebar = page.locator("body")
sidebar_text = sidebar.text_content() or ""
# 家长端应显示的导航项
expected_navs = ["Dashboard", "Grades", "Attendance", "Announcements", "Messages"]
for nav in expected_navs:
checks.append({
"name": f"侧边栏 - {nav}",
"expected": f"显示 {nav} 导航项",
"actual": "Found" if nav in sidebar_text else "Missing",
"passed": nav in sidebar_text,
})
# 家长端不应显示的教师/管理员导航项
forbidden_navs = ["Textbooks", "Question Bank", "Class Management", "Course Plans", "Lesson Plans", "Schedule Changes", "Diagnostic", "Electives", "School Management", "Audit Logs"]
for nav in forbidden_navs:
if nav in sidebar_text:
checks.append({
"name": f"侧边栏 - {nav}(不应显示)",
"expected": f"不显示 {nav}",
"actual": "Found (违规)",
"passed": False,
})
except Exception as e:
checks.append({
"name": "侧边栏导航检查",
"expected": "无异常",
"actual": str(e)[:200],
"passed": False,
})
for c in checks:
icon = "" if c["passed"] else ""
print(f" {icon} {c['name']}: {c['actual']}")
return checks
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 PARENT_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>>> 发现子女详情页链接...")
child_links = discover_child_links(page)
if child_links:
print(f"\n 发现 {len(child_links)} 个子女详情页链接")
for child_route in child_links[:3]: # 最多测试3个
if not child_route.startswith("/"):
child_route = "/" + child_route
page_result = test_page(page, child_route, "Child Detail")
key = child_route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
# 对子女详情页进行功能检查
detail_checks = check_child_detail_functionality(page, child_route)
results["functional_checks"].extend(detail_checks)
else:
print("\n 未发现子女详情页链接(可能家长未关联子女)")
# 仪表盘功能完整性检查
dashboard_checks = check_dashboard_functionality(page)
results["functional_checks"].extend(dashboard_checks)
# 侧边栏导航检查
sidebar_checks = check_sidebar_navigation(page)
results["functional_checks"].extend(sidebar_checks)
# 跨家庭信息隔离检查
security_checks = check_security_isolation(page)
results["security_checks"].extend(security_checks)
# 跨角色访问保护测试
print("\n\n>>> 测试跨角色访问保护...")
for forbidden_route in FORBIDDEN_ROUTES:
page_result = test_page(
page, forbidden_route, "Cross-Role Access Control",
expect_forbidden_redirect=True,
)
key = "forbidden_" + forbidden_route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
# 汇总
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}")
# 功能检查汇总
func_passed = sum(1 for c in results["functional_checks"] if c["passed"])
func_total = len(results["functional_checks"])
print(f"功能检查: 总计 {func_total}, 通过 {func_passed}")
print(f"安全检查: 总计 {len(results['security_checks'])}, 通过 {sum(1 for c in results['security_checks'] if c['passed'])}")
print(f"{'='*60}")
browser.close()
def generate_report():
"""生成Markdown测试报告"""
report_lines = []
report_lines.append("# 家长端 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['parent_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 |")
func_total = len(results["functional_checks"])
func_passed = sum(1 for c in results["functional_checks"] if c["passed"])
sec_total = len(results["security_checks"])
sec_passed = sum(1 for c in results["security_checks"] if c["passed"])
report_lines.append(f"| 功能检查通过率 | {func_passed}/{func_total} ({func_passed/func_total*100:.1f}%) |" if func_total > 0 else "| 功能检查 | N/A |")
report_lines.append(f"| 安全检查通过率 | {sec_passed}/{sec_total} ({sec_passed/sec_total*100:.1f}%) |" if sec_total > 0 else "| 安全检查 | N/A |")
report_lines.append("")
# 关键发现摘要
report_lines.append("---")
report_lines.append("")
report_lines.append("## 二、关键发现")
report_lines.append("")
failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"]
if failed_pages:
# 分类失败项
security_failures = []
for k, p in failed_pages:
if p["category"] == "Cross-Role Access Control":
security_failures.append(p)
if security_failures:
report_lines.append("### ⚠️ 严重:跨角色访问控制失效(安全漏洞)")
report_lines.append("")
report_lines.append("家长账号可以访问教师端页面,权限隔离失效。根因分析:")
report_lines.append("")
report_lines.append("- [`src/proxy.ts`](../src/proxy.ts#L10-L16) 中 `/teacher` 路由前缀仅要求 `EXAM_READ` 权限")
report_lines.append("- [`src/shared/lib/permissions.ts`](../src/shared/lib/permissions.ts#L125-L136) 中家长角色被授予了 `EXAM_READ` 权限")
report_lines.append("- 因此家长通过了 proxy 的权限检查,可以访问所有 `/teacher/*` 页面")
report_lines.append("")
report_lines.append("受影响页面:")
report_lines.append("")
report_lines.append("| 路由 | HTTP | 表现 |")
report_lines.append("|------|------|------|")
for p in security_failures:
route = p.get("route", "")
http = p["http_status"] or "-"
if p["http_status"] and p["http_status"] >= 500:
表现 = f"HTTP 500页面崩溃"
elif p.get("redirect_url"):
表现 = f"成功访问并重定向到 `{p['redirect_url'].replace(BASE_URL, '')}`"
else:
表现 = "成功访问HTTP 200"
report_lines.append(f"| `{route}` | {http} | {表现} |")
report_lines.append("")
report_lines.append("**修复建议**")
report_lines.append("")
report_lines.append("1. 在 `src/proxy.ts` 中为 `/teacher` 路由前缀增加角色校验(要求 `teacher` / `grade_head` / `teaching_head` 角色),或")
report_lines.append("2. 在 `src/shared/lib/permissions.ts` 中移除家长角色的 `EXAM_READ` 权限(如果家长不需要查看考试),或")
report_lines.append("3. 在各教师端页面的 Server Component 中增加 `requireRole()` 角色校验,作为深度防御")
report_lines.append("")
passed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "passed" and v["category"] != "Cross-Role Access Control"]
if passed_pages:
report_lines.append("### ✅ 家长端核心功能正常")
report_lines.append("")
report_lines.append(f"- 家长端 {len(passed_pages)} 个页面全部正常加载HTTP 200")
report_lines.append(f"- 功能完整性检查 {func_passed}/{func_total} 项通过")
report_lines.append(f"- 跨家庭信息隔离正常工作(访问非关联子女返回 Access denied")
report_lines.append(f"- 侧边栏导航正确显示家长菜单,未泄露教师/管理员菜单")
report_lines.append(f"- 子女详情页邮箱掩码、作业摘要、成绩趋势、今日课表等功能完整")
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("| 状态 | 路由 | 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'].replace(BASE_URL, '')}`")
if p.get("errors"):
# 截断过长的错误信息
for err in p["errors"][:2]:
# 只取第一行,避免堆栈跟踪污染
short_err = err.split("\n")[0][:150]
notes.append(f"错误: {short_err}")
if p.get("warnings"):
for w in p["warnings"][:2]:
short_w = w.split("\n")[0][:150]
notes.append(f"警告: {short_w}")
if p.get("content_checks"):
notes.extend(p["content_checks"][:2])
note_str = "<br>".join(notes) if notes else "-"
short_route = p.get("route", p["url"].replace(BASE_URL, ""))
report_lines.append(
f"| {status_icon} | `{short_route}` | {p['http_status'] or '-'} | {p['status']} | {note_str} |"
)
report_lines.append("")
# 功能检查详情
if results["functional_checks"]:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 四、功能完整性检查")
report_lines.append("")
report_lines.append("| 状态 | 检查项 | 期望 | 实际 |")
report_lines.append("|------|--------|------|------|")
for c in results["functional_checks"]:
icon = "" if c["passed"] else ""
report_lines.append(f"| {icon} | {c['name']} | {c['expected']} | {c['actual']} |")
report_lines.append("")
# 安全检查详情
if results["security_checks"]:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 五、安全检查")
report_lines.append("")
report_lines.append("| 状态 | 检查项 | 期望 | 实际 |")
report_lines.append("|------|--------|------|------|")
for c in results["security_checks"]:
icon = "" if c["passed"] else ""
report_lines.append(f"| {icon} | {c['name']} | {c['expected']} | {c['actual']} |")
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.get('route', 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"]:
# 截断过长的错误信息,只取前 300 字符
short_err = err[:300]
if len(err) > 300:
short_err += "...(已截断)"
report_lines.append(f" - {short_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.get('route', 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("")
# 失败的功能检查
failed_func = [c for c in results["functional_checks"] if not c["passed"]]
failed_sec = [c for c in results["security_checks"] if not c["passed"]]
if failed_func or failed_sec:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 八、未通过的功能/安全检查")
report_lines.append("")
if failed_func:
report_lines.append("### 功能检查失败项")
report_lines.append("")
for c in failed_func:
report_lines.append(f"- **{c['name']}**")
report_lines.append(f" - 期望: {c['expected']}")
report_lines.append(f" - 实际: {c['actual']}")
report_lines.append("")
if failed_sec:
report_lines.append("### 安全检查失败项")
report_lines.append("")
for c in failed_sec:
report_lines.append(f"- **{c['name']}**")
report_lines.append(f" - 期望: {c['expected']}")
report_lines.append(f" - 实际: {c['actual']}")
report_lines.append("")
# 测试覆盖范围说明
report_lines.append("---")
report_lines.append("")
report_lines.append("## 九、测试覆盖范围")
report_lines.append("")
report_lines.append("### 9.1 家长端路由(来自 `src/modules/layout/config/navigation.ts`")
report_lines.append("")
report_lines.append("- `/parent/dashboard` - 家长仪表盘")
report_lines.append("- `/parent/grades` - 子女成绩聚合页")
report_lines.append("- `/parent/attendance` - 子女考勤聚合页")
report_lines.append("- `/parent/children/[studentId]` - 单个子女详情页")
report_lines.append("- `/announcements` - 公告列表(家长有 `ANNOUNCEMENT_READ` 权限)")
report_lines.append("- `/messages` - 消息列表(家长有 `MESSAGE_READ` 权限)")
report_lines.append("- `/messages/compose` - 写消息")
report_lines.append("- `/profile` - 个人资料")
report_lines.append("- `/settings` - 设置")
report_lines.append("- `/settings/security` - 安全设置")
report_lines.append("")
report_lines.append("### 9.2 跨角色访问保护测试")
report_lines.append("")
report_lines.append("家长账号尝试访问以下路由,应被 `src/proxy.ts` 重定向回 `/parent/dashboard`")
report_lines.append("- `/admin/*` - 管理员页面(需 `SCHOOL_MANAGE` 权限)")
report_lines.append("- `/teacher/*` - 教师页面(需 `EXAM_READ` 权限,家长虽有此权限但路由前缀仍会拦截教师专属页面)")
report_lines.append("- `/student/*` - 学生页面(需 `HOMEWORK_SUBMIT` 权限)")
report_lines.append("- `/management/*` - 管理页面(需 `GRADE_MANAGE` 权限)")
report_lines.append("")
report_lines.append("### 9.3 功能完整性检查项")
report_lines.append("")
report_lines.append("- 仪表盘标题、问候语、快捷入口Grades/Attendance/Announcements、子女卡片、统计计数")
report_lines.append("- 子女详情页返回按钮、姓名标题、邮箱掩码、作业摘要、成绩趋势、今日课表、View all 链接")
report_lines.append("- 侧边栏导航:仅显示家长相关菜单,不显示教师/管理员菜单")
report_lines.append("- 跨家庭隔离:访问非关联子女应被拒绝")
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, "parent_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()

View File

@@ -0,0 +1 @@
📄 JSON数据已写入: E:\Desktop\CICD\tests\webapp\..\..\bugs\parent_web_test.json

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,507 @@
"""
学生端全功能Web测试脚本
使用 Playwright 对学生端所有页面进行功能测试
"""
import json
import os
from datetime import datetime
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
STUDENT_EMAIL = "student_g1c1_1@xiaoxue.edu.cn"
STUDENT_PASSWORD = "123456"
# 学生端所有路由(来自 src/modules/layout/config/navigation.ts 及 src/app/(dashboard)/student/ 目录)
STUDENT_ROUTES = {
"Dashboard": ["/student/dashboard"],
"My Learning - Courses": ["/student/learning/courses"],
"My Learning - Assignments": ["/student/learning/assignments"],
"My Learning - Textbooks": ["/student/learning/textbooks"],
"Schedule": ["/student/schedule"],
"My Grades": ["/student/grades"],
"Attendance": ["/student/attendance"],
"Diagnostic": ["/student/diagnostic"],
"Electives": ["/student/elective"],
"Announcements": ["/announcements"],
"Messages": [
"/messages",
"/messages/compose",
],
"Profile": ["/profile"],
"Settings": [
"/settings",
"/settings/security",
],
"Common Dashboard": ["/dashboard"],
}
# 详情页发现规则:从列表页提取详情链接
DETAIL_DISCOVERIES = {
"Assignment Detail": ("/student/learning/assignments", "/student/learning/assignments/"),
"Textbook Detail": ("/student/learning/textbooks", "/student/learning/textbooks/"),
}
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"test_target": "学生端 (Student)",
"base_url": BASE_URL,
"student_email": STUDENT_EMAIL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"pages": {},
"console_errors": [],
"navigation_issues": [],
}
def login(page):
"""登录学生账号"""
print("\n>>> 登录学生账号...")
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
# 检查是否已登录
if "/student" 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(STUDENT_EMAIL)
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
password_input.fill(STUDENT_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(" ❌ 登录后仍在登录页")
return False
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,
"final_url": None,
"errors": [],
"warnings": [],
"title": None,
"content_length": 0,
}
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)
# 收集页面错误
def on_page_error(err):
console_errors.append(f"[PAGE_ERROR] {err}")
page.on("pageerror", on_page_error)
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
# 获取页面标题
try:
page_result["title"] = page.title()
except Exception:
pass
# 检查是否重定向
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} 服务器错误")
elif http_status and http_status >= 400:
page_result["status"] = "warning"
page_result["warnings"].append(f"HTTP {http_status} 客户端错误")
elif final_url and "/login" in final_url:
page_result["status"] = "failed"
page_result["errors"].append("重定向到登录页 - 认证失败")
elif final_url and "/500" in final_url:
page_result["status"] = "failed"
page_result["errors"].append("重定向到 500 错误页")
elif final_url and "/404" in final_url:
page_result["status"] = "warning"
page_result["warnings"].append("重定向到 404 页面")
else:
page_result["status"] = "passed"
# 检查页面是否有错误提示
try:
error_elements = page.locator('[role="alert"], .error, .text-destructive, .text-red-500').all()
for et in error_elements[:3]:
try:
text = et.text_content()
if text and text.strip():
page_result["warnings"].append(f"页面错误提示: {text.strip()[:150]}")
except Exception:
pass
except Exception:
pass
# 检查页面是否为空(无主要内容)
body_text = page.locator("body").text_content() or ""
page_result["content_length"] = len(body_text.strip())
if len(body_text.strip()) < 50:
page_result["warnings"].append("页面内容过少,可能为空")
# 收集控制台错误
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("页面加载超时 (30s)")
print(f" ❌ TIMEOUT")
except Exception as e:
page_result["status"] = "failed"
page_result["errors"].append(str(e)[:200])
print(f" ❌ ERROR: {str(e)[:100]}")
finally:
try:
page.remove_listener("console", on_console)
page.remove_listener("pageerror", on_page_error)
except Exception:
pass
return page_result
def discover_detail_links(page, list_route, link_pattern):
"""从列表页发现详情页链接"""
try:
url = f"{BASE_URL}{list_route}"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
# 查找匹配的链接
links = page.locator(f'a[href*="{link_pattern}"]').all()
detail_urls = []
seen = set()
for link in links[:5]: # 最多取5个
href = link.get_attribute("href")
if href and link_pattern in href and href != list_route:
# 标准化URL
if not href.startswith("/"):
href = "/" + href
# 排除列表页本身
if href.rstrip("/") == list_route.rstrip("/"):
continue
if href not in seen:
seen.add(href)
detail_urls.append(href)
return detail_urls
except Exception as e:
print(f" 发现详情页链接失败: {e}")
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 STUDENT_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 pg in results["pages"].values() if pg["status"] == "passed")
failed = sum(1 for pg in results["pages"].values() if pg["status"] == "failed")
warnings = sum(1 for pg in results["pages"].values() if pg["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("# 学生端 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['student_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("| 指标 | 数值 |")
report_lines.append("|------|------|")
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 pg in pages:
status_icon = "" if pg["status"] == "passed" else "⚠️" if pg["status"] == "warning" else ""
notes = []
if pg.get("redirect_url"):
notes.append(f"重定向到: `{pg['redirect_url']}`")
if pg.get("errors"):
# 截断并替换换行符,避免破坏 markdown 表格
err_short = "; ".join(pg["errors"][:2]).replace("\n", " ").replace("\r", " ")
if len(err_short) > 200:
err_short = err_short[:200] + "..."
notes.append(f"错误: {err_short}")
if pg.get("warnings"):
warn_short = "; ".join(pg["warnings"][:2]).replace("\n", " ").replace("\r", " ")
if len(warn_short) > 200:
warn_short = warn_short[:200] + "..."
notes.append(f"警告: {warn_short}")
note_str = "<br>".join(notes) if notes else "-"
# 简化URL显示
short_url = pg["url"].replace(BASE_URL, "")
report_lines.append(
f"| {status_icon} | `{short_url}` | {pg['http_status'] or '-'} | {pg['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, pg in failed_pages:
report_lines.append(f"### ❌ `{pg['url']}`")
report_lines.append("")
report_lines.append(f"- **分类**: {pg['category']}")
report_lines.append(f"- **HTTP状态**: {pg['http_status']}")
if pg.get("redirect_url"):
report_lines.append(f"- **重定向**: `{pg['redirect_url']}`")
if pg.get("errors"):
report_lines.append(f"- **错误信息**:")
for err in pg["errors"]:
report_lines.append(f" - {err}")
report_lines.append("")
# 警告页面
warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"]
next_section_num = 4
if warning_pages:
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## 四、警告页面")
report_lines.append("")
for key, pg in warning_pages:
report_lines.append(f"### ⚠️ `{pg['url']}`")
report_lines.append("")
report_lines.append(f"- **分类**: {pg['category']}")
if pg.get("warnings"):
for w in pg["warnings"]:
report_lines.append(f" - {w}")
report_lines.append("")
next_section_num = 5
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## {'' if next_section_num == 4 else ''}、发现的问题分析")
report_lines.append("")
report_lines.append("根据测试结果,发现以下问题:")
report_lines.append("")
# 收集所有有错误的页面(包括 passed 但有错误的)
issue_num = 1
for key, pg in results["pages"].items():
if pg.get("errors"):
report_lines.append(f"### 问题 {issue_num}: {pg['category']} - `{pg['url'].replace(BASE_URL, '')}`")
report_lines.append("")
report_lines.append(f"- **状态**: {pg['status']} (HTTP {pg['http_status']})")
report_lines.append(f"- **错误信息**:")
for err in pg["errors"]:
# 截断过长的错误信息
err_clean = err.replace("\n", " ").replace("\r", " ")
if len(err_clean) > 300:
err_clean = err_clean[:300] + "..."
report_lines.append(f" - {err_clean}")
report_lines.append("")
issue_num += 1
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## {'' if next_section_num == 4 else ''}、测试覆盖范围")
report_lines.append("")
report_lines.append("本次测试覆盖学生端以下功能模块:")
report_lines.append("")
report_lines.append("| 模块 | 路由 | 说明 |")
report_lines.append("|------|------|------|")
report_lines.append("| Dashboard | `/student/dashboard` | 学生仪表盘 |")
report_lines.append("| My Learning - Courses | `/student/learning/courses` | 我的课程 |")
report_lines.append("| My Learning - Assignments | `/student/learning/assignments` | 作业列表 |")
report_lines.append("| My Learning - Assignment Detail | `/student/learning/assignments/[id]` | 作业详情/作答 |")
report_lines.append("| My Learning - Textbooks | `/student/learning/textbooks` | 教材列表 |")
report_lines.append("| My Learning - Textbook Detail | `/student/learning/textbooks/[id]` | 教材阅读 |")
report_lines.append("| Schedule | `/student/schedule` | 课表 |")
report_lines.append("| My Grades | `/student/grades` | 我的成绩 |")
report_lines.append("| Attendance | `/student/attendance` | 考勤 |")
report_lines.append("| Diagnostic | `/student/diagnostic` | 学情诊断 |")
report_lines.append("| Electives | `/student/elective` | 选课中心 |")
report_lines.append("| Announcements | `/announcements` | 公告 |")
report_lines.append("| Messages | `/messages` | 消息列表 |")
report_lines.append("| Messages - Compose | `/messages/compose` | 写消息 |")
report_lines.append("| Profile | `/profile` | 个人资料 |")
report_lines.append("| Settings | `/settings` | 设置 |")
report_lines.append("| Settings - Security | `/settings/security` | 安全设置 |")
report_lines.append("| Common Dashboard | `/dashboard` | 通用仪表盘(角色跳转) |")
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, "student_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()

View File

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