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 +9 -0
- UIEngine/basecase.py +60 -0
- UIEngine/browser/__init__.py +2 -0
- UIEngine/browser/base_browser.py +158 -0
- UIEngine/caseLog.py +145 -0
- UIEngine/config/__init__.py +1 -0
- UIEngine/config/default.yaml +6 -0
- UIEngine/core/__init__.py +4 -0
- UIEngine/core/exceptions.py +9 -0
- UIEngine/core/keyword_manager.py +109 -0
- UIEngine/core/variable_resolver.py +74 -0
- UIEngine/keywords/__init__.py +7 -0
- UIEngine/keywords/assert_keywords.py +172 -0
- UIEngine/keywords/iframe_keywords.py +124 -0
- UIEngine/keywords/locator_keywords.py +268 -0
- UIEngine/keywords/mouse_keywords.py +74 -0
- UIEngine/keywords/page_keywords.py +144 -0
- UIEngine/keywords/wait_keywords.py +68 -0
- UIEngine/reporting/__init__.py +2 -0
- UIEngine/reporting/result.py +128 -0
- UIEngine/runner/__init__.py +3 -0
- UIEngine/runner/runner.py +140 -0
- UIEngine/runner/screenshot_manager.py +67 -0
- ui_engine_xin-0.0.1.dist-info/LICENSE +21 -0
- ui_engine_xin-0.0.1.dist-info/METADATA +243 -0
- ui_engine_xin-0.0.1.dist-info/RECORD +28 -0
- ui_engine_xin-0.0.1.dist-info/WHEEL +5 -0
- ui_engine_xin-0.0.1.dist-info/top_level.txt +1 -0
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,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,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
|