"""基础数据端到端验证:人员 / 产品 / 客户。 覆盖的链路(只有真实浏览器才能验证的组合): - 侧边菜单「基础数据」分组展开与三页导航 - 人员:新增(营销经理 / 挂经理的营销员)、直属经理名回显、辐射区域 编辑抽屉行内添加保存并重开回显 - 人员删除保护:被团队成员引用 → 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())