From c6ea1854e5269c9e58f97f02dda5de95e90ee9d5 Mon Sep 17 00:00:00 2001 From: wangqifan Date: Wed, 31 Dec 2025 13:51:54 +0800 Subject: [PATCH] =?UTF-8?q?selenium=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + @AutomationLog.txt | 8 ++++ autodemo/executor.py | 42 +++++++++-------- dsl.yaml | 91 +++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + tests/@AutomationLog.txt | 2 + tests/engine.py | 91 +++++++++++++++++++++++++++++++++++++ tests/main.py | 31 +++++++++++++ tests/test-uiautomation.py | 93 ++++++++++++++++++++++++++++++++++++++ tests/test_case.yaml | 30 ++++++++++++ 10 files changed, 370 insertions(+), 20 deletions(-) create mode 100644 dsl.yaml create mode 100644 tests/@AutomationLog.txt create mode 100644 tests/engine.py create mode 100644 tests/main.py create mode 100644 tests/test-uiautomation.py create mode 100644 tests/test_case.yaml diff --git a/.gitignore b/.gitignore index 13b24c3..d8db93c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dsl_schema.json sessions/* artifacts/* .vscode/settings.json +tests/__pycache__/*.pyc diff --git a/@AutomationLog.txt b/@AutomationLog.txt index 1b2d666..bce70d4 100644 --- a/@AutomationLog.txt +++ b/@AutomationLog.txt @@ -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 -> \ No newline at end of file diff --git a/autodemo/executor.py b/autodemo/executor.py index 9fc7975..b0ce0a4 100644 --- a/autodemo/executor.py +++ b/autodemo/executor.py @@ -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)) diff --git a/dsl.yaml b/dsl.yaml new file mode 100644 index 0000000..490f558 --- /dev/null +++ b/dsl.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt index 05f6b5e..1cdf695 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ psutil>=5.9.6 numpy>=1.26.0 requests>=2.31.0 python-dotenv>=1.0.0 +selenium \ No newline at end of file diff --git a/tests/@AutomationLog.txt b/tests/@AutomationLog.txt new file mode 100644 index 0000000..0db7691 --- /dev/null +++ b/tests/@AutomationLog.txt @@ -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} diff --git a/tests/engine.py b/tests/engine.py new file mode 100644 index 0000000..93a4e37 --- /dev/null +++ b/tests/engine.py @@ -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("=== 测试全部通过 ===") \ No newline at end of file diff --git a/tests/main.py b/tests/main.py new file mode 100644 index 0000000..9e936d6 --- /dev/null +++ b/tests/main.py @@ -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() \ No newline at end of file diff --git a/tests/test-uiautomation.py b/tests/test-uiautomation.py new file mode 100644 index 0000000..c467804 --- /dev/null +++ b/tests/test-uiautomation.py @@ -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() \ No newline at end of file diff --git a/tests/test_case.yaml b/tests/test_case.yaml new file mode 100644 index 0000000..fcbedb8 --- /dev/null +++ b/tests/test_case.yaml @@ -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" \ No newline at end of file