PayrollManagementSystem/frontend/e2e/base_data.py
liuwenxin 94c49138bf refactor(e2e): 六个列表页 UI helper 收口 common.py
open_menu 时分组参数化、fill_filter :text-is 精确匹配、messagebox 带
:visible(base_data 实现为准)——两脚本此前各持一份且匹配精度漂移
(has_text 子串 vs :text-is),使用者误以为两者等价;登录/系统管理/
基础数据/业务数据四链路 e2e 复跑全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 11:01:31 +08:00

520 lines
22 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.

"""基础数据端到端验证:人员 / 产品 / 客户。
覆盖的链路(只有真实浏览器才能验证的组合):
- 侧边菜单「基础数据」分组展开与三页导航
- 人员:新增(营销经理 / 挂经理的营销员)、直属经理名回显、辐射区域
编辑抽屉行内添加保存并重开回显
- 人员删除保护:被团队成员引用 → 400 引导「改为离职」,改为离职成功
- 产品:新增(轨道交通类别)、同名称同规格重复 → 400
- 客户:新增(企业户 + 初始归属业务员)、归属调整二次确认文案含
「不回溯历史」(对照表 #19 业务口径)、变更留痕展示旧 → 新归属
- 控制台无 JS 错误(4xx 请求的网络资源日志属预期,见 step_console_assertion)
**运行前置**(缺一不可)::
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端
cd frontend && pnpm dev # 前端
管理员密码经环境变量传入(不写死在源码里)::
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/base_data.py
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;被引用保护拦截的清理项
打印留档说明而非失败(删除保护本身是预期行为)。公共工具见 `e2e/common.py`。
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(__file__))
from playwright.sync_api import Page, sync_playwright
from common import (
E2EFailure,
click_query,
collect_console_errors,
confirm_message_box,
current_path,
expect_toast,
fail,
fill_filter,
login,
open_menu,
select_filter_option,
table_rows,
toast_text,
visible_dialog,
wait_toast,
)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ADMIN = "admin"
SUFFIX = str(int(time.time()))
MANAGER_NAME = f"e2e_经理_{SUFFIX}"
SALESMAN_NAME = f"e2e_业务员_{SUFFIX}"
MANAGER_PHONE = "13800000001"
MANAGER_SALARY = "5000"
SALESMAN_SALARY = "4000"
PRODUCT_NAME = f"e2e_产品_{SUFFIX}"
PRODUCT_SPEC = f"e2e_规格_{SUFFIX}"
CUSTOMER_COMPANY = f"e2e_企业_{SUFFIX}"
CUSTOMER_OWNER = "e2e_客户负责人"
REGION_NAME = "沈阳市铁西区"
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
console_errors: list[str] = []
# ==================== 页面操作辅助 ====================
def confirm_text(page: Page) -> str:
"""当前确认框的完整文本(业务口径文案断言用),轮询等待出现。"""
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if page.locator(".el-message-box:visible").count():
break
page.wait_for_timeout(200)
return page.locator(".el-message-box:visible").last.inner_text()
def fill_form_item(page: Page, scope, label: str, value: str) -> None:
"""在表单(dialog / drawer)范围内按 form-item 标签填写输入框。
标签用 `:text-is` 精确匹配(子串会让「姓名」命中「真实姓名」一类歧义);
只用于真实 el-input 的表单项(el-select 的表单项走 select_form_option)。
"""
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
item.locator("input").first.fill(value)
def fill_date(page: Page, scope, label: str, value: str) -> None:
"""el-date-picker(type=date)按 form-item 标签填日期:点输入框 → 键入 → 回车。"""
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
item.locator("input").first.click()
page.keyboard.type(value)
page.keyboard.press("Enter")
page.wait_for_timeout(300)
def select_form_option(page: Page, scope, label: str, option: str) -> None:
"""在表单范围内展开指定标签的下拉并选择选项。
下拉面板 teleport 到 body,展开与选项均按 `:visible` 过滤;选项渲染
(filterable / 页面挂载时拉取的数据源)可能滞后,轮询等待再点击。
"""
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
item.locator(".el-select").first.click()
option_item = page.locator(
".el-select-dropdown:visible .el-select-dropdown__item", has_text=option
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if option_item.count():
break
page.wait_for_timeout(200)
if option_item.count() == 0:
fail("下拉选择", f"下拉「{label}」中未出现选项「{option}」")
option_item.first.click()
page.wait_for_timeout(200)
def refresh_page_data(page: Page) -> None:
"""刷新当前页面(保持路径):重新挂载页面并重拉挂载时的一次性数据源。"""
page.reload(wait_until="networkidle")
page.wait_for_timeout(800)
def find_row(page: Page, filter_label: str, keyword: str):
"""按筛选条件查询并返回含 keyword 的第一行(找不到时 count() 为 0)。"""
fill_filter(page, filter_label, keyword)
click_query(page)
return table_rows(page).filter(has_text=keyword).first
def delete_row(page: Page, row, success_toast: str) -> None:
"""删除行:点「删除」→ 确认「删除」→ 断言成功提示(各模块成功文案不同)。"""
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, success_toast, "数据清理")
page.wait_for_timeout(600)
# ==================== 基础数据创建(本脚本与 business_data 复用) ====================
def create_manager_via_ui(page: Page, name: str) -> None:
"""导航到人员管理并经界面新增营销经理 → 列表岗位回显。"""
open_menu(page, "人员管理", "基础数据")
if current_path(page) != "/base/personnel":
fail("菜单导航", f"未进入人员管理页,当前 {page.url}")
page.click("button:has-text('新增人员')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "姓名", name)
select_form_option(page, dialog, "岗位", "营销经理")
fill_form_item(page, dialog, "联系电话", MANAGER_PHONE)
fill_form_item(page, dialog, "工资标准", MANAGER_SALARY)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "人员已创建", "新增人员")
row = find_row(page, "姓名", name)
if row.count() == 0:
fail("新增人员", f"列表中未找到新建的 {name}")
if "营销经理" not in row.inner_text():
fail("新增人员", "列表岗位列未回显「营销经理」")
page.wait_for_timeout(400)
def create_salesman_via_ui(page: Page, name: str, manager_name: str) -> None:
"""在人员管理页新增营销员并挂直属经理(先刷新页面重拉经理下拉)。
经理下拉数据源只在页面挂载时拉取一次 —— 同一页面连续「新建经理再
新建业务员」时下拉不含新经理,必须先刷新页面。
"""
open_menu(page, "人员管理", "基础数据")
refresh_page_data(page)
if current_path(page) != "/base/personnel":
fail("菜单导航", f"刷新后未回到人员管理页,当前 {page.url}")
page.click("button:has-text('新增人员')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "姓名", name)
select_form_option(page, dialog, "岗位", "营销员")
select_form_option(page, dialog, "直属经理", manager_name)
fill_form_item(page, dialog, "工资标准", SALESMAN_SALARY)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "人员已创建", "新增业务员")
row = find_row(page, "姓名", name)
if row.count() == 0:
fail("新增业务员", f"列表中未找到新建的 {name}")
if manager_name not in row.inner_text():
fail("新增业务员", "列表「直属经理」列未回显经理姓名")
page.wait_for_timeout(400)
def create_product_via_ui(page: Page, product_name: str, spec: str) -> None:
"""导航到产品管理并经界面新增产品(类别=轨道交通)→ 列表回显。"""
open_menu(page, "产品管理", "基础数据")
if current_path(page) != "/base/products":
fail("菜单导航", f"未进入产品管理页,当前 {page.url}")
page.click("button:has-text('新增产品')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "产品名称", product_name)
fill_form_item(page, dialog, "规格型号", spec)
fill_form_item(page, dialog, "数量", "100")
fill_form_item(page, dialog, "单价(元)", "3000")
select_form_option(page, dialog, "产品类别", "轨道交通")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "产品已创建", "新增产品")
row = find_row(page, "关键词", product_name)
if row.count() == 0:
fail("新增产品", f"列表中未找到新建的 {product_name}")
if "轨道交通" not in row.inner_text():
fail("新增产品", "列表类别列未回显「轨道交通」")
page.wait_for_timeout(400)
def create_customer_via_ui(page: Page, company: str, owner: str, initial_owner: str) -> None:
"""导航到客户管理并经界面新增客户(企业户 + 初始归属)→ 列表回显。"""
open_menu(page, "客户管理", "基础数据")
if current_path(page) != "/base/customers":
fail("菜单导航", f"未进入客户管理页,当前 {page.url}")
page.click("button:has-text('新增客户')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "公司名称", company)
fill_form_item(page, dialog, "负责人", owner)
fill_form_item(page, dialog, "行业", "轨道交通")
select_form_option(page, dialog, "客户分类", "企业户")
select_form_option(page, dialog, "初始归属", initial_owner)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "客户已创建", "新增客户")
row = find_row(page, "关键词", company)
if row.count() == 0:
fail("新增客户", f"列表中未找到新建的 {company}")
if initial_owner not in row.inner_text():
fail("新增客户", "列表「归属人员」列未回显初始归属")
if "企业户" not in row.inner_text():
fail("新增客户", "列表分类列未回显「企业户」")
page.wait_for_timeout(400)
# ==================== 流程步骤 ====================
def step_open_group_and_personnel(page: Page, admin_password: str) -> None:
"""登录、「基础数据」分组出现且含三页,点开「人员管理」。"""
login(page, ADMIN, admin_password)
menu = page.locator(".app-menu")
if menu.locator(".el-sub-menu__title", has_text="基础数据").count() == 0:
fail("菜单分组", "侧边菜单未出现「基础数据」分组")
for title in ("人员管理", "产品管理", "客户管理"):
if menu.locator(".el-menu-item", has_text=title).count() == 0:
fail("菜单分组", f"「基础数据」分组内未找到「{title}」")
open_menu(page, "人员管理", "基础数据")
if current_path(page) != "/base/personnel":
fail("菜单导航", f"未进入人员管理页,当前 {page.url}")
print("✓ 1. 菜单导航 「基础数据」分组出现、三页可点开,已进入人员管理")
def step_person_create_manager(page: Page) -> None:
create_manager_via_ui(page, MANAGER_NAME)
print(f"✓ 2. 新增经理 创建 {MANAGER_NAME}(岗位=营销经理),列表可见")
def step_person_create_salesman(page: Page) -> None:
create_salesman_via_ui(page, SALESMAN_NAME, MANAGER_NAME)
print(f"✓ 3. 新增业务员 创建 {SALESMAN_NAME}(岗位=营销员),经理名回显")
def step_person_edit_regions(page: Page) -> None:
"""编辑抽屉给经理添加一条辐射区域行 → 保存 → 重开回显。"""
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('区域与计划')").click()
page.wait_for_timeout(600)
drawer = page.locator(".el-drawer:visible").last
drawer.locator("button:has-text('添加区域')").click()
page.wait_for_timeout(300)
drawer.locator('input[placeholder="如 沈阳市铁西区"]').last.fill(REGION_NAME)
drawer.locator("button:has-text('保存区域')").click()
expect_toast(page, "辐射区域已保存", "编辑辐射区域")
# 重开抽屉验证回显(保存走全量替换接口,详情须持久化)
page.keyboard.press("Escape")
page.wait_for_timeout(600)
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('区域与计划')").click()
page.wait_for_timeout(600)
drawer = page.locator(".el-drawer:visible").last
region_input = drawer.locator('input[placeholder="如 沈阳市铁西区"]').first
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if region_input.count() and region_input.input_value() == REGION_NAME:
break
page.wait_for_timeout(200)
if region_input.count() == 0 or region_input.input_value() != REGION_NAME:
fail("辐射区域回显", "重新打开抽屉未回显已保存的区域名称")
page.keyboard.press("Escape")
page.wait_for_timeout(500)
print("✓ 4. 辐射区域 编辑抽屉添加区域保存,重开回显正确")
def step_person_delete_protection(page: Page) -> None:
"""删除被引用的经理被 400 拦截并引导「改为离职」。
此处**不真改离职**——后续归属调整步骤要以在职经理为目标;
「改为离职」的动作移到清理步骤执行(删除保护路径已在本步断言)。
"""
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
# 确认框文案本身已含「此时应改为离职」引导;确认后走后端校验
confirm_message_box(page, "删除")
expect_toast(page, "请改为离职", "人员删除保护")
print("✓ 5. 删除保护 删除被引人员被 400 拦截,引导改为离职")
def step_products(page: Page) -> None:
"""新增产品(轨道交通)与同名称同规格重复负例。"""
create_product_via_ui(page, PRODUCT_NAME, PRODUCT_SPEC)
print(f"✓ 6. 新增产品 创建 {PRODUCT_NAME}(类别=轨道交通),列表可见")
# 同名称同规格重复 → 400(唯一键 = 名称 + 规格)
page.click("button:has-text('新增产品')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
fill_form_item(page, dialog, "产品名称", PRODUCT_NAME)
fill_form_item(page, dialog, "规格型号", PRODUCT_SPEC)
select_form_option(page, dialog, "产品类别", "轨道交通")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
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("✓ 7. 产品重复负例 同名称同规格被 400 拒绝并给出中文文案")
def step_customers(page: Page) -> None:
create_customer_via_ui(page, CUSTOMER_COMPANY, CUSTOMER_OWNER, SALESMAN_NAME)
print(f"✓ 8. 新增客户 创建 {CUSTOMER_COMPANY}(企业户,初始归属=业务员)")
def step_ownership_change(page: Page) -> None:
"""归属调整到经理:二次确认含「不回溯历史」,留痕展示旧 → 新。"""
row = find_row(page, "关键词", CUSTOMER_COMPANY)
row.locator("button:has-text('归属变更')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
if SALESMAN_NAME not in dialog.inner_text():
fail("归属变更", "变更弹窗未回显当前归属业务员")
select_form_option(page, dialog, "新归属人员", MANAGER_NAME)
dialog.locator("button:has-text('提交变更')").click()
page.wait_for_timeout(500)
# 业务口径(对照表 #19):二次确认文案必须含「不回溯历史」
box = confirm_text(page)
if "不回溯历史" not in box:
fail("归属变更确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}")
confirm_message_box(page, "确认变更")
expect_toast(page, "归属已调整", "归属调整")
row = find_row(page, "关键词", CUSTOMER_COMPANY)
if row.count() == 0:
fail("归属调整", "按关键词查询未找到客户行")
if MANAGER_NAME not in row.inner_text():
fail("归属调整", "列表「归属人员」列未更新为经理")
print("✓ 9. 归属调整 二次确认含「不回溯历史」,归属已改到经理")
row.locator("button:has-text('变更记录')").click()
page.wait_for_timeout(800)
drawer = page.locator(".el-drawer:visible").last
transition = f"{SALESMAN_NAME} → {MANAGER_NAME}"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if drawer.locator(".el-table__row", has_text=transition).count():
break
page.wait_for_timeout(200)
if drawer.locator(".el-table__row", has_text=transition).count() == 0:
fail("归属留痕", f"变更记录抽屉未看到「{transition}」的留痕行")
page.keyboard.press("Escape")
page.wait_for_timeout(500)
print("✓ 10. 归属留痕 留痕抽屉展示旧 → 新归属一条记录")
def step_cleanup(page: Page) -> None:
"""清理测试数据:按 客户 → 产品 → 业务员 → 经理 依赖序删除。
经理此刻仍在职(删除保护步骤刻意未改);删除被拦时先「设为离职」
再删——届时客户/业务员均已删除,经理应无引用、可正常删除。
"""
open_menu(page, "客户管理", "基础数据")
row = find_row(page, "关键词", CUSTOMER_COMPANY)
if row.count() > 0:
delete_row(page, row, "客户已删除")
open_menu(page, "产品管理", "基础数据")
row = find_row(page, "关键词", PRODUCT_NAME)
if row.count() > 0:
delete_row(page, row, "产品已删除")
open_menu(page, "人员管理", "基础数据")
row = find_row(page, "姓名", SALESMAN_NAME)
if row.count() > 0:
delete_row(page, row, "人员已删除")
row = find_row(page, "姓名", MANAGER_NAME)
if row.count() > 0:
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "人员已删除" in text:
pass
elif "请改为离职" in text or "存在团队成员" in text or "业务归属" in text:
# 引导路径照做:设为离职后再删(引用已随客户/业务员删除解除)
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('设为离职')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "设为离职")
expect_toast(page, "已设为离职", "清理·经理离职")
row = find_row(page, "姓名", MANAGER_NAME)
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "人员已删除", "清理·经理删除")
else:
fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}")
print("✓ 12. 数据清理 测试数据按依赖序清理,被保护项按留档说明处理")
def step_console_assertion() -> None:
"""断言无 JS 控制台错误。
口径:4xx 请求触发的浏览器网络层日志(Failed to load resource)在
负例步骤属预期,不算 JS 错误;只拦脚本错误(console.error / pageerror)。
网络资源日志仍收集在 console_errors 供失败诊断。
"""
real_errors = [e for e in console_errors if "Failed to load resource" not in e]
if real_errors:
fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}")
print("✓ 13. 控制台检查 无 JS 控制台错误")
# ==================== 入口 ====================
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/base_data.py",
)
step_open_group_and_personnel(page, admin_password)
step_person_create_manager(page)
step_person_create_salesman(page)
step_person_edit_regions(page)
step_person_delete_protection(page)
step_products(page)
step_customers(page)
step_ownership_change(page)
step_cleanup(page)
step_console_assertion()
if console_errors:
print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 860})
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())