refactor: 优化代码
- 优化 部分核心功能实现。 - 新增 详细的文档字符串(Docstrings)和注释。 - 移除 代码中的冗余注释和无效代码。
This commit is contained in:
25
conftest.py
25
conftest.py
@@ -10,7 +10,9 @@
|
|||||||
@desc:
|
@desc:
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import allure
|
||||||
import pytest
|
import pytest
|
||||||
from core.run_appium import start_appium_service, stop_appium_service
|
from core.run_appium import start_appium_service, stop_appium_service
|
||||||
from core.driver import CoreDriver
|
from core.driver import CoreDriver
|
||||||
@@ -42,8 +44,10 @@ def driver(app_server):
|
|||||||
"platformName": "Android",
|
"platformName": "Android",
|
||||||
"automationName": "uiautomator2",
|
"automationName": "uiautomator2",
|
||||||
"deviceName": "Android",
|
"deviceName": "Android",
|
||||||
"appPackage": "io.appium.android.apis",
|
"appPackage": "com.manu.wanandroid",
|
||||||
"appActivity": "io.appium.android.apis.ApiDemos",
|
# "appPackage": "com.bocionline.ibmp",
|
||||||
|
"appActivity": "com.manu.wanandroid.ui.main.activity.MainActivity",
|
||||||
|
# "appActivity": "com.bocionline.ibmp.app.main.launcher.LauncherActivity",
|
||||||
"noReset": False, # 不清除应用数据
|
"noReset": False, # 不清除应用数据
|
||||||
"newCommandTimeout": 60
|
"newCommandTimeout": 60
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,21 @@ def pytest_exception_interact(node, call, report):
|
|||||||
if report.failed:
|
if report.failed:
|
||||||
# 获取详细的错误堆栈(包含 assert 的对比信息)
|
# 获取详细的错误堆栈(包含 assert 的对比信息)
|
||||||
exc_info = call.excinfo.getrepr(style='no-locals')
|
exc_info = call.excinfo.getrepr(style='no-locals')
|
||||||
|
name = f"异常截图_{secrets.token_hex(8)}"
|
||||||
|
|
||||||
logger.error(f"TEST FAILED: {node.nodeid}")
|
logger.error(f"TEST FAILED: {node.nodeid}")
|
||||||
logger.error(f"详细错误信息如下:\n{exc_info}")
|
logger.error(f"截图名称: {name}")
|
||||||
|
logger.error(f"详细错误信息如下:\n{exc_info}")
|
||||||
|
|
||||||
|
# 3. 自动截图:尝试从 fixture 中获取 driver
|
||||||
|
# node.funcargs 包含了当前测试用例请求的所有 fixture 实例
|
||||||
|
driver = node.funcargs.get("driver")
|
||||||
|
if driver:
|
||||||
|
try:
|
||||||
|
allure.attach(
|
||||||
|
driver.get_screenshot_as_png(),
|
||||||
|
name=name,
|
||||||
|
attachment_type=allure.attachment_type.PNG
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"执行异常截图失败: {e}")
|
||||||
|
|||||||
@@ -10,31 +10,70 @@
|
|||||||
@desc:
|
@desc:
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
from typing import Type, TypeVar, List, Tuple, Optional
|
|
||||||
import allure
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Type, TypeVar, Optional
|
||||||
|
|
||||||
|
import allure
|
||||||
from appium import webdriver
|
from appium import webdriver
|
||||||
from selenium.common import TimeoutException
|
|
||||||
|
|
||||||
from core.driver import CoreDriver
|
from core.driver import CoreDriver
|
||||||
from utils.decorators import exception_capture
|
|
||||||
|
|
||||||
# 定义一个泛型,用于类型推断(IDE 依然会有补全提示)
|
# 定义一个泛型,用于类型推断
|
||||||
T = TypeVar('T', bound='BasePage')
|
T = TypeVar('T', bound='BasePage')
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BasePage(CoreDriver):
|
class BasePage(CoreDriver):
|
||||||
|
# --- 全局通用的属性 ---
|
||||||
def __init__(self, driver: webdriver.Remote):
|
def __init__(self, driver: webdriver.Remote):
|
||||||
super().__init__(driver)
|
|
||||||
# 定义常见弹窗的关闭按钮定位
|
|
||||||
|
|
||||||
def log_screenshot(self, label: str = "步骤截图"):
|
|
||||||
"""
|
"""
|
||||||
业务级截图:执行截图并附加到 Allure 报告。
|
初始化 BasePage。
|
||||||
用户可自由手动调用此方法。
|
|
||||||
|
:param driver: Appium WebDriver 实例
|
||||||
|
"""
|
||||||
|
super().__init__(driver)
|
||||||
|
|
||||||
|
# --- 所有页面通用的元动作 ---
|
||||||
|
def go_to(self, page_cls: Type[T]) -> T:
|
||||||
|
"""
|
||||||
|
通用的页面跳转/实例化方法 (Page Factory)。
|
||||||
|
|
||||||
|
:param page_cls: 目标页面类 (BasePage 的子类)
|
||||||
|
:return: 目标页面的实例
|
||||||
|
"""
|
||||||
|
logger.info(f"跳转到页面: {page_cls.__name__}")
|
||||||
|
return page_cls(self.driver)
|
||||||
|
|
||||||
|
def handle_permission_popups(self):
|
||||||
|
"""
|
||||||
|
处理通用的系统权限弹窗。
|
||||||
|
遍历预定义的黑名单,尝试关闭出现的系统级弹窗(如权限申请、安装确认等)。
|
||||||
|
"""
|
||||||
|
# 普适性黑名单
|
||||||
|
popup_blacklist = [
|
||||||
|
("id", "com.android.packageinstaller:id/permission_allow_button"),
|
||||||
|
("xpath", "//*[@text='始终允许']"),
|
||||||
|
("xpath", "//*[@text='稍后提醒']"),
|
||||||
|
("xpath", "//*[@text='以后再说']"),
|
||||||
|
("id", "com.app:id/iv_close_global_ad"),
|
||||||
|
("accessibility id", "Close"), # iOS 常用
|
||||||
|
]
|
||||||
|
self.clear_popups(popup_blacklist)
|
||||||
|
|
||||||
|
def handle_business_ads(self):
|
||||||
|
"""
|
||||||
|
处理全 App 通用的业务广告弹窗。
|
||||||
|
针对应用启动后可能出现的全局广告进行关闭处理。
|
||||||
|
"""
|
||||||
|
ads_blacklist = [("id", "com.app:id/global_ad_close")]
|
||||||
|
return self.clear_popups(ads_blacklist)
|
||||||
|
|
||||||
|
def save_and_attach_screenshot(self, label: str = "日志截图") -> None:
|
||||||
|
"""
|
||||||
|
保存截图到本地并附加到 Allure 报告。
|
||||||
|
|
||||||
|
:param label: 截图在报告中显示的名称
|
||||||
"""
|
"""
|
||||||
path_str = self.full_screen_screenshot(name=label)
|
path_str = self.full_screen_screenshot(name=label)
|
||||||
|
|
||||||
@@ -47,15 +86,16 @@ class BasePage(CoreDriver):
|
|||||||
attachment_type=allure.attachment_type.PNG
|
attachment_type=allure.attachment_type.PNG
|
||||||
)
|
)
|
||||||
|
|
||||||
def log_screenshot_bytes(self, label: str = "步骤截图"):
|
def attach_screenshot_bytes(self, label: str = "日志截图") -> None:
|
||||||
"""
|
"""
|
||||||
业务级截图:执行截图并附加到 Allure 报告。
|
直接获取内存中的截图数据并附加到 Allure 报告(不存本地文件)。
|
||||||
用户可自由手动调用此方法。
|
|
||||||
|
:param label: 截图在报告中显示的名称
|
||||||
"""
|
"""
|
||||||
_img: bytes = self.driver.get_screenshot_as_png()
|
screenshot_bytes: bytes = self.driver.get_screenshot_as_png()
|
||||||
|
|
||||||
allure.attach(
|
allure.attach(
|
||||||
_img,
|
screenshot_bytes,
|
||||||
name=label,
|
name=label,
|
||||||
attachment_type=allure.attachment_type.PNG
|
attachment_type=allure.attachment_type.PNG
|
||||||
)
|
)
|
||||||
@@ -64,6 +104,7 @@ class BasePage(CoreDriver):
|
|||||||
def assert_text(self, by: str, value: str, expected_text: str, timeout: Optional[float] = None) -> 'BasePage':
|
def assert_text(self, by: str, value: str, expected_text: str, timeout: Optional[float] = None) -> 'BasePage':
|
||||||
"""
|
"""
|
||||||
断言元素的文本内容是否符合预期。
|
断言元素的文本内容是否符合预期。
|
||||||
|
|
||||||
:param by: 定位策略。
|
:param by: 定位策略。
|
||||||
:param value: 定位值。
|
:param value: 定位值。
|
||||||
:param expected_text: 期望的文本。
|
:param expected_text: 期望的文本。
|
||||||
@@ -87,45 +128,21 @@ class BasePage(CoreDriver):
|
|||||||
logger.info(f"断言通过: 文本匹配 '{actual}'")
|
logger.info(f"断言通过: 文本匹配 '{actual}'")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
# 这里放全局通用的 Page 属性和逻辑
|
def assert_visible(self, by: str, value: str, msg: str = "元素可见性校验") -> 'BasePage':
|
||||||
def assert_visible(self, by: str, value: str, msg: str = "元素可见性校验"):
|
|
||||||
"""
|
"""
|
||||||
增强版断言:成功/失败均截图
|
断言元素是否可见。
|
||||||
|
|
||||||
|
:param by: 定位策略
|
||||||
|
:param value: 定位值
|
||||||
|
:param msg: 断言描述信息
|
||||||
|
:return: self,支持链式调用
|
||||||
"""
|
"""
|
||||||
with allure.step(f"断言检查: {msg}"):
|
with allure.step(f"断言检查: {msg}"):
|
||||||
try:
|
element = self.find_element(by, value)
|
||||||
element = self.find_element(by, value)
|
is_displayed = element.is_displayed()
|
||||||
assert element.is_displayed()
|
|
||||||
# 成功存证
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
# 封装一些所有页面通用的元动作
|
if is_displayed:
|
||||||
def clear_permission_popups(self):
|
logger.info(f"断言通过: 元素 [{value}] 可见")
|
||||||
# 普适性黑名单
|
|
||||||
_black_list = [
|
|
||||||
("id", "com.android.packageinstaller:id/permission_allow_button"),
|
|
||||||
("xpath", "//*[@text='始终允许']"),
|
|
||||||
("xpath", "//*[@text='稍后提醒']"),
|
|
||||||
("xpath", "//*[@text='以后再说']"),
|
|
||||||
("id", "com.app:id/iv_close_global_ad"),
|
|
||||||
("accessibility id", "Close"), # iOS 常用
|
|
||||||
]
|
|
||||||
self.clear_popups(_black_list)
|
|
||||||
|
|
||||||
def clear_business_ads(self):
|
assert is_displayed, f"断言失败: 元素 [{value}] 不可见"
|
||||||
"""在这里定义一些全 App 通用的业务广告清理"""
|
return self
|
||||||
_ads = [("id", "com.app:id/global_ad_close")]
|
|
||||||
return self.clear_popups(_ads)
|
|
||||||
|
|
||||||
def get_toast(self, text):
|
|
||||||
return self.is_visible("text", text)
|
|
||||||
|
|
||||||
def go_to(self, page_name: Type[T]) -> T:
|
|
||||||
"""
|
|
||||||
通用的页面跳转/获取方法
|
|
||||||
:param page_name: 目标页面类
|
|
||||||
:return: 目标页面的实例
|
|
||||||
"""
|
|
||||||
logger.info(f"跳转到页面: {page_name.__name__}")
|
|
||||||
return page_name(self.driver)
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Tuple, Any, Union
|
from typing import Any, Union
|
||||||
|
|
||||||
from appium.webdriver.webdriver import WebDriver
|
from appium.webdriver.webdriver import WebDriver
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.common.exceptions import StaleElementReferenceException, NoSuchElementException
|
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.remote.webelement import WebElement
|
from selenium.webdriver.remote.webelement import WebElement
|
||||||
|
from selenium.common.exceptions import StaleElementReferenceException, NoSuchElementException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,15 +34,30 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# 自定义预期条件(Custom Expected Condition)
|
# 自定义预期条件(Custom Expected Condition)
|
||||||
class BaseCondition:
|
class BaseCondition:
|
||||||
"""基础条件类:负责统一的 WebDriverWait 协议实现和异常拦截"""
|
"""
|
||||||
|
基础条件类:负责统一的 WebDriverWait 协议实现和异常拦截。
|
||||||
|
所有自定义的类形式 EC 都应继承此类
|
||||||
|
"""
|
||||||
|
|
||||||
def __call__(self, driver: WebDriver):
|
def __call__(self, driver: WebDriver):
|
||||||
|
"""
|
||||||
|
WebDriverWait 调用的入口方法。
|
||||||
|
|
||||||
|
:param driver: WebDriver 实例
|
||||||
|
:return: check 方法的返回值,或者在捕获异常时返回 False
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return self.check(driver)
|
return self.check(driver)
|
||||||
except (NoSuchElementException, StaleElementReferenceException):
|
except (NoSuchElementException, StaleElementReferenceException):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def check(self, driver: WebDriver):
|
def check(self, driver: WebDriver):
|
||||||
|
"""
|
||||||
|
执行具体的检查逻辑,由子类实现。
|
||||||
|
|
||||||
|
:param driver: WebDriver 实例
|
||||||
|
:return: 判定成功返回对象或 True,失败返回 False
|
||||||
|
"""
|
||||||
raise NotImplementedError("子类必须实现 check 方法")
|
raise NotImplementedError("子类必须实现 check 方法")
|
||||||
|
|
||||||
|
|
||||||
@@ -51,10 +66,13 @@ EC_MAPPING: dict[str, Any] = {}
|
|||||||
|
|
||||||
def register(name: str = None):
|
def register(name: str = None):
|
||||||
"""
|
"""
|
||||||
强大的注册装饰器:
|
自定义预期条件注册装饰器:
|
||||||
1. @register() -> 使用函数名注册
|
1. @register() -> 使用函数名注册
|
||||||
2. @register("alias") -> 使用别名注册
|
2. @register("alias") -> 使用别名注册
|
||||||
3. register("name", func) -> 手动注入
|
3. register("name", func) -> 手动注入
|
||||||
|
|
||||||
|
:param name: 注册别名,默认为函数/类名
|
||||||
|
:return: 装饰器函数
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(item):
|
def decorator(item):
|
||||||
@@ -67,7 +85,13 @@ def register(name: str = None):
|
|||||||
|
|
||||||
@register("toast_visible")
|
@register("toast_visible")
|
||||||
class ToastVisible(BaseCondition):
|
class ToastVisible(BaseCondition):
|
||||||
|
"""检查 Toast 消息是否可见"""
|
||||||
|
|
||||||
def __init__(self, text: str, partial: Union[str, bool] = True):
|
def __init__(self, text: str, partial: Union[str, bool] = True):
|
||||||
|
"""
|
||||||
|
:param text: 期望的 Toast 文本
|
||||||
|
:param partial: 是否部分匹配 (默认 True)
|
||||||
|
"""
|
||||||
self.text = text
|
self.text = text
|
||||||
# 处理从装饰器传来的字符串 "true"/"false"
|
# 处理从装饰器传来的字符串 "true"/"false"
|
||||||
if isinstance(partial, str):
|
if isinstance(partial, str):
|
||||||
@@ -84,8 +108,16 @@ class ToastVisible(BaseCondition):
|
|||||||
|
|
||||||
@register("attr_contains")
|
@register("attr_contains")
|
||||||
class ElementHasAttribute(BaseCondition):
|
class ElementHasAttribute(BaseCondition):
|
||||||
|
"""检查元素的属性是否包含特定值"""
|
||||||
|
|
||||||
# 扁平化参数以支持字符串调用: "attr_contains:id,btn_id,checked,true"
|
# 扁平化参数以支持字符串调用: "attr_contains:id,btn_id,checked,true"
|
||||||
def __init__(self, by: str, value: str, attribute: str, expect_value: str):
|
def __init__(self, by: str, value: str, attribute: str, expect_value: str):
|
||||||
|
"""
|
||||||
|
:param by: 定位策略
|
||||||
|
:param value: 定位值
|
||||||
|
:param attribute: 属性名
|
||||||
|
:param expect_value: 期望包含的属性值
|
||||||
|
"""
|
||||||
self.locator = (by, value)
|
self.locator = (by, value)
|
||||||
self.attribute = attribute
|
self.attribute = attribute
|
||||||
self.value = expect_value
|
self.value = expect_value
|
||||||
@@ -114,6 +146,13 @@ class ElementCountAtLeast(BaseCondition):
|
|||||||
|
|
||||||
@register() # 使用函数名 is_element_present 注册
|
@register() # 使用函数名 is_element_present 注册
|
||||||
def is_element_present(by: str, value: str):
|
def is_element_present(by: str, value: str):
|
||||||
|
"""
|
||||||
|
检查元素是否存在于 DOM 中 (不一定可见)。
|
||||||
|
|
||||||
|
:param by: 定位策略
|
||||||
|
:param value: 定位值
|
||||||
|
:return: 判定函数
|
||||||
|
"""
|
||||||
locator = (by, value)
|
locator = (by, value)
|
||||||
|
|
||||||
def _predicate(driver):
|
def _predicate(driver):
|
||||||
@@ -128,6 +167,13 @@ def is_element_present(by: str, value: str):
|
|||||||
|
|
||||||
@register()
|
@register()
|
||||||
def system_ready(api_client):
|
def system_ready(api_client):
|
||||||
|
"""
|
||||||
|
检查外部系统 (API) 是否就绪。
|
||||||
|
|
||||||
|
:param api_client: API 客户端实例
|
||||||
|
:return: 判定函数
|
||||||
|
"""
|
||||||
|
|
||||||
def _predicate(_): # 忽略传入的 driver
|
def _predicate(_): # 忽略传入的 driver
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -145,6 +191,11 @@ def get_condition(method: Union[str, Any], *args, **kwargs):
|
|||||||
1. 如果 method 是字符串,先查自定义 EC_MAPPING
|
1. 如果 method 是字符串,先查自定义 EC_MAPPING
|
||||||
2. 如果自定义里没有,去官方 selenium.webdriver.support.expected_conditions 找
|
2. 如果自定义里没有,去官方 selenium.webdriver.support.expected_conditions 找
|
||||||
3. 如果 method 本身就是 Callable (比如 EC.presence_of_element_located),直接透传
|
3. 如果 method 本身就是 Callable (比如 EC.presence_of_element_located),直接透传
|
||||||
|
|
||||||
|
:param method: 预期条件名称 (str) 或 可调用对象
|
||||||
|
:param args: 传递给条件的参数
|
||||||
|
:param kwargs: 传递给条件的关键字参数
|
||||||
|
:return: 实例化后的预期条件对象 (Callable)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 情况 A: 如果传入的是官方 EC 对象或自定义函数实例,直接返回
|
# 情况 A: 如果传入的是官方 EC 对象或自定义函数实例,直接返回
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import secrets # 原生库,用于生成安全的随机数
|
import secrets # 原生库,用于生成安全的随机数
|
||||||
from typing import Optional, Type, TypeVar, Union, Callable
|
from typing import Optional, Type, TypeVar, Union, Callable, Any
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from appium import webdriver
|
from appium import webdriver
|
||||||
@@ -431,8 +431,16 @@ class CoreDriver:
|
|||||||
break
|
break
|
||||||
return active
|
return active
|
||||||
|
|
||||||
|
def back(self) -> 'CoreDriver':
|
||||||
|
"""
|
||||||
|
模拟设备返回键操作。
|
||||||
|
:return: self
|
||||||
|
"""
|
||||||
|
self.driver.back()
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_id(self):
|
def session_id(self) -> Any | None:
|
||||||
"""获取当前 Appium 会话的 Session ID。"""
|
"""获取当前 Appium 会话的 Session ID。"""
|
||||||
return self.driver.session_id
|
return self.driver.session_id
|
||||||
|
|
||||||
@@ -463,7 +471,7 @@ class CoreDriver:
|
|||||||
def swipe(self, direction: str = "up", duration: int = 1000) -> 'CoreDriver':
|
def swipe(self, direction: str = "up", duration: int = 1000) -> 'CoreDriver':
|
||||||
"""
|
"""
|
||||||
封装方向滑动
|
封装方向滑动
|
||||||
:param direction: 滑动方向 up/down/left/right
|
:param direction: 滑动方向 up/down/left/right (指手指滑动的方向)
|
||||||
:param duration: 滑动持续时间 (ms)
|
:param duration: 滑动持续时间 (ms)
|
||||||
:return: self
|
:return: self
|
||||||
"""
|
"""
|
||||||
@@ -486,7 +494,16 @@ class CoreDriver:
|
|||||||
def long_press(self, element: Optional[WebElement] = None, x: Optional[int] = None, y: Optional[int] = None,
|
def long_press(self, element: Optional[WebElement] = None, x: Optional[int] = None, y: Optional[int] = None,
|
||||||
duration: int = 2000) -> 'CoreDriver':
|
duration: int = 2000) -> 'CoreDriver':
|
||||||
"""
|
"""
|
||||||
长按封装:支持传入元素或坐标
|
长按封装:支持传入元素或坐标。
|
||||||
|
如果传入 element,则计算其中心点坐标进行长按。
|
||||||
|
如果传入 x, y,则直接在坐标处长按。
|
||||||
|
|
||||||
|
:param element: 目标元素 (WebElement),可选。
|
||||||
|
:param x: 绝对坐标 X,可选。
|
||||||
|
:param y: 绝对坐标 Y,可选。
|
||||||
|
:param duration: 长按持续时间 (ms),默认 2000ms。
|
||||||
|
:return: self
|
||||||
|
:raises ValueError: 如果既未传入 element 也未传入坐标 (x, y)。
|
||||||
"""
|
"""
|
||||||
if element:
|
if element:
|
||||||
rect = element.rect
|
rect = element.rect
|
||||||
@@ -502,7 +519,13 @@ class CoreDriver:
|
|||||||
|
|
||||||
def drag_and_drop(self, source_el: WebElement, target_el: WebElement, duration: int = 1000) -> 'CoreDriver':
|
def drag_and_drop(self, source_el: WebElement, target_el: WebElement, duration: int = 1000) -> 'CoreDriver':
|
||||||
"""
|
"""
|
||||||
将 source_el 拖拽到 target_el
|
将 source_el 拖拽到 target_el。
|
||||||
|
计算两个元素的中心点,执行从源元素中心到目标元素中心的拖拽操作。
|
||||||
|
|
||||||
|
:param source_el: 源元素 (WebElement)。
|
||||||
|
:param target_el: 目标元素 (WebElement)。
|
||||||
|
:param duration: 拖拽过程持续时间 (ms),默认 1000ms。
|
||||||
|
:return: self
|
||||||
"""
|
"""
|
||||||
s_rect = source_el.rect
|
s_rect = source_el.rect
|
||||||
t_rect = target_el.rect
|
t_rect = target_el.rect
|
||||||
@@ -518,6 +541,7 @@ class CoreDriver:
|
|||||||
智能滚动:自动识别平台并调用最稳定的原生滚动脚本
|
智能滚动:自动识别平台并调用最稳定的原生滚动脚本
|
||||||
:param element: 需要滚动的容器元素 (如 ScrollView, RecyclerView, TableView)
|
:param element: 需要滚动的容器元素 (如 ScrollView, RecyclerView, TableView)
|
||||||
:param direction: 滚动方向 'up', 'down', 'left', 'right'
|
:param direction: 滚动方向 'up', 'down', 'left', 'right'
|
||||||
|
:return: self
|
||||||
"""
|
"""
|
||||||
platform = self.driver.capabilities.get('platformName', '').lower()
|
platform = self.driver.capabilities.get('platformName', '').lower()
|
||||||
match platform:
|
match platform:
|
||||||
@@ -537,10 +561,18 @@ class CoreDriver:
|
|||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def swipe_by_percent(self, start_xp: int, start_yp: int, end_xp: int, end_yp: int,
|
def swipe_by_percent(self, start_xp: float, start_yp: float, end_xp: float, end_yp: float,
|
||||||
duration: int = 1000) -> 'CoreDriver':
|
duration: int = 1000) -> 'CoreDriver':
|
||||||
"""
|
"""
|
||||||
按屏幕比例滑动 (0.5 = 50%)
|
按屏幕比例滑动。
|
||||||
|
坐标值为屏幕宽高的百分比 (0.0 - 1.0)。
|
||||||
|
|
||||||
|
:param start_xp: 起点 X 比例 (如 0.5)。
|
||||||
|
:param start_yp: 起点 Y 比例 (如 0.8)。
|
||||||
|
:param end_xp: 终点 X 比例 (如 0.5)。
|
||||||
|
:param end_yp: 终点 Y 比例 (如 0.2)。
|
||||||
|
:param duration: 滑动持续时间 (ms),默认 1000ms。
|
||||||
|
:return: self
|
||||||
"""
|
"""
|
||||||
size = self.driver.get_window_size()
|
size = self.driver.get_window_size()
|
||||||
w, h = size['width'], size['height']
|
w, h = size['width'], size['height']
|
||||||
|
|||||||
@@ -13,11 +13,18 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
class AppPlatform(Enum):
|
class AppPlatform(Enum):
|
||||||
|
"""
|
||||||
|
定义支持的移动应用平台枚举。
|
||||||
|
"""
|
||||||
ANDROID = "android"
|
ANDROID = "android"
|
||||||
IOS = "ios"
|
IOS = "ios"
|
||||||
|
|
||||||
|
|
||||||
class Locator(str, Enum):
|
class Locator(str, Enum):
|
||||||
|
"""
|
||||||
|
定义元素定位策略枚举。
|
||||||
|
继承 str 以便直接作为参数传递给 Selenium/Appium 方法。
|
||||||
|
"""
|
||||||
# --- 原有 Selenium 支持 ---
|
# --- 原有 Selenium 支持 ---
|
||||||
ID = "id"
|
ID = "id"
|
||||||
NAME = "name"
|
NAME = "name"
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import http.client
|
|||||||
import socket
|
import socket
|
||||||
import json
|
import json
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from core.settings import BASE_DIR, APPIUM_HOST, APPIUM_PORT
|
from core.settings import BASE_DIR, APPIUM_HOST, APPIUM_PORT, MAX_RETRIES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,20 +42,26 @@ class AppiumStatus(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class ServiceRole(Enum):
|
class ServiceRole(Enum):
|
||||||
"""服务角色枚举:明确权责"""
|
"""服务角色枚举:定义服务的所有权和生命周期"""
|
||||||
MANAGED = "由脚本启动 (受控模式)" # 需要负责清理
|
MANAGED = "由脚本启动 (托管模式)" # 由本脚本启动,负责清理
|
||||||
EXTERNAL = "复用外部服务 (共享模式)" # 不负责清理
|
EXTERNAL = "复用外部服务 (共享模式)" # 复用现有服务,不负责清理
|
||||||
NULL = "无效服务 (空模式)" # 占位或失败状态
|
NULL = "无效服务 (空模式)" # 无效或未初始化的服务
|
||||||
|
|
||||||
|
|
||||||
def get_appium_command():
|
def resolve_appium_command() -> List[str]:
|
||||||
"""精确定位 Appium 执行文件,避免 npm 包装层"""
|
"""
|
||||||
|
解析 Appium 可执行文件的绝对路径。
|
||||||
|
优先查找项目 node_modules 下的本地安装版本,避免 npm 包装层带来的信号传递问题。
|
||||||
|
|
||||||
|
:return: 用于 subprocess 的命令列表
|
||||||
|
:raises SystemExit: 如果找不到 Appium 执行文件
|
||||||
|
"""
|
||||||
bin_name = "appium.cmd" if sys.platform == "win32" else "appium"
|
bin_name = "appium.cmd" if sys.platform == "win32" else "appium"
|
||||||
appium_bin = BASE_DIR / "node_modules" / ".bin" / bin_name
|
appium_bin = BASE_DIR / "node_modules" / ".bin" / bin_name
|
||||||
|
|
||||||
if not appium_bin.exists():
|
if not appium_bin.exists():
|
||||||
# 报错提示:找不到本地 Appium,引导用户安装
|
# 报错提示:找不到本地 Appium,引导用户安装
|
||||||
logger.info(f"\n错误: 在路径 {appium_bin} 未找到 Appium 执行文件。")
|
logger.error(f"\n错误: 在路径 {appium_bin} 未找到 Appium 执行文件。")
|
||||||
logger.info("请确保已在项目目录下执行过: npm install appium")
|
logger.info("请确保已在项目目录下执行过: npm install appium")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
# 返回执行列表(用于 shell=False)
|
# 返回执行列表(用于 shell=False)
|
||||||
@@ -62,37 +69,45 @@ def get_appium_command():
|
|||||||
|
|
||||||
|
|
||||||
# 移除全局调用,防止 import 时因找不到 appium 而直接退出
|
# 移除全局调用,防止 import 时因找不到 appium 而直接退出
|
||||||
# APP_CMD_LIST = get_appium_command()
|
# APP_CMD_LIST = resolve_appium_command()
|
||||||
|
|
||||||
def _cleanup_process_tree(process: subprocess.Popen = None):
|
def _cleanup_process_tree(process: subprocess.Popen = None) -> None:
|
||||||
"""核心清理逻辑:针对方案一的跨平台递归关闭"""
|
"""
|
||||||
|
核心清理逻辑:跨平台递归终止进程树。
|
||||||
|
|
||||||
|
:param process: subprocess.Popen 对象
|
||||||
|
"""
|
||||||
if not process or process.poll() is not None:
|
if not process or process.poll() is not None:
|
||||||
return
|
return
|
||||||
logger.info(f"正在关闭 Appium 进程树 (PID: {process.pid})...")
|
logger.info(f"正在关闭 Appium 进程树 (PID: {process.pid})...")
|
||||||
try:
|
try:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
# Windows 下使用 taskkill 强制关闭进程树 /T
|
# Windows 下使用 taskkill 强制关闭进程树 /T
|
||||||
subprocess.run(['taskkill', '/F', '/T', '/PID', str(process.pid)],
|
subprocess.run(
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
['taskkill', '/F', '/T', '/PID', str(process.pid)],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Unix/Mac: 获取进程组 ID 并发送终止信号
|
# Unix/Linux: 获取进程组 ID 并发送终止信号
|
||||||
pgid = os.getpgid(process.pid)
|
pgid = os.getpgid(process.pid)
|
||||||
os.killpg(pgid, signal.SIGTERM)
|
os.killpg(pgid, signal.SIGTERM)
|
||||||
|
|
||||||
process.wait(timeout=5)
|
process.wait(timeout=5)
|
||||||
logger.info("所有相关进程已安全退出。")
|
logger.info("Appium 服务已停止,相关进程已安全退出。")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"关闭进程时遇到意外: {e}")
|
logger.error(f"停止服务时发生异常: {e}")
|
||||||
try:
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
|
logger.warning(f"已强制杀死进程: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"强制关闭: {e}")
|
logger.debug(f"强制杀死进程失败 (可能已退出): {e}")
|
||||||
# 这里通常保持安静,因为我们已经尝试过清理了
|
# 这里通常保持安静,因为我们已经尝试过清理了
|
||||||
logger.info("服务已完全清理。")
|
logger.info("服务已完全清理。")
|
||||||
|
|
||||||
|
|
||||||
class AppiumService:
|
class AppiumService:
|
||||||
"""Appium 服务上下文容器"""
|
"""Appium 服务实例封装,用于管理服务生命周期"""
|
||||||
|
|
||||||
def __init__(self, role: ServiceRole, process: subprocess.Popen = None):
|
def __init__(self, role: ServiceRole, process: subprocess.Popen = None):
|
||||||
self.role = role
|
self.role = role
|
||||||
@@ -102,25 +117,47 @@ class AppiumService:
|
|||||||
"""统一停止接口:根据角色决定是否关闭进程"""
|
"""统一停止接口:根据角色决定是否关闭进程"""
|
||||||
match self.role:
|
match self.role:
|
||||||
case ServiceRole.EXTERNAL:
|
case ServiceRole.EXTERNAL:
|
||||||
logger.info(f"--> [角色: {self.role.value}] 脚本退出,保留原服务运行。")
|
logger.info(f"--> [角色: {self.role.value}] 脚本退出,保留外部服务运行。")
|
||||||
return
|
return
|
||||||
case ServiceRole.MANAGED:
|
case ServiceRole.MANAGED:
|
||||||
_cleanup_process_tree(self.process)
|
_cleanup_process_tree(self.process)
|
||||||
case ServiceRole.NULL:
|
case ServiceRole.NULL:
|
||||||
logger.info(f"--> [角色: {self.role.value}] 无需清理。")
|
logger.info(f"--> [角色: {self.role.value}] 无需执行清理。")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<AppiumService 角色='{self.role.value}' 端口={APPIUM_PORT}>"
|
return f"<AppiumService 角色='{self.role.value}' 端口={APPIUM_PORT}>"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_port_availability() -> AppiumStatus:
|
||||||
|
"""辅助函数:检查端口是否被占用"""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
try:
|
||||||
|
sock.settimeout(1)
|
||||||
|
# 尝试绑定端口,如果成功说明端口空闲
|
||||||
|
sock.bind((APPIUM_HOST, APPIUM_PORT))
|
||||||
|
return AppiumStatus.OFFLINE # 真正未启动
|
||||||
|
except OSError:
|
||||||
|
# 绑定失败,说明端口被占用,但此前 HTTP 请求失败,说明不是 Appium
|
||||||
|
return AppiumStatus.CONFLICT # 端口被占用但没响应 HTTP
|
||||||
|
|
||||||
|
|
||||||
def get_appium_status() -> AppiumStatus:
|
def get_appium_status() -> AppiumStatus:
|
||||||
"""深度探测 Appium 状态"""
|
"""
|
||||||
conn = None
|
检测 Appium 服务当前状态。
|
||||||
|
|
||||||
|
逻辑:
|
||||||
|
1. 尝试 HTTP 连接 /status 接口。
|
||||||
|
2. 如果连接成功,检查 ready 字段。
|
||||||
|
3. 如果连接被拒绝,尝试绑定端口以确认端口是否真正空闲。
|
||||||
|
|
||||||
|
:return: AppiumStatus 枚举
|
||||||
|
"""
|
||||||
|
connection = None
|
||||||
try:
|
try:
|
||||||
# 1. 端口开启,尝试获取 Appium 状态接口
|
# 1. 端口开启,尝试获取 Appium 状态接口
|
||||||
conn = http.client.HTTPConnection(APPIUM_HOST, APPIUM_PORT, timeout=2)
|
connection = http.client.HTTPConnection(APPIUM_HOST, APPIUM_PORT, timeout=2)
|
||||||
conn.request("GET", "/status")
|
connection.request("GET", "/status")
|
||||||
response = conn.getresponse()
|
response = connection.getresponse()
|
||||||
|
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
return AppiumStatus.CONFLICT
|
return AppiumStatus.CONFLICT
|
||||||
@@ -134,95 +171,63 @@ def get_appium_status() -> AppiumStatus:
|
|||||||
|
|
||||||
except (socket.error, ConnectionRefusedError):
|
except (socket.error, ConnectionRefusedError):
|
||||||
# 3. 如果通信拒绝,检查端口是否真的空闲
|
# 3. 如果通信拒绝,检查端口是否真的空闲
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
return _check_port_availability()
|
||||||
try:
|
|
||||||
s.settimeout(1)
|
|
||||||
s.bind((APPIUM_HOST, APPIUM_PORT))
|
|
||||||
return AppiumStatus.OFFLINE # 真正未启动
|
|
||||||
except OSError:
|
|
||||||
return AppiumStatus.CONFLICT # 端口被占用但没响应 HTTP
|
|
||||||
except (http.client.HTTPException, json.JSONDecodeError):
|
except (http.client.HTTPException, json.JSONDecodeError):
|
||||||
return AppiumStatus.UNKNOWN
|
return AppiumStatus.UNKNOWN
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(f"状态检测异常: {e}")
|
||||||
return AppiumStatus.ERROR
|
return AppiumStatus.ERROR
|
||||||
finally:
|
finally:
|
||||||
if conn: conn.close()
|
if connection: connection.close()
|
||||||
|
|
||||||
|
|
||||||
def start_appium_service() -> AppiumService:
|
def start_appium_service() -> AppiumService:
|
||||||
"""管理 Appium 服务的生命周期"""
|
"""
|
||||||
process = None # 1. 预先初始化变量,防止作用域错误
|
管理 Appium 服务的生命周期
|
||||||
managed = False
|
如果服务未启动,则启动本地服务;如果已启动,则复用。
|
||||||
# 轮询等待真正就绪
|
|
||||||
|
|
||||||
# 延迟获取命令,确保只在真正需要启动服务时检查环境
|
|
||||||
app_cmd_list = get_appium_command()
|
|
||||||
max_retries = 40
|
|
||||||
|
|
||||||
for i in range(max_retries):
|
:return: AppiumService 对象
|
||||||
|
"""
|
||||||
|
process = None # 1. 预先初始化变量,防止作用域错误
|
||||||
|
is_managed = False
|
||||||
|
# 轮询等待真正就绪
|
||||||
|
# 延迟获取命令,确保只在真正需要启动服务时检查环境
|
||||||
|
cmd_args = resolve_appium_command()
|
||||||
|
|
||||||
|
for i in range(MAX_RETRIES):
|
||||||
status = get_appium_status()
|
status = get_appium_status()
|
||||||
match status: # Python 3.10+ 的模式匹配
|
match status: # Python 3.10+ 的模式匹配
|
||||||
case AppiumStatus.READY:
|
case AppiumStatus.READY:
|
||||||
if managed:
|
if is_managed:
|
||||||
# 安全打印 PID
|
# 安全打印 PID
|
||||||
pid_str = f"PID: {process.pid}" if process else "EXTERNAL"
|
pid_info = f"PID: {process.pid}" if process else "EXTERNAL"
|
||||||
logger.info(f"Appium 已经完全就绪! ({pid_str})")
|
logger.info(f"Appium 服务启动成功! ({pid_info})")
|
||||||
return AppiumService(ServiceRole.MANAGED, process)
|
return AppiumService(ServiceRole.MANAGED, process)
|
||||||
else:
|
else:
|
||||||
logger.info(f"--> [复用] 有效的 Appium 服务已在运行 (Port: {APPIUM_PORT})")
|
logger.info(f"--> [复用] 有效的 Appium 服务已在运行 (Port: {APPIUM_PORT})")
|
||||||
logger.info("--> [注意] 脚本退出时将保留该服务,不会将其关闭。")
|
logger.info("--> [注意] 脚本退出时将保留该服务,不会将其关闭。")
|
||||||
return AppiumService(ServiceRole.EXTERNAL, process)
|
return AppiumService(ServiceRole.EXTERNAL, process)
|
||||||
case AppiumStatus.CONFLICT:
|
case AppiumStatus.CONFLICT:
|
||||||
logger.warning(f"\n[!] 错误: 端口 {APPIUM_PORT} 被非 Appium 程序占用。")
|
_handle_port_conflict()
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("请手动执行以下命令释放端口后重试:")
|
|
||||||
if sys.platform == "win32":
|
|
||||||
logger.info(
|
|
||||||
f" CMD: for /f \"tokens=5\" %a in ('netstat -aon ^| findstr :{APPIUM_PORT}') do taskkill /F /PID %a")
|
|
||||||
else:
|
|
||||||
logger.info(f" Terminal: lsof -ti:{APPIUM_PORT} | xargs kill -9")
|
|
||||||
logger.info("=" * 60)
|
|
||||||
sys.exit(1)
|
|
||||||
case AppiumStatus.OFFLINE:
|
case AppiumStatus.OFFLINE:
|
||||||
|
if not is_managed:
|
||||||
|
process = _spawn_appium_process(cmd_args)
|
||||||
|
is_managed = True
|
||||||
|
|
||||||
if not managed:
|
|
||||||
logger.info("Appium 服务未启动...")
|
|
||||||
logger.info(f"正在准备启动本地 Appium 服务 (Port: {APPIUM_PORT})...")
|
|
||||||
|
|
||||||
# 注入环境变量,确保 Appium 寻找项目本地的驱动
|
|
||||||
env_vars = os.environ.copy()
|
|
||||||
env_vars["APPIUM_HOME"] = str(BASE_DIR)
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
app_cmd_list,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
env=env_vars,
|
|
||||||
cwd=BASE_DIR,
|
|
||||||
# Windows 和 Linux/Mac 的处理方式不同
|
|
||||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
|
|
||||||
preexec_fn=os.setsid if sys.platform != "win32" else None
|
|
||||||
)
|
|
||||||
managed = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"启动过程发生异常: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
else:
|
||||||
if process and process.poll() is not None:
|
if process and process.poll() is not None:
|
||||||
logger.warning("Appium 进程启动后异常退出。")
|
logger.warning("Appium 进程启动后异常退出。")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
case AppiumStatus.INITIALIZING:
|
case AppiumStatus.INITIALIZING:
|
||||||
if managed and process and process.poll() is not None:
|
if is_managed and process and process.poll() is not None:
|
||||||
logger.warning("Appium 驱动加载期间进程崩溃。")
|
logger.warning("Appium 在初始化期间崩溃。")
|
||||||
_cleanup_process_tree(process)
|
_cleanup_process_tree(process)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if i % 4 == 0: # 每 2 秒提醒一次,避免刷屏
|
if i % 4 == 0: # 每 2 秒提醒一次,避免刷屏
|
||||||
logger.info("Appium 正在加载驱动/插件,请稍候...")
|
logger.info("Appium 正在加载驱动/插件,请稍候...")
|
||||||
case AppiumStatus.ERROR:
|
case AppiumStatus.ERROR:
|
||||||
logger.error("探测接口发生内部错误(可能是解析失败或严重网络异常),脚本终止。")
|
logger.error("探测接口发生内部错误(可能是解析失败或严重网络异常),脚本终止。")
|
||||||
if managed and process:
|
if is_managed and process:
|
||||||
_cleanup_process_tree(process)
|
_cleanup_process_tree(process)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
case _:
|
case _:
|
||||||
@@ -236,13 +241,60 @@ def start_appium_service() -> AppiumService:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def stop_appium_service(server: AppiumService):
|
def _handle_port_conflict():
|
||||||
# """安全关闭服务"""
|
logger.warning(f"\n[!] 错误: 端口 {APPIUM_PORT} 被占用。")
|
||||||
server.stop()
|
logger.info("=" * 60)
|
||||||
|
logger.info("请手动执行以下命令释放端口后重试:")
|
||||||
|
if sys.platform == "win32":
|
||||||
|
logger.info(
|
||||||
|
f" CMD: for /f \"tokens=5\" %a in ('netstat -aon ^| findstr :{APPIUM_PORT}') do taskkill /F /PID %a")
|
||||||
|
else:
|
||||||
|
logger.info(f" Terminal: lsof -ti:{APPIUM_PORT} | xargs kill -9")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_appium_process(cmd_args: List[str]) -> subprocess.Popen:
|
||||||
|
"""启动 Appium 子进程"""
|
||||||
|
logger.info(f"正在启动本地 Appium 服务 (Port: {APPIUM_PORT})...")
|
||||||
|
|
||||||
|
# 注入环境变量,确保 Appium 寻找项目本地的驱动
|
||||||
|
env_vars = os.environ.copy()
|
||||||
|
env_vars["APPIUM_HOME"] = str(BASE_DIR)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return subprocess.Popen(
|
||||||
|
cmd_args,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
env=env_vars,
|
||||||
|
cwd=BASE_DIR,
|
||||||
|
# Windows 和 Linux/Mac 的处理方式不同
|
||||||
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
|
||||||
|
preexec_fn=os.setsid if sys.platform != "win32" else None
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"启动过程发生异常: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_appium_service(service: AppiumService):
|
||||||
|
"""
|
||||||
|
停止 Appium 服务。
|
||||||
|
:param service: AppiumService 对象
|
||||||
|
"""
|
||||||
|
service.stop()
|
||||||
|
|
||||||
|
|
||||||
# --- 装饰器实现 ---
|
# --- 装饰器实现 ---
|
||||||
def with_appium(func):
|
def with_appium(func):
|
||||||
|
"""
|
||||||
|
装饰器:在函数执行前后自动启动和停止 Appium 服务。
|
||||||
|
适用于简单的脚本或调试场景。
|
||||||
|
:param func: 需要包装的函数
|
||||||
|
:return: 包装后的函数
|
||||||
|
"""
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
service = start_appium_service()
|
service = start_appium_service()
|
||||||
@@ -257,18 +309,18 @@ def with_appium(func):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# 使用示例:作为一个上下文管理器或简单的生命周期示例
|
# 使用示例:作为一个上下文管理器或简单的生命周期示例
|
||||||
appium_proc = None
|
appium_service = None
|
||||||
try:
|
try:
|
||||||
appium_proc = start_appium_service()
|
appium_service = start_appium_service()
|
||||||
print(f"\n[项目路径] {BASE_DIR}")
|
print(f"\n[项目路径] {BASE_DIR}")
|
||||||
print("\n[提示] 现在可以手动或通过其他脚本运行测试用例。")
|
print(f"[服务状态] Appium 运行中 (Port: {APPIUM_PORT})")
|
||||||
print("[提示] 按下 Ctrl+C 可停止由本脚本启动的服务。")
|
print("[操作提示] 按 Ctrl+C 停止服务...")
|
||||||
|
|
||||||
# 保持运行,直到手动停止(在实际测试框架中,这里会被替换为测试执行逻辑)
|
# 保持运行,直到手动停止(在实际测试框架中,这里会被替换为测试执行逻辑)
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
print("\n收到停止信号...")
|
||||||
finally:
|
finally:
|
||||||
stop_appium_service(appium_proc)
|
stop_appium_service(appium_service)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ for folder in [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP, SCREENSHOT_DIR]:
|
|||||||
# --- 文件路径 ---
|
# --- 文件路径 ---
|
||||||
LOG_SOURCE = LOG_DIR / "pytest.log"
|
LOG_SOURCE = LOG_DIR / "pytest.log"
|
||||||
|
|
||||||
|
# --- 启动 Appium 最大尝试次数 ---
|
||||||
|
MAX_RETRIES = 40
|
||||||
# --- 业务常量 (可选) ---
|
# --- 业务常量 (可选) ---
|
||||||
IMPLICIT_WAIT_TIMEOUT = 10
|
IMPLICIT_WAIT_TIMEOUT = 10
|
||||||
EXPLICIT_WAIT_TIMEOUT = 10
|
EXPLICIT_WAIT_TIMEOUT = 10
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import logging
|
|||||||
import allure
|
import allure
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from page_objects.api_demod_home import HomePage
|
from page_objects.wan_android_home import HomePage
|
||||||
from page_objects.api_demos_views import ViewsPage
|
from page_objects.wan_android_sidebar import ViewsPage
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@allure.epic("ApiDemos")
|
@allure.epic("ApiDemos")
|
||||||
@allure.feature("登录认证模块")
|
@allure.feature("登录认证模块")
|
||||||
class TestApiDemos:
|
class TestApiDemos:
|
||||||
# @allure.story("常规登录场景")
|
@allure.story("常规登录场景")
|
||||||
@allure.title("使用合法账号登录成功")
|
@allure.title("使用合法账号登录成功")
|
||||||
@allure.severity(allure.severity_level.BLOCKER)
|
@allure.severity(allure.severity_level.BLOCKER)
|
||||||
@allure.description("""
|
@allure.description("""
|
||||||
Reference in New Issue
Block a user