diff --git a/config/caps.yaml b/config/caps.yaml index 758640f..d1d6402 100644 --- a/config/caps.yaml +++ b/config/caps.yaml @@ -26,4 +26,13 @@ wan_android: appActivity: "com.manu.wanandroid.ui.main.activity.MainActivity" noReset: false newCommandTimeout: 60 - # udid: "emulator-5554" # Can be injected via CLI \ No newline at end of file + # 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 \ No newline at end of file diff --git a/core/base_page.py b/core/base_page.py index 11b8a31..e6f37b5 100644 --- a/core/base_page.py +++ b/core/base_page.py @@ -75,7 +75,7 @@ class BasePage(CoreDriver): :param label: 截图在报告中显示的名称 """ - path_str = self.full_screen_screenshot(name=label) + path_str = self.get_screenshot_as_file(filename=label) if path_str: img_path = Path(path_str) @@ -92,7 +92,7 @@ class BasePage(CoreDriver): :param label: 截图在报告中显示的名称 """ - screenshot_bytes: bytes = self.driver.get_screenshot_as_png() + screenshot_bytes: bytes = self.get_screenshot_as_bytes() allure.attach( screenshot_bytes, diff --git a/core/driver.py b/core/driver.py index 224121b..1b01deb 100644 --- a/core/driver.py +++ b/core/driver.py @@ -10,6 +10,7 @@ @desc: Appium 核心驱动封装,提供统一的 API 用于 Appium 会话管理和元素操作。 """ import logging +import io import secrets # 原生库,用于生成安全的随机数 from typing import Optional, Type, TypeVar, Union, Callable, Any 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.pointer_input import PointerInput +from PIL import Image, ImageDraw + from core.enums import AppPlatform 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.decorators import resolve_wait_method @@ -212,7 +216,7 @@ class CoreDriver: try: # 获取函数名称用于日志,兼容 lambda 和普通函数 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) except TimeoutException: logger.error(f"等待超时: {wait_timeout}s 内未满足条件 {method}") @@ -425,7 +429,7 @@ class CoreDriver: file_name = f"popup_fail_{round_idx}_{safe_val}.png" logger.error(f"清理弹窗尝试点击时失败[{safe_val}:{value}]: {e}") - self.full_screen_screenshot(file_name) + self.get_screenshot_as_file(file_name) raise e if skip_count == list_len: break @@ -624,13 +628,30 @@ class CoreDriver: """ 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""。 """ - 截取当前完整屏幕内容 (自愈逻辑、异常报错首选) - :param name: 图片文件名 - :return: 截图保存的路径 + try: + return self.driver.get_screenshot_as_png() + 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() try: @@ -642,21 +663,129 @@ class CoreDriver: logger.error(f"全屏截图失败: {e}") 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对比首选) - :param by: 定位策略 - :param value: 定位值 - :param name: 图片文件名 - :return: 截图保存的路径 + # 1. 获取基础截图 + screenshot_png = self.driver.get_screenshot_as_png() + # 2. 显式管理内存流 + with io.BytesIO(screenshot_png) as stream: + 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() try: - by = by_converter(by) - # 核心:直接调用底层 find_element, - self.driver.find_element(by, value).screenshot(path) + # 核心:直接调用 find_element, + self.find_element(by, value).screenshot(path) logger.info(f"元素截图已保存: {path}") return path except Exception as e: diff --git a/core/types.py b/core/types.py new file mode 100644 index 0000000..8a3d329 --- /dev/null +++ b/core/types.py @@ -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 + diff --git a/page_objects/wan_android_home.py b/page_objects/wan_android_home.py index 501e616..c35fea2 100644 --- a/page_objects/wan_android_home.py +++ b/page_objects/wan_android_home.py @@ -60,5 +60,7 @@ class HomePage(BasePage): self.click(*self.login_button) 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) diff --git a/pyproject.toml b/pyproject.toml index 768f4be..8639365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,9 +9,9 @@ dependencies = [ "appium-python-client>=5.2.4", "pytest>=8.3.5", "PyYAML>=6.0.1", - "openpyxl>=3.1.2", "pytest-rerunfailures>=16.1", "python-dotenv>=1.2.1", + "pillow>=12.1.1", ] [[tool.uv.index]]