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