feat(driver): 新增CoreDriver基础操作,更新文档
- 优化 is_visible,支持快速状态检查。 - 新增 wait_until_visible/wait_until_not_visible 支持元素状态检查。 - 新增 clear_popups 支持弹窗清理。 - 优化 implicit_wait 状态追踪,确保等待时间恢复的准确性。 - 更新 README.md - 其他优化 [clear_popups 采用“非阻塞探测 + 阻塞确认”策略,大幅提升清理效率并减少无效等待]
This commit is contained in:
50
README.md
50
README.md
@@ -1,2 +1,52 @@
|
|||||||
# AppAutoTest
|
# AppAutoTest
|
||||||
|
|
||||||
|
设备能力配置 (Capabilities)
|
||||||
|
|
||||||
|
```python
|
||||||
|
ANDROID_CAPS = {
|
||||||
|
"platformName": "Android",
|
||||||
|
"automationName": "uiautomator2",
|
||||||
|
"deviceName": "Android",
|
||||||
|
"appPackage": "com.android.settings",
|
||||||
|
"appActivity": ".Settings",
|
||||||
|
"noReset": False
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段名称 | 字段含义解释 | 示例值说明 |
|
||||||
|
|----------------|---------------------------------------------|---------------------------------------------------------------------------------------|
|
||||||
|
| platformName | 测试的平台/操作系统。这是必填项,告诉自动化框架(如Appium)目标是什么系统。 | "Android" 表示这是一个Android设备。如果是iOS设备,则为 "iOS"。 |
|
||||||
|
| automationName | 使用的自动化驱动引擎。指定底层用哪个工具来驱动设备进行UI交互。 | "uiautomator2" 是当前主流的Android驱动框架,比旧的 "UiAutomator" 更稳定和高效。 |
|
||||||
|
| deviceName | 设备标识/名称。用于在同时连接多台设备时指定目标。 | "Android" 是一个通用标识。在实际测试中,通常用 adb devices 获取的真实设备序列号(如 emulator-5554)来替换它,以确保连接到正确的设备。 |
|
||||||
|
| appPackage | 要测试的应用程序包名。这是应用的唯一标识,就像它在Android系统中的“身份证号”。 | "com.android.settings" 是Android系统“设置”应用的包名。测试你自己的应用时,需替换为你应用的包名。 |
|
||||||
|
| appActivity | 要启动的应用内具体页面。它指定了应用启动后打开的第一个界面(Activity)。 | ".Settings" 是“设置”应用的主界面。前面的点. 表示它是 appPackage 下的一个相对路径。 |
|
||||||
|
|
||||||
|
获取应用的 appPackage 有几种常用方法
|
||||||
|
|
||||||
|
```shell
|
||||||
|
adb shell pm list packages | findstr your_package_name
|
||||||
|
# adb shell pm list packages -3 仅列出用户安装的第三方应用的包名
|
||||||
|
```
|
||||||
|
|
||||||
|
获取应用的 appActivity 有几种常用方法
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 1,使用 aapt 工具分析 APK 文件(需有安装包)/build-tools/{version}/aapt
|
||||||
|
aapt dump badging your_app.apk | findstr launchable-activity
|
||||||
|
# 输出结果:launchable-activity: name='com.example.myapp.MainActivity'
|
||||||
|
# name= 后面的值 'com.example.myapp.MainActivity' 就是你需要的主 Activity
|
||||||
|
|
||||||
|
# 2,通过 ADB 命令获取(需应用已安装)
|
||||||
|
adb shell dumpsys window | findstr mCurrentFocus
|
||||||
|
# 输出结果:mCurrentFocus=Window{... u0 com.example.myapp/com.example.myapp.MainActivity}
|
||||||
|
# / 后面的部分 com.example.myapp.MainActivity 就是当前 Activity
|
||||||
|
```
|
||||||
|
|
||||||
|
常用补充字段:
|
||||||
|
noReset:True/False。是否在会话开始前重置应用状态(例如清除应用数据)。设置为 True 可以避免每次测试都重新登录。
|
||||||
|
|
||||||
|
platformVersion:指定设备的Android系统版本(如 "11.0")。虽然不是必须,但指定后能增强兼容性。
|
||||||
|
|
||||||
|
unicodeKeyboard 和 resetKeyboard:用于处理中文输入等特殊字符输入。
|
||||||
|
|
||||||
|
newCommandTimeout:设置Appium服务器等待客户端发送新命令的超时时间(秒),默认为60秒。在长时间操作中可能需要增加。
|
||||||
14
conftest.py
14
conftest.py
@@ -38,12 +38,20 @@ def driver(app_server):
|
|||||||
"""
|
"""
|
||||||
# 实例化你提供的类结构
|
# 实例化你提供的类结构
|
||||||
app_helper = CoreDriver()
|
app_helper = CoreDriver()
|
||||||
|
caps = {
|
||||||
|
"platformName": "Android",
|
||||||
|
"automationName": "uiautomator2",
|
||||||
|
"deviceName": "Android",
|
||||||
|
"appPackage": "com.bocionline.ibmp",
|
||||||
|
"appActivity": "com.bocionline.ibmp.app.main.launcher.LauncherActivity",
|
||||||
|
"noReset": False, # 不清除应用数据
|
||||||
|
"newCommandTimeout": 60
|
||||||
|
}
|
||||||
# 连接并获取原生 driver 实例
|
# 连接并获取原生 driver 实例
|
||||||
# 这里可以根据需要扩展,比如通过命令行参数选择平台
|
# 这里可以根据需要扩展,比如通过命令行参数选择平台
|
||||||
app_helper.connect(platform="android", caps=ANDROID_CAPS)
|
app_helper.connect(platform="android", caps=caps)
|
||||||
|
|
||||||
yield app_helper
|
yield app_helper.driver
|
||||||
|
|
||||||
# 用例结束,只关 session,不关 server
|
# 用例结束,只关 session,不关 server
|
||||||
app_helper.quit()
|
app_helper.quit()
|
||||||
|
|||||||
@@ -10,36 +10,53 @@
|
|||||||
@desc:
|
@desc:
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from typing import Type, TypeVar
|
import secrets
|
||||||
|
from typing import Type, TypeVar, List, Tuple
|
||||||
|
|
||||||
|
from appium import webdriver
|
||||||
|
from selenium.common import TimeoutException
|
||||||
|
|
||||||
from core.driver import CoreDriver
|
from core.driver import CoreDriver
|
||||||
|
|
||||||
# 定义一个泛型,用于类型推断(IDE 依然会有补全提示)
|
# 定义一个泛型,用于类型推断(IDE 依然会有补全提示)
|
||||||
T = TypeVar('T', bound='BasePage')
|
T = TypeVar('T', bound='BasePage')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
class BasePage:
|
|
||||||
def __init__(self, driver: CoreDriver):
|
|
||||||
self.driver = driver
|
|
||||||
# 这里放全局通用的 Page 属性和逻辑
|
|
||||||
# --- 页面工厂:属性懒加载 ---
|
|
||||||
# 这样你可以在任何页面直接通过 self.home_page 访问首页
|
|
||||||
@property
|
|
||||||
def home_page(self):
|
|
||||||
from page_objects.home_page import HomePage
|
|
||||||
return HomePage(self.driver)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def login_page(self):
|
class BasePage(CoreDriver):
|
||||||
from page_objects.login_page import LoginPage
|
|
||||||
return LoginPage(self.driver)
|
def __init__(self, driver: webdriver.Remote):
|
||||||
|
super().__init__(driver)
|
||||||
|
# 定义常见弹窗的关闭按钮定位
|
||||||
|
|
||||||
|
# 这里放全局通用的 Page 属性和逻辑
|
||||||
|
|
||||||
# 封装一些所有页面通用的元动作
|
# 封装一些所有页面通用的元动作
|
||||||
def get_toast(self, text):
|
def clear_permission_popups(self):
|
||||||
return self.driver.is_visible("text", text)
|
# 普适性黑名单
|
||||||
|
_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 to_page(self, page_class: Type[T]) -> T:
|
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_class: 目标页面类
|
:param page_name: 目标页面类
|
||||||
:return: 目标页面的实例
|
:return: 目标页面的实例
|
||||||
"""
|
"""
|
||||||
logger.info(f"跳转到页面: {page_class.__name__}")
|
logger.info(f"跳转到页面: {page_name.__name__}")
|
||||||
return page_class(self.driver)
|
return page_name(self.driver)
|
||||||
|
|||||||
212
core/driver.py
212
core/driver.py
@@ -10,6 +10,7 @@
|
|||||||
@desc: Appium 核心驱动封装,提供统一的 API 用于 Appium 会话管理和元素操作。
|
@desc: Appium 核心驱动封装,提供统一的 API 用于 Appium 会话管理和元素操作。
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import secrets # 原生库,用于生成安全的随机数
|
||||||
from typing import Optional, Type, TypeVar, Union, Callable
|
from typing import Optional, Type, TypeVar, Union, Callable
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ from appium.webdriver.webdriver import ExtensionBase
|
|||||||
from appium.webdriver.webelement import WebElement
|
from appium.webdriver.webelement import WebElement
|
||||||
from appium.webdriver.client_config import AppiumClientConfig
|
from appium.webdriver.client_config import AppiumClientConfig
|
||||||
|
|
||||||
from selenium.common import TimeoutException
|
from selenium.common import TimeoutException, StaleElementReferenceException, NoSuchElementException
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.webdriver.common.action_chains import ActionChains
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
@@ -32,7 +33,7 @@ from selenium.webdriver.common.actions.pointer_input import PointerInput
|
|||||||
from utils.finder import by_converter
|
from utils.finder import by_converter
|
||||||
from utils.decorators import resolve_wait_method
|
from utils.decorators import resolve_wait_method
|
||||||
from core.modules import AppPlatform
|
from core.modules import AppPlatform
|
||||||
from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT
|
from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT, SCREENSHOT_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -40,21 +41,58 @@ T = TypeVar("T")
|
|||||||
|
|
||||||
|
|
||||||
class CoreDriver:
|
class CoreDriver:
|
||||||
def __init__(self):
|
def __init__(self, driver: Optional[webdriver.Remote] = None):
|
||||||
"""
|
"""
|
||||||
初始化 CoreDriver 实例。
|
初始化 CoreDriver 实例。
|
||||||
从 settings.py 加载默认的 Appium 服务器主机和端口。
|
从 settings.py 加载默认的 Appium 服务器主机和端口。
|
||||||
"""
|
"""
|
||||||
self.driver: Optional[webdriver.Remote] = None
|
self.driver = driver
|
||||||
|
self._current_implicit_timeout = IMPLICIT_WAIT_TIMEOUT
|
||||||
self._host = APPIUM_HOST
|
self._host = APPIUM_HOST
|
||||||
self._port = APPIUM_PORT
|
self._port = APPIUM_PORT
|
||||||
|
|
||||||
def server_config(self, host: str = APPIUM_HOST, port: int = APPIUM_PORT):
|
@property
|
||||||
|
def server_url(self) -> str:
|
||||||
|
"""
|
||||||
|
动态构造 URL,确保 server_config 修改后立即生效。
|
||||||
|
"""
|
||||||
|
return f"http://{self._host}:{self._port}"
|
||||||
|
|
||||||
|
def server_config(self, host: str = APPIUM_HOST, port: int = APPIUM_PORT)-> 'CoreDriver':
|
||||||
|
"""
|
||||||
|
配置服务器信息。支持链式调用。
|
||||||
|
:param host: ip
|
||||||
|
:param port: 端口
|
||||||
|
:return: 返回 CoreDriver 实例自身,支持链式调用。
|
||||||
|
"""
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
logger.info(f"Appium Server 指向 -> {self._host}:{self._port}")
|
logger.info(f"Appium Server 指向 -> {self._host}:{self._port}")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_options(platform: str | AppPlatform, caps: dict) -> AppiumOptions:
|
||||||
|
"""
|
||||||
|
根据平台生成对应的 Options
|
||||||
|
:param platform: 目标平台 ('android' 或 'ios'),支持 AppPlatform 枚举或字符串。
|
||||||
|
:param caps: Appium capabilities 字典。
|
||||||
|
:return: AppiumOptions
|
||||||
|
"""
|
||||||
|
match platform:
|
||||||
|
case AppPlatform.ANDROID.value:
|
||||||
|
logger.info(f"正在初始化 Android 会话...")
|
||||||
|
return UiAutomator2Options().load_capabilities(caps)
|
||||||
|
|
||||||
|
case AppPlatform.IOS.value:
|
||||||
|
logger.info(f"正在初始化 iOS 会话...")
|
||||||
|
return XCUITestOptions().load_capabilities(caps)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
# 优化:不再默认返回 Android,而是显式报错 (Fail Fast)
|
||||||
|
msg = f"不支持的平台类型: [{platform}]。当前仅支持: [android, ios]"
|
||||||
|
logger.error(msg)
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
def connect(self, platform: str | AppPlatform, caps: dict,
|
def connect(self, platform: str | AppPlatform, caps: dict,
|
||||||
extensions: list[Type[ExtensionBase]] | None = None,
|
extensions: list[Type[ExtensionBase]] | None = None,
|
||||||
client_config: AppiumClientConfig | None = None) -> 'CoreDriver':
|
client_config: AppiumClientConfig | None = None) -> 'CoreDriver':
|
||||||
@@ -71,33 +109,20 @@ class CoreDriver:
|
|||||||
"""
|
"""
|
||||||
# 1. 统一格式化平台名称
|
# 1. 统一格式化平台名称
|
||||||
platform_name = platform.value if isinstance(platform, AppPlatform) else platform.lower().strip()
|
platform_name = platform.value if isinstance(platform, AppPlatform) else platform.lower().strip()
|
||||||
url = f"http://{self._host}:{self._port}"
|
|
||||||
|
|
||||||
# 2. 预校验:如果已经有 driver 正在运行,先清理(防止 Session 冲突)
|
# 2. 预校验:如果已经有 driver 正在运行,先清理(防止 Session 冲突)
|
||||||
if self.driver:
|
if self.driver:
|
||||||
logger.warning("发现旧的 Driver 实例尚未关闭,正在强制重置...")
|
logger.warning("发现旧的 Driver 实例尚未关闭,正在强制重置...")
|
||||||
self.quit()
|
self.quit()
|
||||||
|
|
||||||
try:
|
|
||||||
# 3. 匹配平台并加载 Options
|
# 3. 匹配平台并加载 Options
|
||||||
match platform_name:
|
options: AppiumOptions = self._make_options(platform_name, caps)
|
||||||
case AppPlatform.ANDROID.value:
|
|
||||||
logger.info(f"正在初始化 Android 会话...")
|
|
||||||
options: AppiumOptions = UiAutomator2Options().load_capabilities(caps)
|
|
||||||
|
|
||||||
case AppPlatform.IOS.value:
|
try:
|
||||||
logger.info(f"正在初始化 iOS 会话...")
|
|
||||||
options: AppiumOptions = XCUITestOptions().load_capabilities(caps)
|
|
||||||
|
|
||||||
case _:
|
|
||||||
# 优化:不再默认返回 Android,而是显式报错 (Fail Fast)
|
|
||||||
msg = f"不支持的平台类型: [{platform_name}]。当前仅支持: [android, ios]"
|
|
||||||
logger.error(msg)
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
# 4. 创建连接
|
# 4. 创建连接
|
||||||
self.driver = webdriver.Remote(
|
self.driver = webdriver.Remote(
|
||||||
command_executor=url,
|
command_executor=self.server_url,
|
||||||
options=options,
|
options=options,
|
||||||
extensions=extensions,
|
extensions=extensions,
|
||||||
client_config=client_config
|
client_config=client_config
|
||||||
@@ -158,6 +183,7 @@ class CoreDriver:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
self.driver.implicitly_wait(timeout)
|
self.driver.implicitly_wait(timeout)
|
||||||
|
self._current_implicit_timeout = timeout # 记录等待时间
|
||||||
|
|
||||||
@resolve_wait_method
|
@resolve_wait_method
|
||||||
def explicit_wait(self, method: Union[Callable[[webdriver.Remote], T], str], timeout: Optional[float] = None) -> \
|
def explicit_wait(self, method: Union[Callable[[webdriver.Remote], T], str], timeout: Optional[float] = None) -> \
|
||||||
@@ -256,11 +282,40 @@ class CoreDriver:
|
|||||||
self.explicit_wait(method, timeout).send_keys(text)
|
self.explicit_wait(method, timeout).send_keys(text)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def is_visible(self, by: str, value: str, timeout: Optional[float] = None) -> bool:
|
def is_visible(self, by: str, value: str) -> bool | None:
|
||||||
"""
|
"""
|
||||||
判断元素是否可见
|
判断元素是否可见
|
||||||
:param by: 定位策略。
|
:param by: 定位策略。
|
||||||
:param value: 定位值。
|
:param value: 定位值。
|
||||||
|
:return: bool
|
||||||
|
"""
|
||||||
|
# 禁用隐式等待
|
||||||
|
original_timeout = self._current_implicit_timeout
|
||||||
|
try:
|
||||||
|
self.implicit_wait(0)
|
||||||
|
|
||||||
|
by = by_converter(by)
|
||||||
|
elements = self.driver.find_elements(by, value)
|
||||||
|
|
||||||
|
if elements:
|
||||||
|
return elements[0].is_displayed()
|
||||||
|
# 2. 元素存在于 DOM 中,还需要判断它在 UI 上是否真正可见(宽/高 > 0 且未隐藏)
|
||||||
|
return False
|
||||||
|
except (StaleElementReferenceException, NoSuchElementException):
|
||||||
|
# 这些属于预料中的“不可见”情况
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
_ = e
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
# 恢复原来的隐式等待时间
|
||||||
|
self.implicit_wait(original_timeout)
|
||||||
|
|
||||||
|
def wait_until_visible(self, by: str, value: str, timeout: Optional[float] = None) -> bool:
|
||||||
|
"""
|
||||||
|
等待元素出现
|
||||||
|
:param by: 定位策略。
|
||||||
|
:param value: 定位值。
|
||||||
:param timeout: 等待超时时间。
|
:param timeout: 等待超时时间。
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
@@ -273,6 +328,23 @@ class CoreDriver:
|
|||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def wait_until_not_visible(self, by: str, value: str, timeout: Optional[float] = None) -> bool:
|
||||||
|
"""
|
||||||
|
等待元素消失
|
||||||
|
:param by: 定位策略。
|
||||||
|
:param value: 定位值。
|
||||||
|
:param timeout: 等待超时时间。
|
||||||
|
:return: bool
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
by = by_converter(by)
|
||||||
|
mark = (by, value)
|
||||||
|
method = EC.invisibility_of_element_located(mark)
|
||||||
|
self.explicit_wait(method, timeout)
|
||||||
|
return True
|
||||||
|
except TimeoutException:
|
||||||
|
return False
|
||||||
|
|
||||||
def get_text(self, by: str, value: str, timeout: Optional[float] = None) -> str:
|
def get_text(self, by: str, value: str, timeout: Optional[float] = None) -> str:
|
||||||
"""
|
"""
|
||||||
获取元素文本
|
获取元素文本
|
||||||
@@ -305,6 +377,60 @@ class CoreDriver:
|
|||||||
logger.info(f"获取属性 {name} of {mark}: {attr_value}")
|
logger.info(f"获取属性 {name} of {mark}: {attr_value}")
|
||||||
return attr_value
|
return attr_value
|
||||||
|
|
||||||
|
def clear_popups(self, black_list: list = None, max_rounds: int = 5) -> bool:
|
||||||
|
"""
|
||||||
|
显式清理弹窗函数。
|
||||||
|
说明:
|
||||||
|
1. 快速扫描:使用 is_visible (不等待) 确认弹窗是否存在。
|
||||||
|
2. 动作处理:发现后点击,并触发 wait_until_not_visible (异步等待消失)。
|
||||||
|
3. 自适应退出:当整轮扫描无障碍物时,立即返回,不浪费时间。
|
||||||
|
4. 异常存证:若点击失败或发生错误,自动截图。
|
||||||
|
:param black_list: 允许传入当前页面特有的弹窗定位 [(by, value), ...](如某个活动的特殊广告)
|
||||||
|
:param max_rounds: 最大扫描轮数
|
||||||
|
:return: active: bool(清理过一个弹窗都将返回 True)
|
||||||
|
"""
|
||||||
|
if not black_list:
|
||||||
|
logger.warning("未提供黑名单列表,跳过清理动作。")
|
||||||
|
return False
|
||||||
|
|
||||||
|
list_len = len(black_list)
|
||||||
|
active = False
|
||||||
|
|
||||||
|
logger.info(f"开始执行显式弹窗清理,待检查项: {list_len} 个")
|
||||||
|
for round_idx in range(max_rounds):
|
||||||
|
skip_count = 0
|
||||||
|
for by, value in black_list:
|
||||||
|
|
||||||
|
if not self.is_visible(by, value):
|
||||||
|
skip_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"当前权重{skip_count},第 {round_idx + 1} 轮:待清理弹窗 -> {value}")
|
||||||
|
try:
|
||||||
|
elements = self.find_elements(by, value, timeout=0.5) # 使用极短超时
|
||||||
|
if not elements:
|
||||||
|
skip_count += 1
|
||||||
|
else:
|
||||||
|
elements[0].click()
|
||||||
|
active = True
|
||||||
|
|
||||||
|
# 消失得快返回得就快,最多等 1.5s
|
||||||
|
if self.wait_until_not_visible(by, value, timeout=1.5):
|
||||||
|
logger.info(f"弹窗已成功消失")
|
||||||
|
else:
|
||||||
|
logger.warning(f"弹窗点击后仍存在")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
safe_val = secrets.token_hex(8)
|
||||||
|
file_name = f"popup_fail_{round_idx}_{safe_val}.png"
|
||||||
|
|
||||||
|
logger.error(f"清理弹窗尝试点击时失败[{safe_val}:{value}]: {e}")
|
||||||
|
self.full_screen_screenshot(file_name)
|
||||||
|
raise e
|
||||||
|
if skip_count == list_len:
|
||||||
|
break
|
||||||
|
return active
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_id(self):
|
def session_id(self):
|
||||||
"""获取当前 Appium 会话的 Session ID。"""
|
"""获取当前 Appium 会话的 Session ID。"""
|
||||||
@@ -411,7 +537,8 @@ class CoreDriver:
|
|||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def swipe_by_percent(self, start_xp:int, start_yp:int, end_xp:int, end_yp:int, duration: int = 1000) -> 'CoreDriver':
|
def swipe_by_percent(self, start_xp: int, start_yp: int, end_xp: int, end_yp: int,
|
||||||
|
duration: int = 1000) -> 'CoreDriver':
|
||||||
"""
|
"""
|
||||||
按屏幕比例滑动 (0.5 = 50%)
|
按屏幕比例滑动 (0.5 = 50%)
|
||||||
"""
|
"""
|
||||||
@@ -426,6 +553,45 @@ class CoreDriver:
|
|||||||
duration
|
duration
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def full_screen_screenshot(self, name: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
截取当前完整屏幕内容 (自愈逻辑、异常报错首选)
|
||||||
|
:param name: 图片文件名
|
||||||
|
:return: 截图保存的路径
|
||||||
|
"""
|
||||||
|
file_name = f"{name or secrets.token_hex(8)}.png"
|
||||||
|
path = (SCREENSHOT_DIR / file_name).as_posix()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 核心:save_screenshot 是底层原生方法,不依赖任何元素定位
|
||||||
|
self.driver.save_screenshot(path)
|
||||||
|
logger.info(f"全屏截图已保存: {path}")
|
||||||
|
return path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"全屏截图失败: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def element_screenshot(self, by: str, value: str, name: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
截取特定元素的图像 (业务校验、UI对比首选)
|
||||||
|
:param by: 定位策略
|
||||||
|
:param value: 定位值
|
||||||
|
:param name: 图片文件名
|
||||||
|
:return: 截图保存的路径
|
||||||
|
"""
|
||||||
|
file_name = f"{name or secrets.token_hex(8)}.png"
|
||||||
|
path = (SCREENSHOT_DIR / file_name).as_posix()
|
||||||
|
|
||||||
|
try:
|
||||||
|
by = by_converter(by)
|
||||||
|
# 核心:直接调用底层 find_element,
|
||||||
|
self.driver.find_element(by, value).screenshot(path)
|
||||||
|
logger.info(f"元素截图已保存: {path}")
|
||||||
|
return path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"元素截图失败: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_alive(self) -> bool:
|
def is_alive(self) -> bool:
|
||||||
"""判断当前驱动会话是否仍然存活。"""
|
"""判断当前驱动会话是否仍然存活。"""
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ LOG_DIR = OUTPUT_DIR / "logs"
|
|||||||
LOG_BACKUP_DIR = LOG_DIR / "backups"
|
LOG_BACKUP_DIR = LOG_DIR / "backups"
|
||||||
ALLURE_TEMP = BASE_DIR / "temp"
|
ALLURE_TEMP = BASE_DIR / "temp"
|
||||||
REPORT_DIR = BASE_DIR / "report"
|
REPORT_DIR = BASE_DIR / "report"
|
||||||
|
SCREENSHOT_DIR = OUTPUT_DIR / "screenshots"
|
||||||
# 确保必要的目录存在
|
# 确保必要的目录存在
|
||||||
for folder in [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP]:
|
for folder in [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP, SCREENSHOT_DIR]:
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# --- 文件路径 ---
|
# --- 文件路径 ---
|
||||||
@@ -46,4 +46,21 @@ ANDROID_CAPS = {
|
|||||||
"deviceName": "Android",
|
"deviceName": "Android",
|
||||||
"appPackage": "com.android.settings",
|
"appPackage": "com.android.settings",
|
||||||
"appActivity": ".Settings",
|
"appActivity": ".Settings",
|
||||||
|
"noReset": False
|
||||||
|
}
|
||||||
|
# ANDROID_CAPS = {
|
||||||
|
# "platformName": "Android",
|
||||||
|
# "automationName": "uiautomator2",
|
||||||
|
# "deviceName": "Android",
|
||||||
|
# "appPackage": "com.bocionline.ibmp",
|
||||||
|
# "appActivity": "com.bocionline.ibmp.app.main.launcher.LauncherActivity",
|
||||||
|
# "noReset":False
|
||||||
|
# }
|
||||||
|
IOS_CAPS = {
|
||||||
|
"platformName": "iOS",
|
||||||
|
"automationName": "XCUITest",
|
||||||
|
"autoAcceptAlerts": True, # 自动接受系统权限请求
|
||||||
|
"waitForQuiescence": False, # 设为 False 可加速扫描
|
||||||
|
# 如果是某些特定的业务弹窗 autoAcceptAlerts 无效,
|
||||||
|
# 此时就会触发我们代码里的 PopupManager.solve()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# coding=utf-8
|
|
||||||
|
|
||||||
"""
|
|
||||||
@desc: 模拟登录功能测试用例
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
import logging
|
|
||||||
from core.driver import CoreDriver
|
|
||||||
from core.modules import AppPlatform
|
|
||||||
|
|
||||||
# 配置日志
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
class TestLogin:
|
|
||||||
|
|
||||||
driver: CoreDriver = None
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""
|
|
||||||
每个测试用例开始前执行:初始化 Driver 并连接设备
|
|
||||||
"""
|
|
||||||
# 定义测试设备的 Capabilities
|
|
||||||
# 注意:实际使用时,appPackage 和 appActivity 需要替换为被测 App 的真实值
|
|
||||||
caps = {
|
|
||||||
"platformName": "Android",
|
|
||||||
"automationName": "UiAutomator2",
|
|
||||||
"deviceName": "Android Emulator",
|
|
||||||
"appPackage": "com.example.android.apis", # 替换为你的 App 包名
|
|
||||||
"appActivity": ".ApiDemos", # 替换为你的 App 启动 Activity
|
|
||||||
"noReset": True, # 不清除应用数据
|
|
||||||
"newCommandTimeout": 60
|
|
||||||
}
|
|
||||||
|
|
||||||
self.driver = CoreDriver()
|
|
||||||
# 连接 Appium Server
|
|
||||||
self.driver.server_config(host="127.0.0.1", port=4723)
|
|
||||||
self.driver.connect(platform=AppPlatform.ANDROID, caps=caps)
|
|
||||||
|
|
||||||
def teardown_method(self):
|
|
||||||
"""
|
|
||||||
每个测试用例结束后执行:退出 Driver
|
|
||||||
"""
|
|
||||||
if self.driver:
|
|
||||||
self.driver.quit()
|
|
||||||
|
|
||||||
def test_login_success(self):
|
|
||||||
"""
|
|
||||||
测试场景:使用正确的用户名和密码登录成功
|
|
||||||
"""
|
|
||||||
# 1. 定位元素信息 (建议后续抽离到 Page Object 层)
|
|
||||||
# 假设登录页面的元素 ID 如下:
|
|
||||||
input_user = "id:com.example.app:id/et_username"
|
|
||||||
input_pass = "id:com.example.app:id/et_password"
|
|
||||||
btn_login = "id:com.example.app:id/btn_login"
|
|
||||||
txt_welcome = "xpath://*[@text='登录成功']"
|
|
||||||
|
|
||||||
# 2. 执行操作步骤
|
|
||||||
# 显式等待并输入用户名
|
|
||||||
self.driver.input(input_user, "", "test_user_001")
|
|
||||||
|
|
||||||
# 输入密码 (开启敏感模式,日志中脱敏)
|
|
||||||
self.driver.input(input_pass, "", "Password123!", sensitive=True)
|
|
||||||
|
|
||||||
# 点击登录按钮
|
|
||||||
self.driver.click(btn_login, "")
|
|
||||||
|
|
||||||
# 3. 断言结果
|
|
||||||
# 方式 A: 检查特定文本是否存在
|
|
||||||
# self.driver.assert_text(txt_welcome, "", "登录成功")
|
|
||||||
|
|
||||||
# 方式 B: 检查跳转后的页面元素是否可见
|
|
||||||
is_login_success = self.driver.is_visible(txt_welcome, "")
|
|
||||||
assert is_login_success, "登录失败:未检测到欢迎提示或主页元素"
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 允许直接运行此文件进行调试
|
|
||||||
pytest.main(["-v", "-s", __file__])
|
|
||||||
@@ -2,8 +2,10 @@ import logging
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Union, Callable
|
from typing import Union, Callable
|
||||||
|
|
||||||
|
|
||||||
from core.custom_expected_conditions import get_condition
|
from core.custom_expected_conditions import get_condition
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user