kdtest-pw 2.0.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.
- kdtest_pw/__init__.py +50 -0
- kdtest_pw/action/__init__.py +7 -0
- kdtest_pw/action/base_keyword.py +292 -0
- kdtest_pw/action/element_plus/__init__.py +23 -0
- kdtest_pw/action/element_plus/el_cascader.py +263 -0
- kdtest_pw/action/element_plus/el_datepicker.py +324 -0
- kdtest_pw/action/element_plus/el_dialog.py +317 -0
- kdtest_pw/action/element_plus/el_form.py +443 -0
- kdtest_pw/action/element_plus/el_menu.py +456 -0
- kdtest_pw/action/element_plus/el_select.py +268 -0
- kdtest_pw/action/element_plus/el_table.py +442 -0
- kdtest_pw/action/element_plus/el_tree.py +364 -0
- kdtest_pw/action/element_plus/el_upload.py +313 -0
- kdtest_pw/action/key_retrieval.py +311 -0
- kdtest_pw/action/page_action.py +1129 -0
- kdtest_pw/api/__init__.py +6 -0
- kdtest_pw/api/api_keyword.py +251 -0
- kdtest_pw/api/request_handler.py +232 -0
- kdtest_pw/cases/__init__.py +6 -0
- kdtest_pw/cases/case_collector.py +182 -0
- kdtest_pw/cases/case_executor.py +359 -0
- kdtest_pw/cases/read/__init__.py +6 -0
- kdtest_pw/cases/read/cell_handler.py +305 -0
- kdtest_pw/cases/read/excel_reader.py +223 -0
- kdtest_pw/cli/__init__.py +5 -0
- kdtest_pw/cli/run.py +318 -0
- kdtest_pw/common.py +106 -0
- kdtest_pw/core/__init__.py +7 -0
- kdtest_pw/core/browser_manager.py +196 -0
- kdtest_pw/core/config_loader.py +235 -0
- kdtest_pw/core/page_context.py +228 -0
- kdtest_pw/data/__init__.py +5 -0
- kdtest_pw/data/init_data.py +105 -0
- kdtest_pw/data/static/elementData.yaml +59 -0
- kdtest_pw/data/static/parameters.json +24 -0
- kdtest_pw/plugins/__init__.py +6 -0
- kdtest_pw/plugins/element_plus_plugin/__init__.py +5 -0
- kdtest_pw/plugins/element_plus_plugin/elementData/elementData.yaml +144 -0
- kdtest_pw/plugins/element_plus_plugin/element_plus_plugin.py +237 -0
- kdtest_pw/plugins/element_plus_plugin/my.ini +23 -0
- kdtest_pw/plugins/plugin_base.py +180 -0
- kdtest_pw/plugins/plugin_loader.py +260 -0
- kdtest_pw/product.py +5 -0
- kdtest_pw/reference.py +99 -0
- kdtest_pw/utils/__init__.py +13 -0
- kdtest_pw/utils/built_in_function.py +376 -0
- kdtest_pw/utils/decorator.py +211 -0
- kdtest_pw/utils/log/__init__.py +6 -0
- kdtest_pw/utils/log/html_report.py +336 -0
- kdtest_pw/utils/log/logger.py +123 -0
- kdtest_pw/utils/public_script.py +366 -0
- kdtest_pw-2.0.0.dist-info/METADATA +169 -0
- kdtest_pw-2.0.0.dist-info/RECORD +57 -0
- kdtest_pw-2.0.0.dist-info/WHEEL +5 -0
- kdtest_pw-2.0.0.dist-info/entry_points.txt +2 -0
- kdtest_pw-2.0.0.dist-info/licenses/LICENSE +21 -0
- kdtest_pw-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Playwright 浏览器生命周期管理"""
|
|
2
|
+
|
|
3
|
+
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
|
|
4
|
+
from typing import Optional, Literal, Dict, Any
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..reference import GSTORE, INFO
|
|
8
|
+
|
|
9
|
+
BrowserType = Literal["chromium", "firefox", "webkit"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BrowserManager:
|
|
13
|
+
"""Playwright 浏览器生命周期管理器
|
|
14
|
+
|
|
15
|
+
负责浏览器的启动、配置和关闭。
|
|
16
|
+
支持 chromium、firefox、webkit 三种浏览器。
|
|
17
|
+
支持 trace 录制和视频录制。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._playwright: Optional[Playwright] = None
|
|
22
|
+
self._browser: Optional[Browser] = None
|
|
23
|
+
self._context: Optional[BrowserContext] = None
|
|
24
|
+
self._page: Optional[Page] = None
|
|
25
|
+
self._trace_enabled: bool = False
|
|
26
|
+
self._video_enabled: bool = False
|
|
27
|
+
|
|
28
|
+
def launch(
|
|
29
|
+
self,
|
|
30
|
+
browser_type: BrowserType = "chromium",
|
|
31
|
+
headless: bool = False,
|
|
32
|
+
slow_mo: int = 0,
|
|
33
|
+
trace: bool = False,
|
|
34
|
+
video: bool = False,
|
|
35
|
+
timeout: int = 30000,
|
|
36
|
+
viewport: Optional[Dict[str, int]] = None,
|
|
37
|
+
locale: str = "zh-CN",
|
|
38
|
+
timezone_id: str = "Asia/Shanghai",
|
|
39
|
+
**kwargs
|
|
40
|
+
) -> Page:
|
|
41
|
+
"""启动浏览器并返回页面
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
browser_type: 浏览器类型 (chromium, firefox, webkit)
|
|
45
|
+
headless: 是否无头模式
|
|
46
|
+
slow_mo: 操作延迟 (毫秒)
|
|
47
|
+
trace: 是否开启 trace 录制
|
|
48
|
+
video: 是否开启视频录制
|
|
49
|
+
timeout: 默认超时时间 (毫秒)
|
|
50
|
+
viewport: 视口大小 {"width": 1920, "height": 1080}
|
|
51
|
+
locale: 语言区域
|
|
52
|
+
timezone_id: 时区
|
|
53
|
+
**kwargs: 其他 Playwright 启动参数
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Page: Playwright 页面实例
|
|
57
|
+
"""
|
|
58
|
+
INFO(f"启动浏览器: {browser_type}, headless={headless}")
|
|
59
|
+
|
|
60
|
+
self._playwright = sync_playwright().start()
|
|
61
|
+
|
|
62
|
+
# 选择浏览器类型
|
|
63
|
+
browser_launcher = getattr(self._playwright, browser_type)
|
|
64
|
+
|
|
65
|
+
# 浏览器启动参数
|
|
66
|
+
launch_options = {
|
|
67
|
+
'headless': headless,
|
|
68
|
+
'slow_mo': slow_mo,
|
|
69
|
+
}
|
|
70
|
+
launch_options.update(kwargs)
|
|
71
|
+
|
|
72
|
+
self._browser = browser_launcher.launch(**launch_options)
|
|
73
|
+
|
|
74
|
+
# 创建上下文参数
|
|
75
|
+
context_options: Dict[str, Any] = {
|
|
76
|
+
'locale': locale,
|
|
77
|
+
'timezone_id': timezone_id,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if viewport:
|
|
81
|
+
context_options['viewport'] = viewport
|
|
82
|
+
else:
|
|
83
|
+
context_options['viewport'] = {'width': 1920, 'height': 1080}
|
|
84
|
+
|
|
85
|
+
# 视频录制配置
|
|
86
|
+
if video:
|
|
87
|
+
self._video_enabled = True
|
|
88
|
+
video_dir = Path('result/video')
|
|
89
|
+
video_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
context_options['record_video_dir'] = str(video_dir)
|
|
91
|
+
context_options['record_video_size'] = {'width': 1920, 'height': 1080}
|
|
92
|
+
INFO("视频录制已启用")
|
|
93
|
+
|
|
94
|
+
self._context = self._browser.new_context(**context_options)
|
|
95
|
+
|
|
96
|
+
# 开启 trace 录制
|
|
97
|
+
if trace:
|
|
98
|
+
self._trace_enabled = True
|
|
99
|
+
self._context.tracing.start(
|
|
100
|
+
screenshots=True,
|
|
101
|
+
snapshots=True,
|
|
102
|
+
sources=True
|
|
103
|
+
)
|
|
104
|
+
INFO("Trace 录制已启用")
|
|
105
|
+
|
|
106
|
+
# 创建页面
|
|
107
|
+
self._page = self._context.new_page()
|
|
108
|
+
|
|
109
|
+
# 设置默认超时
|
|
110
|
+
self._page.set_default_timeout(timeout)
|
|
111
|
+
self._page.set_default_navigation_timeout(timeout)
|
|
112
|
+
|
|
113
|
+
# 更新全局存储
|
|
114
|
+
GSTORE['browser'] = self._browser
|
|
115
|
+
GSTORE['context'] = self._context
|
|
116
|
+
GSTORE['page'] = self._page
|
|
117
|
+
|
|
118
|
+
INFO("浏览器启动成功")
|
|
119
|
+
return self._page
|
|
120
|
+
|
|
121
|
+
def new_page(self) -> Page:
|
|
122
|
+
"""创建新页面
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Page: 新的页面实例
|
|
126
|
+
"""
|
|
127
|
+
if not self._context:
|
|
128
|
+
raise RuntimeError("浏览器上下文未初始化")
|
|
129
|
+
|
|
130
|
+
page = self._context.new_page()
|
|
131
|
+
return page
|
|
132
|
+
|
|
133
|
+
def close(self, save_trace: Optional[str] = None) -> None:
|
|
134
|
+
"""关闭浏览器
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
save_trace: trace 文件保存路径 (如果开启了 trace)
|
|
138
|
+
"""
|
|
139
|
+
INFO("关闭浏览器...")
|
|
140
|
+
|
|
141
|
+
# 保存 trace
|
|
142
|
+
if self._trace_enabled and self._context and save_trace:
|
|
143
|
+
trace_path = Path(save_trace)
|
|
144
|
+
trace_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
self._context.tracing.stop(path=str(trace_path))
|
|
146
|
+
INFO(f"Trace 已保存: {trace_path}")
|
|
147
|
+
|
|
148
|
+
# 关闭上下文
|
|
149
|
+
if self._context:
|
|
150
|
+
self._context.close()
|
|
151
|
+
self._context = None
|
|
152
|
+
|
|
153
|
+
# 关闭浏览器
|
|
154
|
+
if self._browser:
|
|
155
|
+
self._browser.close()
|
|
156
|
+
self._browser = None
|
|
157
|
+
|
|
158
|
+
# 停止 Playwright
|
|
159
|
+
if self._playwright:
|
|
160
|
+
self._playwright.stop()
|
|
161
|
+
self._playwright = None
|
|
162
|
+
|
|
163
|
+
# 清理全局存储
|
|
164
|
+
GSTORE['browser'] = None
|
|
165
|
+
GSTORE['context'] = None
|
|
166
|
+
GSTORE['page'] = None
|
|
167
|
+
|
|
168
|
+
INFO("浏览器已关闭")
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def page(self) -> Optional[Page]:
|
|
172
|
+
"""当前页面实例"""
|
|
173
|
+
return self._page
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def context(self) -> Optional[BrowserContext]:
|
|
177
|
+
"""浏览器上下文实例"""
|
|
178
|
+
return self._context
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def browser(self) -> Optional[Browser]:
|
|
182
|
+
"""浏览器实例"""
|
|
183
|
+
return self._browser
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def is_running(self) -> bool:
|
|
187
|
+
"""浏览器是否在运行"""
|
|
188
|
+
return self._browser is not None and self._browser.is_connected()
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> 'BrowserManager':
|
|
191
|
+
"""上下文管理器入口"""
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
195
|
+
"""上下文管理器出口"""
|
|
196
|
+
self.close()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""配置加载器"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from ..common import DEFAULT_CONFIG
|
|
9
|
+
from ..reference import GSDSTORE, INFO
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigLoader:
|
|
13
|
+
"""配置加载器
|
|
14
|
+
|
|
15
|
+
支持 JSON 和 YAML 格式的配置文件。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config_path: Optional[str] = None):
|
|
19
|
+
"""初始化配置加载器
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config_path: 配置文件路径,支持 .json 或 .yaml/.yml
|
|
23
|
+
"""
|
|
24
|
+
self._config: Dict[str, Any] = DEFAULT_CONFIG.copy()
|
|
25
|
+
self._config_path: Optional[Path] = None
|
|
26
|
+
|
|
27
|
+
if config_path:
|
|
28
|
+
self.load(config_path)
|
|
29
|
+
|
|
30
|
+
def load(self, config_path: str) -> Dict[str, Any]:
|
|
31
|
+
"""加载配置文件
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
config_path: 配置文件路径
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
配置字典
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
FileNotFoundError: 配置文件不存在
|
|
41
|
+
ValueError: 不支持的配置文件格式
|
|
42
|
+
"""
|
|
43
|
+
path = Path(config_path)
|
|
44
|
+
|
|
45
|
+
if not path.exists():
|
|
46
|
+
raise FileNotFoundError(f"配置文件不存在: {config_path}")
|
|
47
|
+
|
|
48
|
+
self._config_path = path
|
|
49
|
+
suffix = path.suffix.lower()
|
|
50
|
+
|
|
51
|
+
if suffix == '.json':
|
|
52
|
+
config = self._load_json(path)
|
|
53
|
+
elif suffix in ('.yaml', '.yml'):
|
|
54
|
+
config = self._load_yaml(path)
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError(f"不支持的配置文件格式: {suffix}")
|
|
57
|
+
|
|
58
|
+
# 合并配置
|
|
59
|
+
self._config.update(config)
|
|
60
|
+
|
|
61
|
+
# 处理相对路径
|
|
62
|
+
self._resolve_paths()
|
|
63
|
+
|
|
64
|
+
# 更新全局存储
|
|
65
|
+
GSDSTORE['START'] = self._config
|
|
66
|
+
|
|
67
|
+
INFO(f"配置已加载: {config_path}")
|
|
68
|
+
return self._config
|
|
69
|
+
|
|
70
|
+
def _load_json(self, path: Path) -> Dict[str, Any]:
|
|
71
|
+
"""加载 JSON 配置文件"""
|
|
72
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
73
|
+
return json.load(f)
|
|
74
|
+
|
|
75
|
+
def _load_yaml(self, path: Path) -> Dict[str, Any]:
|
|
76
|
+
"""加载 YAML 配置文件"""
|
|
77
|
+
try:
|
|
78
|
+
import yaml
|
|
79
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
80
|
+
return yaml.safe_load(f) or {}
|
|
81
|
+
except ImportError:
|
|
82
|
+
INFO("PyYAML 未安装,无法加载 YAML 配置", "WARNING")
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
def _resolve_paths(self) -> None:
|
|
86
|
+
"""解析配置中的相对路径"""
|
|
87
|
+
if not self._config_path:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
base_dir = self._config_path.parent
|
|
91
|
+
|
|
92
|
+
# 处理测试用例文件路径
|
|
93
|
+
if 'testCaseFile' in self._config:
|
|
94
|
+
for case_file in self._config['testCaseFile']:
|
|
95
|
+
if 'caseFilePath' in case_file:
|
|
96
|
+
case_path = Path(case_file['caseFilePath'])
|
|
97
|
+
if not case_path.is_absolute():
|
|
98
|
+
case_file['caseFilePath'] = str(base_dir / case_path)
|
|
99
|
+
|
|
100
|
+
# 存储工作路径
|
|
101
|
+
GSDSTORE['WORKPATH'] = {
|
|
102
|
+
'config_dir': str(base_dir),
|
|
103
|
+
'project_dir': str(base_dir.parent) if base_dir.name == 'data' else str(base_dir),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def config(self) -> Dict[str, Any]:
|
|
108
|
+
"""获取配置字典"""
|
|
109
|
+
return self._config.copy()
|
|
110
|
+
|
|
111
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
112
|
+
"""获取配置项
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
key: 配置键名,支持点号分隔的嵌套键 (如 "browser.headless")
|
|
116
|
+
default: 默认值
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
配置值或默认值
|
|
120
|
+
"""
|
|
121
|
+
keys = key.split('.')
|
|
122
|
+
value = self._config
|
|
123
|
+
|
|
124
|
+
for k in keys:
|
|
125
|
+
if isinstance(value, dict):
|
|
126
|
+
value = value.get(k)
|
|
127
|
+
else:
|
|
128
|
+
return default
|
|
129
|
+
|
|
130
|
+
if value is None:
|
|
131
|
+
return default
|
|
132
|
+
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
def set(self, key: str, value: Any) -> None:
|
|
136
|
+
"""设置配置项
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
key: 配置键名
|
|
140
|
+
value: 配置值
|
|
141
|
+
"""
|
|
142
|
+
keys = key.split('.')
|
|
143
|
+
|
|
144
|
+
if len(keys) == 1:
|
|
145
|
+
self._config[key] = value
|
|
146
|
+
else:
|
|
147
|
+
# 处理嵌套键
|
|
148
|
+
target = self._config
|
|
149
|
+
for k in keys[:-1]:
|
|
150
|
+
if k not in target:
|
|
151
|
+
target[k] = {}
|
|
152
|
+
target = target[k]
|
|
153
|
+
target[keys[-1]] = value
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def browser(self) -> str:
|
|
157
|
+
"""浏览器类型"""
|
|
158
|
+
return self._config.get('browser', 'chromium')
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def headless(self) -> bool:
|
|
162
|
+
"""是否无头模式"""
|
|
163
|
+
return self._config.get('headless', False)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def slow_mo(self) -> int:
|
|
167
|
+
"""操作延迟"""
|
|
168
|
+
return self._config.get('slowMo', 0)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def timeout(self) -> int:
|
|
172
|
+
"""默认超时"""
|
|
173
|
+
return self._config.get('timeout', 30000)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def trace(self) -> bool:
|
|
177
|
+
"""是否开启 trace"""
|
|
178
|
+
return self._config.get('trace', False)
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def video(self) -> bool:
|
|
182
|
+
"""是否录制视频"""
|
|
183
|
+
return self._config.get('video', False)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def url(self) -> Optional[str]:
|
|
187
|
+
"""起始 URL"""
|
|
188
|
+
return self._config.get('url')
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def test_case_files(self) -> List[Dict[str, Any]]:
|
|
192
|
+
"""测试用例文件配置"""
|
|
193
|
+
return self._config.get('testCaseFile', [])
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def self_defined_parameters(self) -> Dict[str, Any]:
|
|
197
|
+
"""自定义参数"""
|
|
198
|
+
return self._config.get('selfDefinedParameter', {})
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def retry_switch(self) -> bool:
|
|
202
|
+
"""是否开启重试"""
|
|
203
|
+
return self._config.get('retrySwitch', False)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def retry_count(self) -> int:
|
|
207
|
+
"""重试次数"""
|
|
208
|
+
return self._config.get('retryCount', 1)
|
|
209
|
+
|
|
210
|
+
def save(self, path: Optional[str] = None) -> None:
|
|
211
|
+
"""保存配置到文件
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
path: 保存路径,默认为加载时的路径
|
|
215
|
+
"""
|
|
216
|
+
save_path = Path(path) if path else self._config_path
|
|
217
|
+
|
|
218
|
+
if not save_path:
|
|
219
|
+
raise ValueError("未指定保存路径")
|
|
220
|
+
|
|
221
|
+
suffix = save_path.suffix.lower()
|
|
222
|
+
|
|
223
|
+
if suffix == '.json':
|
|
224
|
+
with open(save_path, 'w', encoding='utf-8') as f:
|
|
225
|
+
json.dump(self._config, f, indent=4, ensure_ascii=False)
|
|
226
|
+
elif suffix in ('.yaml', '.yml'):
|
|
227
|
+
try:
|
|
228
|
+
import yaml
|
|
229
|
+
with open(save_path, 'w', encoding='utf-8') as f:
|
|
230
|
+
yaml.dump(self._config, f, default_flow_style=False, allow_unicode=True)
|
|
231
|
+
except ImportError:
|
|
232
|
+
INFO("PyYAML 未安装,无法保存 YAML 配置", "ERROR")
|
|
233
|
+
raise
|
|
234
|
+
|
|
235
|
+
INFO(f"配置已保存: {save_path}")
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""页面上下文管理(多标签页支持)"""
|
|
2
|
+
|
|
3
|
+
from playwright.sync_api import Page, BrowserContext
|
|
4
|
+
from typing import Optional, List, Dict, Callable
|
|
5
|
+
from ..reference import GSTORE, PRIVATEDATA, INFO
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PageContext:
|
|
9
|
+
"""页面上下文管理器
|
|
10
|
+
|
|
11
|
+
管理多标签页,支持页面切换、新页面监听等功能。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, context: Optional[BrowserContext] = None):
|
|
15
|
+
"""初始化页面上下文管理器
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
context: BrowserContext 实例,如果不提供则从 GSTORE 获取
|
|
19
|
+
"""
|
|
20
|
+
self._context = context or GSTORE.get('context')
|
|
21
|
+
self._pages: List[Page] = []
|
|
22
|
+
self._current_index: int = 0
|
|
23
|
+
self._page_handlers: Dict[str, Callable] = {}
|
|
24
|
+
|
|
25
|
+
if self._context:
|
|
26
|
+
# 初始化已有页面
|
|
27
|
+
self._pages = list(self._context.pages)
|
|
28
|
+
|
|
29
|
+
# 监听新页面
|
|
30
|
+
self._context.on('page', self._on_new_page)
|
|
31
|
+
|
|
32
|
+
def _on_new_page(self, page: Page) -> None:
|
|
33
|
+
"""新页面事件处理
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
page: 新打开的页面
|
|
37
|
+
"""
|
|
38
|
+
self._pages.append(page)
|
|
39
|
+
INFO(f"检测到新页面: {page.url}")
|
|
40
|
+
|
|
41
|
+
# 执行注册的处理器
|
|
42
|
+
for handler in self._page_handlers.values():
|
|
43
|
+
try:
|
|
44
|
+
handler(page)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
INFO(f"页面处理器执行失败: {e}", "ERROR")
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def current_page(self) -> Optional[Page]:
|
|
50
|
+
"""获取当前活动页面"""
|
|
51
|
+
if not self._pages:
|
|
52
|
+
return GSTORE.get('page')
|
|
53
|
+
return self._pages[self._current_index]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def page_count(self) -> int:
|
|
57
|
+
"""获取页面数量"""
|
|
58
|
+
return len(self._pages)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def pages(self) -> List[Page]:
|
|
62
|
+
"""获取所有页面"""
|
|
63
|
+
return self._pages.copy()
|
|
64
|
+
|
|
65
|
+
def switch_to_page(self, index: int) -> Page:
|
|
66
|
+
"""切换到指定索引的页面
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
index: 页面索引 (支持负数索引)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Page: 切换后的页面
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
IndexError: 索引超出范围
|
|
76
|
+
"""
|
|
77
|
+
if not self._pages:
|
|
78
|
+
raise IndexError("没有可用的页面")
|
|
79
|
+
|
|
80
|
+
# 处理负数索引
|
|
81
|
+
if index < 0:
|
|
82
|
+
index = len(self._pages) + index
|
|
83
|
+
|
|
84
|
+
if index < 0 or index >= len(self._pages):
|
|
85
|
+
raise IndexError(f"页面索引超出范围: {index}")
|
|
86
|
+
|
|
87
|
+
self._current_index = index
|
|
88
|
+
page = self._pages[index]
|
|
89
|
+
|
|
90
|
+
# 更新全局存储
|
|
91
|
+
GSTORE['page'] = page
|
|
92
|
+
PRIVATEDATA['PAGES'] = self._pages
|
|
93
|
+
|
|
94
|
+
INFO(f"切换到页面 {index}: {page.url}")
|
|
95
|
+
return page
|
|
96
|
+
|
|
97
|
+
def switch_to_new_page(self, timeout: int = 30000) -> Page:
|
|
98
|
+
"""等待并切换到新打开的页面
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
timeout: 等待超时 (毫秒)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Page: 新页面
|
|
105
|
+
"""
|
|
106
|
+
if not self._context:
|
|
107
|
+
raise RuntimeError("浏览器上下文未初始化")
|
|
108
|
+
|
|
109
|
+
with self._context.expect_page(timeout=timeout) as new_page_info:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
new_page = new_page_info.value
|
|
113
|
+
new_page.wait_for_load_state('domcontentloaded')
|
|
114
|
+
|
|
115
|
+
# 更新页面列表
|
|
116
|
+
if new_page not in self._pages:
|
|
117
|
+
self._pages.append(new_page)
|
|
118
|
+
|
|
119
|
+
self._current_index = len(self._pages) - 1
|
|
120
|
+
GSTORE['page'] = new_page
|
|
121
|
+
|
|
122
|
+
INFO(f"切换到新页面: {new_page.url}")
|
|
123
|
+
return new_page
|
|
124
|
+
|
|
125
|
+
def switch_to_page_by_url(self, url_pattern: str) -> Optional[Page]:
|
|
126
|
+
"""根据 URL 模式切换页面
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
url_pattern: URL 匹配模式 (支持部分匹配)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Page: 匹配的页面,未找到返回 None
|
|
133
|
+
"""
|
|
134
|
+
for i, page in enumerate(self._pages):
|
|
135
|
+
if url_pattern in page.url:
|
|
136
|
+
return self.switch_to_page(i)
|
|
137
|
+
|
|
138
|
+
INFO(f"未找到匹配的页面: {url_pattern}", "WARNING")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def switch_to_page_by_title(self, title_pattern: str) -> Optional[Page]:
|
|
142
|
+
"""根据标题模式切换页面
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
title_pattern: 标题匹配模式 (支持部分匹配)
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Page: 匹配的页面,未找到返回 None
|
|
149
|
+
"""
|
|
150
|
+
for i, page in enumerate(self._pages):
|
|
151
|
+
if title_pattern in page.title():
|
|
152
|
+
return self.switch_to_page(i)
|
|
153
|
+
|
|
154
|
+
INFO(f"未找到匹配的页面: {title_pattern}", "WARNING")
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def close_current_page(self) -> Optional[Page]:
|
|
158
|
+
"""关闭当前页面并切换到上一个页面
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Page: 切换后的当前页面,如果没有其他页面返回 None
|
|
162
|
+
"""
|
|
163
|
+
if not self._pages:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
current = self._pages[self._current_index]
|
|
167
|
+
current.close()
|
|
168
|
+
self._pages.pop(self._current_index)
|
|
169
|
+
|
|
170
|
+
if not self._pages:
|
|
171
|
+
GSTORE['page'] = None
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# 切换到上一个页面
|
|
175
|
+
if self._current_index >= len(self._pages):
|
|
176
|
+
self._current_index = len(self._pages) - 1
|
|
177
|
+
|
|
178
|
+
new_current = self._pages[self._current_index]
|
|
179
|
+
GSTORE['page'] = new_current
|
|
180
|
+
|
|
181
|
+
INFO(f"关闭页面,切换到: {new_current.url}")
|
|
182
|
+
return new_current
|
|
183
|
+
|
|
184
|
+
def close_page(self, index: int) -> None:
|
|
185
|
+
"""关闭指定索引的页面
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
index: 页面索引
|
|
189
|
+
"""
|
|
190
|
+
if index < 0 or index >= len(self._pages):
|
|
191
|
+
raise IndexError(f"页面索引超出范围: {index}")
|
|
192
|
+
|
|
193
|
+
page = self._pages[index]
|
|
194
|
+
page.close()
|
|
195
|
+
self._pages.pop(index)
|
|
196
|
+
|
|
197
|
+
# 调整当前索引
|
|
198
|
+
if self._current_index >= len(self._pages):
|
|
199
|
+
self._current_index = max(0, len(self._pages) - 1)
|
|
200
|
+
|
|
201
|
+
if self._pages:
|
|
202
|
+
GSTORE['page'] = self._pages[self._current_index]
|
|
203
|
+
else:
|
|
204
|
+
GSTORE['page'] = None
|
|
205
|
+
|
|
206
|
+
def register_page_handler(self, name: str, handler: Callable[[Page], None]) -> None:
|
|
207
|
+
"""注册新页面处理器
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
name: 处理器名称
|
|
211
|
+
handler: 处理函数,接收 Page 参数
|
|
212
|
+
"""
|
|
213
|
+
self._page_handlers[name] = handler
|
|
214
|
+
|
|
215
|
+
def unregister_page_handler(self, name: str) -> None:
|
|
216
|
+
"""注销页面处理器
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
name: 处理器名称
|
|
220
|
+
"""
|
|
221
|
+
self._page_handlers.pop(name, None)
|
|
222
|
+
|
|
223
|
+
def refresh(self) -> None:
|
|
224
|
+
"""刷新页面列表(同步上下文中的页面)"""
|
|
225
|
+
if self._context:
|
|
226
|
+
self._pages = list(self._context.pages)
|
|
227
|
+
if self._pages and self._current_index >= len(self._pages):
|
|
228
|
+
self._current_index = len(self._pages) - 1
|