定位健壮性原则: 语义文本 > 组件库稳定 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, 已结算, 双支付完成)。
104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
"""Web Page Object 基类 — 封装所有页面共享的通用操作 (Playwright)。
|
|
|
|
每个具体页面应继承 BasePage,在 __init__ 中声明所有定位器。
|
|
|
|
Usage:
|
|
class LoginPage(BasePage):
|
|
def __init__(self, page):
|
|
super().__init__(page)
|
|
self.phone_input = page.get_by_placeholder("请输入用户名")
|
|
self.login_btn = page.get_by_role("button", name="登录")
|
|
"""
|
|
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from playwright.sync_api import Page, Locator, expect
|
|
|
|
from tests.core.allure_support import attach_png
|
|
from tests.core.paths import SCREENSHOTS_DIR
|
|
|
|
|
|
class BasePage:
|
|
"""Web Page Object 基类。"""
|
|
|
|
def __init__(self, page: Page, timeout_ms: int = 30_000):
|
|
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:
|
|
"""等待元素可见。"""
|
|
expect(locator).to_be_visible(timeout=timeout_ms or self.timeout)
|
|
|
|
def wait_hidden(self, locator: Locator, timeout_ms: Optional[int] = None) -> None:
|
|
"""等待元素消失(如 Loading 遮罩)。"""
|
|
expect(locator).to_be_hidden(timeout=timeout_ms or self.timeout)
|
|
|
|
def wait_for_text(self, text: str, timeout_ms: Optional[int] = None) -> None:
|
|
"""等待页面上出现指定文本。"""
|
|
expect(self.page.get_by_text(text)).to_be_visible(timeout=timeout_ms or self.timeout)
|
|
|
|
# ── 通用操作 ──
|
|
|
|
def fill_field(self, locator: Locator, value: str) -> None:
|
|
"""安全填写输入框(先等待可见,再清空后填写)。"""
|
|
self.wait_visible(locator)
|
|
locator.clear()
|
|
locator.fill(value)
|
|
|
|
def click_when_ready(self, locator: Locator) -> None:
|
|
"""安全点击(等待可见 + 可操作)。"""
|
|
self.wait_visible(locator)
|
|
locator.click()
|
|
|
|
def get_text(self, locator: Locator) -> str:
|
|
"""获取元素的 inner text。"""
|
|
self.wait_visible(locator)
|
|
return locator.inner_text()
|
|
|
|
# ── 截图 ──
|
|
|
|
def screenshot(self, name: str, full_page: bool = False) -> str:
|
|
"""关键节点截图(存盘 + attach Allure),返回文件路径。"""
|
|
scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__
|
|
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
path = str(scenario_dir / f"{name}_{ts}.png")
|
|
self.page.screenshot(path=path, full_page=full_page)
|
|
attach_png(path, name=name)
|
|
return path
|
|
|
|
# ── 导航 ──
|
|
|
|
def navigate(self, url: str) -> None:
|
|
"""导航到指定 URL 并等待页面加载完成。"""
|
|
self.page.goto(url, wait_until="domcontentloaded")
|