PayrollManagementSystem/frontend/e2e/common.py
liuwenxin 39a3fa7796 fix(dashboard): 修复普通用户落地页与待办角色过滤
- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款),
  守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向
- 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部
- 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围
- 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单
- 新增 role_home_flow e2e,更新 system_management 普通用户断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:45:03 +08:00

224 lines
8.8 KiB
Python
Raw Permalink 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.

"""端到端测试公共工具。
抽取自 `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` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里
读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。
返回值语义:匹配成功返回该条;超时返回**见过的最后一条非空提示** ——
toast 只有 3 秒生命周期,等到超时才回来断言的场景(如「请求已 400、
错误 toast 一闪而过」)若返回最后一次的空串,诊断信息会全部丢失。
"""
deadline = time.monotonic() + timeout_ms / 1000
last_seen = ""
while time.monotonic() < deadline:
text = toast_text(page)
if text:
last_seen = text
if expected in text:
return text
page.wait_for_timeout(200)
return last_seen
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, expected_path: str = "/dashboard") -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入落地页。
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
``expected_path`` 按角色传入:管理员/财务/领导默认工作台,普通用户
落业务数据页(工作台权限收紧后守卫按角色解析首页)。
"""
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) != expected_path:
detail = f"未跳转到 {expected_path},当前 {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)
# ==================== 列表页 UI 操作(base_data/system_management 共用) ====================
def open_menu(page: Page, title: str, group: str) -> None:
"""点击侧边菜单项,必要时先展开指定分组。
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
"""
menu = page.locator(".app-menu")
item = menu.locator(".el-menu-item", has_text=title).first
if not item.is_visible():
menu.locator(".el-sub-menu__title", has_text=group).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」;
选择器带 `:visible` —— 同页存在多个表单实例(v-show 隐藏未销毁)时
会命中隐藏元素。
"""
form_item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。"""
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):
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
return page.locator(".el-table__body .el-table__row:visible")
def visible_dialog(page: Page):
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
return page.locator(".el-dialog:visible").last
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)