PayrollManagementSystem/frontend/e2e/login_flow.py
liuwenxin 7f8f88c148 fix(frontend): 错误提示单一化/成功提示后置/日志页首次成功拉取
- request.ts blob 失败分支:先解析真实文案再统一提示——此前先弹兜底
  「导出失败」再异步补弹真实 message(双提示),401 时跳转提示与
  实际原因错位;401 语义文案在导出场景不再失真
- useConfirmAction:收尾(列表刷新)成功后才弹成功提示——此前先弹
  「已删除」再刷新,after 失败被吞时成功/失败提示同屏矛盾
- 日志页登录 tab 首次成功加载后不再重复拉取(loginLoadedOk 由
  fetcher 成功返回置位,失败切回可重拉,不复活「失败停空表」旧坑)
- login_flow.py 密码改从 E2E_ADMIN_PASSWORD 读(与 system_management
  同一约定),硬编码默认值在他机上会假绿为「凭据错误」
- e2e 冒烟:登录 8 项 + 系统管理 13 项全过(拦截器全局改动验证)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:22:12 +08:00

159 lines
6.2 KiB
Python
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.

"""浏览器端到端验证:登录 + 图形验证码。
验证的是**只有真实浏览器才能暴露**的问题:
- 路由守卫是否真的拦住了未登录访问
- 验证码图片能否经 Vite 代理正常渲染(跨前后端的完整链路)
- 登录失败后验证码是否自动刷新 —— 后端用 GETDEL 消费了验证码,
前端不刷新的话第二次提交必然失败,而这个问题在人工测试时极易被忽略
- 刷新页面能否用 /me 恢复会话
- 登出后令牌是否立即失效
**运行前置**(缺一不可)::
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 && python e2e/login_flow.py
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
公共工具见 `e2e/common.py`。
"""
import os
from playwright.sync_api import Page, sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
fail,
redis_get,
toast_text,
)
USERNAME = "admin"
# 与 system_management 同一约定:开发库 admin 密码从环境变量传入(各库不同,
# 硬编码会在他机上假绿为「凭据错误」),缺省保留历史默认值兜底本机老库
PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD", "Admin@2026")
def run(page: Page) -> None:
captured_captcha_ids: 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:
return
try:
captured_captcha_ids.append(response.json()["data"]["captcha_id"])
except Exception:
# 页面导航后响应体已不可读(Playwright 会抛协议错误)。
# 事件回调里未捕获的异常会成为不稳定的噪声来源,故静默跳过。
pass
page.on("response", on_response)
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
page.goto(BASE_URL, wait_until="networkidle")
if "/login" not in page.url:
fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
image = page.locator(".captcha__image img")
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]}")
if len(src) < 1000:
fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
first_id = captured_captcha_ids[-1]
page.fill('input[autocomplete="username"]', USERNAME)
page.fill('input[autocomplete="current-password"]', PASSWORD)
page.fill('input[autocomplete="off"]', "XXXX")
page.click("button:has-text('登 录')")
page.wait_for_timeout(1500)
if "/login" not in page.url:
fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
second_id = captured_captcha_ids[-1]
if second_id == first_id:
fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
# ---- 4. 正确登录 ----
answer = redis_get(f"ys:captcha:{second_id}")
if not answer:
fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
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}"
if console_errors:
detail += f",控制台错误:{console_errors[:3]}"
fail("登录", detail)
page.wait_for_load_state("networkidle")
print(f"✓ 4. 登录成功 跳转到 {page.url}")
if USERNAME not in page.content():
fail("用户信息", "首页未显示当前用户名")
print("✓ 5. 用户信息 首页已展示当前账号")
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
page.reload(wait_until="networkidle")
if current_path(page) != "/dashboard":
fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
# ---- 7. 登出 → 令牌立即失效 ----
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
print(f"✓ 7. 登出 回到 {page.url}")
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
page.wait_for_timeout(1000)
if current_path(page) == "/dashboard":
fail("登出失效", "登出后仍能访问受保护页面")
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
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}")
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(" 已保存截图 e2e/failure.png")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())