Files
appium_ui_test/ui_test.py
2025-10-31 17:53:12 +08:00

666 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Appium 交互式UI测试工具 - 主入口文件
支持Android应用的UI自动化测试、XML布局分析、视觉比对等功能
"""
import os
import time
import threading
import datetime
import base64
from pathlib import Path
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
import keyboard
# 导入自定义模块
from config import Config
from xml_analyzer import XMLAnalyzer
from visual_comparator import VisualComparator
from test_reporter import TestReporter
class InteractiveUITester:
"""交互式UI测试工具"""
def __init__(self):
self.driver = None
self.running = False
# 使用配置文件中的设置
self.screenshots_dir = Config.SCREENSHOTS_DIR
self.xml_dir = Config.XML_LAYOUTS_DIR
self.designs_dir = Config.DESIGN_REFERENCES_DIR
self.comparisons_dir = Config.VISUAL_COMPARISONS_DIR
# 设置目录
Config.setup_directories()
# 初始化分析器
self.xml_analyzer = XMLAnalyzer()
self.visual_comparator = VisualComparator()
self.test_reporter = TestReporter()
# 性能监控数据
self.performance_data = {
'start_time': None,
'operations': [],
'memory_usage': []
}
# Appium配置
self.capabilities = Config.DEVICE_CAPABILITIES
self.appium_server_url = Config.APPIUM_SERVER_URL
def connect_device(self):
"""连接到Appium服务器"""
try:
print("🔌 正在连接Appium服务器...")
self.driver = webdriver.Remote(
self.appium_server_url,
options=UiAutomator2Options().load_capabilities(self.capabilities)
)
self.performance_data['start_time'] = time.time()
print("✅ 成功连接到设备!")
return True
except Exception as e:
print(f"❌ 连接失败: {e}")
return False
def disconnect_device(self):
"""断开设备连接"""
if self.driver:
try:
self.driver.quit()
print("🔌 已断开设备连接")
except:
pass
self.driver = None
def take_screenshot(self):
"""截图功能"""
if not self.driver:
print("❌ 设备未连接,无法截图")
return None
try:
start_time = time.time()
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshot_{timestamp}.png"
filepath = self.screenshots_dir / filename
# 获取截图
screenshot_base64 = self.driver.get_screenshot_as_base64()
screenshot_data = base64.b64decode(screenshot_base64)
# 保存截图
with open(filepath, 'wb') as f:
f.write(screenshot_data)
# 记录性能数据
operation_time = time.time() - start_time
self.performance_data['operations'].append({
'type': 'screenshot',
'time': operation_time,
'timestamp': timestamp
})
print(f"📸 截图已保存: {filepath}")
return filepath
except Exception as e:
print(f"❌ 截图失败: {e}")
return None
def save_page_xml(self):
"""保存当前页面XML布局"""
if not self.driver:
print("❌ 设备未连接无法获取XML")
return None
try:
start_time = time.time()
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"layout_{timestamp}.xml"
filepath = self.xml_dir / filename
# 获取页面源码
page_source = self.driver.page_source
# 保存XML
with open(filepath, 'w', encoding='utf-8') as f:
f.write(page_source)
# 分析XML
if self.xml_analyzer.parse_xml(page_source):
stats = self.xml_analyzer.get_statistics()
print(f"📄 XML布局已保存: {filepath}")
print(f"📊 分析结果: {stats['total_elements']}个元素, {stats['issues_count']}个问题")
# 显示主要问题
if self.xml_analyzer.issues:
print("⚠️ 发现的主要问题:")
for issue in self.xml_analyzer.issues[:3]: # 只显示前3个
print(f"{issue['element']}: {issue['issue']}")
if len(self.xml_analyzer.issues) > 3:
print(f" ... 还有 {len(self.xml_analyzer.issues) - 3} 个问题")
# 记录性能数据
operation_time = time.time() - start_time
self.performance_data['operations'].append({
'type': 'xml_analysis',
'time': operation_time,
'timestamp': timestamp
})
return filepath
except Exception as e:
print(f"❌ 保存XML失败: {e}")
return None
def compare_with_design(self):
"""与设计图进行视觉比对(仅视觉比对)"""
if not self.driver:
print("❌ 设备未连接,无法进行比对")
return
# 检查设计图目录
supported_formats = Config.get_supported_design_formats()
design_files = []
for fmt in supported_formats:
design_files.extend(list(self.designs_dir.glob(f"*{fmt}")))
if not design_files:
print(f"❌ 未找到设计图,请将设计图放入 {self.designs_dir} 目录")
print(f"💡 支持的格式: {', '.join(supported_formats)}")
return
# 截图
screenshot_path = self.take_screenshot()
if not screenshot_path:
return
try:
# 使用最新的设计图进行比对
design_path = max(design_files, key=lambda x: x.stat().st_mtime)
print(f"🎨 使用设计图: {design_path.name}")
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
comparison_path = self.comparisons_dir / f"comparison_{timestamp}.png"
# 执行视觉比对
print("👁️ 开始视觉比对...")
visual_result = self.visual_comparator.compare_images(
screenshot_path, design_path, comparison_path
)
if visual_result:
similarity = visual_result['similarity_score'] * 100
diff_count = len(visual_result['diff_regions'])
print(f"📊 视觉比对结果:")
print(f" 相似度: {similarity:.1f}%")
print(f" 差异区域: {diff_count}")
print(f" 比对结果: {comparison_path}")
if similarity >= Config.VISUAL_COMPARISON['excellent_threshold'] * 100:
print("✅ 视觉效果优秀,与设计高度一致")
elif similarity >= Config.VISUAL_COMPARISON['good_threshold'] * 100:
print("⚠️ 视觉效果良好,有轻微差异")
else:
print("❌ 视觉差异较大,建议检查实现")
except Exception as e:
print(f"❌ 视觉比对失败: {e}")
def compare_layout_only(self):
"""仅进行XML-SVG布局比对"""
if not self.driver:
print("❌ 设备未连接,无法进行比对")
return
# 检查SVG设计图
svg_files = list(self.designs_dir.glob("*.svg"))
if not svg_files:
print(f"❌ 未找到SVG设计图请将SVG文件放入 {self.designs_dir} 目录")
return
# 保存当前页面XML
xml_path = self.save_page_xml()
if not xml_path:
return
try:
# 使用最新的SVG设计图
svg_path = max(svg_files, key=lambda x: x.stat().st_mtime)
print(f"📐 使用SVG设计图: {svg_path.name}")
# 执行布局比对
layout_result = self.xml_analyzer.compare_layouts(str(svg_path))
if layout_result:
print(f"\n📊 布局比对详细结果:")
print(f" XML元素数量: {layout_result['xml_elements_count']}")
print(f" SVG元素数量: {layout_result['svg_elements_count']}")
print(f" 匹配元素数量: {len(layout_result['matched_elements'])}")
print(f" 布局相似度: {layout_result['layout_similarity']:.1%}")
# 显示匹配详情
if layout_result['matched_elements']:
print(f"\n✅ 匹配的元素:")
for i, match in enumerate(layout_result['matched_elements'][:5]): # 只显示前5个
xml_elem = match['xml_element']
svg_elem = match['svg_element']
print(f" {i+1}. {xml_elem['type']}{svg_elem['type']} (相似度: {match['similarity']:.1%})")
if xml_elem.get('text') or svg_elem.get('text'):
print(f" 文本: '{xml_elem.get('text', '')}''{svg_elem.get('text', '')}'")
# 显示未匹配元素
if layout_result['unmatched_xml']:
print(f"\n⚠️ 未匹配的XML元素 ({len(layout_result['unmatched_xml'])}个):")
for i, elem in enumerate(layout_result['unmatched_xml'][:3]): # 只显示前3个
print(f" {i+1}. {elem['type']} - '{elem.get('text', '无文本')}'")
if layout_result['unmatched_svg']:
print(f"\n⚠️ 未匹配的SVG元素 ({len(layout_result['unmatched_svg'])}个):")
for i, elem in enumerate(layout_result['unmatched_svg'][:3]): # 只显示前3个
print(f" {i+1}. {elem['type']} - '{elem.get('text', '无文本')}'")
# 总结
print(f"\n{self.xml_analyzer.get_layout_comparison_summary()}")
except Exception as e:
print(f"❌ 布局比对失败: {e}")
def analyze_current_page(self):
"""分析当前页面XML分析和布局比对"""
if not self.driver:
print("❌ 设备未连接")
return
print("🔍 开始综合页面分析...")
# 1. 保存XML并分析
xml_path = self.save_page_xml()
# 2. 截图
screenshot_path = self.take_screenshot()
# 3. 检查是否有SVG设计图进行布局比对
supported_formats = Config.get_supported_design_formats()
design_files = []
for fmt in supported_formats:
design_files.extend(list(self.designs_dir.glob(f"*{fmt}")))
# 查找SVG设计图
svg_files = [f for f in design_files if f.suffix.lower() == '.svg']
if svg_files:
print("\n📐 发现SVG设计图开始布局比对分析...")
# 使用最新的SVG设计图
svg_design = max(svg_files, key=lambda x: x.stat().st_mtime)
print(f"🎨 使用SVG设计图: {svg_design.name}")
# 执行XML-SVG布局比对
print("🔍 开始XML-SVG布局比对...")
layout_result = self.xml_analyzer.compare_layouts(str(svg_design))
if layout_result:
print(f"📐 布局比对结果:")
print(f" XML元素: {layout_result['xml_elements_count']}")
print(f" SVG元素: {layout_result['svg_elements_count']}")
print(f" 匹配元素: {len(layout_result['matched_elements'])}")
print(f" 布局相似度: {layout_result['layout_similarity']:.1%}")
if layout_result['layout_similarity'] >= 0.8:
print("✅ 布局高度一致")
elif layout_result['layout_similarity'] >= 0.6:
print("⚠️ 布局基本一致,有少量差异")
else:
print("❌ 布局差异较大,建议检查")
# 显示未匹配的元素
if layout_result['unmatched_xml']:
print(f" 未匹配XML元素: {len(layout_result['unmatched_xml'])}")
if layout_result['unmatched_svg']:
print(f" 未匹配SVG元素: {len(layout_result['unmatched_svg'])}")
else:
print("\n💡 未发现SVG设计图跳过布局比对分析")
print(f" 提示将SVG设计图放入 {self.designs_dir} 目录可进行布局比对")
# 4. 生成报告
print("\n📋 生成测试报告...")
self.generate_test_report()
def generate_test_report(self):
"""生成测试报告"""
try:
# 添加分析数据到报告
self.test_reporter.add_xml_analysis(self.xml_analyzer)
self.test_reporter.add_visual_comparison(self.visual_comparator)
self.test_reporter.add_performance_data(self.performance_data)
# 生成报告
report_format = Config.REPORT['default_format']
report_path = self.test_reporter.generate_report(report_format)
# 如果配置了生成HTML报告也生成HTML版本
if Config.REPORT['generate_html'] and report_format != 'html':
html_report = self.test_reporter.generate_report('html')
print(f"📊 HTML报告: {html_report}")
except Exception as e:
print(f"❌ 生成报告失败: {e}")
def find_and_click_element(self):
"""智能元素定位和点击"""
if not self.driver:
print("❌ 设备未连接")
return
try:
# 获取所有可点击元素
clickable_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@clickable="true"]')
if not clickable_elements:
print("❌ 未找到可点击元素")
return
print(f"🔘 找到 {len(clickable_elements)} 个可点击元素:")
for i, element in enumerate(clickable_elements[:10]): # 显示前10个
try:
text = element.text or element.get_attribute('content-desc') or f"元素{i+1}"
bounds = element.get_attribute('bounds')
print(f" {i+1}. {text} - {bounds}")
except:
print(f" {i+1}. 未知元素")
# 让用户选择要点击的元素
try:
choice = input("\n请输入要点击的元素编号 (1-10, 或按Enter跳过): ").strip()
if choice and choice.isdigit():
index = int(choice) - 1
if 0 <= index < len(clickable_elements):
element = clickable_elements[index]
element.click()
print(f"✅ 已点击元素 {choice}")
time.sleep(1) # 等待页面响应
else:
print("❌ 无效的元素编号")
except:
pass
except Exception as e:
print(f"❌ 元素定位失败: {e}")
def monitor_performance(self):
"""性能监控"""
if not self.driver:
print("❌ 设备未连接")
return
try:
# 获取设备信息
device_info = {
'platform': self.driver.capabilities.get('platformName'),
'device_name': self.driver.capabilities.get('deviceName'),
'app_package': self.driver.current_package,
'app_activity': self.driver.current_activity
}
# 计算运行时间
if self.performance_data['start_time']:
runtime = time.time() - self.performance_data['start_time']
print(f"⏱️ 性能监控:")
print(f" 运行时间: {runtime:.1f}")
print(f" 操作次数: {len(self.performance_data['operations'])}")
if self.performance_data['operations']:
avg_time = sum(op['time'] for op in self.performance_data['operations']) / len(self.performance_data['operations'])
print(f" 平均操作时间: {avg_time:.2f}")
print(f" 当前应用: {device_info['app_package']}")
print(f" 当前Activity: {device_info['app_activity']}")
except Exception as e:
print(f"❌ 性能监控失败: {e}")
def get_current_activity(self):
"""获取当前Activity信息"""
if not self.driver:
print("❌ 设备未连接")
return
try:
current_activity = self.driver.current_activity
current_package = self.driver.current_package
print(f"📱 当前应用: {current_package}")
print(f"📱 当前Activity: {current_activity}")
except Exception as e:
print(f"❌ 获取Activity信息失败: {e}")
def find_elements_info(self):
"""显示当前页面元素信息"""
if not self.driver:
print("❌ 设备未连接")
return
try:
# 查找所有可点击元素
clickable_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@clickable="true"]')
print(f"🔘 找到 {len(clickable_elements)} 个可点击元素")
# 查找所有文本元素
text_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@text!=""]')
print(f"📝 找到 {len(text_elements)} 个文本元素")
# 显示前5个文本元素
print("📝 前5个文本元素:")
for i, element in enumerate(text_elements[:5]):
try:
text = element.text
bounds = element.get_attribute('bounds')
print(f" {i+1}. '{text}' - 位置: {bounds}")
except:
pass
except Exception as e:
print(f"❌ 获取元素信息失败: {e}")
def setup_hotkeys(self):
"""设置热键监听"""
print("⌨️ 设置热键监听...")
# 从配置文件获取热键设置
hotkeys = Config.HOTKEYS
# 基础功能热键
keyboard.add_hotkey(hotkeys['screenshot'], self.take_screenshot)
print(f" {hotkeys['screenshot'].upper()} - 截图")
keyboard.add_hotkey(hotkeys['save_xml'], self.save_page_xml)
print(f" {hotkeys['save_xml'].upper()} - 保存XML布局")
keyboard.add_hotkey(hotkeys['get_activity'], self.get_current_activity)
print(f" {hotkeys['get_activity'].upper()} - 获取当前Activity")
keyboard.add_hotkey(hotkeys['element_info'], self.find_elements_info)
print(f" {hotkeys['element_info'].upper()} - 显示页面元素信息")
# 新增功能热键
keyboard.add_hotkey(hotkeys['visual_compare'], self.compare_with_design)
print(f" {hotkeys['visual_compare'].upper()} - 视觉比对")
keyboard.add_hotkey(hotkeys['analyze_page'], self.analyze_current_page)
print(f" {hotkeys['analyze_page'].upper()} - 综合页面分析")
keyboard.add_hotkey(hotkeys['monitor_performance'], self.monitor_performance)
print(f" {hotkeys['monitor_performance'].upper()} - 性能监控")
keyboard.add_hotkey(hotkeys['click_element'], self.find_and_click_element)
print(f" {hotkeys['click_element'].upper()} - 智能元素点击")
keyboard.add_hotkey(hotkeys['generate_report'], self.generate_test_report)
print(f" {hotkeys['generate_report'].upper()} - 生成测试报告")
# 退出热键
keyboard.add_hotkey(hotkeys['quit'], self.stop)
print(f" {hotkeys['quit'].upper()} - 退出程序")
def show_menu(self):
"""显示操作菜单"""
hotkeys = Config.HOTKEYS
supported_formats = Config.get_supported_design_formats()
print("\n" + "="*60)
print("🤖 Appium 交互式UI测试工具")
print("="*60)
print("热键操作:")
print(f" {hotkeys['screenshot'].upper()} - 截图")
print(f" {hotkeys['save_xml'].upper()} - 保存XML布局")
print(f" {hotkeys['get_activity'].upper()} - 获取当前Activity")
print(f" {hotkeys['element_info'].upper()} - 显示页面元素信息")
print(f" {hotkeys['visual_compare'].upper()} - 视觉比对(仅图像对比)")
print(f" {hotkeys['analyze_page'].upper()} - XML分析和布局比对")
print(f" {hotkeys['monitor_performance'].upper()} - 性能监控")
print(f" {hotkeys['click_element'].upper()} - 智能元素点击")
print(f" {hotkeys['generate_report'].upper()} - 生成测试报告")
print(f" {hotkeys['quit'].upper()} - 退出程序")
print("\n命令行操作:")
print(" help - 显示帮助")
print(" status - 显示连接状态")
print(" reconnect - 重新连接设备")
print(" compare - 视觉比对(仅图像对比)")
print(" layout - XML-SVG布局比对")
print(" analyze - XML分析和布局比对")
print(" performance - 性能监控")
print(" click - 智能元素点击")
print(" report - 生成测试报告")
print(" quit - 退出程序")
print("\n💡 使用提示:")
print(f" • F5/compare: 纯视觉图像比对检查UI外观")
print(f" • F6/analyze: XML结构分析 + SVG布局比对")
print(f" • layout: 专门的XML-SVG布局结构比对")
print(f" • 将设计图放入 {self.designs_dir} 目录")
print(f" • 支持的设计图格式: {', '.join(supported_formats)}")
print(" • 测试报告会自动保存为JSON和HTML格式")
print("="*60)
def handle_command(self, command):
"""处理命令行输入"""
command = command.strip().lower()
if command == 'help':
self.show_menu()
elif command == 'status':
if self.driver:
print("✅ 设备已连接")
self.get_current_activity()
else:
print("❌ 设备未连接")
elif command == 'reconnect':
self.disconnect_device()
self.connect_device()
elif command in ['quit', 'exit', 'q']:
self.stop()
elif command == '1' or command == 'screenshot':
self.take_screenshot()
elif command == '2' or command == 'xml':
self.save_page_xml()
elif command == '3' or command == 'activity':
self.get_current_activity()
elif command == '4' or command == 'elements':
self.find_elements_info()
elif command == '5' or command == 'compare':
self.compare_with_design()
elif command == 'layout':
self.compare_layout_only()
elif command == '6' or command == 'analyze':
self.analyze_current_page()
elif command == '7' or command == 'performance':
self.monitor_performance()
elif command == '8' or command == 'click':
self.find_and_click_element()
elif command == '9' or command == 'report':
self.generate_test_report()
else:
print(f"❓ 未知命令: {command}")
print("输入 'help' 查看可用命令")
def command_loop(self):
"""命令行输入循环"""
while self.running:
try:
command = input("\n> ").strip()
if command:
self.handle_command(command)
except (KeyboardInterrupt, EOFError):
self.stop()
break
except Exception as e:
print(f"❌ 命令执行错误: {e}")
def stop(self):
"""停止程序"""
print("\n🛑 正在退出...")
self.running = False
self.disconnect_device()
def run(self):
"""运行交互式测试工具"""
print("🚀 启动Appium交互式UI测试工具")
# 连接设备
if not self.connect_device():
print("❌ 无法连接到设备请检查Appium服务器是否启动")
return
# 设置热键
self.setup_hotkeys()
# 显示菜单
self.show_menu()
# 标记为运行状态
self.running = True
# 启动命令行输入线程
command_thread = threading.Thread(target=self.command_loop, daemon=True)
command_thread.start()
print("\n✅ 工具已启动! 使用热键或输入命令进行操作...")
print("💡 提示: 输入 'help' 查看所有可用命令")
try:
# 保持主线程运行
while self.running:
time.sleep(0.1)
except KeyboardInterrupt:
self.stop()
print("👋 程序已退出")
def main():
"""主函数"""
print("🎯 交互式UI测试工具")
print("📱 支持Android应用UI测试")
print("🔧 集成XML分析、视觉比对、性能监控等功能")
print("-" * 50)
tester = InteractiveUITester()
try:
tester.run()
except Exception as e:
print(f"❌ 程序运行出错: {e}")
finally:
print("🔚 程序已退出")
if __name__ == '__main__':
main()