feat(core,utils): 新增CoreDriver基础操作,更新文档
- 新增 switch_to_webview/switch_to_native 切换视图。 - 新增 config_loader.py 配置文件系统 - 优化 conftest.py,支持获取设备信息和默认参数。 - 优化 run_appium.py - 更新 README.md - 其他优化
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,7 +19,7 @@ outputs/screenshots/
|
||||
|
||||
# --- Allure 报告 ---
|
||||
temp/
|
||||
report/
|
||||
reports/
|
||||
.allure/
|
||||
|
||||
# --- pytest缓存 ---
|
||||
|
||||
14
README.md
14
README.md
@@ -63,3 +63,17 @@ newCommandTimeout:设置Appium服务器等待客户端发送新命令的超时
|
||||
| @allure.description | 详细描述(支持 Markdown) | 解释测试背景或前提条件 |
|
||||
| @allure.link | 外部链接(Bug系统、需求文档) | 快速点击跳转 |
|
||||
| @allure.issue | 缺陷链接(通常会自动带上 ISSUE 前缀) | 追踪已知 Bug |
|
||||
|
||||
Pytest 原生高频参数 和 你的自定义参数。
|
||||
|
||||
| **分类** | **长参数** | **短参数/别名** | **作用说明** |
|
||||
|-----------|------------------|------------|------------------------------------|
|
||||
| **基础运行** | `--verbose` | `-v` | 打印详细运行过程(显示用例名称) |
|
||||
| | (无) | `-s` | 允许在控制台打印代码里的 `print` 内容 |
|
||||
| **调试控制** | `--exitfirst` | `-x` | 遇到第一个失败的用例就立即停止测试 |
|
||||
| | `--maxfail=n` | (无) | 累计失败 `n` 个用例后停止运行 |
|
||||
| | `--last-failed` | `--lf` | 只运行上次运行失败的用例 |
|
||||
| **自定义参数** | **`--platform`** | **`-P`** | 指定运行平台 \[Android\/IOS\/别名\]\(可自定义) |
|
||||
| | **`--udid`** | **`-U`** | 指定手机唯一标识 (可自定义) |
|
||||
| | **`--host`** | **`-H`** | 指定 Appium 服务器地址 (可自定义) |
|
||||
| **报告相关** | `--alluredir` | (无) | 指定 Allure 原始数据的存放路径 |
|
||||
|
||||
19
config/caps.yaml
Normal file
19
config/caps.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
# Appium Capabilities Configuration
|
||||
android:
|
||||
platformName: "Android"
|
||||
automationName: "uiautomator2"
|
||||
deviceName: "Android"
|
||||
appPackage: "com.manu.wanandroid"
|
||||
appActivity: "com.manu.wanandroid.ui.main.activity.MainActivity"
|
||||
noReset: false
|
||||
newCommandTimeout: 60
|
||||
# udid: "emulator-5554" # Can be injected via CLI
|
||||
|
||||
ios:
|
||||
platformName: "iOS"
|
||||
automationName: "XCUITest"
|
||||
deviceName: "iPhone 14"
|
||||
bundleId: "com.example.app"
|
||||
autoAcceptAlerts: true
|
||||
waitForQuiescence: false
|
||||
newCommandTimeout: 60
|
||||
178
conftest.py
178
conftest.py
@@ -7,74 +7,110 @@
|
||||
@contact: t6g888@163.com
|
||||
@file: conftest
|
||||
@date: 2026/1/16 10:52
|
||||
@desc:
|
||||
@desc: Pytest 核心配置与 Fixture 管理
|
||||
"""
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Generator, Any
|
||||
|
||||
import allure
|
||||
import pytest
|
||||
import allure
|
||||
|
||||
from core.run_appium import start_appium_service, stop_appium_service
|
||||
from core.driver import CoreDriver
|
||||
from utils.dirs_manager import ensure_dirs_ok
|
||||
from core.settings import APPIUM_HOST, APPIUM_PORT
|
||||
from core.config_loader import get_caps
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
# 注册命令行参数
|
||||
def pytest_addoption(parser: Any) -> None:
|
||||
"""
|
||||
Pytest 钩子函数:在测试收集开始前执行
|
||||
注册自定义命令行参数
|
||||
:param parser: Pytest 参数解析器对象
|
||||
"""
|
||||
# ensure_dirs_ok()
|
||||
...
|
||||
parser.addoption("--platform", action="store", default="Android", help="目标平台: Android or IOS")
|
||||
parser.addoption("--udid", action="store", default=None, help="设备唯一标识")
|
||||
parser.addoption("--host", action="store", default=APPIUM_HOST, help="Appium Server Host")
|
||||
parser.addoption("--port", action="store", default=str(APPIUM_PORT), help="Appium Server Port")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_server():
|
||||
def app_server(request: pytest.FixtureRequest) -> Generator[Any, None, None]:
|
||||
"""
|
||||
第一层:管理 Appium Server 进程。
|
||||
利用start_appium_service 里的 40 次轮询和 sys.exit(1) 逻辑。
|
||||
:param request: Pytest 请求对象
|
||||
:return: Appium 服务进程实例
|
||||
"""
|
||||
# 启动服务
|
||||
service = start_appium_service()
|
||||
# 获取命令行参数
|
||||
host = request.config.getoption("--host")
|
||||
port = int(request.config.getoption("--port"))
|
||||
|
||||
service = start_appium_service(host, port)
|
||||
yield service
|
||||
# 所有测试结束,清理进程
|
||||
stop_appium_service(service)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def driver(app_server):
|
||||
@pytest.fixture(scope="session")
|
||||
def driver_session(request: pytest.FixtureRequest, app_server: Any) -> Generator[CoreDriver, None, None]:
|
||||
"""
|
||||
第二层:管理 WebDriver 会话。
|
||||
依赖 app_server,确保服务 Ready 后才创建连接。
|
||||
第二层:全局单例 Driver 管理 (Session Scope)。
|
||||
负责创建和销毁 Driver,整个测试过程只启动一次 App。
|
||||
:param request: Pytest 请求对象
|
||||
:param app_server: Appium 服务 fixture 依赖
|
||||
:return: CoreDriver 实例
|
||||
"""
|
||||
# 实例化你提供的类结构
|
||||
platform = request.config.getoption("--platform")
|
||||
ud_id = request.config.getoption("--udid")
|
||||
host = request.config.getoption("--host")
|
||||
port = int(request.config.getoption("--port"))
|
||||
|
||||
# 1. 获取基础 Caps
|
||||
caps = get_caps(platform)
|
||||
|
||||
# 2. 动态注入参数
|
||||
if ud_id: caps["udid"] = ud_id
|
||||
|
||||
# 将最终生效的 caps 存入 pytest 配置,方便报告读取
|
||||
request.config._final_caps = caps
|
||||
|
||||
# 3. 初始化 Driver
|
||||
app_helper = CoreDriver()
|
||||
caps = {
|
||||
"platformName": "Android",
|
||||
"automationName": "uiautomator2",
|
||||
"deviceName": "Android",
|
||||
"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
|
||||
}
|
||||
# 连接并获取原生 driver 实例
|
||||
# 这里可以根据需要扩展,比如通过命令行参数选择平台
|
||||
app_helper.connect(platform="android", caps=caps)
|
||||
app_helper.server_config(host=host, port=port)
|
||||
|
||||
yield app_helper.driver
|
||||
try:
|
||||
app_helper.connect(platform=platform, caps=caps)
|
||||
except Exception as e:
|
||||
pytest.exit(f"无法初始化 Driver: {e}")
|
||||
|
||||
# 用例结束,只关 session,不关 server
|
||||
yield app_helper
|
||||
|
||||
# 4. 清理
|
||||
app_helper.quit()
|
||||
|
||||
|
||||
def pytest_exception_interact(node, call, report):
|
||||
@pytest.fixture(scope="function")
|
||||
def driver(driver_session: CoreDriver) -> Generator[Any, None, None]:
|
||||
"""
|
||||
第三层:用例级 Driver 注入。
|
||||
每个用例直接获取已存在的 Driver 实例。
|
||||
可以在这里添加 reset_app() 逻辑,确保用例间独立性。
|
||||
:param driver_session: CoreDriver 会话实例
|
||||
:return: 原始 Appium Driver 对象 (webdriver.Remote)
|
||||
"""
|
||||
# 可选:如果需要在每个用例前重置 App 状态
|
||||
# driver_session.driver.reset()
|
||||
yield driver_session.driver
|
||||
|
||||
|
||||
def pytest_exception_interact(node: Any, call: Any, report: Any) -> None:
|
||||
"""
|
||||
当测试用例抛出异常(断言失败或代码报错)时,Pytest 会调用这个钩子。
|
||||
我们在这里手动把错误信息喂给 logging。
|
||||
:param node: 测试节点
|
||||
:param call: 调用信息
|
||||
:param report: 测试报告
|
||||
"""
|
||||
# 获取名为 'Error' 的记录器,它会遵循 pytest.ini 中的 log_file 配置
|
||||
logger = logging.getLogger("pytest")
|
||||
|
||||
if report.failed:
|
||||
@@ -83,23 +119,71 @@ def pytest_exception_interact(node, call, report):
|
||||
exc_info = call.excinfo.getrepr(style='short')
|
||||
screenshot_name = f"异常截图_{secrets.token_hex(4)}"
|
||||
|
||||
logger.error("\n" + "=" * 40 + " TEST FAILED " + "=" * 40)
|
||||
logger.error(f"Node ID: {node.nodeid}")
|
||||
logger.error(f"截图名称: {screenshot_name}")
|
||||
logger.error(f"详细错误信息如下:\n{exc_info}")
|
||||
logger.error(f"\n{'=' * 40} TEST FAILED {'=' * 40}\n"
|
||||
f"Node ID: {node.nodeid}\n"
|
||||
f"截图名称: {screenshot_name}\n"
|
||||
f"详细错误信息:\n{exc_info}"
|
||||
)
|
||||
|
||||
# 3. 自动截图:尝试从 fixture 中获取 driver
|
||||
# node.funcargs 包含了当前测试用例请求的所有 fixture 实例
|
||||
driver = node.funcargs.get("driver")
|
||||
if driver:
|
||||
# 尝试获取 driver_session (CoreDriver 实例)
|
||||
if "driver_session" in node.funcargs:
|
||||
helper = node.funcargs["driver_session"]
|
||||
try:
|
||||
# 同时保存到 Allure 和本地(如果你需要本地有一份备份)
|
||||
screenshot_data = driver.get_screenshot_as_png()
|
||||
# 截图并附加到 Allure
|
||||
screenshot_bytes = helper.driver.get_screenshot_as_png()
|
||||
allure.attach(
|
||||
screenshot_data,
|
||||
name=name,
|
||||
screenshot_bytes,
|
||||
name=screenshot_name,
|
||||
attachment_type=allure.attachment_type.PNG
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"执行异常截图失败: {e}")
|
||||
logger.error("=" * 93 + "\n")
|
||||
|
||||
|
||||
def pytest_sessionfinish(session: Any, exitstatus: int) -> None:
|
||||
"""
|
||||
测试会话结束时,收集环境信息到 Allure 报告
|
||||
:param session: Pytest 会话对象
|
||||
:param exitstatus: 退出状态码
|
||||
"""
|
||||
match exitstatus:
|
||||
case pytest.ExitCode.OK:
|
||||
logging.info("测试全部通过!")
|
||||
case pytest.ExitCode.TESTS_FAILED:
|
||||
logging.warning("部分测试用例执行失败,请检查报告。")
|
||||
case pytest.ExitCode.INTERRUPTED:
|
||||
logging.error("测试被用户手动中断(Ctrl+C)。")
|
||||
case pytest.ExitCode.INTERNAL_ERROR:
|
||||
logging.critical("Pytest 发生内部错误!")
|
||||
case pytest.ExitCode.USAGE_ERROR:
|
||||
logging.error("Pytest 命令行参数错误或用法不当。")
|
||||
case pytest.ExitCode.NO_TESTS_COLLECTED:
|
||||
logging.warning("未发现任何测试用例。")
|
||||
case _:
|
||||
logging.error(f"未知错误状态码: {exitstatus}")
|
||||
|
||||
report_dir = session.config.getoption("--alluredir")
|
||||
final_caps = getattr(session.config, "_final_caps", {})
|
||||
if not report_dir:
|
||||
return
|
||||
report_path = Path(report_dir)
|
||||
# 收集环境信息
|
||||
env_info = {
|
||||
"Platform": session.config.getoption("--platform"),
|
||||
"UDID": final_caps.get("udid") or session.config.getoption("--udid") or "未指定",
|
||||
"Host": session.config.getoption("--host"),
|
||||
"Python": "3.11+"
|
||||
}
|
||||
|
||||
try:
|
||||
if not report_path.exists():
|
||||
report_path.mkdir(parents=True, exist_ok=True)
|
||||
# 生成 environment.properties 文件
|
||||
env_file = report_path / "environment.properties"
|
||||
with env_file.open("w", encoding="utf-8") as f:
|
||||
for k, v in env_info.items():
|
||||
f.write(f"{k}={v}\n")
|
||||
logging.info("Allure 环境信息已生成。")
|
||||
except Exception as e:
|
||||
logging.error(f"无法写入环境属性: {e}")
|
||||
|
||||
67
core/config_loader.py
Normal file
67
core/config_loader.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
|
||||
"""
|
||||
@author: CNWei,ChenWei
|
||||
@Software: PyCharm
|
||||
@contact: t6g888@163.com
|
||||
@file: config_loader
|
||||
@date: 2026/1/16 10:52
|
||||
@desc: Pytest 核心配置与 Fixture 管理
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from utils.data_loader import load_yaml
|
||||
from core.settings import CAPS_CONFIG_PATH, ENV_CONFIG, CURRENT_ENV
|
||||
from core.modules import AppPlatform
|
||||
|
||||
|
||||
def get_env_config() -> dict[str, str]:
|
||||
"""
|
||||
根据当前环境 (CURRENT_ENV) 获取对应的业务配置
|
||||
"""
|
||||
return ENV_CONFIG.get(CURRENT_ENV, ENV_CONFIG["test"])
|
||||
|
||||
|
||||
def get_caps(platform: str) -> dict[str, Any]:
|
||||
"""
|
||||
从 YAML 文件加载 Capabilities 配置
|
||||
"""
|
||||
try:
|
||||
all_caps = load_yaml(CAPS_CONFIG_PATH)
|
||||
all_caps = {k.lower(): v for k, v in all_caps.items()}
|
||||
platform_key = platform.lower()
|
||||
|
||||
if platform_key not in all_caps:
|
||||
|
||||
base_caps: dict[str, Any] = {
|
||||
"noReset": False,
|
||||
"newCommandTimeout": 60,
|
||||
}
|
||||
|
||||
match platform_key:
|
||||
case AppPlatform.ANDROID:
|
||||
android_caps = {
|
||||
"platformName": "Android",
|
||||
"automationName": "uiautomator2",
|
||||
"deviceName": "Android",
|
||||
"appPackage": "com.manu.wanandroid",
|
||||
"appActivity": "com.manu.wanandroid.ui.main.activity.MainActivity",
|
||||
}
|
||||
return base_caps | android_caps
|
||||
case AppPlatform.IOS:
|
||||
|
||||
ios_caps = {
|
||||
"platformName": "iOS",
|
||||
"automationName": "XCUITest",
|
||||
"deviceName": "iPhone 14",
|
||||
"bundleId": "com.example.app",
|
||||
"autoAcceptAlerts": True,
|
||||
"waitForQuiescence": False,
|
||||
}
|
||||
return base_caps | ios_caps
|
||||
|
||||
return all_caps[platform_key]
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"无法加载“caps”内容 {CAPS_CONFIG_PATH}: {e}")
|
||||
@@ -585,6 +585,44 @@ class CoreDriver:
|
||||
duration
|
||||
)
|
||||
|
||||
# --- 上下文切换 (Context Switch) ---
|
||||
def switch_to_context(self, context_name: str) -> 'CoreDriver':
|
||||
"""
|
||||
切换到指定的上下文 (Context)。
|
||||
:param context_name: 上下文名称 (如 'NATIVE_APP', 'WEBVIEW_com.example.app')
|
||||
:return: self
|
||||
"""
|
||||
logger.info(f"尝试切换到上下文: {context_name}")
|
||||
try:
|
||||
self.driver.switch_to.context(context_name)
|
||||
logger.info(f"成功切换到上下文: {context_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"切换上下文失败: {e}")
|
||||
available_contexts = self.driver.contexts
|
||||
logger.info(f"当前可用上下文: {available_contexts}")
|
||||
raise e
|
||||
return self
|
||||
|
||||
def switch_to_webview(self) -> 'CoreDriver':
|
||||
"""
|
||||
自动切换到第一个可用的 WebView 上下文。
|
||||
:return: self
|
||||
"""
|
||||
logger.info("尝试自动切换到 WebView 上下文...")
|
||||
contexts = self.driver.contexts
|
||||
for context in contexts:
|
||||
if 'WEBVIEW' in context:
|
||||
return self.switch_to_context(context)
|
||||
|
||||
logger.warning("未找到 WebView 上下文,保持在当前上下文。")
|
||||
return self
|
||||
|
||||
def switch_to_native(self) -> 'CoreDriver':
|
||||
"""
|
||||
切换回原生应用上下文 (NATIVE_APP)。
|
||||
:return: self
|
||||
"""
|
||||
return self.switch_to_context('NATIVE_APP')
|
||||
def full_screen_screenshot(self, name: str | None = None) -> str:
|
||||
"""
|
||||
截取当前完整屏幕内容 (自愈逻辑、异常报错首选)
|
||||
|
||||
@@ -29,6 +29,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 使用 npm run 确保 APPIUM_HOME=. 变量和本地版本生效
|
||||
# NODE_CMD = f"npm run appium -- -p {APPIUM_PORT}"
|
||||
# --- 1. 异常体系设计 (精确分类) ---
|
||||
class AppiumStartupError(Exception):
|
||||
"""所有 Appium 启动相关异常的基类"""
|
||||
pass
|
||||
|
||||
|
||||
class AppiumPortConflictError(AppiumStartupError):
|
||||
"""端口被其他非 Appium 程序占用"""
|
||||
pass
|
||||
|
||||
|
||||
class AppiumProcessCrashError(AppiumStartupError):
|
||||
"""进程启动后立即崩溃或在运行中意外退出"""
|
||||
pass
|
||||
|
||||
|
||||
class AppiumTimeoutError(AppiumStartupError):
|
||||
"""服务在规定时间内未进入 READY 状态"""
|
||||
pass
|
||||
|
||||
|
||||
class AppiumInternalError(AppiumStartupError):
|
||||
"""探测接口返回了无法解析的数据或网络异常"""
|
||||
pass
|
||||
|
||||
|
||||
class AppiumStatus(Enum):
|
||||
@@ -43,12 +67,12 @@ class AppiumStatus(Enum):
|
||||
|
||||
class ServiceRole(Enum):
|
||||
"""服务角色枚举:定义服务的所有权和生命周期"""
|
||||
MANAGED = "由脚本启动 (托管模式)" # 由本脚本启动,负责清理
|
||||
EXTERNAL = "复用外部服务 (共享模式)" # 复用现有服务,不负责清理
|
||||
NULL = "无效服务 (空模式)" # 无效或未初始化的服务
|
||||
MANAGED = "托管模式" # 由本脚本启动,负责清理
|
||||
EXTERNAL = "共享模式" # 复用现有服务,不负责清理
|
||||
NULL = "空模式" # 无效或未初始化的服务
|
||||
|
||||
|
||||
def resolve_appium_command() -> List[str]:
|
||||
def resolve_appium_command(host: str, port: int | str) -> List[str]:
|
||||
"""
|
||||
解析 Appium 可执行文件的绝对路径。
|
||||
优先查找项目 node_modules 下的本地安装版本,避免 npm 包装层带来的信号传递问题。
|
||||
@@ -64,8 +88,8 @@ def resolve_appium_command() -> List[str]:
|
||||
logger.error(f"\n错误: 在路径 {appium_bin} 未找到 Appium 执行文件。")
|
||||
logger.info("请确保已在项目目录下执行过: npm install appium")
|
||||
sys.exit(1)
|
||||
# 返回执行列表(用于 shell=False)
|
||||
return [str(appium_bin), "-p", str(APPIUM_PORT)]
|
||||
# 返回执行列表(用于 shell=False)--address 127.0.0.1 --port 4723 appium -a 127.0.0.1 -p 4723
|
||||
return [str(appium_bin), "--address", host, "--port", str(port)]
|
||||
|
||||
|
||||
# 移除全局调用,防止 import 时因找不到 appium 而直接退出
|
||||
@@ -79,21 +103,27 @@ def _cleanup_process_tree(process: subprocess.Popen = None) -> None:
|
||||
"""
|
||||
if not process or process.poll() is not None:
|
||||
return
|
||||
logger.info(f"正在关闭 Appium 进程树 (PID: {process.pid})...")
|
||||
pid = process.pid
|
||||
logger.info(f"正在关闭 Appium 进程树 (PID: {pid})...")
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# Windows 下使用 taskkill 强制关闭进程树 /T
|
||||
subprocess.run(
|
||||
['taskkill', '/F', '/T', '/PID', str(process.pid)],
|
||||
['taskkill', '/F', '/T', '/PID', str(pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
else:
|
||||
# Unix/Linux: 获取进程组 ID 并发送终止信号
|
||||
pgid = os.getpgid(process.pid)
|
||||
pgid = os.getpgid(pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
# 5秒没关掉,强制杀掉整个进程组
|
||||
# 如果 SIGTERM 不起作用,直接 SIGKILL 整个组
|
||||
logger.warning(f"进程 {pid} 未响应 SIGTERM,发送 SIGKILL...")
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
logger.info("Appium 服务已停止,相关进程已安全退出。")
|
||||
except Exception as e:
|
||||
logger.error(f"停止服务时发生异常: {e}")
|
||||
@@ -109,10 +139,21 @@ def _cleanup_process_tree(process: subprocess.Popen = None) -> None:
|
||||
class AppiumService:
|
||||
"""Appium 服务实例封装,用于管理服务生命周期"""
|
||||
|
||||
def __init__(self, role: ServiceRole, process: subprocess.Popen = None):
|
||||
def __init__(self, role: ServiceRole, host: str, port: int | str, process: subprocess.Popen = None):
|
||||
self.role = role
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.process = process
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppiumService 角色='{self.role.value}' 地址=http://{self.host}:{self.port}>"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
"""统一停止接口:根据角色决定是否关闭进程"""
|
||||
match self.role:
|
||||
@@ -120,28 +161,27 @@ class AppiumService:
|
||||
logger.info(f"--> [角色: {self.role.value}] 脚本退出,保留外部服务运行。")
|
||||
return
|
||||
case ServiceRole.MANAGED:
|
||||
logger.info(f"正在关闭托管的 Appium 服务 (PID: {self.process.pid})...")
|
||||
_cleanup_process_tree(self.process)
|
||||
self.process = None
|
||||
case ServiceRole.NULL:
|
||||
logger.info(f"--> [角色: {self.role.value}] 无需执行清理。")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppiumService 角色='{self.role.value}' 端口={APPIUM_PORT}>"
|
||||
|
||||
|
||||
def _check_port_availability() -> AppiumStatus:
|
||||
def _check_port_availability(host: str, port: int) -> AppiumStatus:
|
||||
"""辅助函数:检查端口是否被占用"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
sock.settimeout(1)
|
||||
# 尝试绑定端口,如果成功说明端口空闲
|
||||
sock.bind((APPIUM_HOST, APPIUM_PORT))
|
||||
sock.bind((host, port))
|
||||
return AppiumStatus.OFFLINE # 真正未启动
|
||||
except OSError:
|
||||
# 绑定失败,说明端口被占用,但此前 HTTP 请求失败,说明不是 Appium
|
||||
return AppiumStatus.CONFLICT # 端口被占用但没响应 HTTP
|
||||
|
||||
|
||||
def get_appium_status() -> AppiumStatus:
|
||||
def get_appium_status(host: str, port: int | str) -> AppiumStatus:
|
||||
"""
|
||||
检测 Appium 服务当前状态。
|
||||
|
||||
@@ -153,16 +193,17 @@ def get_appium_status() -> AppiumStatus:
|
||||
:return: AppiumStatus 枚举
|
||||
"""
|
||||
connection = None
|
||||
port = int(port) # 确保端口是整数
|
||||
try:
|
||||
# 1. 端口开启,尝试获取 Appium 状态接口
|
||||
connection = http.client.HTTPConnection(APPIUM_HOST, APPIUM_PORT, timeout=2)
|
||||
connection = http.client.HTTPConnection(host, port, timeout=2)
|
||||
connection.request("GET", "/status")
|
||||
response = connection.getresponse()
|
||||
|
||||
if response.status != 200:
|
||||
return AppiumStatus.CONFLICT
|
||||
|
||||
data = json.loads(response.read().decode())
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
# 2. 解析 Appium 3.x 标准响应结构
|
||||
# 即使服务响应了,也要看驱动是否加载完成 (ready 字段)
|
||||
@@ -170,8 +211,8 @@ def get_appium_status() -> AppiumStatus:
|
||||
return AppiumStatus.READY if is_ready else AppiumStatus.INITIALIZING
|
||||
|
||||
except (socket.error, ConnectionRefusedError):
|
||||
# 3. 如果通信拒绝,检查端口是否真的空闲
|
||||
return _check_port_availability()
|
||||
# 3. 如果通信拒绝,检查端口是否被占用
|
||||
return _check_port_availability(host, port)
|
||||
except (http.client.HTTPException, json.JSONDecodeError):
|
||||
return AppiumStatus.UNKNOWN
|
||||
except Exception as e:
|
||||
@@ -181,7 +222,7 @@ def get_appium_status() -> AppiumStatus:
|
||||
if connection: connection.close()
|
||||
|
||||
|
||||
def start_appium_service() -> AppiumService:
|
||||
def start_appium_service(host: str = APPIUM_HOST, port: int | str = APPIUM_PORT) -> AppiumService:
|
||||
"""
|
||||
管理 Appium 服务的生命周期
|
||||
如果服务未启动,则启动本地服务;如果已启动,则复用。
|
||||
@@ -192,71 +233,65 @@ def start_appium_service() -> AppiumService:
|
||||
is_managed = False
|
||||
# 轮询等待真正就绪
|
||||
# 延迟获取命令,确保只在真正需要启动服务时检查环境
|
||||
cmd_args = resolve_appium_command()
|
||||
|
||||
cmd_args = resolve_appium_command(host, port)
|
||||
try:
|
||||
for i in range(MAX_RETRIES):
|
||||
status = get_appium_status()
|
||||
status = get_appium_status(host, port)
|
||||
match status: # Python 3.10+ 的模式匹配
|
||||
case AppiumStatus.READY:
|
||||
if is_managed:
|
||||
# 安全打印 PID
|
||||
pid_info = f"PID: {process.pid}" if process else "EXTERNAL"
|
||||
logger.info(f"Appium 服务启动成功! ({pid_info})")
|
||||
return AppiumService(ServiceRole.MANAGED, process)
|
||||
return AppiumService(ServiceRole.MANAGED, host, port, process)
|
||||
else:
|
||||
logger.info(f"--> [复用] 有效的 Appium 服务已在运行 (Port: {APPIUM_PORT})")
|
||||
logger.info(f"--> [复用] 有效的 Appium 服务已在运行 (http://{host}:{port})")
|
||||
logger.info("--> [注意] 脚本退出时将保留该服务,不会将其关闭。")
|
||||
return AppiumService(ServiceRole.EXTERNAL, process)
|
||||
return AppiumService(ServiceRole.EXTERNAL, host, port, process)
|
||||
case AppiumStatus.CONFLICT:
|
||||
_handle_port_conflict()
|
||||
_handle_port_conflict(port)
|
||||
case AppiumStatus.OFFLINE:
|
||||
if not is_managed:
|
||||
process = _spawn_appium_process(cmd_args)
|
||||
is_managed = True
|
||||
|
||||
else:
|
||||
if process and process.poll() is not None:
|
||||
logger.warning("Appium 进程启动后异常退出。")
|
||||
sys.exit(1)
|
||||
elif process and process.poll() is not None:
|
||||
raise AppiumProcessCrashError("Appium 进程启动后异常退出。")
|
||||
|
||||
case AppiumStatus.INITIALIZING:
|
||||
if is_managed and process and process.poll() is not None:
|
||||
logger.warning("Appium 在初始化期间崩溃。")
|
||||
_cleanup_process_tree(process)
|
||||
sys.exit(1)
|
||||
raise AppiumProcessCrashError("Appium 在初始化期间崩溃。")
|
||||
|
||||
if i % 4 == 0: # 每 2 秒提醒一次,避免刷屏
|
||||
logger.info("Appium 正在加载驱动/插件,请稍候...")
|
||||
case AppiumStatus.ERROR:
|
||||
logger.error("探测接口发生内部错误(可能是解析失败或严重网络异常),脚本终止。")
|
||||
if is_managed and process:
|
||||
_cleanup_process_tree(process)
|
||||
sys.exit(1)
|
||||
raise AppiumInternalError("探测接口发生内部错误(可能是解析失败或严重网络异常),脚本终止。")
|
||||
case _:
|
||||
logger.error("Appium 启动异常")
|
||||
sys.exit(1)
|
||||
raise AppiumInternalError("Appium 启动异常")
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.warning("启动超时:Appium 在规定时间内未完成初始化。")
|
||||
raise AppiumTimeoutError("启动超时:Appium 在规定时间内未完成初始化。")
|
||||
except AppiumStartupError as e:
|
||||
if process:
|
||||
logger.warning(f"检测到启动阶段异常,正在回收进程资源 (PID: {process.pid})...")
|
||||
_cleanup_process_tree(process)
|
||||
sys.exit(1)
|
||||
# 将清理后的异常继续向上传递给装饰器
|
||||
raise e
|
||||
|
||||
|
||||
def _handle_port_conflict():
|
||||
logger.warning(f"\n[!] 错误: 端口 {APPIUM_PORT} 被占用。")
|
||||
logger.info("=" * 60)
|
||||
logger.info("请手动执行以下命令释放端口后重试:")
|
||||
def _handle_port_conflict(port: int | str) -> AppiumStartupError:
|
||||
hand = f"端口 {port} 被其他程序占用,建议清理命令:\n"
|
||||
if sys.platform == "win32":
|
||||
logger.info(
|
||||
f" CMD: for /f \"tokens=5\" %a in ('netstat -aon ^| findstr :{APPIUM_PORT}') do taskkill /F /PID %a")
|
||||
raise AppiumPortConflictError(
|
||||
f"{hand}CMD: for /f \"tokens=5\" %a in ('netstat -aon ^| findstr :{port}') do taskkill /F /PID %a")
|
||||
else:
|
||||
logger.info(f" Terminal: lsof -ti:{APPIUM_PORT} | xargs kill -9")
|
||||
logger.info("=" * 60)
|
||||
sys.exit(1)
|
||||
raise AppiumPortConflictError(f"{hand}Unix: lsof -ti:{port} | xargs kill -9")
|
||||
|
||||
|
||||
def _spawn_appium_process(cmd_args: List[str]) -> subprocess.Popen:
|
||||
"""启动 Appium 子进程"""
|
||||
logger.info(f"正在启动本地 Appium 服务 (Port: {APPIUM_PORT})...")
|
||||
logger.info(f"正在启动本地 Appium 服务...")
|
||||
|
||||
# 注入环境变量,确保 Appium 寻找项目本地的驱动
|
||||
env_vars = os.environ.copy()
|
||||
@@ -281,31 +316,29 @@ def _spawn_appium_process(cmd_args: List[str]) -> subprocess.Popen:
|
||||
def stop_appium_service(service: AppiumService):
|
||||
"""
|
||||
停止 Appium 服务。
|
||||
如果已经使用了 with 语句,通常不需要手动调用此函数。
|
||||
:param service: AppiumService 对象
|
||||
"""
|
||||
if service:
|
||||
service.stop()
|
||||
|
||||
|
||||
# --- 装饰器实现 ---
|
||||
def with_appium(func):
|
||||
"""
|
||||
装饰器:在函数执行前后自动启动和停止 Appium 服务。
|
||||
适用于简单的脚本或调试场景。
|
||||
:param func: 需要包装的函数
|
||||
:return: 包装后的函数
|
||||
"""
|
||||
|
||||
def managed_appium(host: str = APPIUM_HOST, port: int | str = APPIUM_PORT):
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
service = start_appium_service()
|
||||
try:
|
||||
# return func(service, *args, **kwargs)
|
||||
# 1. start_appium_service 返回 AppiumService 实例
|
||||
# 2. with 触发 __enter__
|
||||
# 3. 结束或异常触发 __exit__ -> stop()
|
||||
with start_appium_service(host, port) as service:
|
||||
logger.debug(f"装饰器环境就绪: {service}")
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 使用示例:作为一个上下文管理器或简单的生命周期示例
|
||||
@@ -313,7 +346,7 @@ if __name__ == "__main__":
|
||||
try:
|
||||
appium_service = start_appium_service()
|
||||
print(f"\n[项目路径] {BASE_DIR}")
|
||||
print(f"[服务状态] Appium 运行中 (Port: {APPIUM_PORT})")
|
||||
print(f"[服务状态] Appium 运行中...)")
|
||||
print("[操作提示] 按 Ctrl+C 停止服务...")
|
||||
|
||||
# 保持运行,直到手动停止(在实际测试框架中,这里会被替换为测试执行逻辑)
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
@contact: t6g888@163.com
|
||||
@file: settings
|
||||
@date: 2026/1/19 16:54
|
||||
@desc:
|
||||
@desc: 全局配置管理
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录 (core 的上一级)
|
||||
# BASE_DIR = Path(__file__).parent.parent
|
||||
BASE_DIR = Path(__file__).resolve().parents[1] # 获取根路径(绝对路径)
|
||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
# --- 目录配置 ---
|
||||
OUTPUT_DIR = BASE_DIR / "outputs"
|
||||
@@ -23,48 +23,43 @@ LOG_DIR = OUTPUT_DIR / "logs"
|
||||
LOG_BACKUP_DIR = LOG_DIR / "backups"
|
||||
ALLURE_TEMP = BASE_DIR / "temp"
|
||||
REPORT_DIR = BASE_DIR / "report"
|
||||
CONFIG_DIR = BASE_DIR / "config"
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
|
||||
|
||||
# 需要初始化的目录列表 (将在入口文件(mani.py)或 conftest.py 中被调用创建)
|
||||
# 需要初始化的目录列表
|
||||
REQUIRED_DIRS = [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP, SCREENSHOT_DIR]
|
||||
|
||||
# --- 文件路径 ---
|
||||
LOG_SOURCE = LOG_DIR / "pytest.log"
|
||||
CAPS_CONFIG_PATH = CONFIG_DIR / "caps.yaml"
|
||||
|
||||
# --- 启动 Appium 最大尝试次数 ---
|
||||
MAX_RETRIES = 40
|
||||
|
||||
# --- 业务常量 (可选) ---
|
||||
APPIUM_SERVER = "http://127.0.0.1:4723"
|
||||
|
||||
# --- 核心配置 ---
|
||||
IMPLICIT_WAIT_TIMEOUT = 10
|
||||
EXPLICIT_WAIT_TIMEOUT = 10
|
||||
|
||||
# 默认 Appium Server 地址 (可通过命令行参数覆盖)
|
||||
APPIUM_HOST = "127.0.0.1"
|
||||
APPIUM_PORT = 4723
|
||||
|
||||
# --- 设备能力配置 (Capabilities) ---
|
||||
ANDROID_CAPS = {
|
||||
"platformName": "Android",
|
||||
"automationName": "uiautomator2",
|
||||
"deviceName": "Android",
|
||||
"appPackage": "com.android.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()
|
||||
# --- 环境配置 (Environment Switch) ---
|
||||
CURRENT_ENV = os.getenv("APP_ENV", "test")
|
||||
|
||||
# base_url:业务接口的入口地址。主要用于通过 API 快速构造测试数据(前置条件)或查询数据库/接口状态来验证 App 操作是否生效(数据断言)
|
||||
# source_address:后端服务器的物理/源站地址。通常用于绕过负载均衡或 DNS 直接指定访问特定的服务器节点。
|
||||
ENV_CONFIG = {
|
||||
"test": {
|
||||
"base_url": "https://test.example.com",
|
||||
"source_address": "192.168.1.100"
|
||||
},
|
||||
"uat": {
|
||||
"base_url": "https://uat.example.com",
|
||||
"source_address": "192.168.1.200"
|
||||
},
|
||||
"prod": {
|
||||
"base_url": "https://www.example.com",
|
||||
"source_address": "10.0.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@desc:
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
import allure
|
||||
from appium import webdriver
|
||||
@@ -22,13 +23,18 @@ logger = logging.getLogger(__name__)
|
||||
class HomePage(BasePage):
|
||||
# 定位参数
|
||||
menu = ("accessibility id", "开启")
|
||||
home = ("-android uiautomator", 'new UiSelector().resourceId("com.manu.wanandroid:id/icon").instance(0)')
|
||||
home = ("id", 'com.manu.wanandroid:id/largeLabel')
|
||||
project = ("-android uiautomator", 'new UiSelector().text("项目")')
|
||||
system = ("-android uiautomator", 'new UiSelector().text("体系")')
|
||||
|
||||
|
||||
tv_name = ("id", "com.manu.wanandroid:id/tvName")
|
||||
|
||||
account=("-android uiautomator",'new UiSelector().text("账号")')
|
||||
pass_word=("-android uiautomator",'new UiSelector().text("密码")')
|
||||
|
||||
|
||||
login_button = ("accessibility id", '登录')
|
||||
|
||||
def __init__(self, driver: webdriver.Remote):
|
||||
super().__init__(driver)
|
||||
|
||||
@@ -41,17 +47,23 @@ class HomePage(BasePage):
|
||||
self.click(*self.tv_name)
|
||||
|
||||
@allure.step("登录账号:{1}")
|
||||
def click_unicode(self, username,password):
|
||||
def login(self, username, password):
|
||||
"""执行登录业务逻辑"""
|
||||
account_input = {"elementId": '00000000-0000-0ecb-0000-0017000002d8', "text": username}
|
||||
pass_word_input = {"elementId": '00000000-0000-0ecb-0000-0018000002d8', "text": password}
|
||||
if self.wait_until_visible(*self.login_button):
|
||||
self.click(*self.account).driver.execute_script('mobile: type', account_input)
|
||||
self.click(*self.pass_word).driver.execute_script('mobile: type', pass_word_input)
|
||||
|
||||
if self.wait_until_visible(*self.unicode):
|
||||
self.swipe("left")
|
||||
self.click(*self.login_button).full_screen_screenshot()
|
||||
|
||||
self.click(*self.unicode).log_screenshot()
|
||||
|
||||
@allure.step("获取 “Text ”文本")
|
||||
def get_home_text(self):
|
||||
"""执行登录业务逻辑"""
|
||||
# 调用继承自 CoreDriver 的方法(假设你的 CoreDriver 已经被注入或组合)
|
||||
|
||||
return self.get_text(*self.text)
|
||||
# @allure.step("获取 “Text ”文本")
|
||||
# def get_home_text(self):
|
||||
# """执行登录业务逻辑"""os.getenv("USER_NAME") os.getenv("PASS_WORD")
|
||||
# # 调用继承自 CoreDriver 的方法(假设你的 CoreDriver 已经被注入或组合)
|
||||
# """
|
||||
# { "elementId": "00000000-0000-0ecb-0000-0017000002d8", "text": "admintest123456" }
|
||||
# { "elementId": "00000000-0000-0ecb-0000-0018000002d8", "text": "admin123456" }
|
||||
# """
|
||||
#
|
||||
# return self.get_text(*self.text)
|
||||
|
||||
@@ -11,6 +11,8 @@ dependencies = [
|
||||
"pytest>=8.3.5",
|
||||
"PyYAML>=6.0.1",
|
||||
"openpyxl>=3.1.2",
|
||||
"pytest-rerunfailures>=16.1",
|
||||
"python-dotenv>=1.2.1",
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
addopts = -q --show-capture=no
|
||||
addopts = -q --show-capture=no --reruns 2 --reruns-delay 1
|
||||
|
||||
# 1. 开启实时控制台日志
|
||||
log_cli = True
|
||||
|
||||
@@ -10,16 +10,20 @@
|
||||
@desc:
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
import allure
|
||||
import pytest
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from page_objects.wan_android_home import HomePage
|
||||
from page_objects.wan_android_sidebar import ViewsPage
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@allure.epic("ApiDemos")
|
||||
@allure.feature("登录认证模块")
|
||||
class TestApiDemos:
|
||||
@@ -34,20 +38,20 @@ class TestApiDemos:
|
||||
""")
|
||||
@allure.link("https://docs.example.com/login_spec", name="登录业务说明文档")
|
||||
@allure.issue("BUG-1001", "已知偶发:部分机型广告Banner无法滑动")
|
||||
def test_api_demos_success(self, driver,user):
|
||||
def test_api_demos_success(self, driver):
|
||||
"""
|
||||
测试场景:使用正确的用户名和密码登录成功
|
||||
"""
|
||||
api_demos = HomePage(driver)
|
||||
wan = HomePage(driver)
|
||||
|
||||
# 执行业务逻辑
|
||||
api_demos.click_text()
|
||||
api_demos.click_unicode()
|
||||
wan.click_open()
|
||||
wan.login(os.getenv("USER_NAME"),os.getenv("PASS_WORD"))
|
||||
# 断言部分使用 allure.step 包装,使其在报告中也是一个可读的步骤
|
||||
with allure.step("最终校验:检查是否进入首页并显示‘交易’标题"):
|
||||
actual_text = api_demos.get_home_text()
|
||||
assert actual_text == "Text"
|
||||
|
||||
# actual_text = api_demos.get_home_text()
|
||||
# assert actual_text == "Text"
|
||||
print("开发中。。。")
|
||||
# 页面跳转
|
||||
api_demos.go_to(ViewsPage).screenshot_views()
|
||||
api_demos.delay(5)
|
||||
# wan.go_to(ViewsPage).screenshot_views()
|
||||
wan.delay(5)
|
||||
|
||||
28
utils/data_loader.py
Normal file
28
utils/data_loader.py
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
|
||||
"""
|
||||
@author: CNWei,ChenWei
|
||||
@Software: PyCharm
|
||||
@contact: t6g888@163.com
|
||||
@file: data_loader
|
||||
@date: 2026/1/27 10:00
|
||||
@desc: 数据加载工具
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
def load_yaml(file_path: Path | str) -> dict[str, Any] | List[Any]:
|
||||
"""
|
||||
加载 YAML 文件
|
||||
:param file_path: 文件路径
|
||||
:return: 数据字典或列表
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"YAML file not found: {path}")
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
Reference in New Issue
Block a user