selenium测试

This commit is contained in:
wangqifan 2025-12-31 13:51:54 +08:00
parent bcbf100356
commit c6ea1854e5
10 changed files with 370 additions and 20 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ dsl_schema.json
sessions/*
artifacts/*
.vscode/settings.json
tests/__pycache__/*.pyc

View File

@ -3,3 +3,11 @@
Can not load UIAutomationCore.dll.
1, You may need to install Windows Update KB971513 if your OS is Windows XP, see https://github.com/yinkaisheng/WindowsUpdateKB971513ForIUIAutomation
2, You need to use an UIAutomationInitializerInThread object if use uiautomation in a thread, see demos/uiautomation_in_thread.py
2025-12-30 22:39:52.738 pydevd_safe_repr.py[343] _repr_obj -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:40:02.757 pydevd_safe_repr.py[343] _repr_obj -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:40:12.791 pydevd_safe_repr.py[343] _repr_obj -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:40:25.854 pydevd_safe_repr.py[343] _repr_obj -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:40:35.867 pydevd_safe_repr.py[343] _repr_obj -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:40:45.888 pydevd_safe_repr.py[343] _repr_obj -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:40:55.909 pydevd_resolver.py[193] _get_py_dictionary -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:41:05.933 pydevd_resolver.py[193] _get_py_dictionary ->

View File

@ -1,6 +1,6 @@
# MIT License
# Copyright (c) 2024
"""执行层:基于 DSL 进行 UI 自动化,并支持可选视觉校验与结构化日志"""
"""执行层:基于 DSL 进行 UI 自动化,并支持可选视觉校验与结构化日志"""
from __future__ import annotations
@ -21,7 +21,7 @@ from .schema import DSLSpec
@dataclass
class ExecContext:
"""执行上下文"""
"""执行上下文"""
allow_title: str
dry_run: bool = False
@ -29,7 +29,7 @@ class ExecContext:
def _match_window(allow_title: str) -> Optional[auto.Control]:
"""仅在窗口标题匹配白名单时返回窗口,容忍标题前缀/包含"""
"""仅在窗口标题匹配白名单时返回窗口,容忍标题前缀或包含。"""
patterns = [allow_title]
if " - " in allow_title:
patterns.append(allow_title.split(" - ", 1)[0])
@ -43,7 +43,7 @@ def _match_window(allow_title: str) -> Optional[auto.Control]:
return False
def _ascend_to_top(node: auto.Control) -> auto.Control:
"""向上寻找最可能的顶层窗口(Chrome 主窗口类名/WindowControl 优先)"""
"""向上寻找最可能的顶层窗口(优先返回类似 Chrome 的 WindowControl"""
best = node
cur = node
while True:
@ -88,7 +88,7 @@ def _match_window(allow_title: str) -> Optional[auto.Control]:
def _find_control(root: auto.Control, locator: Dict[str, Any], timeout: float) -> Optional[auto.Control]:
"""Find a control under root according to locator."""
"""根据 locator 在 root 下查找控件。"""
start = time.time()
try:
print(
@ -98,7 +98,7 @@ def _find_control(root: auto.Control, locator: Dict[str, Any], timeout: float) -
pass
def _matches(ctrl: auto.Control) -> bool:
"""Simple property match without relying on uiautomation AndCondition."""
"""简单属性匹配,避免依赖 uiautomation 的 AndCondition。"""
try:
name_val = locator.get("Name")
name_contains = locator.get("Name__contains")
@ -130,11 +130,11 @@ def _find_control(root: auto.Control, locator: Dict[str, Any], timeout: float) -
if not locator:
print("10001")
return root
# Check root itself first
# 先检查根节点自身
if _matches(root):
print("10002")
return root
# Simple BFS when AndCondition is unavailable
# AndCondition 不可用时,使用简单 BFS
queue: List[Any] = [(root, 0)]
while queue:
node, depth = queue.pop(0)
@ -184,7 +184,7 @@ def _find_control(root: auto.Control, locator: Dict[str, Any], timeout: float) -
def _capture_screenshot(ctrl: Optional[auto.Control], out_path: Path) -> Optional[Path]:
"""截取控件区域或全屏"""
"""截取控件区域或全屏"""
try:
with mss.mss() as sct:
if ctrl and getattr(ctrl, "BoundingRectangle", None):
@ -203,7 +203,7 @@ def _capture_screenshot(ctrl: Optional[auto.Control], out_path: Path) -> Optiona
def _capture_tree(ctrl: Optional[auto.Control], max_depth: int = 3) -> List[Dict[str, Any]]:
"""采集浅层 UIA 树摘要"""
"""采集浅层 UIA 树摘要"""
if ctrl is None:
return []
nodes: List[Dict[str, Any]] = []
@ -241,7 +241,7 @@ def _save_tree(ctrl: Optional[auto.Control], out_path: Path) -> Optional[Path]:
def _image_similarity(full_img_path: Path, template_path: Path, threshold: float = 0.8) -> bool:
"""简单模板匹配,相似度 >= 阈值视为通过"""
"""简单模板匹配,相似度 >= 阈值视为通过"""
if not full_img_path.exists() or not template_path.exists():
return False
full = cv2.imread(str(full_img_path), cv2.IMREAD_COLOR)
@ -254,7 +254,7 @@ def _image_similarity(full_img_path: Path, template_path: Path, threshold: float
def _visual_check(expected: Dict[str, Any], ctrl: Optional[auto.Control], artifacts_dir: Path, step_idx: int, attempt: int) -> bool:
"""执行可选视觉校验:模板匹配"""
"""执行可选视觉校验:模板匹配"""
template_path = expected.get("template_path")
threshold = float(expected.get("threshold", 0.8))
if not template_path:
@ -274,7 +274,7 @@ def _log_event(log_path: Path, record: Dict[str, Any]) -> None:
def _render_value(val: Any, params: Dict[str, Any]) -> Any:
"""简单占位符替换 ${param}"""
"""简单占位符替换 ${param}"""
if isinstance(val, str):
out = val
for k, v in params.items():
@ -290,7 +290,7 @@ def _render_value(val: Any, params: Dict[str, Any]) -> Any:
def _do_action(ctrl: auto.Control, step: Dict[str, Any], dry_run: bool) -> None:
"""执行单步动作"""
"""执行单步动作"""
action = step.get("action")
text = step.get("text", "")
send_enter = bool(step.get("send_enter"))
@ -318,11 +318,13 @@ def _do_action(ctrl: auto.Control, step: Dict[str, Any], dry_run: bool) -> None:
def execute_spec(spec: DSLSpec, ctx: ExecContext) -> None:
"""执行完整 DSL。
流程概览
1. 先根据 allow_title 找到当前前台窗口作为根控件 root
2. 逐步标准化 DSL字段兼容文本替换等待策略等
3. 对每个步骤依次查找目标控件 -> 视觉校验可选-> 执行动作/记录 dry-run
4. 每次尝试都会落盘截图UI 树和日志方便回溯"""
3. 对每个步骤依次查找目标控件 -> 视觉校验可选 -> 执行动作/记录 dry-run
4. 每次尝试都会落盘截图UI 树和日志方便回溯
"""
# 给前台窗口切换预留时间,避免刚启动命令时窗口还未聚焦
time.sleep(1.0)
root = _match_window(ctx.allow_title)
@ -340,7 +342,7 @@ def execute_spec(spec: DSLSpec, ctx: ExecContext) -> None:
log_path = artifacts / "executor_log.jsonl"
def _normalize_target(tgt: Dict[str, Any]) -> Dict[str, Any]:
"""规范 target 键名,兼容窗口标题匹配/包含等写法"""
"""规范 target 键名,兼容窗口标题匹配/包含等写法"""
norm: Dict[str, Any] = {}
for k, v in tgt.items():
lk = k.lower()
@ -365,7 +367,7 @@ def execute_spec(spec: DSLSpec, ctx: ExecContext) -> None:
return norm
def normalize_step(step: Dict[str, Any]) -> Dict[str, Any]:
"""归一化字段,兼容不同 DSL 变"""
"""归一化字段,兼容不同 DSL 变量。"""
out = _render_value(dict(step), spec.params)
if "target" not in out and "selector" in out:
out["target"] = out.get("selector")
@ -425,7 +427,7 @@ def execute_spec(spec: DSLSpec, ctx: ExecContext) -> None:
for item in iterable:
run_steps(step.get("steps", []))
elif "if_condition" in step:
# if_condition参数布尔值选择分支
# if_condition参数布尔值选择分支
cond = step["if_condition"]
if spec.params.get(cond):
run_steps(step.get("steps", []))
@ -436,7 +438,7 @@ def execute_spec(spec: DSLSpec, ctx: ExecContext) -> None:
target = step.get("target", {})
timeout = float(step.get("waits", {}).get("appear", spec.waits.get("appear", 1.0)))
if ctx.dry_run:
timeout = min(timeout, 1) # dry-run 场景快速返回,避免长时间等待
timeout = min(timeout, 1) # dry-run 场景快速返回,避免长时间等待
retry = step.get("retry_policy", spec.retry_policy)
attempts = int(retry.get("max_attempts", 1))
interval = float(retry.get("interval", 1.0))

91
dsl.yaml Normal file
View File

@ -0,0 +1,91 @@
params:
baidu_url: www.baidu.com
search_text: "你好"
steps:
- id: wait_new_tab_chrome
action: wait_for
timeout_ms: 10000
target:
window_title: "新标签页 - Google Chrome"
class_name: Chrome_WidgetWin_1
control_type: WindowControl
- id: focus_address_bar
action: click
waits:
- type: wait_for
selector:
name: 地址和搜索栏
class_name: Chrome_WidgetWin_1
control_type: EditControl
timeout_ms: 5000
target:
name: 地址和搜索栏
class_name: Chrome_WidgetWin_1
control_type: EditControl
- id: type_baidu_url
action: type
text_param: baidu_url
send_enter: true
waits:
- type: wait_for
selector:
window_title: "百度一下,你就知道 - Google Chrome"
class_name: Chrome_WidgetWin_1
control_type: WindowControl
timeout_ms: 15000
target:
name: 地址和搜索栏
class_name: Chrome_WidgetWin_1
control_type: EditControl
- id: click_baidu_search_box
action: click
waits:
- type: wait_for
selector:
control_type: EditControl
timeout_ms: 5000
target:
control_type: EditControl
- id: type_search_text
action: type
text_param: search_text
send_enter: true
waits:
- type: wait_for
selector:
window_title_contains_param: search_text
class_name: Chrome_WidgetWin_1
control_type: WindowControl
timeout_ms: 15000
target:
control_type: EditControl
assertions:
- id: assert_baidu_home_opened
action: assert_exists
selector:
window_title: "百度一下,你就知道 - Google Chrome"
class_name: Chrome_WidgetWin_1
control_type: WindowControl
timeout_ms: 5000
- id: assert_search_result_page
action: assert_exists
selector:
window_title_contains_param: search_text
class_name: Chrome_WidgetWin_1
control_type: WindowControl
timeout_ms: 10000
retry_policy:
max_attempts: 2
interval: 1.0
waits:
appear: 5.0
disappear: 5.0

View File

@ -10,3 +10,4 @@ psutil>=5.9.6
numpy>=1.26.0
requests>=2.31.0
python-dotenv>=1.0.0
selenium

2
tests/@AutomationLog.txt Normal file
View File

@ -0,0 +1,2 @@
2025-12-30 22:33:41.606 test-uiautomation.py[71] run -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}
2025-12-30 22:34:54.215 test-uiautomation.py[71] run -> Find Control Timeout(10s): {Name: '帮助', ControlType: MenuItemControl}

91
tests/engine.py Normal file
View File

@ -0,0 +1,91 @@
import yaml
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class DSLEngine:
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(self.driver, 10)
def _parse_locator(self, locator_str):
"""
解析定位符字符串例如 "id:username" -> (By.ID, "username")
"""
if not locator_str:
return None
by_map = {
"id": By.ID,
"xpath": By.XPATH,
"css": By.CSS_SELECTOR,
"name": By.NAME,
"class": By.CLASS_NAME,
"tag": By.TAG_NAME
}
try:
method, value = locator_str.split(":", 1)
return by_map.get(method.lower()), value
except ValueError:
raise Exception(f"定位符格式错误: {locator_str},应为 '类型:值'")
def execute_step(self, step):
"""
执行单个 DSL 步骤
"""
action = step.get('action')
locator_str = step.get('locator')
value = step.get('value')
desc = step.get('desc', '无描述')
print(f"正在执行: [{action}] - {desc}")
# 解析定位符 (如果有)
locator = self._parse_locator(locator_str)
# === 动作分发 (Action Dispatch) ===
if action == "open":
self.driver.get(value)
elif action == "input":
el = self.wait.until(EC.visibility_of_element_located(locator))
el.clear()
el.send_keys(str(value))
elif action == "click":
el = self.wait.until(EC.element_to_be_clickable(locator))
el.click()
elif action == "wait":
time.sleep(float(value))
elif action == "assert_text":
el = self.wait.until(EC.visibility_of_element_located(locator))
actual_text = el.text
assert value in actual_text, f"断言失败: 期望 '{value}' 包含在 '{actual_text}'"
print(f" -> 断言通过: 发现 '{actual_text}'")
else:
raise Exception(f"未知的动作指令: {action}")
def run_yaml(self, yaml_path):
"""
加载并运行 YAML 文件
"""
with open(yaml_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
print(f"=== 开始测试: {data.get('name')} ===")
for step in data.get('steps', []):
try:
self.execute_step(step)
except Exception as e:
print(f" [X] 步骤执行失败: {e}")
raise e # 抛出异常以终止测试
print("=== 测试全部通过 ===")

31
tests/main.py Normal file
View File

@ -0,0 +1,31 @@
from selenium import webdriver
from engine import DSLEngine
import os
def main():
# 1. 设置 WebDriver (这里以 Chrome 为例)
options = webdriver.ChromeOptions()
# options.add_argument('--headless') # 如果需要无头模式可开启
driver = webdriver.Chrome(options=options)
try:
# 2. 初始化 DSL 引擎
engine = DSLEngine(driver)
# 3. 指定 YAML 文件路径并运行
yaml_file = "test_case.yaml"
if os.path.exists(yaml_file):
engine.run_yaml(yaml_file)
else:
print(f"找不到文件: {yaml_file}")
except Exception as e:
print("测试过程中发生严重错误。")
finally:
# 4. 清理环境
# time.sleep(3) # 调试时可以暂停一下看结果
driver.quit()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,93 @@
import uiautomation as auto
import subprocess
import time
import yaml
class AutomationEngine:
def __init__(self, config_path):
with open(config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
self.window = None
def find_control(self, parent, selector_dict):
"""根据字典动态查找控件"""
if not selector_dict:
return parent
args = selector_dict.copy()
# 1. 提取 ControlType例如 "MenuItemControl"
# 如果 YAML 里没写,默认用 generic 的 Control
control_type = args.pop('ControlType', 'Control')
# 2. 动态获取查找方法,例如 parent.MenuItemControl(...)
# 这样比直接用 parent.Control(ControlType=...) 更符合库的设计
if hasattr(parent, control_type):
finder_method = getattr(parent, control_type)
else:
print(f"警告: 未知的 ControlType '{control_type}',回退到通用查找。")
finder_method = parent.Control
# 如果回退,需要把 ControlType 加回去作为属性过滤
if control_type != 'Control':
args['ControlType'] = control_type
# 3. 执行查找
return finder_method(**args)
def run(self):
print(f"开始执行任务: {self.config['name']}")
subprocess.Popen(self.config['app'])
time.sleep(1) # 等待启动
# 查找主窗口
self.window = auto.WindowControl(**self.config['target_window'])
if not self.window.Exists(5):
raise Exception("❌ 主窗口未找到,请检查 ClassName 是否正确 (Win11记事本可能是 'Notepad' 但内部结构不同)")
self.window.SetTopmost(True)
print(f"✅ 锁定主窗口: {self.window.Name}")
for i, step in enumerate(self.config['steps']):
action = step.get('action')
desc = step.get('desc', action)
print(f"\n--- 步骤 {i+1}: {desc} ---")
# 确定父级
target_control = self.window
if 'parent' in step:
# 弹窗通常是顶层窗口,从 Root 找searchDepth=1 表示只查桌面的一级子窗口
target_control = self.find_control(auto.GetRootControl(), step['parent'])
# 确定目标控件
if 'selector' in step:
target_control = self.find_control(target_control, step['selector'])
# !!! 关键调试信息 !!!
# 检查控件是否存在,如果不存在,打印详细信息并停止
if not target_control.Exists(3):
print(f"❌ 错误: 无法找到控件!")
print(f" 查找参数: {step.get('selector')}")
print(f" 父级控件: {target_control.GetParentControl()}")
# 可以在这里抛出异常停止脚本,方便调试
break
# 执行动作
if action == 'input':
target_control.Click()
target_control.SendKeys(step['value'])
print(f" 输入: {step['value']}")
elif action == 'click':
target_control.Click()
print(" 点击成功")
elif action == 'sleep':
time.sleep(step['value'])
print(f" 等待 {step['value']}")
print("\n自动化结束")
if __name__ == '__main__':
engine = AutomationEngine(r'D:\project\audoWin\tests\test_case.yaml')
engine.run()

30
tests/test_case.yaml Normal file
View File

@ -0,0 +1,30 @@
# test_case.yaml
name: 标准用户登录测试
description: 测试SauceDemo网站的登录流程
steps:
- action: open
value: "https://www.saucedemo.com/"
desc: "打开首页"
- action: input
locator: "id:user-name"
value: "standard_user"
desc: "输入用户名"
- action: input
locator: "id:password"
value: "secret_sauce"
desc: "输入密码"
- action: click
locator: "id:login-button"
desc: "点击登录按钮"
- action: wait
value: 1
desc: "强制等待1秒(可选)"
- action: assert_text
locator: "class:title"
value: "Products"
desc: "验证页面标题包含Products"