2 Commits

Author SHA1 Message Date
a8d0b75dd9 feat(driver): 增加元素高亮截图功能并重构截图矩阵
- 新增 `_get_highlighted_image` 核心逻辑,支持自动缩放 (Scale) 适配。
- 新增 `get_highlight_screenshot_as_file/bytes` 接口,支持诊断级高亮截图。
- 重构 `get_screenshot_as_file/bytes` 统一命名规范。
- 引入 `ImagePath` 与 `ImageBytes` 类型别名,增强代码语义化。
- 优化内存管理,使用 `io.BytesIO` 与 `img.load()` 确保在高频截图下的稳定性。
2026-03-03 17:46:03 +08:00
b3dfd2f3e6 refactor: 优化 README.md 2026-02-28 16:46:53 +08:00
7 changed files with 181 additions and 23 deletions

View File

@@ -73,7 +73,7 @@ AppAutoTest/
1. **克隆仓库:** 1. **克隆仓库:**
```bash ```bash
git clone <your-repository-url> git clone https://github.com/CNWeiWei/AppAutoTest
cd AppAutoTest cd AppAutoTest
``` ```

View File

@@ -26,4 +26,13 @@ wan_android:
appActivity: "com.manu.wanandroid.ui.main.activity.MainActivity" appActivity: "com.manu.wanandroid.ui.main.activity.MainActivity"
noReset: false noReset: false
newCommandTimeout: 60 newCommandTimeout: 60
# udid: "emulator-5554" # Can be injected via CLI # udid: "emulator-5554" # Can be injected via CLI
huawei:
platformName: "Android"
automationName: "uiautomator2"
deviceName: "Android"
appPackage: "com.android.settings"
appActivity: ".Settings"
noReset: false
newCommandTimeout: 60

View File

