pytest-dsl-ui 0.1.0__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.
@@ -0,0 +1,39 @@
1
+ """pytest-dsl-ui: Playwright-based UI automation keywords for pytest-dsl
2
+
3
+ 这个包为pytest-dsl框架提供基于Playwright的UI自动化测试关键字。
4
+ 通过entry_points机制自动集成到pytest-dsl中。
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __author__ = "Chen Shuanglin"
9
+
10
+ from pytest_dsl.core.keyword_manager import keyword_manager
11
+
12
+
13
+ def register_keywords(keyword_manager_instance=None):
14
+ """注册所有UI关键字到关键字管理器
15
+
16
+ 这个函数会被pytest-dsl的插件发现机制自动调用。
17
+
18
+ Args:
19
+ keyword_manager_instance: 关键字管理器实例,如果为None则使用全局实例
20
+ """
21
+ if keyword_manager_instance is None:
22
+ keyword_manager_instance = keyword_manager
23
+
24
+ # 导入所有关键字模块,触发关键字注册
25
+ try:
26
+ from . import keywords
27
+ print("pytest-dsl-ui: 已成功加载UI自动化关键字")
28
+ except ImportError as e:
29
+ print(f"pytest-dsl-ui: 加载关键字时出错: {e}")
30
+ raise
31
+
32
+
33
+ # 当模块被直接导入时,自动注册关键字
34
+ # 这确保了即使没有调用register_keywords函数,关键字也会被注册
35
+ try:
36
+ from . import keywords
37
+ except ImportError:
38
+ # 在某些情况下(如安装时),可能无法导入依赖
39
+ pass
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pytest-dsl-ui 包的主入口点
4
+
5
+ 支持通过 python -m pytest_dsl_ui 运行
6
+ """
7
+
8
+ import sys
9
+ import argparse
10
+ from pathlib import Path
11
+
12
+
13
+ def show_help():
14
+ """显示帮助信息"""
15
+ help_text = """
16
+ pytest-dsl-ui 工具集
17
+
18
+ 用法:
19
+ python -m pytest_dsl_ui <command> [options]
20
+
21
+ 命令:
22
+ convert - 转换Playwright脚本为DSL格式
23
+ help - 显示帮助信息
24
+
25
+ 示例:
26
+ # 转换Playwright脚本
27
+ python -m pytest_dsl_ui convert script.py output.dsl
28
+
29
+ # 显示帮助
30
+ python -m pytest_dsl_ui help
31
+ """
32
+ print(help_text.strip())
33
+
34
+
35
+ def convert_command(args):
36
+ """处理转换命令"""
37
+ if len(args) < 1:
38
+ print("错误: convert命令需要输入文件")
39
+ print("用法: python -m pytest_dsl_ui convert <input_file> [output_file]")
40
+ return 1
41
+
42
+ # 导入转换器
43
+ try:
44
+ from .utils.playwright_converter import PlaywrightToDSLConverter
45
+ except ImportError:
46
+ print("错误: 无法导入playwright_converter模块")
47
+ return 1
48
+
49
+ input_file = args[0]
50
+ output_file = args[1] if len(args) > 1 else None
51
+
52
+ # 检查输入文件
53
+ input_path = Path(input_file)
54
+ if not input_path.exists():
55
+ print(f"错误: 输入文件 {input_path} 不存在")
56
+ return 1
57
+
58
+ try:
59
+ # 读取文件
60
+ with open(input_path, 'r', encoding='utf-8') as f:
61
+ script_content = f.read()
62
+
63
+ # 转换
64
+ converter = PlaywrightToDSLConverter()
65
+ dsl_content = converter.convert_script(script_content)
66
+
67
+ # 输出结果
68
+ if output_file:
69
+ output_path = Path(output_file)
70
+ with open(output_path, 'w', encoding='utf-8') as f:
71
+ f.write(dsl_content)
72
+ print(f"转换完成! DSL文件已保存到: {output_path}")
73
+ else:
74
+ print("转换结果:")
75
+ print("=" * 50)
76
+ print(dsl_content)
77
+
78
+ return 0
79
+
80
+ except Exception as e:
81
+ print(f"错误: {e}")
82
+ return 1
83
+
84
+
85
+ def main():
86
+ """主函数"""
87
+ if len(sys.argv) < 2:
88
+ show_help()
89
+ return 0
90
+
91
+ command = sys.argv[1]
92
+ args = sys.argv[2:]
93
+
94
+ if command == 'convert':
95
+ return convert_command(args)
96
+ elif command == 'help':
97
+ show_help()
98
+ return 0
99
+ else:
100
+ print(f"错误: 未知命令 '{command}'")
101
+ show_help()
102
+ return 1
103
+
104
+
105
+ if __name__ == '__main__':
106
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ # Core modules for pytest-dsl-ui
@@ -0,0 +1,248 @@
1
+ """认证状态管理器
2
+
3
+ 基于Playwright的storage_state功能,提供登录状态的保存和重用机制。
4
+ 参考: https://playwright.net.cn/python/docs/auth
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import Dict, Any, Optional
11
+ from playwright.sync_api import BrowserContext
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class AuthManager:
17
+ """认证状态管理器
18
+
19
+ 提供登录状态的保存、加载和重用功能。
20
+ """
21
+
22
+ def __init__(self, auth_dir: str = "playwright/.auth"):
23
+ """初始化认证管理器
24
+
25
+ Args:
26
+ auth_dir: 认证状态文件存储目录
27
+ """
28
+ self.auth_dir = Path(auth_dir)
29
+ self.auth_dir.mkdir(parents=True, exist_ok=True)
30
+
31
+ # 确保.gitignore包含认证目录
32
+ self._ensure_gitignore()
33
+
34
+ def _ensure_gitignore(self):
35
+ """确保.gitignore包含认证目录"""
36
+ gitignore_path = Path(".gitignore")
37
+ auth_pattern = str(self.auth_dir)
38
+
39
+ if gitignore_path.exists():
40
+ with open(gitignore_path, 'r', encoding='utf-8') as f:
41
+ content = f.read()
42
+
43
+ if auth_pattern not in content:
44
+ with open(gitignore_path, 'a', encoding='utf-8') as f:
45
+ f.write(f"\n# Playwright认证状态文件\n{auth_pattern}\n")
46
+ logger.info(f"已将 {auth_pattern} 添加到 .gitignore")
47
+ else:
48
+ with open(gitignore_path, 'w', encoding='utf-8') as f:
49
+ f.write(f"# Playwright认证状态文件\n{auth_pattern}\n")
50
+ logger.info(f"已创建 .gitignore 并添加 {auth_pattern}")
51
+
52
+ def save_auth_state(self, context: BrowserContext,
53
+ state_name: str,
54
+ metadata: Optional[Dict[str, Any]] = None,
55
+ include_indexed_db: bool = True) -> str:
56
+ """保存认证状态
57
+
58
+ Args:
59
+ context: 已认证的浏览器上下文
60
+ state_name: 状态名称(用于标识不同的认证状态)
61
+ metadata: 元数据(如用户名、网站信息等)
62
+ include_indexed_db: 是否包含IndexedDB数据(Playwright 1.20+支持)
63
+
64
+ Returns:
65
+ str: 状态文件路径
66
+ """
67
+ try:
68
+ # 生成状态文件路径
69
+ state_file = self.auth_dir / f"{state_name}.json"
70
+
71
+ # 保存存储状态 - 根据Playwright版本支持IndexedDB
72
+ try:
73
+ if include_indexed_db:
74
+ # 尝试使用IndexedDB选项(需要Playwright 1.20+)
75
+ storage_state = context.storage_state(indexed_db=True)
76
+ logger.info("使用IndexedDB支持保存认证状态")
77
+ else:
78
+ storage_state = context.storage_state()
79
+ logger.info("使用标准方式保存认证状态")
80
+ except TypeError as te:
81
+ # 旧版本Playwright不支持indexed_db参数
82
+ logger.warning(f"当前Playwright版本不支持IndexedDB选项: {str(te)}")
83
+ storage_state = context.storage_state()
84
+ except Exception as e:
85
+ logger.error(f"获取存储状态失败: {str(e)}")
86
+ raise
87
+
88
+ # 添加元数据
89
+ if metadata:
90
+ storage_state["metadata"] = metadata
91
+
92
+ # 确保保存时间戳
93
+ storage_state["saved_at"] = self._get_timestamp()
94
+
95
+ # 添加IndexedDB标识
96
+ storage_state["include_indexed_db"] = include_indexed_db
97
+
98
+ # 添加统计信息到元数据
99
+ stats = {
100
+ "cookies_count": len(storage_state.get("cookies", [])),
101
+ "origins_count": len(storage_state.get("origins", [])),
102
+ "file_path": str(state_file)
103
+ }
104
+
105
+ if "metadata" not in storage_state:
106
+ storage_state["metadata"] = {}
107
+ storage_state["metadata"].update(stats)
108
+
109
+ # 写入文件
110
+ with open(state_file, 'w', encoding='utf-8') as f:
111
+ json.dump(storage_state, f, indent=2, ensure_ascii=False)
112
+
113
+ logger.info(f"认证状态已保存: {state_file} (cookies: {stats['cookies_count']}, origins: {stats['origins_count']})")
114
+ return str(state_file)
115
+
116
+ except Exception as e:
117
+ logger.error(f"保存认证状态失败: {str(e)}")
118
+ raise
119
+
120
+ def load_auth_state(self, state_name: str) -> Optional[Dict[str, Any]]:
121
+ """加载认证状态
122
+
123
+ Args:
124
+ state_name: 状态名称
125
+
126
+ Returns:
127
+ Optional[Dict[str, Any]]: 认证状态数据,如果不存在则返回None
128
+ """
129
+ try:
130
+ state_file = self.auth_dir / f"{state_name}.json"
131
+
132
+ if not state_file.exists():
133
+ logger.warning(f"认证状态文件不存在: {state_file}")
134
+ return None
135
+
136
+ with open(state_file, 'r', encoding='utf-8') as f:
137
+ storage_state = json.load(f)
138
+
139
+ logger.info(f"认证状态已加载: {state_file}")
140
+ return storage_state
141
+
142
+ except Exception as e:
143
+ logger.error(f"加载认证状态失败: {str(e)}")
144
+ return None
145
+
146
+ def has_auth_state(self, state_name: str) -> bool:
147
+ """检查认证状态是否存在
148
+
149
+ Args:
150
+ state_name: 状态名称
151
+
152
+ Returns:
153
+ bool: 是否存在
154
+ """
155
+ state_file = self.auth_dir / f"{state_name}.json"
156
+ return state_file.exists()
157
+
158
+ def delete_auth_state(self, state_name: str) -> bool:
159
+ """删除认证状态
160
+
161
+ Args:
162
+ state_name: 状态名称
163
+
164
+ Returns:
165
+ bool: 删除是否成功
166
+ """
167
+ try:
168
+ state_file = self.auth_dir / f"{state_name}.json"
169
+
170
+ if state_file.exists():
171
+ state_file.unlink()
172
+ logger.info(f"认证状态已删除: {state_file}")
173
+ return True
174
+ else:
175
+ logger.warning(f"认证状态文件不存在: {state_file}")
176
+ return False
177
+
178
+ except Exception as e:
179
+ logger.error(f"删除认证状态失败: {str(e)}")
180
+ return False
181
+
182
+ def list_auth_states(self) -> Dict[str, Dict[str, Any]]:
183
+ """列出所有认证状态
184
+
185
+ Returns:
186
+ Dict[str, Dict[str, Any]]: 状态名称到元数据的映射
187
+ """
188
+ states = {}
189
+
190
+ try:
191
+ for state_file in self.auth_dir.glob("*.json"):
192
+ state_name = state_file.stem
193
+
194
+ try:
195
+ with open(state_file, 'r', encoding='utf-8') as f:
196
+ data = json.load(f)
197
+
198
+ metadata = data.get("metadata", {})
199
+ metadata["file_path"] = str(state_file)
200
+ metadata["file_size"] = state_file.stat().st_size
201
+
202
+ states[state_name] = metadata
203
+
204
+ except Exception as e:
205
+ logger.warning(f"无法读取状态文件 {state_file}: {str(e)}")
206
+
207
+ except Exception as e:
208
+ logger.error(f"列出认证状态失败: {str(e)}")
209
+
210
+ return states
211
+
212
+ def cleanup_expired_states(self, max_age_days: int = 30):
213
+ """清理过期的认证状态
214
+
215
+ Args:
216
+ max_age_days: 最大保存天数
217
+ """
218
+ import time
219
+
220
+ try:
221
+ current_time = time.time()
222
+ cutoff_time = current_time - (max_age_days * 24 * 60 * 60)
223
+
224
+ deleted_count = 0
225
+ for state_file in self.auth_dir.glob("*.json"):
226
+ file_mtime = state_file.stat().st_mtime
227
+
228
+ if file_mtime < cutoff_time:
229
+ state_file.unlink()
230
+ deleted_count += 1
231
+ logger.info(f"已删除过期认证状态: {state_file}")
232
+
233
+ if deleted_count > 0:
234
+ logger.info(f"清理完成,删除了 {deleted_count} 个过期认证状态")
235
+ else:
236
+ logger.info("没有过期的认证状态需要清理")
237
+
238
+ except Exception as e:
239
+ logger.error(f"清理过期认证状态失败: {str(e)}")
240
+
241
+ def _get_timestamp(self) -> str:
242
+ """获取当前时间戳"""
243
+ import datetime
244
+ return datetime.datetime.now().isoformat()
245
+
246
+
247
+ # 全局认证管理器实例
248
+ auth_manager = AuthManager()
@@ -0,0 +1,261 @@
1
+ """浏览器管理器
2
+
3
+ 负责管理Playwright浏览器实例的生命周期,包括启动、关闭和配置。
4
+ 支持多浏览器、多页面的管理。
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, Optional
9
+ from playwright.sync_api import (
10
+ sync_playwright, Browser, BrowserContext, Page, Playwright
11
+ )
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class BrowserManager:
17
+ """浏览器管理器
18
+
19
+ 管理Playwright浏览器实例,支持多浏览器类型和多页面。
20
+ """
21
+
22
+ def __init__(self):
23
+ """初始化浏览器管理器"""
24
+ self.playwright: Optional[Playwright] = None
25
+ self.browsers: Dict[str, Browser] = {}
26
+ self.contexts: Dict[str, BrowserContext] = {}
27
+ self.pages: Dict[str, Page] = {}
28
+ self.current_browser: Optional[str] = None
29
+ self.current_context: Optional[str] = None
30
+ self.current_page: Optional[str] = None
31
+
32
+ def _ensure_playwright(self):
33
+ """确保Playwright实例已启动"""
34
+ if self.playwright is None:
35
+ self.playwright = sync_playwright().start()
36
+
37
+ def launch_browser(self, browser_type: str = "chromium", **config) -> str:
38
+ """启动浏览器
39
+
40
+ Args:
41
+ browser_type: 浏览器类型 (chromium, firefox, webkit)
42
+ **config: 浏览器启动配置
43
+
44
+ Returns:
45
+ str: 浏览器ID
46
+ """
47
+ self._ensure_playwright()
48
+
49
+ # 获取浏览器类型
50
+ if browser_type.lower() == "chromium":
51
+ browser_launcher = self.playwright.chromium
52
+ elif browser_type.lower() == "firefox":
53
+ browser_launcher = self.playwright.firefox
54
+ elif browser_type.lower() == "webkit":
55
+ browser_launcher = self.playwright.webkit
56
+ else:
57
+ raise ValueError(f"不支持的浏览器类型: {browser_type}")
58
+
59
+ # 处理配置参数
60
+ launch_config = {
61
+ "headless": config.get("headless", True),
62
+ "slow_mo": config.get("slow_mo", 0),
63
+ }
64
+
65
+ # 添加启动参数
66
+ if "args" in config:
67
+ launch_config["args"] = config["args"]
68
+
69
+ # 添加可执行文件路径
70
+ if "executable_path" in config:
71
+ launch_config["executable_path"] = config["executable_path"]
72
+
73
+ # 启动浏览器
74
+ browser = browser_launcher.launch(**launch_config)
75
+
76
+ # 生成浏览器ID
77
+ browser_id = f"{browser_type}_{len(self.browsers)}"
78
+ self.browsers[browser_id] = browser
79
+ self.current_browser = browser_id
80
+
81
+ logger.info(f"已启动浏览器: {browser_id}")
82
+ return browser_id
83
+
84
+ def create_context(self, browser_id: Optional[str] = None, **config) -> str:
85
+ """创建浏览器上下文
86
+
87
+ Args:
88
+ browser_id: 浏览器ID,如果为None则使用当前浏览器
89
+ **config: 上下文配置,支持storage_state参数加载认证状态
90
+
91
+ Returns:
92
+ str: 上下文ID
93
+ """
94
+ if browser_id is None:
95
+ browser_id = self.current_browser
96
+
97
+ if browser_id is None:
98
+ raise ValueError("没有可用的浏览器实例")
99
+
100
+ if browser_id not in self.browsers:
101
+ raise ValueError(f"浏览器 {browser_id} 不存在")
102
+
103
+ browser = self.browsers[browser_id]
104
+
105
+ # 处理配置参数
106
+ context_config = {}
107
+
108
+ # 视口配置
109
+ if "viewport" in config:
110
+ context_config["viewport"] = config["viewport"]
111
+ elif "width" in config and "height" in config:
112
+ context_config["viewport"] = {
113
+ "width": config["width"],
114
+ "height": config["height"]
115
+ }
116
+
117
+ # 用户代理
118
+ if "user_agent" in config:
119
+ context_config["user_agent"] = config["user_agent"]
120
+
121
+ # 地理位置
122
+ if "geolocation" in config:
123
+ context_config["geolocation"] = config["geolocation"]
124
+
125
+ # 权限
126
+ if "permissions" in config:
127
+ context_config["permissions"] = config["permissions"]
128
+
129
+ # SSL证书忽略配置
130
+ ignore_https_errors = config.get("ignore_https_errors", False)
131
+ if ignore_https_errors:
132
+ context_config["ignore_https_errors"] = True
133
+
134
+ # 认证状态配置
135
+ if "storage_state" in config:
136
+ context_config["storage_state"] = config["storage_state"]
137
+ logger.info("将使用认证状态创建浏览器上下文")
138
+
139
+ context = browser.new_context(**context_config)
140
+
141
+ # 生成上下文ID
142
+ context_id = f"{browser_id}_ctx_{len(self.contexts)}"
143
+ self.contexts[context_id] = context
144
+ self.current_context = context_id
145
+
146
+ # 标记上下文是否支持HTTPS证书错误忽略
147
+ if context_config.get('ignore_https_errors', False):
148
+ setattr(context, '_ignore_https_errors', True)
149
+
150
+ logger.info(f"已创建浏览器上下文: {context_id}")
151
+ return context_id
152
+
153
+ def create_page(self, context_id: Optional[str] = None) -> str:
154
+ """创建页面
155
+
156
+ Args:
157
+ context_id: 上下文ID,如果为None则使用当前上下文
158
+
159
+ Returns:
160
+ str: 页面ID
161
+ """
162
+ if context_id is None:
163
+ context_id = self.current_context
164
+
165
+ if context_id is None:
166
+ raise ValueError("没有可用的浏览器上下文")
167
+
168
+ if context_id not in self.contexts:
169
+ raise ValueError(f"浏览器上下文 {context_id} 不存在")
170
+
171
+ context = self.contexts[context_id]
172
+ page = context.new_page()
173
+
174
+ # 生成页面ID
175
+ page_id = f"{context_id}_page_{len(self.pages)}"
176
+ self.pages[page_id] = page
177
+ self.current_page = page_id
178
+
179
+ logger.info(f"已创建页面: {page_id}")
180
+ return page_id
181
+
182
+ def get_current_page(self) -> Page:
183
+ """获取当前页面实例"""
184
+ if self.current_page is None or self.current_page not in self.pages:
185
+ raise ValueError("没有可用的页面实例")
186
+ return self.pages[self.current_page]
187
+
188
+ def get_page(self, page_id: str) -> Page:
189
+ """获取指定页面实例"""
190
+ if page_id not in self.pages:
191
+ raise ValueError(f"页面 {page_id} 不存在")
192
+ return self.pages[page_id]
193
+
194
+ def switch_page(self, page_id: str):
195
+ """切换到指定页面"""
196
+ if page_id not in self.pages:
197
+ raise ValueError(f"页面 {page_id} 不存在")
198
+ self.current_page = page_id
199
+ logger.info(f"已切换到页面: {page_id}")
200
+
201
+ def close_browser(self, browser_id: Optional[str] = None):
202
+ """关闭浏览器
203
+
204
+ Args:
205
+ browser_id: 浏览器ID,如果为None则关闭当前浏览器
206
+ """
207
+ if browser_id is None:
208
+ browser_id = self.current_browser
209
+
210
+ if browser_id is None:
211
+ logger.warning("没有可关闭的浏览器实例")
212
+ return
213
+
214
+ if browser_id in self.browsers:
215
+ browser = self.browsers[browser_id]
216
+ browser.close()
217
+ del self.browsers[browser_id]
218
+
219
+ # 清理相关的上下文和页面
220
+ contexts_to_remove = [
221
+ ctx_id for ctx_id in self.contexts.keys()
222
+ if ctx_id.startswith(browser_id)
223
+ ]
224
+ for ctx_id in contexts_to_remove:
225
+ del self.contexts[ctx_id]
226
+
227
+ pages_to_remove = [
228
+ page_id for page_id in self.pages.keys()
229
+ if page_id.startswith(browser_id)
230
+ ]
231
+ for page_id in pages_to_remove:
232
+ del self.pages[page_id]
233
+
234
+ # 更新当前引用
235
+ if self.current_browser == browser_id:
236
+ self.current_browser = None
237
+ self.current_context = None
238
+ self.current_page = None
239
+
240
+ logger.info(f"已关闭浏览器: {browser_id}")
241
+
242
+ def close_all(self):
243
+ """关闭所有浏览器实例"""
244
+ for browser in self.browsers.values():
245
+ browser.close()
246
+
247
+ if self.playwright:
248
+ self.playwright.stop()
249
+
250
+ self.browsers.clear()
251
+ self.contexts.clear()
252
+ self.pages.clear()
253
+ self.playwright = None
254
+ self.current_browser = None
255
+ self.current_context = None
256
+ self.current_page = None
257
+ logger.info("已关闭所有浏览器实例")
258
+
259
+
260
+ # 全局浏览器管理器实例
261
+ browser_manager = BrowserManager()