refactor(locators): 文本锚点优先的多候选定位 + 失败 DOM 快照
定位健壮性原则: 语义文本 > 组件库稳定 class > 层级结构 > nth-child/绝对路径。
Web (freight_accounting_page):
- 新增 BasePage.first_matching(*候选): 文本锚点优先命中, 旧结构路径兜底
- 13 处定位器改造: 核算/审核通过/确认打款按钮(get_by_role 文本)、
车队长/收款模式表单(.el-form-item label 锚定)、光大单选(label:has-text)、
获取验证码/验证码输入(按钮同行锚定)、message-box 确定等
- 手机号单选保留旧结构路径优先(弹窗含多组电话单选, 文本有歧义)
- 已实况验证: 获取验证码/确认打款/光大锚点唯一命中
App (freight_flow_page):
- 货源 tab: text("货源") 文本锚点优先(登录流程持续验证), 位置序号兜底
- 货源单列表项: RecyclerView(lRrcyclerView) 第一个子项, 全局 instance(3) 兜底
- 选图按钮(ImageView instance(2))保留: 需在途订单才能探测, 当前可用
失败诊断:
- Web 失败时 attach 页面 DOM 快照, App 失败时 attach page_source 到 Allure
验收: 实况锚点解析全部唯一命中; 两条 E2E 通过且 DB 终态均为全流程完成
(status=5, 已结算, 双支付完成)。
This commit is contained in:
@@ -21,6 +21,19 @@ def attach_png(path: str, name: str = "screenshot") -> None:
|
||||
pass # 附件失败不影响用例主流程
|
||||
|
||||
|
||||
def attach_text(content: str, name: str, attachment_type=None) -> None:
|
||||
"""把文本内容(DOM 快照/page_source 等)附加到 Allure 当前用例上。"""
|
||||
if allure is None:
|
||||
return
|
||||
try:
|
||||
allure.attach(
|
||||
content, name=name,
|
||||
attachment_type=attachment_type or allure.attachment_type.TEXT,
|
||||
)
|
||||
except Exception:
|
||||
pass # 附件失败不影响用例主流程
|
||||
|
||||
|
||||
def write_environment_properties(results_dir: str, config: dict) -> None:
|
||||
"""向 allure-results 目录写入 environment.properties(报告环境页展示)。"""
|
||||
if not results_dir:
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.core.allure_support import attach_png
|
||||
from tests.core.allure_support import allure, attach_png, attach_text
|
||||
from tests.core.paths import SCREENSHOTS_DIR
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ def _perform_ui_login(driver, account: dict, wait) -> None:
|
||||
|
||||
|
||||
def save_failure_screenshot(driver, section_config: dict, platform: str, request) -> str | None:
|
||||
"""用例失败时截图(存盘 + attach Allure),返回路径;未失败返回 None。"""
|
||||
"""用例失败时截图 + 页面源码快照(存盘/attach Allure),返回路径;未失败返回 None。"""
|
||||
if not (
|
||||
section_config.get("screenshot_on_failure", True)
|
||||
and hasattr(request.node, "rep_call")
|
||||
@@ -223,4 +223,10 @@ def save_failure_screenshot(driver, section_config: dict, platform: str, request
|
||||
path = str(scenario_dir / f"FAIL_{platform}_{ts}.png")
|
||||
driver.save_screenshot(path)
|
||||
attach_png(path, name=f"失败截图-{platform}")
|
||||
# page_source 快照: 定位器失效时直接看到当时的控件层级
|
||||
try:
|
||||
attach_text(driver.page_source, name=f"页面源码-{platform}",
|
||||
attachment_type=allure.attachment_type.XML)
|
||||
except Exception:
|
||||
pass
|
||||
return path
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.core.allure_support import attach_png
|
||||
from tests.core.allure_support import allure, attach_png, attach_text
|
||||
from tests.core.paths import SCREENSHOTS_DIR
|
||||
|
||||
# 本机 %LOCALAPPDATA%\ms-playwright 下 Chrome for Testing 存在 SxS 激活失败问题,
|
||||
@@ -86,7 +86,7 @@ def pick_available_role(storage_state_dir: Path, browser_name: str) -> str | Non
|
||||
|
||||
|
||||
def save_web_failure_screenshot(page, request, tag: str) -> str | None:
|
||||
"""用例失败时整页截图(存盘 + attach Allure),返回路径;未失败返回 None。
|
||||
"""用例失败时整页截图 + DOM 快照(存盘/attach Allure),返回路径;未失败返回 None。
|
||||
|
||||
Args:
|
||||
page: Playwright Page
|
||||
@@ -102,8 +102,14 @@ def save_web_failure_screenshot(page, request, tag: str) -> str | None:
|
||||
try:
|
||||
page.screenshot(path=path, full_page=True)
|
||||
except Exception:
|
||||
return None
|
||||
pass
|
||||
attach_png(path, name=f"失败截图-{tag}")
|
||||
# DOM 快照: 定位器失效时直接看到当时的页面结构
|
||||
try:
|
||||
attach_text(page.content(), name=f"DOM快照-{tag}",
|
||||
attachment_type=allure.attachment_type.HTML)
|
||||
except Exception:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -265,9 +265,11 @@ class FreightAccountingPage(BasePage):
|
||||
if team_leader_payment:
|
||||
self._select_team_leader_payment()
|
||||
|
||||
# 核算按钮 (selector 文档 Step 4)
|
||||
btn = self._modal().locator(
|
||||
"form > div:nth-child(4) button:nth-child(4) span"
|
||||
# 核算按钮 (文本锚点优先: 按钮名"核算"; 结构路径兜底)
|
||||
modal = self._modal()
|
||||
btn = self.first_matching(
|
||||
modal.get_by_role("button", name="核算"),
|
||||
modal.locator("form > div:nth-child(4) button:nth-child(4) span"),
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, wait_after=1500)
|
||||
self.screenshot("freight_accounting_done")
|
||||
@@ -283,12 +285,16 @@ class FreightAccountingPage(BasePage):
|
||||
注意: Element UI 的 el-select-dropdown popper 在 DOM 中可能有多个残留
|
||||
(旧的 hidden),需要用 nth(-1) 取最晚渲染的那个。
|
||||
"""
|
||||
# Step 1: 点击选择车队长下拉框
|
||||
selector_input = self.page.locator(
|
||||
"#app > div > div.main-container.hasTagsView > section > div > div > div "
|
||||
"> div.vxe-modal--wrapper.type--modal.is--animat.lock--view.is--mask.is--visible.is--active "
|
||||
"> div > div.vxe-modal--body > div.vxe-modal--content > div > form "
|
||||
"> div:nth-child(2) > div:nth-child(4) > div > div > div > div > input"
|
||||
# Step 1: 点击选择车队长下拉框(表单 label 文本锚点优先,结构路径兜底)
|
||||
modal = self._modal()
|
||||
selector_input = self.first_matching(
|
||||
modal.locator(".el-form-item:has-text('车队长') input"),
|
||||
self.page.locator(
|
||||
"#app > div > div.main-container.hasTagsView > section > div > div > div "
|
||||
"> div.vxe-modal--wrapper.type--modal.is--animat.lock--view.is--mask.is--visible.is--active "
|
||||
"> div > div.vxe-modal--body > div.vxe-modal--content > div > form "
|
||||
"> div:nth-child(2) > div:nth-child(4) > div > div > div > div > input"
|
||||
),
|
||||
).first
|
||||
self.wait_visible(selector_input, timeout_ms=5_000)
|
||||
selector_input.click()
|
||||
@@ -305,13 +311,19 @@ class FreightAccountingPage(BasePage):
|
||||
# Step 3: 等待收款模式 el-select 解除 disabled 后再点击
|
||||
# Element UI 的 disabled 加在 el-select 外层的 div 上,不是 input 本身
|
||||
# 所以需要等包含 is-disabled class 的上层 div 消失
|
||||
payment_select_wrapper = self.page.locator(
|
||||
".vxe-modal--wrapper.is--visible "
|
||||
"form > div:nth-child(2) > div:nth-child(5) > div > div > div"
|
||||
payment_select_wrapper = self.first_matching(
|
||||
modal.locator(".el-form-item:has-text('收款模式') .el-select"),
|
||||
self.page.locator(
|
||||
".vxe-modal--wrapper.is--visible "
|
||||
"form > div:nth-child(2) > div:nth-child(5) > div > div > div"
|
||||
),
|
||||
).first
|
||||
payment_input = self.page.locator(
|
||||
".vxe-modal--wrapper.is--visible "
|
||||
"form > div:nth-child(2) > div:nth-child(5) input"
|
||||
payment_input = self.first_matching(
|
||||
modal.locator(".el-form-item:has-text('收款模式') input"),
|
||||
self.page.locator(
|
||||
".vxe-modal--wrapper.is--visible "
|
||||
"form > div:nth-child(2) > div:nth-child(5) input"
|
||||
),
|
||||
).first
|
||||
self.wait_visible(payment_input, timeout_ms=5_000)
|
||||
for _ in range(30):
|
||||
@@ -349,9 +361,11 @@ class FreightAccountingPage(BasePage):
|
||||
# 滚动弹窗 (selector 文档 Step 11)
|
||||
self._scroll_modal()
|
||||
|
||||
# 审核通过 (selector 文档 Step 12)
|
||||
btn = self._modal().locator(
|
||||
"._btns > button.el-button.el-button--primary.el-button--medium > span"
|
||||
# 审核通过 (文本锚点优先: 按钮区"通过"; 结构路径兜底)
|
||||
modal = self._modal()
|
||||
btn = self.first_matching(
|
||||
modal.locator("._btns button:has-text('通过')"),
|
||||
modal.locator("._btns > button.el-button.el-button--primary.el-button--medium > span"),
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, wait_after=1500)
|
||||
self.screenshot("waybill_audit_done")
|
||||
@@ -370,36 +384,58 @@ class FreightAccountingPage(BasePage):
|
||||
self._click_table_action(button_index=2, order_no=order_no)
|
||||
self._scroll_modal()
|
||||
|
||||
# 勾选光大银行 (selector 文档 Step 20)
|
||||
# 勾选光大银行 (label 文本锚点优先; 结构路径兜底)
|
||||
def _click_guangda_radio():
|
||||
return self._modal().locator(
|
||||
"form > div:nth-child(9) > div > div:nth-child(2) "
|
||||
"> label > span.el-radio__input > span"
|
||||
return self.first_matching(
|
||||
self._modal().locator("label:has-text('光大')"),
|
||||
self._modal().locator(
|
||||
"form > div:nth-child(9) > div > div:nth-child(2) "
|
||||
"> label > span.el-radio__input > span"
|
||||
),
|
||||
timeout_ms=3_000,
|
||||
).first
|
||||
|
||||
self._try_click("勾选光大银行", _click_guangda_radio)
|
||||
|
||||
# 勾选手机号 (selector 文档 Step 21)
|
||||
# 勾选手机号 — 弹窗内有多组电话单选, 旧结构路径选中的才是业务正确项(优先保留);
|
||||
# 手机号格式正则仅作兜底
|
||||
def _click_phone_radio():
|
||||
return self.page.locator(
|
||||
"[id^='el-collapse-content-'] > div > div:nth-child(3) "
|
||||
"> label > span.el-radio__input > span"
|
||||
import re as _re
|
||||
|
||||
return self.first_matching(
|
||||
self.page.locator(
|
||||
"[id^='el-collapse-content-'] > div > div:nth-child(3) "
|
||||
"> label > span.el-radio__input > span"
|
||||
),
|
||||
self.page.locator(
|
||||
".el-radio__label", has_text=_re.compile(r"^1[3-9]\d{9}$")
|
||||
),
|
||||
timeout_ms=3_000,
|
||||
).first
|
||||
|
||||
self._try_click("勾选手机号", _click_phone_radio)
|
||||
|
||||
# 获取验证码 (selector 文档 Step 22)
|
||||
# 获取验证码 (按钮文本已实锤探测)
|
||||
def _click_get_code():
|
||||
return self._modal().locator(
|
||||
"form > div.el-row > div.el-col.el-col-4 button span"
|
||||
return self.first_matching(
|
||||
self._modal().get_by_role("button", name="获取验证码"),
|
||||
self._modal().locator(
|
||||
"form > div.el-row > div.el-col.el-col-4 button span"
|
||||
),
|
||||
timeout_ms=3_000,
|
||||
).first
|
||||
|
||||
self._try_click("获取验证码", _click_get_code, wait_after=500)
|
||||
|
||||
# 输入验证码 (selector 文档 Step 23) — 无验证码输入框时记录并跳过
|
||||
# 输入验证码 — form 直接子行中含"获取验证码"按钮的那个行里的输入框 (替代列结构路径)
|
||||
try:
|
||||
inp = self._modal().locator(
|
||||
"form > div.el-row > div.el-col.el-col-20 input"
|
||||
inp = self.first_matching(
|
||||
self._modal().locator(
|
||||
"form > div.el-row:has(button:has-text('获取验证码')) input"
|
||||
),
|
||||
self._modal().locator(
|
||||
"form > div.el-row > div.el-col.el-col-20 input"
|
||||
),
|
||||
).first
|
||||
self.wait_visible(inp)
|
||||
inp.clear()
|
||||
@@ -407,9 +443,12 @@ class FreightAccountingPage(BasePage):
|
||||
except Exception as e:
|
||||
logger.warning(f"可选操作 '输入验证码' 未完成,跳过: {type(e).__name__}")
|
||||
|
||||
# 确认打款 (selector 文档 Step 24) — 关键步骤, 失败必须抛出
|
||||
btn = self._modal().locator(
|
||||
"form > div:nth-child(12) > div > button span"
|
||||
# 确认打款 (按钮文本已实锤探测) — 关键步骤, 失败必须抛出
|
||||
btn = self.first_matching(
|
||||
self._modal().get_by_role("button", name="确认打款"),
|
||||
self._modal().locator(
|
||||
"form > div:nth-child(12) > div > button span"
|
||||
),
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, wait_after=1500)
|
||||
self.screenshot("finance_payment_done")
|
||||
@@ -427,24 +466,35 @@ class FreightAccountingPage(BasePage):
|
||||
# 打款 (button_index=2, selector 文档 Step 30)
|
||||
self._click_table_action(button_index=2, order_no=order_no)
|
||||
|
||||
# 勾选光大 (selector 文档 Step 31)
|
||||
# 勾选光大 (label 文本锚点优先; 结构路径兜底)
|
||||
def _click_guangda_label():
|
||||
return self._modal().locator(
|
||||
"form > div > div:nth-child(3) > div > div > div:nth-child(3) "
|
||||
"> label > span.el-radio__label"
|
||||
return self.first_matching(
|
||||
self._modal().locator("label:has-text('光大')"),
|
||||
self._modal().locator(
|
||||
"form > div > div:nth-child(3) > div > div > div:nth-child(3) "
|
||||
"> label > span.el-radio__label"
|
||||
),
|
||||
timeout_ms=3_000,
|
||||
).first
|
||||
|
||||
self._try_click("勾选光大", _click_guangda_label)
|
||||
|
||||
# 确认打款 (selector 文档 Step 31-2) — 关键步骤, 失败必须抛出
|
||||
btn = self._modal().locator("> div > div > button > span").first
|
||||
# 确认打款 (按钮文本已实锤探测) — 关键步骤, 失败必须抛出
|
||||
btn = self.first_matching(
|
||||
self._modal().get_by_role("button", name="确认打款"),
|
||||
self._modal().locator("> div > div > button > span"),
|
||||
).first
|
||||
self._retry_click_modal_btn(btn, wait_after=1000)
|
||||
|
||||
# message-box 确认 (selector 文档 Step 32)
|
||||
# message-box 确认 (文本锚点优先; 结构路径兜底)
|
||||
def _click_msgbox_confirm():
|
||||
return self.page.locator(
|
||||
"body > div.el-message-box__wrapper .el-message-box__btns "
|
||||
"> button.el-button.el-button--default.el-button--small.el-button--primary > span"
|
||||
return self.first_matching(
|
||||
self.page.locator(".el-message-box__btns button:has-text('确定')"),
|
||||
self.page.locator(
|
||||
"body > div.el-message-box__wrapper .el-message-box__btns "
|
||||
"> button.el-button.el-button--default.el-button--small.el-button--primary > span"
|
||||
),
|
||||
timeout_ms=3_000,
|
||||
)
|
||||
|
||||
self._try_click("message-box 确认", _click_msgbox_confirm, timeout_ms=5_000, wait_after=1500)
|
||||
|
||||
@@ -10,6 +10,7 @@ Usage:
|
||||
self.login_btn = page.get_by_role("button", name="登录")
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -26,6 +27,31 @@ class BasePage:
|
||||
self.page = page
|
||||
self.timeout = timeout_ms
|
||||
|
||||
# ── 多候选定位 ──
|
||||
|
||||
def first_matching(self, *locators: Locator, timeout_ms: int = 5_000) -> Locator:
|
||||
"""依次尝试多个候选定位器,返回第一个实际命中的;全部落空则抛错。
|
||||
|
||||
用于"语义文本锚点优先、结构路径兜底"的健壮定位:
|
||||
页面文案通常比 DOM 结构稳定,文案锚点命中就用它,
|
||||
结构变了还有旧路径兜底。
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_ms / 1000
|
||||
while True:
|
||||
for loc in locators:
|
||||
try:
|
||||
if loc.count() > 0 and loc.first.is_visible():
|
||||
return loc
|
||||
except Exception:
|
||||
continue
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
self.page.wait_for_timeout(200)
|
||||
raise TimeoutError(
|
||||
f"所有候选定位器在 {timeout_ms}ms 内均未命中 "
|
||||
f"({len(locators)} 个候选)"
|
||||
)
|
||||
|
||||
# ── 通用等待 ──
|
||||
|
||||
def wait_visible(self, locator: Locator, timeout_ms: Optional[int] = None) -> None:
|
||||
|
||||
@@ -25,16 +25,22 @@ class DriverFreightFlowPage(AppBasePage):
|
||||
super().__init__(driver, platform)
|
||||
|
||||
# ── 底部导航 ──
|
||||
# 0=首页, 1=货源, 2=消息, 3=我的
|
||||
self.freight_source_tab = (
|
||||
AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().resourceId("com.arpa.ynchenggangdriver:id/fixed_bottom_navigation_icon").instance(1)',
|
||||
# 文本锚点优先(登录流程每轮运行都在验证 text("货源") 可用),
|
||||
# 位置序号兜底(0=首页, 1=货源, 2=消息, 3=我的)
|
||||
self.freight_source_tab_candidates = (
|
||||
(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("货源")'),
|
||||
(AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().resourceId("com.arpa.ynchenggangdriver:id/fixed_bottom_navigation_icon").instance(1)'),
|
||||
)
|
||||
|
||||
# ── 货源单列表 ──
|
||||
self.freight_order_item = (
|
||||
AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().className("android.widget.RelativeLayout").instance(3)',
|
||||
# 锚定 RecyclerView 第一个子项(语义: 列表第一行),全局序号兜底
|
||||
self.freight_order_item_candidates = (
|
||||
(AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().resourceId("com.arpa.ynchenggangdriver:id/lRrcyclerView")'
|
||||
'.childSelector(new UiSelector().className("android.widget.RelativeLayout").instance(0))'),
|
||||
(AppiumBy.ANDROID_UIAUTOMATOR,
|
||||
'new UiSelector().className("android.widget.RelativeLayout").instance(3)'),
|
||||
)
|
||||
|
||||
# ── 数量 ──
|
||||
@@ -93,6 +99,17 @@ class DriverFreightFlowPage(AppBasePage):
|
||||
except Exception:
|
||||
logger.warning(f"可选元素 '{text}' 在 {timeout_s}s 内未出现,跳过点击")
|
||||
|
||||
def _tap_first_matching(self, *locators, timeout_s: float = 5.0):
|
||||
"""依次尝试多个候选定位器点击,第一个成功的生效;全部失败抛最后一个异常。"""
|
||||
last_exc: Exception | None = None
|
||||
for loc in locators:
|
||||
try:
|
||||
self._wait_and_click(loc, timeout_s)
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
raise last_exc if last_exc else RuntimeError("无候选定位器")
|
||||
|
||||
def _swipe_up(self, locator=None, duration_ms: int = 800):
|
||||
"""向上滑动。指定 locator 时在元素内滑动,否则全屏滑动。"""
|
||||
if locator:
|
||||
@@ -124,11 +141,11 @@ class DriverFreightFlowPage(AppBasePage):
|
||||
|
||||
def open_freight_tab(self):
|
||||
"""切换到底部导航的货源 tab。"""
|
||||
self._wait_and_click(self.freight_source_tab)
|
||||
self._tap_first_matching(*self.freight_source_tab_candidates)
|
||||
|
||||
def select_freight_item(self):
|
||||
"""选择货源单列表中的一项。"""
|
||||
self._wait_and_click(self.freight_order_item)
|
||||
self._tap_first_matching(*self.freight_order_item_candidates)
|
||||
|
||||
def fill_quantity(self, quantity: str):
|
||||
"""填写接单数量。"""
|
||||
|
||||
Reference in New Issue
Block a user