@@ -75,7 +75,7 @@ class BasePage(CoreDriver):
:param label: 截图在报告中显示的名称 :param label: 截图在报告中显示的名称
""" """
path_str = self.full_screen_screenshot(name=label) path_str = self.get_screenshot_as_file(filename=label)
if path_str: if path_str:
img_path = Path(path_str) img_path = Path(path_str)
@@ -92,7 +92,7 @@ class BasePage(CoreDriver):
:param label: 截图在报告中显示的名称 :param label: 截图在报告中显示的名称
""" """
screenshot_bytes: bytes = self.driver.get_screenshot_as_png() screenshot_bytes: bytes = self.get_screenshot_as_bytes()
allure.attach( allure.attach(
screenshot_bytes, screenshot_bytes,

View File

@@ -10,6 +10,7 @@
@desc: Appium 核心驱动封装,提供统一的 API 用于 Appium 会话管理和元素操作。 @desc: Appium 核心驱动封装,提供统一的 API 用于 Appium 会话管理和元素操作。
""" """
import logging import logging
import io
import secrets # 原生库,用于生成安全的随机数 import secrets # 原生库,用于生成安全的随机数
from typing import Optional, Type, TypeVar, Union, Callable, Any from typing import Optional, Type, TypeVar, Union, Callable, Any
from time import sleep from time import sleep
@@ -30,8 +31,11 @@ from selenium.webdriver.common.actions import interaction
from selenium.webdriver.common.actions.action_builder import ActionBuilder from selenium.webdriver.common.actions.action_builder import ActionBuilder
from selenium.webdriver.common.actions.pointer_input import PointerInput from selenium.webdriver.common.actions.pointer_input import PointerInput
from PIL import Image, ImageDraw
from core.enums import AppPlatform from core.enums import AppPlatform
from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT, SCREENSHOT_DIR from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT, SCREENSHOT_DIR
from core.types import ImagePath, ImageBytes
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
@@ -212,7 +216,7 @@ class CoreDriver:
try: try:
# 获取函数名称用于日志,兼容 lambda 和普通函数 # 获取函数名称用于日志,兼容 lambda 和普通函数
func_name = getattr(method, '__name__', repr(method)) func_name = getattr(method, '__name__', repr(method))
logger.info(f"执行显式等待: {func_name}, 超时: {wait_timeout}s") logger.info(f"执行显式等待: {func_name}, 最大超时时长: {wait_timeout}s")
return WebDriverWait(self.driver, wait_timeout).until(method) return WebDriverWait(self.driver, wait_timeout).until(method)
except TimeoutException: except TimeoutException:
logger.error(f"等待超时: {wait_timeout}s 内未满足条件 {method}") logger.error(f"等待超时: {wait_timeout}s 内未满足条件 {method}")
@@ -425,7 +429,7 @@ class CoreDriver:
file_name = f"popup_fail_{round_idx}_{safe_val}.png" file_name = f"popup_fail_{round_idx}_{safe_val}.png"
logger.error(f"清理弹窗尝试点击时失败[{safe_val}:{value}]: {e}") logger.error(f"清理弹窗尝试点击时失败[{safe_val}:{value}]: {e}")
self.full_screen_screenshot(file_name) self.get_screenshot_as_file(file_name)
raise e raise e
if skip_count == list_len: if skip_count == list_len:
break break
@@ -624,13 +628,30 @@ class CoreDriver:
""" """
return self.switch_to_context('NATIVE_APP') return self.switch_to_context('NATIVE_APP')
def full_screen_screenshot(self, name: str | None = None) -> str: def get_screenshot_as_bytes(self) -> ImageBytes:
"""截取当前完整屏幕并以字节流形式返回。
与 get_screenshot_as_file 类似,但它不将图片写入磁盘,而是直接返回
PNG 格式的二进制数据。适用于需要动态处理或传输截图的场景。
:return: PNG 格式的图像字节流 (bytes)。如果获取失败,则返回空字节串 b""
""" """
截取当前完整屏幕内容 (自愈逻辑、异常报错首选) try:
:param name: 图片文件名 return self.driver.get_screenshot_as_png()
:return: 截图保存的路径 except Exception as e:
logger.error(f"获取全屏字节流失败: {e}")
return b""
def get_screenshot_as_file(self, filename: str | None = None) -> ImagePath:
"""截取当前完整屏幕并保存为文件。
这是一个通用的全屏截图功能,常用于记录测试执行的某个特定状态、
在发生未知错误时捕获现场,或用于视觉回归测试的基准图像。
:param filename: 保存的文件名 (不含扩展名)。如果为 None将生成一个随机文件名。
:return: 保存后的图像文件的绝对路径 (str)。如果保存失败,则返回空字符串。
""" """
file_name = f"{name or secrets.token_hex(8)}.png" file_name = f"{filename or secrets.token_hex(8)}.png"
path = (SCREENSHOT_DIR / file_name).as_posix() path = (SCREENSHOT_DIR / file_name).as_posix()
try: try:
@@ -642,21 +663,129 @@ class CoreDriver:
logger.error(f"全屏截图失败: {e}") logger.error(f"全屏截图失败: {e}")
return "" return ""
def element_screenshot(self, by: str, value: str, name: str | None = None) -> str: def _get_highlighted_image(self, by: str, value: str, color: str = "red") -> Image.Image:
"""[内部核心] 截取屏幕,高亮指定元素,并返回 PIL Image 对象。
此方法是高亮截图功能的基础。它首先获取全屏截图,然后定位指定元素,
计算其在截图上的物理像素位置(处理分辨率缩放),最后在元素周围绘制一个
矩形框。
如果元素定位失败或在绘制过程中发生任何异常,为了不中断主流程,
该方法会记录警告并返回未经修改的原始截图。
:param by: 元素定位策略 (如 'id', 'xpath')。
:param value: 元素定位值。
:param color: 高亮框的颜色 (CSS 颜色名称,如 'red', 'blue'),默认为 'red'
:return: 一个 PIL.Image.Image 对象。成功时为带高亮框的截图,失败时为原始截图。
""" """
截取特定元素的图像 (业务校验、UI对比首选) # 1. 获取基础截图
:param by: 定位策略 screenshot_png = self.driver.get_screenshot_as_png()
:param value: 定位值 # 2. 显式管理内存流
:param name: 图片文件名 with io.BytesIO(screenshot_png) as stream:
:return: 截图保存的路径 img = Image.open(stream)
# 强制加载进入内存,确保 stream 关闭后 img 对象依然可用
img.load()
try:
# 3. 定位元素并计算缩放 (逻辑像素 vs 物理像素)
el = self.find_element(by, value)
window_size = self.driver.get_window_size()
scale = img.size[0] / (window_size['width'] or 1)
# 4. 计算坐标
loc, sz = el.location, el.size
rect = [
(loc['x'] - 2) * scale,
(loc['y'] - 2) * scale,
(loc['x'] + sz['width'] + 2) * scale,
(loc['y'] + sz['height'] + 2) * scale
]
# 5. 绘制高亮
draw = ImageDraw.Draw(img)
line_width = max(int(3 * scale), 5)
draw.rectangle(rect, outline=color, width=line_width)
return img
except Exception as e:
# 如果元素找不到或其他异常,记录并返回原图,确保流程不中断
logger.warning(f"无法为元素({by}={value})绘制高亮,返回原图。原因: {e}")
return img
def get_highlight_screenshot_as_bytes(self, by: str, value: str, color: str = "red") -> ImageBytes:
"""获取高亮元素的截图,并以字节流形式返回。
此方法适用于需要将截图数据直接在内存中处理或通过网络传输的场景,
例如,将其嵌入到测试报告中或发送到图像识别服务,而无需先保存到磁盘。
:param by: 元素定位策略。
:param value: 元素定位值。
:param color: 高亮框的颜色,默认为 'red'
:return: PNG 格式的图像字节流 (bytes)。
""" """
file_name = f"{name or secrets.token_hex(8)}.png" img = self._get_highlighted_image(by, value, color)
with io.BytesIO() as img_byte_arr:
img.save(img_byte_arr, format='PNG')
return img_byte_arr.getvalue()
def get_highlight_screenshot_as_file(self, by: str, value: str, filename: str | None = None,
color: str = "red") -> ImagePath:
"""获取高亮元素的截图,并将其保存为文件。
当需要在调试、错误分析或生成可视化报告时,将带有上下文(高亮元素)的
截图持久化到磁盘时,应使用此方法。
:param by: 元素定位策略。
:param value: 元素定位值。
:param filename: 保存的文件名 (不含扩展名)。如果为 None将生成一个随机文件名。
:param color: 高亮框的颜色,默认为 'red'
:return: 保存后的图像文件的绝对路径 (str)。如果保存失败,则返回空字符串。
"""
file_name = f"{filename or secrets.token_hex(8)}.png"
path = (SCREENSHOT_DIR / file_name).as_posix()
try:
img = self._get_highlighted_image(by, value, color)
img.save(path)
return path
except Exception as e:
logger.error(f"全屏截图失败: {e}")
return ""
def get_element_screenshot_as_bytes(self, by: str, value: str) -> ImageBytes:
"""截取指定元素的图像,并以字节流形式返回。
此方法只捕获元素自身矩形区域内的图像,不包含屏幕的其他部分。
非常适合用于元素级别的图像对比、OCR 识别或在报告中精确展示某个控件。
:param by: 元素定位策略。
:param value: 元素定位值。
:return: 元素截图的 PNG 格式图像字节流 (bytes)。如果失败,则返回空字节串 b""
"""
try:
el = self.find_element(by, value)
return el.screenshot_as_png
except Exception as e:
logger.error(f"获取元素字节流失败: {e}")
return b""
def get_element_screenshot_as_file(self, by: str, value: str, filename: str | None = None) -> ImagePath:
"""截取指定元素的图像,并将其保存为文件。
与 get_element_screenshot_as_bytes 功能相同,但将结果直接持久化到磁盘。
适用于需要对特定 UI 控件进行存档或后续分析的场景。
:param by: 元素定位策略。
:param value: 元素定位值。
:param filename: 保存的文件名 (不含扩展名)。如果为 None将生成一个随机文件名。
:return: 保存后的图像文件的绝对路径 (str)。如果保存失败,则返回空字符串。
"""
file_name = f"{filename or secrets.token_hex(8)}.png"
path = (SCREENSHOT_DIR / file_name).as_posix() path = (SCREENSHOT_DIR / file_name).as_posix()
try: try:
by = by_converter(by) # 核心:直接调用 find_element
# 核心:直接调用底层 find_element self.find_element(by, value).screenshot(path)
self.driver.find_element(by, value).screenshot(path)
logger.info(f"元素截图已保存: {path}") logger.info(f"元素截图已保存: {path}")
return path return path
except Exception as e: except Exception as e:

18
core/types.py Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: types
@date: 2026/3/3 17:10
@desc:
"""
from typing import TypeAlias
# 标识这是一个指向磁盘图片的字符串路径
ImagePath: TypeAlias = str
# 标识这是一个图片的二进制字节流
ImageBytes: TypeAlias = bytes

View File

@@ -60,5 +60,7 @@ class HomePage(BasePage):
self.click(*self.login_button) self.click(*self.login_button)
if self.wait_until_visible(*self.tv_name): if self.wait_until_visible(*self.tv_name):
self.full_screen_screenshot("登陆成功") self.get_screenshot_as_file("登陆成功")
self.get_highlight_screenshot_as_file(*self.tv_name, "登陆成功-高亮")
self.get_element_screenshot_as_file(*self.tv_name)
self.long_press(x=636, y=117, duration=300) self.long_press(x=636, y=117, duration=300)

View File

@@ -9,9 +9,9 @@ dependencies = [
"appium-python-client>=5.2.4", "appium-python-client>=5.2.4",
"pytest>=8.3.5", "pytest>=8.3.5",
"PyYAML>=6.0.1", "PyYAML>=6.0.1",
"openpyxl>=3.1.2",
"pytest-rerunfailures>=16.1", "pytest-rerunfailures>=16.1",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"pillow>=12.1.1",
] ]
[[tool.uv.index]] [[tool.uv.index]]