feat(system): 完成前端系统管理(布局框架、三个管理页面、e2e)
- BasicLayout:侧边栏/顶栏/面包屑/移动端抽屉;菜单从路由表派生; 路由守卫新增 requiresAdmin 层(菜单隐藏 + 直连 URL 弹回) - 用户/角色/日志三个页面:分页请求序号防竞态、确认动作流抽 useConfirmAction、 时间范围配 default-time、日志详情按需懒加载并缓存 - 空 catch 统一改 handledError 显式声明;词表/选项常量集中(与后端对齐), 「停用」文案全站统一为「禁用」 - 品牌色提升为全局 token,Element Plus 主色指向赭金 - e2e:公共工具抽 common.py;系统管理 13 项(含详情懒加载脱敏断言), 管理员密码改 E2E_ADMIN_PASSWORD 环境变量;login_flow 8 项回归通过 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
c83287819a
commit
8355e0cc7d
26 files changed
+2785
-180
No files matched your search
@@ -0,0 +1,153 @@
|
||||
"""端到端测试公共工具。
|
||||
|
||||
抽取自 `login_flow.py`,供各流程脚本复用:环境常量、Redis 读取(取验证码
|
||||
答案,仅自动化测试可用)、完整登录动作、失败处理与控制台错误收集。
|
||||
|
||||
约定:断言失败抛 `E2EFailure`(消息格式 `步骤:原因`),由各脚本的 `main()`
|
||||
统一截图并返回退出码 1。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5173"
|
||||
REDIS_CONTAINER = "ys-redis-dev"
|
||||
|
||||
# Windows 终端默认 cp936,✓/✗ 等符号会抛 UnicodeEncodeError 而非打印失败原因。
|
||||
# 与后端 core/logging.py 的处理一致,显式重配标准输出编码。
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
class E2EFailure(Exception):
|
||||
"""步骤断言失败。"""
|
||||
|
||||
|
||||
def redis_password() -> str:
|
||||
"""从 docker/.env 读取 Redis 密码。
|
||||
|
||||
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
|
||||
"""
|
||||
if value := os.environ.get("REDIS_PASSWORD"):
|
||||
return value
|
||||
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
|
||||
with open(env_file, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("REDIS_PASSWORD="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
|
||||
|
||||
|
||||
def redis_get(key: str) -> str | None:
|
||||
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
|
||||
result = subprocess.run(
|
||||
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", redis_password(),
|
||||
"--no-auth-warning", "get", key],
|
||||
capture_output=True, text=True, encoding="utf-8", check=False,
|
||||
)
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def fail(step: str, detail: str) -> None:
|
||||
"""断言失败:抛异常而非进程退出 —— 由 main() 统一截图后再退出。"""
|
||||
raise E2EFailure(f"{step}:{detail}")
|
||||
|
||||
|
||||
def current_path(page: Page) -> str:
|
||||
"""当前 URL 的路径部分。
|
||||
|
||||
比较路径**不能**用 `**/dashboard` 这类 glob —— 它会把
|
||||
`/login?redirect=/dashboard` 也匹配上,"登录失败"会被误判为成功。
|
||||
"""
|
||||
return urlparse(page.url).path
|
||||
|
||||
|
||||
def collect_console_errors(page: Page, sink: list[str] | None = None) -> list[str]:
|
||||
"""注册控制台错误收集(失败诊断时打印最近几条)。
|
||||
|
||||
:param sink: 可选的外部列表(如脚本的模块级变量,供 main() 的失败路径读取)
|
||||
"""
|
||||
errors = sink if sink is not None else []
|
||||
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda exc: errors.append(str(exc)))
|
||||
return errors
|
||||
|
||||
|
||||
def toast_text(page: Page) -> str:
|
||||
"""当前显示的全局提示文本(el-message),无提示时为空串。"""
|
||||
locator = page.locator(".el-message")
|
||||
return locator.first.text_content().strip() if locator.count() else ""
|
||||
|
||||
|
||||
def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str:
|
||||
"""等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。
|
||||
|
||||
直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里
|
||||
读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_ms / 1000
|
||||
text = ""
|
||||
while time.monotonic() < deadline:
|
||||
text = toast_text(page)
|
||||
if expected in text:
|
||||
return text
|
||||
page.wait_for_timeout(200)
|
||||
return text
|
||||
|
||||
|
||||
def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None:
|
||||
"""断言出现了包含指定文本的全局提示。"""
|
||||
text = wait_toast(page, expected, timeout_ms)
|
||||
if expected not in text:
|
||||
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
|
||||
|
||||
|
||||
def login(page: Page, username: str, password: str) -> None:
|
||||
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
|
||||
|
||||
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
|
||||
"""
|
||||
captcha_ids: list[str] = []
|
||||
|
||||
def on_response(response) -> None:
|
||||
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
||||
return
|
||||
try:
|
||||
captcha_ids.append(response.json()["data"]["captcha_id"])
|
||||
except Exception:
|
||||
# 页面导航后响应体已不可读(会抛协议错误),属可忽略的噪声
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
try:
|
||||
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
|
||||
for _ in range(50):
|
||||
if captcha_ids:
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
if not captcha_ids:
|
||||
fail("登录", "未捕获到验证码请求")
|
||||
|
||||
captcha_id = captcha_ids[-1]
|
||||
answer = redis_get(f"ys:captcha:{captcha_id}")
|
||||
if not answer:
|
||||
fail("登录", f"Redis 中未找到验证码 ys:captcha:{captcha_id}")
|
||||
|
||||
page.fill('input[autocomplete="username"]', username)
|
||||
page.fill('input[autocomplete="current-password"]', password)
|
||||
page.fill('input[autocomplete="off"]', answer)
|
||||
page.click("button:has-text('登 录')")
|
||||
page.wait_for_timeout(2000)
|
||||
if current_path(page) != "/dashboard":
|
||||
detail = f"未跳转到首页,当前 {page.url}"
|
||||
if message := toast_text(page):
|
||||
detail += f",提示:{message}"
|
||||
fail("登录", detail)
|
||||
page.wait_for_load_state("networkidle")
|
||||
finally:
|
||||
page.remove_listener("response", on_response)
|
||||
+33
-69
@@ -1,4 +1,4 @@
|
||||
"""浏览器端端到端验证:登录 + 图形验证码。
|
||||
"""浏览器端到端验证:登录 + 图形验证码。
|
||||
|
||||
验证的是**只有真实浏览器才能暴露**的问题:
|
||||
|
||||
@@ -20,53 +20,28 @@
|
||||
cd frontend && python e2e/login_flow.py
|
||||
|
||||
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||
公共工具见 `e2e/common.py`。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5173"
|
||||
from common import (
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
current_path,
|
||||
fail,
|
||||
redis_get,
|
||||
toast_text,
|
||||
)
|
||||
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "Admin@2026"
|
||||
REDIS_CONTAINER = "ys-redis-dev"
|
||||
|
||||
|
||||
def _redis_password() -> str:
|
||||
"""从 docker/.env 读取 Redis 密码。
|
||||
|
||||
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
|
||||
"""
|
||||
if value := os.environ.get("REDIS_PASSWORD"):
|
||||
return value
|
||||
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
|
||||
with open(env_file, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("REDIS_PASSWORD="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
|
||||
|
||||
|
||||
def redis_get(key: str) -> str | None:
|
||||
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
|
||||
result = subprocess.run(
|
||||
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(),
|
||||
"--no-auth-warning", "get", key],
|
||||
capture_output=True, text=True, encoding="utf-8", check=False,
|
||||
)
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def _fail(step: str, detail: str) -> None:
|
||||
print(f" ✗ {step}:{detail}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
captured_captcha_ids: list[str] = []
|
||||
console_errors: list[str] = []
|
||||
console_errors = collect_console_errors(page)
|
||||
|
||||
def on_response(response) -> None:
|
||||
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
||||
@@ -79,27 +54,11 @@ def run(page: Page) -> None:
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
|
||||
|
||||
def at_dashboard() -> bool:
|
||||
"""判断是否真的在首页。
|
||||
|
||||
不能用 `**/dashboard` 这类 glob —— 它会把
|
||||
`/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
return urlparse(page.url).path == "/dashboard"
|
||||
|
||||
def toast_text() -> str:
|
||||
locator = page.locator(".el-message")
|
||||
return locator.first.text_content().strip() if locator.count() else ""
|
||||
|
||||
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
|
||||
page.goto(BASE_URL, wait_until="networkidle")
|
||||
if "/login" not in page.url:
|
||||
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||
fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
||||
|
||||
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
||||
@@ -107,9 +66,9 @@ def run(page: Page) -> None:
|
||||
image.wait_for(state="visible", timeout=10_000)
|
||||
src = image.get_attribute("src") or ""
|
||||
if not src.startswith("data:image/png;base64,"):
|
||||
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||
fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||
if len(src) < 1000:
|
||||
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||
fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
||||
|
||||
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
||||
@@ -121,38 +80,38 @@ def run(page: Page) -> None:
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
if "/login" not in page.url:
|
||||
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||
fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||
second_id = captured_captcha_ids[-1]
|
||||
if second_id == first_id:
|
||||
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||
fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
||||
|
||||
# ---- 4. 正确登录 ----
|
||||
answer = redis_get(f"ys:captcha:{second_id}")
|
||||
if not answer:
|
||||
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||
fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||
page.fill('input[autocomplete="off"]', answer)
|
||||
page.click("button:has-text('登 录')")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
if not at_dashboard():
|
||||
if current_path(page) != "/dashboard":
|
||||
detail = f"仍停留在 {page.url}"
|
||||
if message := toast_text():
|
||||
if message := toast_text(page):
|
||||
detail += f",提示:{message}"
|
||||
if console_errors:
|
||||
detail += f",控制台错误:{console_errors[:3]}"
|
||||
_fail("登录", detail)
|
||||
fail("登录", detail)
|
||||
page.wait_for_load_state("networkidle")
|
||||
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
||||
|
||||
if USERNAME not in page.content():
|
||||
_fail("用户信息", "首页未显示当前用户名")
|
||||
fail("用户信息", "首页未显示当前用户名")
|
||||
print("✓ 5. 用户信息 首页已展示当前账号")
|
||||
|
||||
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
||||
page.reload(wait_until="networkidle")
|
||||
if "/dashboard" not in page.url:
|
||||
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||
if current_path(page) != "/dashboard":
|
||||
fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
||||
|
||||
# ---- 7. 登出 → 令牌立即失效 ----
|
||||
@@ -162,8 +121,8 @@ def run(page: Page) -> None:
|
||||
|
||||
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
||||
page.wait_for_timeout(1000)
|
||||
if at_dashboard():
|
||||
_fail("登出失效", "登出后仍能访问受保护页面")
|
||||
if current_path(page) == "/dashboard":
|
||||
fail("登出失效", "登出后仍能访问受保护页面")
|
||||
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
||||
|
||||
|
||||
@@ -174,9 +133,14 @@ def main() -> int:
|
||||
page.set_default_timeout(10_000)
|
||||
try:
|
||||
run(page)
|
||||
except E2EFailure as exc:
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。
|
||||
|
||||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||
|
||||
- 侧边菜单导航与「系统管理」分组
|
||||
- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除
|
||||
- 角色保护:内置 admin 行的禁用/删除按钮禁用;角色 CRUD 全流程
|
||||
- 操作日志:写操作后可按条件查到记录,展开详情懒加载出脱敏后的请求参数
|
||||
- 登录日志:能查到当前会话的成功登录
|
||||
- 权限:普通用户看不到「系统管理」菜单,直连管理页面被守卫弹回首页
|
||||
|
||||
**运行前置**(缺一不可)::
|
||||
|
||||
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
|
||||
cd backend && uv run uvicorn app.main:app --port 8000 # 后端
|
||||
cd frontend && pnpm dev # 前端
|
||||
|
||||
管理员密码经环境变量传入(不写死在源码里)::
|
||||
|
||||
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
|
||||
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;中途失败会残留在开发库,
|
||||
可用后端集成测试的前缀清理或手工删除。公共工具见 `e2e/common.py`。
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
current_path,
|
||||
expect_toast,
|
||||
fail,
|
||||
login,
|
||||
toast_text,
|
||||
wait_toast,
|
||||
)
|
||||
|
||||
ADMIN = "admin"
|
||||
E2E_PASSWORD = "E2ePwd@2026"
|
||||
RESET_PASSWORD = "E2eReset@2026"
|
||||
|
||||
SUFFIX = str(int(time.time()))
|
||||
NEW_USERNAME = f"e2e_user_{SUFFIX}"
|
||||
VIEWER_USERNAME = f"e2e_viewer_{SUFFIX}"
|
||||
NEW_ROLE_CODE = f"e2e_role_{SUFFIX}"
|
||||
|
||||
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
|
||||
console_errors: list[str] = []
|
||||
|
||||
|
||||
# ==================== 页面操作辅助 ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开「系统管理」分组。
|
||||
|
||||
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.get_by_text(title, exact=True)
|
||||
if not item.is_visible():
|
||||
menu.get_by_text("系统管理", exact=True).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
|
||||
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
|
||||
"""
|
||||
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项。
|
||||
|
||||
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
|
||||
"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def create_user_via_ui(
|
||||
page: Page, username: str, real_name: str, expect_success: bool = True
|
||||
) -> None:
|
||||
"""经界面新增用户(角色固定选择「普通用户」)。"""
|
||||
page.click("button:has-text('新增用户')")
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
dialog.locator('input[placeholder="登录用户名,创建后不可修改"]').fill(username)
|
||||
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(E2E_PASSWORD)
|
||||
dialog.locator('input[placeholder="真实姓名"]').fill(real_name)
|
||||
|
||||
# 角色多选:展开下拉后选择「普通用户」
|
||||
dialog.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="普通用户").first.click()
|
||||
page.wait_for_timeout(200)
|
||||
dialog.locator(".el-dialog__header").click() # 收起下拉
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
if expect_success:
|
||||
expect_toast(page, "用户已创建", "新增用户")
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
|
||||
def find_user_row(page: Page, username: str):
|
||||
"""在用户列表中按用户名筛选并返回目标行。"""
|
||||
fill_filter(page, "用户名", username)
|
||||
click_query(page)
|
||||
return table_rows(page).filter(has_text=username).first
|
||||
|
||||
|
||||
# ==================== 流程步骤 ====================
|
||||
|
||||
|
||||
def step_user_create(page: Page, admin_password: str) -> None:
|
||||
"""登录、菜单导航、新增用户与重名负例。"""
|
||||
login(page, ADMIN, admin_password)
|
||||
open_menu(page, "用户管理")
|
||||
if current_path(page) != "/system/users":
|
||||
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
|
||||
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
|
||||
|
||||
create_user_via_ui(page, NEW_USERNAME, "E2E测试")
|
||||
row = find_user_row(page, NEW_USERNAME)
|
||||
if row.count() == 0:
|
||||
fail("新增用户", f"列表中未找到新建的 {NEW_USERNAME}")
|
||||
print(f"✓ 2. 新增用户 创建 {NEW_USERNAME} 并可在列表查到")
|
||||
|
||||
create_user_via_ui(page, NEW_USERNAME, "E2E重复", expect_success=False)
|
||||
# 用 wait_toast 而非直读:上一条「用户已创建」提示尚在停留窗口内时,
|
||||
# 直读会命中它造成偶发误判
|
||||
if "已存在" not in wait_toast(page, "已存在"):
|
||||
fail("重名负例", f"重复用户名未提示已存在,当前提示:{toast_text(page) or '(无)'}")
|
||||
visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
|
||||
page.wait_for_timeout(500)
|
||||
print("✓ 3. 重名负例 重复用户名被拒并给出中文提示")
|
||||
|
||||
|
||||
def step_user_edit_and_status(page: Page) -> None:
|
||||
"""编辑用户、重置密码、启用/禁用。"""
|
||||
row = find_user_row(page, NEW_USERNAME)
|
||||
row.locator("button:has-text('编辑')").click()
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
dialog.locator('input[placeholder="真实姓名"]').fill("E2E已改名")
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
expect_toast(page, "已保存", "编辑用户")
|
||||
row = find_user_row(page, NEW_USERNAME)
|
||||
if "E2E已改名" not in row.inner_text():
|
||||
fail("编辑用户", "列表未显示修改后的姓名")
|
||||
print("✓ 4. 编辑用户 姓名修改生效")
|
||||
|
||||
row = find_user_row(page, NEW_USERNAME)
|
||||
row.locator("button:has-text('重置密码')").click()
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(RESET_PASSWORD)
|
||||
dialog.locator(".el-dialog__footer button:has-text('确认重置')").click()
|
||||
expect_toast(page, "密码已重置", "重置密码")
|
||||
page.wait_for_timeout(600)
|
||||
print("✓ 5. 重置密码 提示该用户需重新登录")
|
||||
|
||||
row = find_user_row(page, NEW_USERNAME)
|
||||
row.locator("button:has-text('禁用')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "禁用")
|
||||
expect_toast(page, "已禁用", "禁用用户")
|
||||
|
||||
row = find_user_row(page, NEW_USERNAME)
|
||||
if "禁用" not in row.inner_text():
|
||||
fail("禁用用户", "列表中状态未更新为禁用")
|
||||
row.locator("button:has-text('启用')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "启用")
|
||||
expect_toast(page, "已启用", "启用用户")
|
||||
print("✓ 6. 启用/禁用 状态切换与后端联动正常")
|
||||
|
||||
|
||||
def step_role_management(page: Page) -> None:
|
||||
"""角色保护与角色 CRUD。"""
|
||||
open_menu(page, "角色管理")
|
||||
if current_path(page) != "/system/roles":
|
||||
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
|
||||
|
||||
admin_row = table_rows(page).filter(has_text="系统管理员").first
|
||||
if not admin_row.locator("button:has-text('禁用')").is_disabled():
|
||||
fail("角色保护", "内置 admin 角色的禁用按钮未禁用")
|
||||
if not admin_row.locator("button:has-text('删除')").is_disabled():
|
||||
fail("角色保护", "内置 admin 角色的删除按钮未禁用")
|
||||
print("✓ 7. 角色保护 内置 admin 行的禁用/删除按钮均禁用")
|
||||
|
||||
page.click("button:has-text('新增角色')")
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
dialog.locator('input[placeholder="如 sales_manager,创建后不可修改"]').fill(NEW_ROLE_CODE)
|
||||
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E测试角色")
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
expect_toast(page, "角色已创建", "新增角色")
|
||||
|
||||
fill_filter(page, "关键词", NEW_ROLE_CODE)
|
||||
click_query(page)
|
||||
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
|
||||
if role_row.count() == 0:
|
||||
fail("新增角色", f"列表中未找到 {NEW_ROLE_CODE}")
|
||||
|
||||
role_row.locator("button:has-text('编辑')").click()
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E角色改名")
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
expect_toast(page, "已保存", "编辑角色")
|
||||
|
||||
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
|
||||
role_row.locator("button:has-text('禁用')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "禁用")
|
||||
expect_toast(page, "已禁用", "禁用角色")
|
||||
print("✓ 8. 角色管理 新增/编辑/禁用全流程正常")
|
||||
|
||||
|
||||
def step_logs_query(page: Page) -> None:
|
||||
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
|
||||
open_menu(page, "日志查询")
|
||||
if current_path(page) != "/system/logs":
|
||||
fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
|
||||
|
||||
fill_filter(page, "操作人", ADMIN)
|
||||
select_filter_option(page, "操作", "新增")
|
||||
click_query(page)
|
||||
|
||||
log_row = table_rows(page).filter(has_text=NEW_USERNAME).first
|
||||
if log_row.count() == 0:
|
||||
fail("操作日志", f"未查到「新增用户 {NEW_USERNAME}」的审计记录")
|
||||
log_row.locator(".el-table__expand-icon").click()
|
||||
|
||||
# 详情按 id 懒加载:等「加载中…」被替换为内容(含脱敏标记)
|
||||
detail = ""
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
detail = page.locator(".log-detail__data:visible").first.text_content() or ""
|
||||
if "***" in detail:
|
||||
break
|
||||
page.wait_for_timeout(200)
|
||||
if "***" not in detail:
|
||||
fail("日志脱敏", f"展开详情未加载出已脱敏参数,当前内容:{detail[:80]}")
|
||||
if E2E_PASSWORD in detail:
|
||||
fail("日志脱敏", "请求参数中出现明文密码 —— 脱敏未生效")
|
||||
print("✓ 9. 操作日志 写操作留痕、详情懒加载且密码已脱敏")
|
||||
|
||||
page.locator(".el-tabs__item:has-text('登录日志')").click()
|
||||
page.wait_for_timeout(800)
|
||||
fill_filter(page, "用户名", ADMIN)
|
||||
click_query(page)
|
||||
if table_rows(page).filter(has_text="成功").count() == 0:
|
||||
fail("登录日志", "未查到当前会话的成功登录记录")
|
||||
print("✓ 10. 登录日志 当前会话登录记录可查")
|
||||
|
||||
|
||||
def step_permission_check(page: Page, admin_password: str) -> None:
|
||||
"""普通用户菜单隐藏与路由守卫。"""
|
||||
open_menu(page, "用户管理")
|
||||
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
|
||||
page.click("text=退出登录")
|
||||
page.wait_for_url("**/login**", timeout=10_000)
|
||||
|
||||
login(page, VIEWER_USERNAME, E2E_PASSWORD)
|
||||
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
|
||||
fail("菜单权限", "普通用户看到了「系统管理」菜单")
|
||||
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
|
||||
|
||||
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
if current_path(page) != "/dashboard":
|
||||
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
|
||||
if "需要管理员权限" not in toast_text(page):
|
||||
fail("路由守卫", "直连管理页未给出权限提示")
|
||||
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
|
||||
|
||||
page.click("text=退出登录")
|
||||
page.wait_for_url("**/login**", timeout=10_000)
|
||||
login(page, ADMIN, admin_password)
|
||||
|
||||
|
||||
def step_cleanup(page: Page) -> None:
|
||||
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
|
||||
open_menu(page, "用户管理")
|
||||
for username in (NEW_USERNAME, VIEWER_USERNAME):
|
||||
row = find_user_row(page, username)
|
||||
if row.count() == 0:
|
||||
continue
|
||||
row.locator("button:has-text('删除')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "删除")
|
||||
expect_toast(page, "已删除", "数据清理")
|
||||
|
||||
open_menu(page, "角色管理")
|
||||
fill_filter(page, "关键词", NEW_ROLE_CODE)
|
||||
click_query(page)
|
||||
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
|
||||
if role_row.count() > 0:
|
||||
role_row.locator("button:has-text('删除')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "删除")
|
||||
expect_toast(page, "已删除", "数据清理")
|
||||
print("✓ 13. 数据清理 e2e 测试用户与角色已删除")
|
||||
|
||||
|
||||
# ==================== 入口 ====================
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
collect_console_errors(page, console_errors)
|
||||
|
||||
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||
if not admin_password:
|
||||
fail(
|
||||
"环境变量",
|
||||
"未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码),"
|
||||
"示例:E2E_ADMIN_PASSWORD=xxx python e2e/system_management.py",
|
||||
)
|
||||
|
||||
step_user_create(page, admin_password)
|
||||
step_user_edit_and_status(page)
|
||||
step_role_management(page)
|
||||
step_logs_query(page)
|
||||
step_permission_check(page, admin_password)
|
||||
step_cleanup(page)
|
||||
|
||||
if console_errors:
|
||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.set_default_timeout(10_000)
|
||||
try:
|
||||
run(page)
|
||||
except E2EFailure as exc:
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ {exc}")
|
||||
if console_errors:
|
||||
print(f" 控制台错误:{console_errors[-5:]}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
if console_errors:
|
||||
print(f" 控制台错误:{console_errors[-5:]}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
print("\n全部通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user