Files
NextEdu/tests/e2e/teacher-web-test.spec.ts
SpecialX 978d9a8309
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
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 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)
2026-06-22 01:06:16 +08:00

416 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 教师端全功能 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 []
}
}