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:
xst
2026-09-02 14:59:27 +08:00
parent 2b4b2a4050
commit dd0877653e
110 changed files with 386 additions and 94 deletions
+95 -45
View File
@@ -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)