""" 设置和个人信息模块 - 全功能 Web 测试脚本 测试范围: 1. /profile 页面(所有角色) 2. /settings 页面(所有角色,5 个标签页:通用/通知/外观/安全/AI) 3. /admin/settings 页面(仅管理员) 测试角色: - admin: admin@xiaoxue.edu.cn - teacher: t_chinese_1@xiaoxue.edu.cn - student: student_g1c1_1@xiaoxue.edu.cn - parent: parent_g1c1_1@xiaoxue.edu.cn 测试功能: - 页面加载与渲染 - 个人资料表单(ProfileSettingsForm) - 通知偏好表单(NotificationPreferencesForm) - 外观偏好(ThemePreferencesCard) - 修改密码(PasswordChangeForm) - 安全中心(SecurityCenterCard) - 头像上传(AvatarUpload) - 管理员系统设置(AdminSettingsView) - AI 服务商配置(AiProviderSettingsCard) - 学生/教师概览(ProfileStudentOverview / ProfileTeacherOverview) """ import json import os import sys import time import traceback from datetime import datetime from urllib.parse import urlparse, parse_qs from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout BASE_URL = "http://localhost:3000" # 测试账号 TEST_ACCOUNTS = { "admin": { "email": "admin@xiaoxue.edu.cn", "password": "123456", "label": "管理员", "dashboard": "/admin/dashboard", }, "teacher": { "email": "t_chinese_1@xiaoxue.edu.cn", "password": "123456", "label": "教师", "dashboard": "/teacher/dashboard", }, "student": { "email": "student_g1c1_1@xiaoxue.edu.cn", "password": "123456", "label": "学生", "dashboard": "/student/dashboard", }, "parent": { "email": "parent_g1c1_1@xiaoxue.edu.cn", "password": "123456", "label": "家长", "dashboard": "/parent/dashboard", }, } # 全局测试结果 results = { "test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "test_target": "设置和个人信息模块 (Settings & Profile)", "base_url": BASE_URL, "summary": { "total": 0, "passed": 0, "failed": 0, "warnings": 0, }, "roles": {}, "failures": [], } # 当前测试上下文 _current_role = None _current_test_category = None def _record(test_name, status, details="", errors=None, screenshots=None): """记录一条测试结果""" results["summary"]["total"] += 1 if status == "passed": results["summary"]["passed"] += 1 elif status == "failed": results["summary"]["failed"] += 1 results["failures"].append({ "role": _current_role, "category": _current_test_category, "test": test_name, "details": details, "errors": errors or [], }) elif status == "warning": results["summary"]["warnings"] += 1 role_data = results["roles"].setdefault(_current_role, { "label": TEST_ACCOUNTS.get(_current_role, {}).get("label", _current_role), "categories": {}, }) cat_data = role_data["categories"].setdefault(_current_test_category, { "tests": [], "passed": 0, "failed": 0, "warnings": 0, }) cat_data["tests"].append({ "name": test_name, "status": status, "details": details, "errors": errors or [], }) if status == "passed": cat_data["passed"] += 1 elif status == "failed": cat_data["failed"] += 1 elif status == "warning": cat_data["warnings"] += 1 icon = {"passed": "✅", "failed": "❌", "warning": "⚠️"}[status] print(f" {icon} [{_current_role}/{_current_test_category}] {test_name}: {status}" + (f" - {details[:120]}" if details else "")) def _is_login_redirect(url: str) -> bool: """判断 URL 是否为登录页重定向""" parsed = urlparse(url) path = parsed.path.rstrip("/") if path.endswith("/login"): return True query = parse_qs(parsed.query) callback = query.get("callbackUrl", [""])[0] if callback and "/login" in callback: return True return False def login(page, role_key): """登录指定角色""" account = TEST_ACCOUNTS[role_key] print(f"\n>>> 登录 {account['label']} ({account['email']})...") page.goto(f"{BASE_URL}/login", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) # 已登录检查 if account["dashboard"] 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"]') email_input.wait_for(state="visible", timeout=5000) email_input.fill(account["email"]) password_input = page.locator('input[name="password"]') if password_input.count() == 0: password_input = page.locator('input[type="password"]') password_input.wait_for(state="visible", timeout=5000) password_input.fill(account["password"]) # 登录按钮 - 表单内第一个 button 元素(默认 type=submit) login_btn = page.locator('form button').first if login_btn.count() == 0: login_btn = page.locator('button:has-text("Sign In with Email")').first if login_btn.count() == 0: login_btn = page.locator('button:has-text("Sign In")').first if login_btn.count() == 0: login_btn = page.locator('button:has-text("登录")').first login_btn.click() # 等待 URL 离开 /login(signIn 是异步的,router.push 后才跳转) try: page.wait_for_url(lambda url: "/login" not in url, timeout=15000) except PlaywrightTimeout: print(f" ❌ 登录超时,仍在: {page.url}") return False page.wait_for_timeout(2000) if _is_login_redirect(page.url): print(f" ❌ 登录失败,仍在登录页: {page.url}") return False print(f" 登录成功,URL: {page.url}") # 如果被重定向到 /onboarding,完成引导流程 if "/onboarding" in page.url: print(" 检测到未完成引导,开始完成 onboarding...") if not complete_onboarding(page, role_key): print(" ❌ Onboarding 完成失败") return False print(f" Onboarding 完成,URL: {page.url}") return True except Exception as e: print(f" ❌ 登录异常: {e}") return False def complete_onboarding(page, role_key): """完成 onboarding 引导流程""" try: page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # Step 0: 角色确认 - 点击 "下一步" next_btn = page.locator("button:has-text('下一步')").first if next_btn.count() == 0: next_btn = page.locator("button:has-text('Next')").first if next_btn.count() == 0: print(" 未找到 '下一步' 按钮(可能已在其他步骤)") else: next_btn.click() page.wait_for_timeout(1000) # Step 1: 基础信息 - 填写姓名和电话 name_input = page.locator('#onb_name').first if name_input.count() > 0: account = TEST_ACCOUNTS[role_key] # 从邮箱前缀提取用户名 default_name = account["email"].split("@")[0] name_input.fill(default_name) phone_input = page.locator('#onb_phone').first if phone_input.count() > 0: phone_input.fill("13800000000") # 点击 "下一步" next_btn = page.locator("button:has-text('下一步')").first if next_btn.count() > 0: next_btn.click() page.wait_for_timeout(1000) # Step 2: 角色信息 if role_key == "parent": # 家长需要绑定子女 - 填写子女信息 child_email_input = page.locator('#onb_child_email_0').first if child_email_input.count() > 0: child_email_input.fill("student_g1c1_1@xiaoxue.edu.cn") child_birth_input = page.locator('#onb_child_birth_0').first if child_birth_input.count() > 0: # 从 seed.ts: new Date(`2018-01-15`) for G1C1_1 child_birth_input.fill("2018-01-15") child_phone_input = page.locator('#onb_child_phone_0').first if child_phone_input.count() > 0: # 学生的 guardianPhone 是 13800000001,但绑定需要学生的 phone # seed 中学生没有 phone 字段,这里尝试用 guardianPhone 后4位 child_phone_input.fill("0001") # 点击 "下一步" next_btn = page.locator("button:has-text('下一步')").first if next_btn.count() > 0: next_btn.click() page.wait_for_timeout(1500) else: # 非家长角色可以跳过 Step 2 skip_btn = page.locator("button:has-text('跳过')").first if skip_btn.count() > 0: skip_btn.click() page.wait_for_timeout(1000) else: # 如果没有跳过按钮,尝试点击下一步 next_btn = page.locator("button:has-text('下一步')").first if next_btn.count() > 0: next_btn.click() page.wait_for_timeout(1000) # Step 3 (or Step 2 for admin): 完成 - 点击 "完成" finish_btn = page.locator("button:has-text('完成')").first if finish_btn.count() == 0: finish_btn = page.locator("button:has-text('Finish')").first if finish_btn.count() == 0: print(" 未找到 '完成' 按钮") return False finish_btn.click() # 等待跳转到 dashboard try: page.wait_for_url(lambda url: "/onboarding" not in url, timeout=15000) except PlaywrightTimeout: print(f" Onboarding 完成后未跳转,URL: {page.url}") # 检查是否有错误 toast error_toast = page.locator("[data-sonner-toast]:has-text('失败')") if error_toast.count() > 0: toast_text = error_toast.first.text_content() or "" print(f" Onboarding 错误: {toast_text[:100]}") return False page.wait_for_timeout(2000) return True except Exception as e: print(f" Onboarding 异常: {e}") return False def logout(page): """退出登录""" try: page.goto(f"{BASE_URL}/login", timeout=15000) page.wait_for_load_state("networkidle", timeout=10000) # 清除 cookies page.context.clear_cookies() page.wait_for_timeout(500) except Exception: pass def safe_click(page, selector, timeout=5000): """安全点击元素""" try: el = page.locator(selector).first el.wait_for(state="visible", timeout=timeout) el.click(timeout=timeout) return True except Exception: return False def safe_fill(page, selector, value, timeout=5000): """安全填充输入框""" try: el = page.locator(selector).first el.wait_for(state="visible", timeout=timeout) el.fill(value, timeout=timeout) return True except Exception: return False def element_exists(page, selector, timeout=3000): """检查元素是否存在""" try: page.locator(selector).first.wait_for(state="visible", timeout=timeout) return True except Exception: return False def wait_for_tab_content(page, content_marker, timeout=10000): """等待标签页内容加载完成(骨架屏消失,实际内容出现) Args: page: Playwright page content_marker: 内容标记文本(如 "保存修改")或 CSS 选择器 timeout: 超时时间(毫秒) """ try: # 等待内容出现 page.locator(f"text={content_marker}").first.wait_for(state="visible", timeout=timeout) return True except Exception: # 检查是否仍在加载(骨架屏) skeleton = page.locator(".animate-pulse, [class*='skeleton']") if skeleton.count() > 0: # 等待更长时间 try: page.locator(f"text={content_marker}").first.wait_for(state="visible", timeout=10000) return True except Exception: return False # 检查是否 ErrorBoundary 显示错误 body_text = page.locator("body").text_content() or "" if "加载失败" in body_text or "load failed" in body_text.lower(): return False return False # ============================================================ # 测试用例 - /profile 页面 # ============================================================ def test_profile_page_loads(page, role_key): """测试 /profile 页面加载""" global _current_test_category _current_test_category = "Profile 页面" try: response = page.goto(f"{BASE_URL}/profile", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) http_status = response.status if response else None if http_status and http_status >= 400: _record("页面加载", "failed", f"HTTP {http_status}") return if _is_login_redirect(page.url): _record("页面加载", "failed", "重定向到登录页") return # 检查页面标题 - "个人资料" body_text = page.locator("body").text_content() or "" if "个人资料" not in body_text and "Profile" not in body_text: _record("页面加载", "failed", "页面缺少 '个人资料' 标题") return _record("页面加载", "passed", f"HTTP {http_status}") except Exception as e: _record("页面加载", "failed", str(e)) def test_profile_personal_info_card(page, role_key): """测试个人信息卡片显示""" global _current_test_category _current_test_category = "Profile 页面" try: # 检查 "个人信息" 卡片 body_text = page.locator("body").text_content() or "" if "个人信息" not in body_text: _record("个人信息卡片", "failed", "未找到 '个人信息' 卡片") return # 检查关键字段 required_fields = ["姓名", "性别", "电话", "地址"] missing = [f for f in required_fields if f not in body_text] if missing: _record("个人信息卡片", "failed", f"缺少字段: {missing}") return _record("个人信息卡片", "passed", "所有字段显示正常") except Exception as e: _record("个人信息卡片", "failed", str(e)) def test_profile_account_info_card(page, role_key): """测试账户信息卡片显示""" global _current_test_category _current_test_category = "Profile 页面" try: body_text = page.locator("body").text_content() or "" if "账户信息" not in body_text: _record("账户信息卡片", "failed", "未找到 '账户信息' 卡片") return required_fields = ["邮箱", "角色", "注册时间"] missing = [f for f in required_fields if f not in body_text] if missing: _record("账户信息卡片", "failed", f"缺少字段: {missing}") return # 检查邮箱格式 if "@" not in body_text: _record("账户信息卡片", "warning", "未检测到邮箱格式") _record("账户信息卡片", "passed", "所有字段显示正常") except Exception as e: _record("账户信息卡片", "failed", str(e)) def test_profile_avatar_upload_component(page, role_key): """测试头像上传组件显示""" global _current_test_category _current_test_category = "Profile 页面" try: # 头像上传按钮 - "上传头像" upload_btn = page.locator("button:has-text('上传头像')") if upload_btn.count() == 0: # 尝试英文 upload_btn = page.locator("button:has-text('Upload')") if upload_btn.count() == 0: _record("头像上传组件", "failed", "未找到 '上传头像' 按钮") return # 检查提示文字 body_text = page.locator("body").text_content() or "" if "JPG" not in body_text and "2MB" not in body_text: _record("头像上传组件", "warning", "未找到文件格式/大小提示") _record("头像上传组件", "passed", "组件渲染正常") except Exception as e: _record("头像上传组件", "failed", str(e)) def test_profile_role_overview(page, role_key): """测试角色专属概览(学生/教师)""" global _current_test_category _current_test_category = "Profile 页面" try: body_text = page.locator("body").text_content() or "" if role_key == "student": if "学生概览" not in body_text: _record("学生概览", "failed", "未找到 '学生概览' 区块") return _record("学生概览", "passed", "学生概览区块显示") elif role_key == "teacher": if "教师概览" not in body_text: _record("教师概览", "failed", "未找到 '教师概览' 区块") return # 检查 "任教科目" / "任教班级" if "任教科目" not in body_text and "任教班级" not in body_text: _record("教师概览", "warning", "未找到任教科目/班级字段") else: _record("教师概览", "passed", "教师概览区块显示") else: _record("角色概览", "passed", f"{role_key} 角色无专属概览,跳过") except Exception as e: _record("角色概览", "failed", str(e)) def test_profile_edit_link(page, role_key): """测试 '编辑资料' 链接跳转""" global _current_test_category _current_test_category = "Profile 页面" try: edit_link = page.locator("a:has-text('编辑资料')") if edit_link.count() == 0: edit_link = page.locator("a:has-text('Edit')") if edit_link.count() == 0: _record("编辑资料链接", "failed", "未找到 '编辑资料' 链接") return href = edit_link.get_attribute("href") if href != "/settings": _record("编辑资料链接", "warning", f"链接 href 异常: {href}") else: _record("编辑资料链接", "passed", f"链接指向 {href}") except Exception as e: _record("编辑资料链接", "failed", str(e)) # ============================================================ # 测试用例 - /settings 页面 # ============================================================ def test_settings_page_loads(page, role_key): """测试 /settings 页面加载""" global _current_test_category _current_test_category = "Settings 页面" try: response = page.goto(f"{BASE_URL}/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) http_status = response.status if response else None if http_status and http_status >= 400: _record("页面加载", "failed", f"HTTP {http_status}") return if _is_login_redirect(page.url): _record("页面加载", "failed", "重定向到登录页") return body_text = page.locator("body").text_content() or "" if "设置" not in body_text and "Settings" not in body_text: _record("页面加载", "failed", "页面缺少 '设置' 标题") return _record("页面加载", "passed", f"HTTP {http_status}") except Exception as e: _record("页面加载", "failed", str(e)) def test_settings_tabs_visible(page, role_key): """测试设置页标签页显示""" global _current_test_category _current_test_category = "Settings 页面" try: # 4 个标准标签页(所有角色可见) standard_tabs = ["通用", "通知", "外观", "安全"] body_text = page.locator("body").text_content() or "" missing = [t for t in standard_tabs if t not in body_text] if missing: _record("标准标签页", "failed", f"缺少标签页: {missing}") return # AI 标签页仅管理员可见 if role_key == "admin": if "AI" not in body_text: _record("AI 标签页", "failed", "管理员未见 AI 标签页") return _record("标签页显示", "passed", "所有标签页显示正常") except Exception as e: _record("标签页显示", "failed", str(e)) def test_settings_general_tab_profile_form(page, role_key): """测试通用标签页 - 个人资料表单""" global _current_test_category _current_test_category = "Settings - 通用标签页" try: # 确保在通用标签页 page.goto(f"{BASE_URL}/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 检查 "个人信息" 表单标题 body_text = page.locator("body").text_content() or "" if "个人信息" not in body_text and "个人资料" not in body_text: _record("个人资料表单显示", "failed", "未找到个人资料表单") return # 检查表单字段 required_fields = ["姓名", "邮箱", "电话", "性别", "年龄", "地址"] missing = [f for f in required_fields if f not in body_text] if missing: _record("个人资料表单字段", "failed", f"缺少字段: {missing}") return # 检查 "保存修改" 按钮 save_btn = page.locator("button:has-text('保存修改')") if save_btn.count() == 0: save_btn = page.locator("button[type='submit']:has-text('Save')") if save_btn.count() == 0: _record("保存按钮", "failed", "未找到 '保存修改' 按钮") return # 检查邮箱字段是否禁用 email_input = page.locator('input[name="email"]') if email_input.count() > 0: is_disabled = email_input.is_disabled() if not is_disabled: _record("邮箱字段禁用", "warning", "邮箱字段未禁用") else: _record("邮箱字段禁用", "passed", "邮箱字段已禁用") _record("个人资料表单显示", "passed", "表单字段完整") except Exception as e: _record("个人资料表单显示", "failed", str(e)) def test_settings_general_tab_profile_update(page, role_key): """测试通用标签页 - 个人资料表单提交""" global _current_test_category _current_test_category = "Settings - 通用标签页" try: page.goto(f"{BASE_URL}/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 修改姓名字段(添加测试标记后恢复) name_input = page.locator('input[name="name"]').first name_input.wait_for(state="visible", timeout=5000) original_name = name_input.input_value() test_name = original_name + "_test" if not original_name.endswith("_test") else original_name.replace("_test", "") name_input.fill(test_name) # 点击保存 save_btn = page.locator("button:has-text('保存修改')").first save_btn.click() # 等待 toast 出现 page.wait_for_timeout(3000) # 检查是否出现成功 toast success_toast = page.locator("[data-sonner-toast]:has-text('成功')") if success_toast.count() == 0: success_toast = page.locator("[data-sonner-toast]:has-text('success')") if success_toast.count() == 0: # 检查失败 toast fail_toast = page.locator("[data-sonner-toast]:has-text('失败')") if fail_toast.count() > 0: toast_text = fail_toast.first.text_content() or "" _record("个人资料表单提交", "failed", f"保存失败: {toast_text[:100]}") # 恢复原始姓名 name_input.fill(original_name) return _record("个人资料表单提交", "warning", "未检测到成功/失败 toast") else: _record("个人资料表单提交", "passed", "保存成功") # 恢复原始姓名 name_input.fill(original_name) save_btn = page.locator("button:has-text('保存修改')").first save_btn.click() page.wait_for_timeout(2000) except Exception as e: _record("个人资料表单提交", "failed", str(e)) def test_settings_general_tab_quick_links(page, role_key): """测试通用标签页 - 角色快捷链接""" global _current_test_category _current_test_category = "Settings - 通用标签页" try: page.goto(f"{BASE_URL}/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" if role_key == "admin": # 管理员无快捷链接 _record("角色快捷链接", "passed", "管理员无快捷链接(符合预期)") elif role_key == "teacher": if "快捷链接" not in body_text: _record("角色快捷链接", "failed", "未找到 '快捷链接' 区块") return if "仪表盘" not in body_text or "教材" not in body_text: _record("角色快捷链接", "warning", "教师快捷链接不完整") else: _record("角色快捷链接", "passed", "教师快捷链接显示") elif role_key == "student": if "快捷链接" not in body_text: _record("角色快捷链接", "failed", "未找到 '快捷链接' 区块") return _record("角色快捷链接", "passed", "学生快捷链接显示") elif role_key == "parent": if "快捷链接" not in body_text: _record("角色快捷链接", "failed", "未找到 '快捷链接' 区块") return _record("角色快捷链接", "passed", "家长快捷链接显示") except Exception as e: _record("角色快捷链接", "failed", str(e)) def test_settings_notifications_tab(page, role_key): """测试通知标签页""" global _current_test_category _current_test_category = "Settings - 通知标签页" try: page.goto(f"{BASE_URL}/settings?tab=notifications", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 检查通知偏好标题 if "通知偏好" not in body_text: _record("通知偏好显示", "failed", "未找到 '通知偏好' 标题") return # 检查渠道 channels = ["推送通知", "邮件", "短信"] missing = [c for c in channels if c not in body_text] if missing: _record("通知渠道显示", "failed", f"缺少渠道: {missing}") return # 检查类别 categories = ["消息", "公告", "作业", "成绩", "考勤"] missing = [c for c in categories if c not in body_text] if missing: _record("通知类别显示", "failed", f"缺少类别: {missing}") return # 检查免打扰时段 if "免打扰时段" not in body_text: _record("免打扰时段显示", "failed", "未找到 '免打扰时段' 区块") return # 检查保存按钮状态(dirty 检测) save_btn = page.locator("button:has-text('保存偏好')").first if save_btn.count() == 0: _record("保存按钮显示", "failed", "未找到 '保存偏好' 按钮") return # 初始状态应禁用(dirty 检测) is_disabled = save_btn.is_disabled() if not is_disabled: _record("保存按钮 dirty 检测", "warning", "初始状态保存按钮未禁用") else: _record("保存按钮 dirty 检测", "passed", "初始状态保存按钮已禁用") _record("通知偏好显示", "passed", "所有区块显示正常") except Exception as e: _record("通知偏好显示", "failed", str(e)) def test_settings_notifications_toggle(page, role_key): """测试通知标签页 - 切换开关""" global _current_test_category _current_test_category = "Settings - 通知标签页" try: page.goto(f"{BASE_URL}/settings?tab=notifications", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 找到第一个 Switch 并切换 switches = page.locator('[role="switch"]') if switches.count() == 0: _record("切换通知开关", "failed", "未找到任何开关") return first_switch = switches.first initial_state = first_switch.get_attribute("aria-checked") == "true" first_switch.click() page.wait_for_timeout(500) new_state = first_switch.get_attribute("aria-checked") == "true" if new_state == initial_state: _record("切换通知开关", "failed", "开关状态未变化") return # 检查保存按钮是否启用 save_btn = page.locator("button:has-text('保存偏好')").first if save_btn.is_disabled(): _record("切换通知开关", "warning", "切换后保存按钮仍禁用") else: _record("切换通知开关", "passed", "开关切换成功,保存按钮启用") # 恢复初始状态 first_switch.click() page.wait_for_timeout(500) except Exception as e: _record("切换通知开关", "failed", str(e)) def test_settings_appearance_tab(page, role_key): """测试外观标签页""" global _current_test_category _current_test_category = "Settings - 外观标签页" try: page.goto(f"{BASE_URL}/settings?tab=appearance", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 检查主题标题 if "主题" not in body_text: _record("主题卡片显示", "failed", "未找到 '主题' 标题") return # 检查主题选项 themes = ["跟随系统", "浅色", "深色"] missing = [t for t in themes if t not in body_text] if missing: _record("主题选项显示", "failed", f"缺少主题选项: {missing}") return # 检查语言切换 if "语言" not in body_text: _record("语言切换显示", "failed", "未找到 '语言' 区块") return _record("外观偏好显示", "passed", "主题和语言切换显示正常") except Exception as e: _record("外观偏好显示", "failed", str(e)) def test_settings_appearance_theme_switch(page, role_key): """测试外观标签页 - 主题切换""" global _current_test_category _current_test_category = "Settings - 外观标签页" try: page.goto(f"{BASE_URL}/settings?tab=appearance", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 点击主题选择器(使用 #theme 定位,避免误选语言切换器) # ThemePreferencesCard 中 SelectTrigger 的 id="theme" theme_select = page.locator('#theme').first if theme_select.count() == 0: _record("主题切换", "failed", "未找到主题选择器 (#theme)") return # #theme 是 SelectTrigger,点击它打开下拉 theme_select.click() # 等待 "深色" 选项出现(确保所有选项已渲染) try: page.locator('[role="option"]:has-text("深色"), [role="option"]:has-text("Dark")').first.wait_for( state="visible", timeout=8000 ) except Exception: page.wait_for_timeout(2000) # 检查下拉选项 options = page.locator('[role="option"]') option_count = options.count() if option_count < 3: # 再等待一次 page.wait_for_timeout(1500) option_count = options.count() if option_count < 3: _record("主题切换", "failed", f"主题选项数量异常: {option_count}") return # 选择 "深色" dark_option = page.locator('[role="option"]:has-text("深色")') if dark_option.count() == 0: dark_option = page.locator('[role="option"]:has-text("Dark")') if dark_option.count() == 0: _record("主题切换", "warning", "未找到 '深色' 选项") return dark_option.click() page.wait_for_timeout(1000) # 检查 html class 是否包含 dark html_class = page.locator("html").get_attribute("class") or "" if "dark" not in html_class: _record("主题切换", "warning", "切换后 html 未添加 dark class") else: _record("主题切换", "passed", "深色主题切换成功") # 切回系统主题 theme_select.click() page.locator('[role="option"]').first.wait_for(state="visible", timeout=5000) system_option = page.locator('[role="option"]:has-text("跟随系统")') if system_option.count() == 0: system_option = page.locator('[role="option"]:has-text("System")') if system_option.count() > 0: system_option.click() page.wait_for_timeout(500) except Exception as e: _record("主题切换", "failed", str(e)) def test_settings_security_tab(page, role_key): """测试安全标签页""" global _current_test_category _current_test_category = "Settings - 安全标签页" try: page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 检查修改密码表单 if "修改密码" not in body_text: _record("修改密码表单显示", "failed", "未找到 '修改密码' 标题") return # 检查密码字段 required_fields = ["当前密码", "新密码", "确认新密码"] missing = [f for f in required_fields if f not in body_text] if missing: _record("密码字段显示", "failed", f"缺少字段: {missing}") return # 检查安全中心 if "安全中心" not in body_text: _record("安全中心显示", "failed", "未找到 '安全中心' 区块") return # 检查 2FA 区块 if "两步验证" not in body_text and "2FA" not in body_text: _record("2FA 区块显示", "failed", "未找到 2FA 区块") return # 检查 "即将推出" 提示(2FA 应为禁用状态) if "即将推出" not in body_text and "coming soon" not in body_text.lower(): _record("2FA 禁用状态", "warning", "未找到 '即将推出' 提示") # 检查最近登录 if "最近登录" not in body_text: _record("最近登录显示", "failed", "未找到 '最近登录' 区块") return # 检查会话区块 if "会话" not in body_text: _record("会话区块显示", "failed", "未找到 '会话' 区块") return # 检查 "退出登录" 按钮 signout_btn = page.locator("button:has-text('退出登录')") if signout_btn.count() == 0: _record("退出登录按钮", "failed", "未找到 '退出登录' 按钮") return _record("安全标签页显示", "passed", "所有区块显示正常") except Exception as e: _record("安全标签页显示", "failed", str(e)) def test_settings_security_2fa_disabled(page, role_key): """测试安全标签页 - 2FA 开关禁用状态""" global _current_test_category _current_test_category = "Settings - 安全标签页" try: page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 找到 2FA 的 Switch(第一个 Switch 在 2FA 区块) switches = page.locator('[role="switch"]') if switches.count() == 0: _record("2FA 开关禁用", "failed", "未找到任何开关") return # 2FA 开关应为禁用状态 first_switch = switches.first is_disabled = first_switch.is_disabled() if not is_disabled: _record("2FA 开关禁用", "failed", "2FA 开关未禁用(应为 '即将推出' 状态)") return _record("2FA 开关禁用", "passed", "2FA 开关已禁用('即将推出' 状态)") except Exception as e: _record("2FA 开关禁用", "failed", str(e)) def test_settings_security_password_validation(page, role_key): """测试安全标签页 - 密码修改表单验证""" global _current_test_category _current_test_category = "Settings - 安全标签页" try: page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 填写错误的当前密码 current_input = page.locator('input[name="currentPassword"]').first current_input.wait_for(state="visible", timeout=5000) current_input.fill("wrongpassword") new_input = page.locator('input[name="newPassword"]').first new_input.fill("NewPass123!") confirm_input = page.locator('input[name="confirmPassword"]').first confirm_input.fill("NewPass123!") # 点击更新密码 submit_btn = page.locator("button:has-text('更新密码')").first submit_btn.click() # 等待 toast page.wait_for_timeout(3000) # 应该出现失败 toast(当前密码错误) fail_toast = page.locator("[data-sonner-toast]:has-text('失败')") if fail_toast.count() == 0: fail_toast = page.locator("[data-sonner-toast]:has-text('错误')") if fail_toast.count() == 0: fail_toast = page.locator("[data-sonner-toast]:has-text('incorrect')") if fail_toast.count() == 0: _record("密码修改验证", "warning", "未检测到失败 toast(可能密码错误未触发)") else: _record("密码修改验证", "passed", "错误密码触发失败提示") except Exception as e: _record("密码修改验证", "failed", str(e)) def test_settings_security_revoke_sessions_button(page, role_key): """测试安全标签页 - 登出其他会话按钮""" global _current_test_category _current_test_category = "Settings - 安全标签页" try: page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 检查 "登出所有其他会话" 按钮 revoke_btn = page.locator("button:has-text('登出所有其他会话')") if revoke_btn.count() == 0: # 可能没有登录历史记录,按钮不显示 body_text = page.locator("body").text_content() or "" if "暂无登录记录" in body_text or "empty" in body_text.lower(): _record("登出其他会话按钮", "passed", "无登录记录,按钮未显示(符合预期)") return _record("登出其他会话按钮", "warning", "未找到 '登出所有其他会话' 按钮") return _record("登出其他会话按钮", "passed", "按钮显示正常") except Exception as e: _record("登出其他会话按钮", "failed", str(e)) def test_settings_security_password_strength(page, role_key): """测试安全标签页 - 密码强度指示器""" global _current_test_category _current_test_category = "Settings - 安全标签页" try: page.goto(f"{BASE_URL}/settings?tab=security", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) new_input = page.locator('input[name="newPassword"]').first new_input.wait_for(state="visible", timeout=5000) # 输入弱密码 new_input.fill("123") page.wait_for_timeout(500) body_text = page.locator("body").text_content() or "" if "弱" not in body_text and "Weak" not in body_text: _record("密码强度指示器", "warning", "弱密码未显示 '弱' 标签") else: # 输入强密码 new_input.fill("StrongP@ssw0rd2024!") page.wait_for_timeout(500) body_text = page.locator("body").text_content() or "" if "强" not in body_text and "Strong" not in body_text: _record("密码强度指示器", "warning", "强密码未显示 '强' 标签") else: _record("密码强度指示器", "passed", "强度指示器工作正常") return _record("密码强度指示器", "passed", "强度指示器显示") except Exception as e: _record("密码强度指示器", "failed", str(e)) def test_settings_ai_tab_admin_only(page, role_key): """测试 AI 标签页 - 仅管理员可见""" global _current_test_category _current_test_category = "Settings - AI 标签页" try: page.goto(f"{BASE_URL}/settings?tab=ai", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) if role_key == "admin": # 管理员应能看到 AI 服务商配置 # 等待 AI 卡片标题出现(CardTitle 渲染 "AI 服务商") try: page.locator('text=AI 服务商').first.wait_for(state="visible", timeout=10000) _record("AI 标签页显示", "passed", "管理员可见 AI 服务商配置") except Exception: # 也检查英文 try: page.locator('text=AI Provider').first.wait_for(state="visible", timeout=3000) _record("AI 标签页显示", "passed", "管理员可见 AI 服务商配置") except Exception: _record("AI 标签页显示", "failed", "管理员未见 AI 服务商配置") return else: # 非管理员应回退到 general 标签页(不应看到 AI 内容) # 检查可见的 tabpanel 内容 visible_panel = page.locator('[role="tabpanel"]:visible').text_content() or "" if "AI 服务商" in visible_panel: _record("AI 标签页权限", "failed", f"{role_key} 角色不应能访问 AI 标签页") return # 也检查 AI tab trigger 是否不存在 ai_trigger = page.locator('[role="tab"][value="ai"]') if ai_trigger.count() > 0: _record("AI 标签页权限", "failed", f"{role_key} 角色不应看到 AI 标签页按钮") return _record("AI 标签页权限", "passed", f"{role_key} 角色无法访问 AI 标签页(符合预期)") except Exception as e: _record("AI 标签页显示", "failed", str(e)) # ============================================================ # 测试用例 - /admin/settings 页面 # ============================================================ def test_admin_settings_page_loads(page, role_key): """测试 /admin/settings 页面加载""" global _current_test_category _current_test_category = "Admin Settings 页面" try: response = page.goto(f"{BASE_URL}/admin/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) http_status = response.status if response else None if role_key != "admin": # 非管理员应被拒绝访问 if _is_login_redirect(page.url) or (http_status and http_status >= 400): _record("权限控制", "passed", f"{role_key} 角色被拒绝访问(符合预期)") return # 检查是否显示 403/404 body_text = page.locator("body").text_content() or "" if "403" in body_text or "Forbidden" in body_text or "无权限" in body_text: _record("权限控制", "passed", f"{role_key} 角色被拒绝访问") return _record("权限控制", "warning", f"{role_key} 角色访问结果未明确: HTTP {http_status}, URL: {page.url}") return # 管理员应能访问 if http_status and http_status >= 400: _record("页面加载", "failed", f"HTTP {http_status}") return if _is_login_redirect(page.url): _record("页面加载", "failed", "重定向到登录页") return body_text = page.locator("body").text_content() or "" if "系统设置" not in body_text: _record("页面加载", "failed", "未找到 '系统设置' 标题") return _record("页面加载", "passed", f"HTTP {http_status}") except Exception as e: _record("页面加载", "failed", str(e)) def test_admin_settings_cards_display(page, role_key): """测试管理员系统设置 - 4 个卡片显示""" global _current_test_category _current_test_category = "Admin Settings 页面" if role_key != "admin": _record("卡片显示", "passed", f"{role_key} 角色跳过") return try: page.goto(f"{BASE_URL}/admin/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) body_text = page.locator("body").text_content() or "" required_cards = ["学校信息", "安全策略", "文件上传", "通知配置"] missing = [c for c in required_cards if c not in body_text] if missing: _record("卡片显示", "failed", f"缺少卡片: {missing}") return # 检查学校信息字段 school_fields = ["学校名称", "学校代码", "联系电话", "联系邮箱", "学校地址", "学校简介"] missing = [f for f in school_fields if f not in body_text] if missing: _record("学校信息字段", "failed", f"缺少字段: {missing}") return # 检查安全策略字段 security_fields = ["密码最小长度", "会话超时", "密码必须包含特殊字符", "密码必须包含大写字母", "首次登录强制修改密码"] missing = [f for f in security_fields if f not in body_text] if missing: _record("安全策略字段", "failed", f"缺少字段: {missing}") return # 检查文件上传字段 upload_fields = ["单文件最大大小", "允许的文件类型"] missing = [f for f in upload_fields if f not in body_text] if missing: _record("文件上传字段", "failed", f"缺少字段: {missing}") return # 检查通知配置字段 notify_fields = ["新用户注册通知管理员", "课表变更通知教师", "公告发布通知目标用户"] missing = [f for f in notify_fields if f not in body_text] if missing: _record("通知配置字段", "failed", f"缺少字段: {missing}") return _record("卡片显示", "passed", "4 个卡片及所有字段显示正常") except Exception as e: _record("卡片显示", "failed", str(e)) def test_admin_settings_dirty_detection(page, role_key): """测试管理员系统设置 - dirty 检测""" global _current_test_category _current_test_category = "Admin Settings 页面" if role_key != "admin": _record("dirty 检测", "passed", f"{role_key} 角色跳过") return try: page.goto(f"{BASE_URL}/admin/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 初始状态保存按钮应禁用 save_btn = page.locator("button:has-text('保存设置')").first if save_btn.count() == 0: save_btn = page.locator("button[type='submit']").first if save_btn.count() == 0: _record("dirty 检测", "failed", "未找到保存按钮") return is_disabled = save_btn.is_disabled() if not is_disabled: _record("dirty 检测", "warning", "初始状态保存按钮未禁用") else: # 修改一个字段 school_name_input = page.locator('#school-name').first if school_name_input.count() == 0: _record("dirty 检测", "failed", "未找到学校名称输入框") return original_value = school_name_input.input_value() school_name_input.fill(original_value + "_test") page.wait_for_timeout(500) # 保存按钮应启用 is_disabled = save_btn.is_disabled() if is_disabled: _record("dirty 检测", "failed", "修改后保存按钮仍禁用") else: # 重置按钮也应启用 reset_btn = page.locator("button:has-text('重置')").first if reset_btn.count() > 0 and not reset_btn.is_disabled(): # 点击重置 reset_btn.click() page.wait_for_timeout(500) # 保存按钮应再次禁用 if save_btn.is_disabled(): _record("dirty 检测", "passed", "dirty 检测和重置功能正常") else: _record("dirty 检测", "warning", "重置后保存按钮未禁用") else: _record("dirty 检测", "passed", "dirty 检测正常(重置按钮未启用)") # 恢复原始值 school_name_input.fill(original_value) page.wait_for_timeout(500) except Exception as e: _record("dirty 检测", "failed", str(e)) def test_admin_settings_save_and_reset(page, role_key): """测试管理员系统设置 - 保存和重置""" global _current_test_category _current_test_category = "Admin Settings 页面" if role_key != "admin": _record("保存和重置", "passed", f"{role_key} 角色跳过") return try: page.goto(f"{BASE_URL}/admin/settings", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(2000) # 修改学校名称 school_name_input = page.locator('#school-name').first if school_name_input.count() == 0: _record("保存和重置", "failed", "未找到学校名称输入框") return original_value = school_name_input.input_value() test_value = f"测试学校_{int(time.time())}" school_name_input.fill(test_value) page.wait_for_timeout(500) # 点击保存 save_btn = page.locator("button:has-text('保存设置')").first save_btn.click() # 等待 toast page.wait_for_timeout(3000) # 检查成功 toast success_toast = page.locator("[data-sonner-toast]:has-text('保存成功')") if success_toast.count() == 0: success_toast = page.locator("[data-sonner-toast]:has-text('success')") if success_toast.count() == 0: fail_toast = page.locator("[data-sonner-toast]:has-text('失败')") if fail_toast.count() > 0: toast_text = fail_toast.first.text_content() or "" _record("保存和重置", "failed", f"保存失败: {toast_text[:100]}") school_name_input.fill(original_value) return _record("保存和重置", "warning", "未检测到 toast") else: _record("保存和重置", "passed", "保存成功") # 恢复原始值 school_name_input.fill(original_value) page.wait_for_timeout(500) save_btn = page.locator("button:has-text('保存设置')").first save_btn.click() page.wait_for_timeout(2000) except Exception as e: _record("保存和重置", "failed", str(e)) # ============================================================ # 主测试流程 # ============================================================ def run_tests_for_role(page, role_key): """运行指定角色的所有测试""" global _current_role _current_role = role_key print(f"\n{'='*60}") print(f"测试角色: {TEST_ACCOUNTS[role_key]['label']} ({role_key})") print(f"{'='*60}") # 登录 if not login(page, role_key): print(f"❌ {role_key} 登录失败,跳过该角色所有测试") global _current_test_category _current_test_category = "登录" _record("登录", "failed", f"{role_key} 登录失败") return # Profile 页面测试 print(f"\n--- Profile 页面测试 ---") test_profile_page_loads(page, role_key) test_profile_personal_info_card(page, role_key) test_profile_account_info_card(page, role_key) test_profile_avatar_upload_component(page, role_key) test_profile_role_overview(page, role_key) test_profile_edit_link(page, role_key) # Settings 页面测试 print(f"\n--- Settings 页面测试 ---") test_settings_page_loads(page, role_key) test_settings_tabs_visible(page, role_key) # 通用标签页 print(f"\n--- Settings - 通用标签页 ---") test_settings_general_tab_profile_form(page, role_key) test_settings_general_tab_profile_update(page, role_key) test_settings_general_tab_quick_links(page, role_key) # 通知标签页 print(f"\n--- Settings - 通知标签页 ---") test_settings_notifications_tab(page, role_key) test_settings_notifications_toggle(page, role_key) # 外观标签页 print(f"\n--- Settings - 外观标签页 ---") test_settings_appearance_tab(page, role_key) test_settings_appearance_theme_switch(page, role_key) # 安全标签页 print(f"\n--- Settings - 安全标签页 ---") test_settings_security_tab(page, role_key) test_settings_security_2fa_disabled(page, role_key) test_settings_security_password_validation(page, role_key) test_settings_security_password_strength(page, role_key) test_settings_security_revoke_sessions_button(page, role_key) # AI 标签页 print(f"\n--- Settings - AI 标签页 ---") test_settings_ai_tab_admin_only(page, role_key) # Admin Settings 页面(仅管理员) print(f"\n--- Admin Settings 页面测试 ---") test_admin_settings_page_loads(page, role_key) test_admin_settings_cards_display(page, role_key) test_admin_settings_dirty_detection(page, role_key) test_admin_settings_save_and_reset(page, role_key) # 退出登录 logout(page) def generate_report(): """生成 Markdown 测试报告""" report = [] report.append("# 设置和个人信息模块 Web 功能测试报告") report.append("") report.append(f"> 测试日期:{results['test_date']}") report.append(f"> 测试范围:设置和个人信息模块(`/profile`、`/settings`、`/admin/settings`)") report.append(f"> 测试工具:Playwright + Chromium (headless)") report.append(f"> Base URL:{results['base_url']}") report.append(f"> 测试账号:") for key, acc in TEST_ACCOUNTS.items(): report.append(f"> - {acc['label']}({key}):{acc['email']}") report.append("") report.append("---") report.append("") # 测试概览 report.append("## 一、测试概览") report.append("") s = results["summary"] report.append("| 指标 | 数值 |") report.append("|------|------|") report.append(f"| 总测试用例数 | {s['total']} |") report.append(f"| 通过 | {s['passed']} |") report.append(f"| 失败 | {s['failed']} |") report.append(f"| 警告 | {s['warnings']} |") pass_rate = (s['passed'] / s['total'] * 100) if s['total'] > 0 else 0 report.append(f"| 通过率 | {pass_rate:.1f}% |") report.append("") report.append("---") report.append("") # 按角色分组 report.append("## 二、按角色测试结果") report.append("") report.append("| 角色 | 总数 | 通过 | 失败 | 警告 | 通过率 |") report.append("|------|------|------|------|------|--------|") for role_key, role_data in results["roles"].items(): total = sum(c["passed"] + c["failed"] + c["warnings"] for c in role_data["categories"].values()) passed = sum(c["passed"] for c in role_data["categories"].values()) failed = sum(c["failed"] for c in role_data["categories"].values()) warnings = sum(c["warnings"] for c in role_data["categories"].values()) rate = (passed / total * 100) if total > 0 else 0 report.append(f"| {role_data['label']}({role_key}) | {total} | {passed} | {failed} | {warnings} | {rate:.1f}% |") report.append("") report.append("---") report.append("") # 详细测试结果 report.append("## 三、详细测试结果") report.append("") for role_key, role_data in results["roles"].items(): report.append(f"### {role_data['label']}({role_key})") report.append("") for category, cat_data in role_data["categories"].items(): report.append(f"#### {category}") report.append("") report.append("| 状态 | 测试用例 | 详情 |") report.append("|------|----------|------|") for test in cat_data["tests"]: icon = {"passed": "✅", "failed": "❌", "warning": "⚠️"}[test["status"]] details = test["details"].replace("|", "\\|") if test["details"] else "-" if test["errors"]: err_str = "; ".join(test["errors"][:2]).replace("|", "\\|") details = f"{details}
错误: {err_str}" if details != "-" else f"错误: {err_str}" report.append(f"| {icon} | {test['name']} | {details} |") report.append("") report.append("---") report.append("") # 失败用例汇总 if results["failures"]: report.append("## 四、失败用例汇总") report.append("") for f in results["failures"]: report.append(f"### ❌ [{f['role']}/{f['category']}] {f['test']}") report.append("") report.append(f"- **详情**: {f['details']}") if f["errors"]: report.append(f"- **错误**:") for err in f["errors"]: report.append(f" - {err}") report.append("") report.append("---") report.append("") # 改进建议 report.append("## 五、测试结论与改进建议") report.append("") if results["summary"]["failed"] == 0: report.append("### ✅ 测试通过") report.append("") report.append("设置和个人信息模块在所有 4 个角色(管理员/教师/学生/家长)下的所有功能均正常工作。") else: report.append(f"### ⚠️ 发现 {results['summary']['failed']} 个失败用例") report.append("") report.append("需要修复的失败用例详见上文 '失败用例汇总' 章节。") report.append("") report.append("### 测试覆盖范围") report.append("") report.append("- **Profile 页面**(`/profile`):页面加载、个人信息卡片、账户信息卡片、头像上传组件、角色概览、编辑资料链接") report.append("- **Settings 页面**(`/settings`):5 个标签页(通用/通知/外观/安全/AI)") report.append(" - 通用:个人资料表单显示与提交、角色快捷链接") report.append(" - 通知:通知渠道/类别/免打扰时段显示、开关切换、dirty 检测") report.append(" - 外观:主题切换、语言切换") report.append(" - 安全:修改密码表单、密码强度指示器、密码验证、2FA 禁用状态、最近登录、登出其他会话、退出登录") report.append(" - AI:管理员可见,其他角色不可见") report.append("- **Admin Settings 页面**(`/admin/settings`):4 个卡片显示、dirty 检测、保存与重置、权限控制") report.append("") report.append("---") report.append("") report.append(f"*报告自动生成于 {results['test_date']}*") return "\n".join(report) def main(): """主测试函数""" print("=" * 60) print("设置和个人信息模块 - 全功能 Web 测试") print(f"测试时间: {results['test_date']}") print(f"Base URL: {BASE_URL}") print("=" * 60) 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() # 收集控制台错误 console_errors = [] def on_console(msg): try: if msg.type == "error": text = msg.text[:200] console_errors.append(text) except Exception: pass page.on("console", on_console) # 按角色顺序测试 for role_key in ["admin", "teacher", "student", "parent"]: try: run_tests_for_role(page, role_key) except Exception as e: print(f"\n❌ {role_key} 测试过程中断: {e}") traceback.print_exc() # 清理 cookies 以便继续下一个角色 try: page.context.clear_cookies() except Exception: pass browser.close() # 生成报告 print("\n\n" + "=" * 60) print("生成测试报告...") print("=" * 60) report = generate_report() # 写入文件 output_dir = os.path.join(os.path.dirname(__file__), "..", "..", "webtest") os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, "settings_v1.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}") # 打印汇总 s = results["summary"] print(f"\n{'='*60}") print(f"测试完成: 总计 {s['total']}, 通过 {s['passed']}, 失败 {s['failed']}, 警告 {s['warnings']}") print(f"通过率: {(s['passed']/s['total']*100) if s['total'] > 0 else 0:.1f}%") print(f"{'='*60}") return 0 if s["failed"] == 0 else 1 if __name__ == "__main__": sys.exit(main())