ui-engine-xin 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
UIEngine/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """UIEngine - 基于 Playwright 的关键字驱动 UI 自动化测试引擎"""
2
+ __version__ = "0.0.1"
3
+
4
+ from UIEngine.basecase import BaseCase
5
+ from UIEngine.core.keyword_manager import KeyWordManager
6
+ from UIEngine.core.variable_resolver import VariableResolver
7
+ from UIEngine.runner.runner import Runner
8
+ from UIEngine.reporting.result import TestResult
9
+ from UIEngine.caseLog import CaseLogHandler
UIEngine/basecase.py ADDED
@@ -0,0 +1,60 @@
1
+ """BaseCase 组合类
2
+
3
+ 将所有 Mixin 组合为统一的测试用例基类。
4
+ 包含关键字调度(perform)和变量替换功能。
5
+ 所有 Mixin 方法在类定义后自动注册到 KeyWordManager。
6
+ """
7
+ from UIEngine.browser.base_browser import BaseBrowser
8
+ from UIEngine.keywords.page_keywords import PageMixin
9
+ from UIEngine.keywords.locator_keywords import LocatorMixin
10
+ from UIEngine.keywords.mouse_keywords import MouseMixin
11
+ from UIEngine.keywords.wait_keywords import WaitMixin
12
+ from UIEngine.keywords.iframe_keywords import IFrameMixin
13
+ from UIEngine.keywords.assert_keywords import AssertMixin
14
+ from UIEngine.core.keyword_manager import KeyWordManager
15
+ from UIEngine.core.variable_resolver import VariableResolver
16
+ from UIEngine.core.exceptions import KeywordNotFoundError
17
+
18
+
19
+ class BaseCase(PageMixin, LocatorMixin, MouseMixin, WaitMixin, IFrameMixin, AssertMixin):
20
+ """测试用例基类 - 组合所有关键字 Mixin
21
+
22
+ 继承链(MRO):
23
+ BaseCase → PageMixin → LocatorMixin → MouseMixin → WaitMixin
24
+ → IFrameMixin → AssertMixin → BaseBrowser
25
+ """
26
+
27
+ def __init__(self, config, log, **kwargs):
28
+ super().__init__(config, log, **kwargs)
29
+ self.variable_resolver = VariableResolver(config, log)
30
+
31
+ def perform(self, step):
32
+ """执行测试步骤
33
+
34
+ 支持两种执行模式:
35
+ 1. 关键字模式:通过 KeyWordManager.maps 查表调用(step 中使用 "keyword" 或 "method")
36
+ 2. 直接调用模式:关键字未注册时,回退到 getattr(self, method) 直接调用
37
+
38
+ 模式 2 允许调用未注册为关键字的实例方法(如 open_browser, close, reset_browser_context 等)。
39
+
40
+ :param step: 测试步骤字典,包含 keyword/method、params、desc 等字段
41
+ """
42
+ keyword = step.get("keyword") or step.get("method")
43
+ # 优先通过关键字注册表查找
44
+ method = KeyWordManager.get_keyword_maps(keyword)
45
+ if method:
46
+ params = step.get("params", {})
47
+ params = self.variable_resolver.resolve(params)
48
+ method(self, **params)
49
+ elif hasattr(self, keyword):
50
+ # 回退:直接调用实例方法(兼容未注册为关键字的方法)
51
+ params = step.get("params", {})
52
+ params = self.variable_resolver.resolve(params)
53
+ getattr(self, keyword)(**params)
54
+ else:
55
+ raise KeywordNotFoundError(f"{step.get('desc', '')}执行的关键字 '{keyword}' 不存在")
56
+
57
+
58
+ # 自动注册:将 BaseCase 的所有公共方法注册到关键字映射表
59
+ # 已通过 @register 装饰器注册的方法不会被覆盖
60
+ KeyWordManager.auto_register_methods(BaseCase)
@@ -0,0 +1,2 @@
1
+ """浏览器管理模块"""
2
+ from UIEngine.browser.base_browser import BaseBrowser
@@ -0,0 +1,158 @@
1
+ """浏览器生命周期管理
2
+
3
+ 包含:浏览器创建/关闭、上下文管理、多页面管理。
4
+ 修复:self.pages 未初始化、headless 逻辑反转。
5
+ 新增:上下文管理器(__enter__/__exit__)。
6
+ """
7
+ import re
8
+ from playwright.sync_api import sync_playwright
9
+
10
+
11
+ class BaseBrowser:
12
+ """浏览器基类:管理 browser/context/page 三层对象"""
13
+
14
+ def __init__(self, config, log, browser=None, context=None, page=None):
15
+ """
16
+ :param config: 环境配置字典
17
+ :param log: 日志处理器
18
+ :param browser: 可选,复用的浏览器实例
19
+ :param context: 可选,复用的上下文实例
20
+ :param page: 可选,复用的页面实例
21
+ """
22
+ self.config = config
23
+ self.log = log
24
+ self.pw = None
25
+ self.browser = None
26
+ self.context = None
27
+ self.page = None
28
+ self.pages = {} # 修复:初始化 pages 字典
29
+ self._current_frame = None # iframe 上下文状态
30
+ # 三个对象全部存在才复用
31
+ if all([browser, context, page]):
32
+ self.browser = browser
33
+ self.context = context
34
+ self.page = page
35
+ self.pages['default'] = self.page
36
+
37
+ def __getattr__(self, item):
38
+ """当浏览器没有创建时,自动创建浏览器"""
39
+ if item in ["browser", "context", "page"]:
40
+ self.log.debug_log(f"当前浏览器没有启动,{item}属性不存在,正在为您启动浏览器")
41
+ self.open_browser(self.config.get("browser_type"))
42
+ return getattr(self, item)
43
+ else:
44
+ raise AttributeError(f"{item}属性不存在")
45
+
46
+ def __enter__(self):
47
+ """上下文管理器入口"""
48
+ return self
49
+
50
+ def __exit__(self, exc_type, exc_val, exc_tb):
51
+ """上下文管理器出口:确保浏览器资源被释放"""
52
+ try:
53
+ self.close()
54
+ except Exception:
55
+ pass
56
+ return False
57
+
58
+ def open_browser(self, browser_type):
59
+ """打开浏览器"""
60
+ browser_type = browser_type or self.config.get("browser_type")
61
+ self.browser, self.context, self.page = self.create_browser(browser_type)
62
+ self.pages['default'] = self.page
63
+ self.log.debug_log("打开浏览器成功")
64
+
65
+ def create_browser(self, browser_type):
66
+ """创建浏览器对象"""
67
+ # 1. 启动 Playwright 运行时核心
68
+ self.pw = sync_playwright().start()
69
+ # 2. 动态获取浏览器启动器(chromium/firefox/webkit)
70
+ browser_type_obj = getattr(self.pw, browser_type)
71
+ # 3. 启动浏览器实例(修复:is_debug=True 时显示浏览器窗口)
72
+ browser = browser_type_obj.launch(headless=not self.config.get("is_debug", False))
73
+ # 4. 创建隔离浏览器上下文(独立会话环境)
74
+ context = browser.new_context()
75
+ # 5. 创建页面 Tab(对应浏览器标签页)
76
+ page = context.new_page()
77
+ # 6. 返回三层对象
78
+ return browser, context, page
79
+
80
+ def reset_browser_context(self):
81
+ """重置浏览器运行环境:清除 cookie 和浏览器的缓存信息"""
82
+ self.page.close()
83
+ self.context.close()
84
+ self.context = self.browser.new_context()
85
+ self.page = self.context.new_page()
86
+
87
+ def close(self):
88
+ """关闭浏览器"""
89
+ try:
90
+ if self.page:
91
+ self.page.close()
92
+ if self.context:
93
+ self.context.close()
94
+ if self.browser:
95
+ self.browser.close()
96
+ if self.pw:
97
+ self.pw.stop()
98
+ except Exception:
99
+ pass
100
+
101
+ def open_new_page(self, tag, timeout=3000):
102
+ """
103
+ 打开新页面
104
+ :param tag: 页面标签
105
+ :param timeout: 超时时间
106
+ """
107
+ self.pages[tag] = self.context.new_page()
108
+ self.log.debug_log("页面已经打开")
109
+
110
+ def find_page(self, tag='', index='', title='', url=''):
111
+ """查找页面
112
+
113
+ :param tag: 页面标签
114
+ :param index: 页面索引
115
+ :param title: 页面标题
116
+ :param url: 页面 URL(支持正则匹配)
117
+ :return: 匹配的页面对象
118
+ """
119
+ if tag:
120
+ return self.pages[tag]
121
+ elif index:
122
+ return self.context.pages[int(index)]
123
+ elif title:
124
+ for page in self.context.pages:
125
+ if page.title() == title:
126
+ return page
127
+ elif url:
128
+ for page in self.context.pages:
129
+ if re.search(url, page.url):
130
+ return page
131
+ else:
132
+ return self.context.pages[-1]
133
+
134
+ def switch_to_page(self, tag='', index='', title='', url=''):
135
+ """
136
+ 切换到指定页面:默认切换到最新的窗口页面
137
+ :param tag: 页面标签
138
+ :param index: 页面打开的顺序
139
+ :param title: 页面标题
140
+ :param url: 页面的 url
141
+ """
142
+ page = self.find_page(tag, index, title, url)
143
+ self.page = page
144
+
145
+ def close_page(self, tag='', index='', title='', url=''):
146
+ """
147
+ 关闭页面:默认关闭最新打开的页面
148
+ :param tag: 页面标签
149
+ :param index: 页面打开的顺序
150
+ :param title: 页面标题
151
+ :param url: 页面的 url
152
+ """
153
+ page = self.find_page(tag, index, title, url)
154
+ if page == self.page and len(self.context.pages) > 1:
155
+ page.close()
156
+ self.page = self.context.pages[0]
157
+ else:
158
+ page.close()
UIEngine/caseLog.py ADDED
@@ -0,0 +1,145 @@
1
+ """用例日志处理类
2
+
3
+ 基于 Python 标准 logging 模块实现,对外接口与原版完全兼容。
4
+ - log_data 属性保持 [(level, msg), ...] 格式
5
+ - debug_log/info_log/warning_log/error_log 等方法名不变
6
+ - 支持日志级别过滤、文件输出等 logging 原生能力
7
+ """
8
+ import logging
9
+ import os
10
+ import time
11
+ from pathlib import Path
12
+
13
+
14
+ class _MemoryHandler(logging.Handler):
15
+ """内存日志 Handler:将日志记录存储为兼容原有格式的列表"""
16
+
17
+ def __init__(self):
18
+ super().__init__()
19
+ self.log_data = []
20
+
21
+ def emit(self, record):
22
+ ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.created))
23
+ msg = f"{ts} | {record.getMessage()}"
24
+ self.log_data.append((record.levelname, msg))
25
+
26
+
27
+ class CaseLogHandler:
28
+ """用例日志处理类(基于 logging 模块)
29
+
30
+ 对外接口与原版完全兼容:
31
+ - debug_log / info_log / warning_log / error_log / critical_log / print_log
32
+ - log_data 属性返回 [(level, msg), ...] 列表
33
+ """
34
+
35
+ def __init__(self, name="testcase", console=True, level=logging.DEBUG):
36
+ """
37
+ :param name: logger 名称(不同用例建议使用不同名称避免日志混合)
38
+ :param console: 是否输出到控制台
39
+ :param level: 最低日志级别
40
+ """
41
+ self._logger = logging.getLogger(name)
42
+ self._logger.setLevel(level)
43
+ # 避免重复添加 handler(同名 logger 多次实例化时)
44
+ if not self._logger.handlers:
45
+ # 内存 Handler(用于结果数据收集)
46
+ self._memory = _MemoryHandler()
47
+ self._logger.addHandler(self._memory)
48
+ # 控制台 Handler(用于实时输出)
49
+ if console:
50
+ console_handler = logging.StreamHandler()
51
+ console_handler.setFormatter(
52
+ logging.Formatter("%(levelname)s | %(message)s")
53
+ )
54
+ self._logger.addHandler(console_handler)
55
+ else:
56
+ # 复用已有 handler,找到 memory handler
57
+ self._memory = next(
58
+ (h for h in self._logger.handlers if isinstance(h, _MemoryHandler)),
59
+ _MemoryHandler()
60
+ )
61
+
62
+ @property
63
+ def log_data(self):
64
+ """兼容 Runner 中 getattr(self.log, 'log_data') 的访问方式"""
65
+ return self._memory.log_data
66
+
67
+ def _format_args(self, args):
68
+ """将可变参数拼接为字符串"""
69
+ return " ".join(str(i) for i in args)
70
+
71
+ def debug_log(self, *args):
72
+ """记录 debug 日志"""
73
+ self._logger.debug(self._format_args(args))
74
+
75
+ def info_log(self, *args):
76
+ """记录 info 日志"""
77
+ self._logger.info(self._format_args(args))
78
+
79
+ def warning_log(self, *args):
80
+ """记录 warning 日志"""
81
+ self._logger.warning(self._format_args(args))
82
+
83
+ def error_log(self, *args):
84
+ """记录 error 日志"""
85
+ self._logger.error(self._format_args(args))
86
+
87
+ def critical_log(self, *args):
88
+ """记录 critical 日志"""
89
+ self._logger.critical(self._format_args(args))
90
+
91
+ def print_log(self, *args):
92
+ """记录日志(等同 info 级别)"""
93
+ self._logger.info(self._format_args(args))
94
+
95
+ def add_file_handler(self, filepath=None, level=logging.DEBUG, max_files=20):
96
+ """添加文件输出 Handler
97
+
98
+ :param filepath: 日志文件路径(默认保存到 UIEngine/files/logs/<name>_<timestamp>.log)
99
+ :param level: 文件日志的最低级别
100
+ :param max_files: 日志文件最大保留数量(默认 20)
101
+ """
102
+ if not filepath:
103
+ engine_root = Path(__file__).resolve().parent
104
+ log_dir = engine_root / "files" / "logs"
105
+ log_dir.mkdir(parents=True, exist_ok=True)
106
+ ts = time.strftime("%Y%m%d_%H%M%S", time.localtime())
107
+ filepath = str(log_dir / f"{self._logger.name}_{ts}.log")
108
+ # 清理超出限制的旧日志文件
109
+ self._cleanup_log_files(str(log_dir), max_files)
110
+
111
+ file_handler = logging.FileHandler(filepath, encoding='utf-8')
112
+ file_handler.setLevel(level)
113
+ file_handler.setFormatter(
114
+ logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
115
+ )
116
+ self._logger.addHandler(file_handler)
117
+
118
+ @staticmethod
119
+ def _cleanup_log_files(log_dir, max_files):
120
+ """清理旧日志文件,保留最新的 max_files 个"""
121
+ files = []
122
+ for entry in os.listdir(log_dir):
123
+ if entry.endswith('.log'):
124
+ full_path = os.path.join(log_dir, entry)
125
+ files.append((full_path, os.path.getmtime(full_path)))
126
+ files.sort(key=lambda x: x[1], reverse=True)
127
+ for old_file, _ in files[max_files:]:
128
+ try:
129
+ os.remove(old_file)
130
+ except OSError:
131
+ pass
132
+
133
+ def set_level(self, level):
134
+ """动态调整日志级别(logging 新增能力)
135
+
136
+ :param level: logging.DEBUG / logging.INFO / logging.WARNING 等
137
+ """
138
+ self._logger.setLevel(level)
139
+
140
+
141
+ class PreconditionChainError(Exception):
142
+ """前置条件链执行错误(用于 stop 模式中止)"""
143
+ def __init__(self, errors):
144
+ self.errors = errors
145
+ super().__init__(f"前置条件链中止,共 {len(errors)} 个错误")
@@ -0,0 +1 @@
1
+ """配置模块"""
@@ -0,0 +1,6 @@
1
+ # UI 自动化测试引擎默认配置
2
+ browser_type: chromium
3
+ is_debug: false
4
+ host: ""
5
+ max_suite_screenshot_dirs: 10
6
+ global_variable: {}
@@ -0,0 +1,4 @@
1
+ """核心模块:关键字管理、变量解析、自定义异常"""
2
+ from UIEngine.core.keyword_manager import KeyWordManager
3
+ from UIEngine.core.variable_resolver import VariableResolver
4
+ from UIEngine.core.exceptions import KeywordNotFoundError
@@ -0,0 +1,9 @@
1
+ """自定义异常类"""
2
+
3
+
4
+ class KeywordNotFoundError(AttributeError):
5
+ """关键字未找到异常
6
+
7
+ 当 perform() 在 KeyWordManager.maps 和实例方法中都找不到对应的关键字时抛出。
8
+ """
9
+ pass
@@ -0,0 +1,109 @@
1
+ """关键字管理器:维护关键字名称与函数的映射关系
2
+
3
+ 支持功能:
4
+ - 装饰器注册(中英文双名)
5
+ - 动态字符串代码注册
6
+ - 大小写兼容查找
7
+ - 自动注册类方法
8
+ """
9
+ import inspect
10
+
11
+
12
+ class KeyWordManager:
13
+ """关键字的映射关系管理"""
14
+ maps = {}
15
+
16
+ @classmethod
17
+ def register(cls, *keywords):
18
+ """装饰器注册关键字,支持同时注册中英文名称
19
+
20
+ 用法示例:
21
+ @KeyWordManager.register("click_element", "点击元素")
22
+ def click_element(self, locator, timeout=3000):
23
+ ...
24
+
25
+ :param keywords: 一个或多个关键字名称(建议:英文在前,中文在后)
26
+ :return: 装饰器函数
27
+ """
28
+ def wrapper(func):
29
+ for kw in keywords:
30
+ cls.maps[kw] = func
31
+ return func
32
+ return wrapper
33
+
34
+ @classmethod
35
+ def register_keyword(cls, keywords, func_code):
36
+ """动态注册关键字(从字符串代码)
37
+
38
+ :param keywords: str 或 list[str],一个或多个关键字名称
39
+ :param func_code: 字符串形式的函数定义代码
40
+ :return: None
41
+
42
+ 用法示例:
43
+ KeyWordManager.register_keyword(
44
+ ["open_browser", "打开浏览器"],
45
+ '''
46
+ def open_browser(self, browser_type):
47
+ print(f"打开{browser_type}浏览器")
48
+ '''
49
+ )
50
+ """
51
+ if isinstance(keywords, str):
52
+ keywords = [keywords]
53
+ temp_map = {}
54
+ exec(func_code, temp_map)
55
+ for k, v in temp_map.items():
56
+ if inspect.isfunction(v):
57
+ for kw in keywords:
58
+ cls.maps[kw] = v
59
+ # 动态挂载到 BaseCase 类(延迟导入避免循环依赖)
60
+ from UIEngine.basecase import BaseCase
61
+ setattr(BaseCase, k, v)
62
+
63
+ @classmethod
64
+ def get_keyword_maps(cls, keyword):
65
+ """查找关键字对应的函数,支持大小写兼容
66
+
67
+ :param keyword: 关键字名称
68
+ :return: 对应的函数对象,未找到返回 None
69
+ """
70
+ if keyword is None:
71
+ return None
72
+ # 精确匹配优先
73
+ result = cls.maps.get(keyword)
74
+ if result is not None:
75
+ return result
76
+ # 大小写不敏感兜底匹配
77
+ return cls.maps.get(keyword.lower())
78
+
79
+ @classmethod
80
+ def auto_register_methods(cls, target_class):
81
+ """自动注册目标类的所有公共方法到关键字映射表
82
+
83
+ 用于在 BaseCase 定义完成后,将所有 Mixin 方法自动注册到 maps 中。
84
+ 已通过 @register 装饰器注册的方法不会被覆盖。
85
+
86
+ :param target_class: 要扫描注册的类
87
+ """
88
+ # 不需要注册为关键字的内部方法列表
89
+ excluded = {
90
+ 'perform', 'replace_params', 'close', 'open_browser',
91
+ 'create_browser', 'reset_browser_context', 'find_page',
92
+ 'switch_to_page', 'close_page',
93
+ 'save_page_img',
94
+ }
95
+ for name, method in inspect.getmembers(target_class, predicate=inspect.isfunction):
96
+ if name.startswith('_'):
97
+ continue
98
+ if name in excluded:
99
+ continue
100
+ if name not in cls.maps:
101
+ cls.maps[name] = method
102
+
103
+ @classmethod
104
+ def list_keywords(cls):
105
+ """列出所有已注册的关键字
106
+
107
+ :return: 关键字名称列表
108
+ """
109
+ return list(cls.maps.keys())
@@ -0,0 +1,74 @@
1
+ """变量解析器:处理参数中的 ${variable} 占位符替换
2
+
3
+ 采用递归遍历数据结构的方式,直接替换字符串值中的变量,
4
+ 避免 str(dict) + literal_eval 导致的特殊字符解析崩溃问题。
5
+ """
6
+ import re
7
+
8
+
9
+ class VariableResolver:
10
+ """解析参数中的变量占位符 ${var_name}
11
+
12
+ 从 config["global_variable"] 字典中查找变量值并替换。
13
+ 递归遍历 dict/list/str,保持原始数据结构不变。
14
+ """
15
+
16
+ _pattern = re.compile(r'\$\{(.+?)\}')
17
+
18
+ def __init__(self, config, log):
19
+ """
20
+ :param config: 配置字典,包含 global_variable 字段
21
+ :param log: 日志处理器实例
22
+ """
23
+ self.config = config
24
+ self.log = log
25
+
26
+ def resolve(self, value):
27
+ """替换参数中的变量占位符,保持原始数据结构
28
+
29
+ :param value: 要进行变量替换的参数(dict / list / str / 其他)
30
+ :return: 替换后的参数,类型与输入保持一致
31
+ """
32
+ if isinstance(value, dict):
33
+ return {k: self.resolve(v) for k, v in value.items()}
34
+ if isinstance(value, list):
35
+ return [self.resolve(item) for item in value]
36
+ if isinstance(value, str):
37
+ return self._resolve_string(value)
38
+ return value
39
+
40
+ def _resolve_string(self, text):
41
+ """替换单个字符串中的变量占位符
42
+
43
+ 处理逻辑:
44
+ - 如果整个字符串就是一个占位符 ${var},且变量值是非字符串类型,
45
+ 直接返回原始类型(int/float/bool/list/dict),保持类型不变
46
+ - 如果字符串包含多个占位符或混合文本,变量值统一转为字符串拼接
47
+
48
+ :param text: 包含 ${var} 占位符的字符串
49
+ :return: 替换后的值(可能是 str,也可能是变量的原始类型)
50
+ """
51
+ # 快速检查:字符串中是否包含占位符
52
+ if '${' not in text:
53
+ return text
54
+
55
+ # 特殊情况:整个字符串就是单个占位符,保留变量原始类型
56
+ match = self._pattern.fullmatch(text.strip())
57
+ if match:
58
+ key = match.group(1)
59
+ var_value = self.config.get('global_variable', {}).get(key)
60
+ if var_value is not None:
61
+ return var_value
62
+ self.log.debug_log(f"测试环境中全局变量中没有:{key}这个变量")
63
+ return text
64
+
65
+ # 通用情况:字符串中混合了文本和占位符,统一替换为字符串
66
+ def _replacer(m):
67
+ key = m.group(1)
68
+ var_value = self.config.get('global_variable', {}).get(key)
69
+ if var_value is not None:
70
+ return str(var_value)
71
+ self.log.debug_log(f"测试环境中全局变量中没有:{key}这个变量")
72
+ return m.group(0)
73
+
74
+ return self._pattern.sub(_replacer, text)
@@ -0,0 +1,7 @@
1
+ """关键字模块:封装所有 Playwright 操作的关键字实现"""
2
+ from UIEngine.keywords.page_keywords import PageMixin
3
+ from UIEngine.keywords.locator_keywords import LocatorMixin
4
+ from UIEngine.keywords.mouse_keywords import MouseMixin
5
+ from UIEngine.keywords.wait_keywords import WaitMixin
6
+ from UIEngine.keywords.assert_keywords import AssertMixin
7
+ from UIEngine.keywords.iframe_keywords import IFrameMixin