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.
Files changed (57) hide show
  1. kdtest_pw/__init__.py +50 -0
  2. kdtest_pw/action/__init__.py +7 -0
  3. kdtest_pw/action/base_keyword.py +292 -0
  4. kdtest_pw/action/element_plus/__init__.py +23 -0
  5. kdtest_pw/action/element_plus/el_cascader.py +263 -0
  6. kdtest_pw/action/element_plus/el_datepicker.py +324 -0
  7. kdtest_pw/action/element_plus/el_dialog.py +317 -0
  8. kdtest_pw/action/element_plus/el_form.py +443 -0
  9. kdtest_pw/action/element_plus/el_menu.py +456 -0
  10. kdtest_pw/action/element_plus/el_select.py +268 -0
  11. kdtest_pw/action/element_plus/el_table.py +442 -0
  12. kdtest_pw/action/element_plus/el_tree.py +364 -0
  13. kdtest_pw/action/element_plus/el_upload.py +313 -0
  14. kdtest_pw/action/key_retrieval.py +311 -0
  15. kdtest_pw/action/page_action.py +1129 -0
  16. kdtest_pw/api/__init__.py +6 -0
  17. kdtest_pw/api/api_keyword.py +251 -0
  18. kdtest_pw/api/request_handler.py +232 -0
  19. kdtest_pw/cases/__init__.py +6 -0
  20. kdtest_pw/cases/case_collector.py +182 -0
  21. kdtest_pw/cases/case_executor.py +359 -0
  22. kdtest_pw/cases/read/__init__.py +6 -0
  23. kdtest_pw/cases/read/cell_handler.py +305 -0
  24. kdtest_pw/cases/read/excel_reader.py +223 -0
  25. kdtest_pw/cli/__init__.py +5 -0
  26. kdtest_pw/cli/run.py +318 -0
  27. kdtest_pw/common.py +106 -0
  28. kdtest_pw/core/__init__.py +7 -0
  29. kdtest_pw/core/browser_manager.py +196 -0
  30. kdtest_pw/core/config_loader.py +235 -0
  31. kdtest_pw/core/page_context.py +228 -0
  32. kdtest_pw/data/__init__.py +5 -0
  33. kdtest_pw/data/init_data.py +105 -0
  34. kdtest_pw/data/static/elementData.yaml +59 -0
  35. kdtest_pw/data/static/parameters.json +24 -0
  36. kdtest_pw/plugins/__init__.py +6 -0
  37. kdtest_pw/plugins/element_plus_plugin/__init__.py +5 -0
  38. kdtest_pw/plugins/element_plus_plugin/elementData/elementData.yaml +144 -0
  39. kdtest_pw/plugins/element_plus_plugin/element_plus_plugin.py +237 -0
  40. kdtest_pw/plugins/element_plus_plugin/my.ini +23 -0
  41. kdtest_pw/plugins/plugin_base.py +180 -0
  42. kdtest_pw/plugins/plugin_loader.py +260 -0
  43. kdtest_pw/product.py +5 -0
  44. kdtest_pw/reference.py +99 -0
  45. kdtest_pw/utils/__init__.py +13 -0
  46. kdtest_pw/utils/built_in_function.py +376 -0
  47. kdtest_pw/utils/decorator.py +211 -0
  48. kdtest_pw/utils/log/__init__.py +6 -0
  49. kdtest_pw/utils/log/html_report.py +336 -0
  50. kdtest_pw/utils/log/logger.py +123 -0
  51. kdtest_pw/utils/public_script.py +366 -0
  52. kdtest_pw-2.0.0.dist-info/METADATA +169 -0
  53. kdtest_pw-2.0.0.dist-info/RECORD +57 -0
  54. kdtest_pw-2.0.0.dist-info/WHEEL +5 -0
  55. kdtest_pw-2.0.0.dist-info/entry_points.txt +2 -0
  56. kdtest_pw-2.0.0.dist-info/licenses/LICENSE +21 -0
  57. kdtest_pw-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1129 @@
1
+ """核心关键字实现 - KeyWordTest 类"""
2
+
3
+ from playwright.sync_api import Page, Locator, expect
4
+ from typing import Optional, Union, List
5
+ import re
6
+ import time
7
+
8
+ from .base_keyword import BaseKeyword
9
+ from ..common import KEY_MAP
10
+ from ..reference import GSTORE, PRIVATEDATA, INFO, set_element_value
11
+
12
+
13
+ class KeyWordTest(BaseKeyword):
14
+ """核心关键字实现类
15
+
16
+ 兼容原 kdtest API,提供所有基础关键字操作。
17
+ """
18
+
19
+ def __init__(self, page: Page):
20
+ """初始化关键字测试类
21
+
22
+ Args:
23
+ page: Playwright Page 实例
24
+ """
25
+ super().__init__(page)
26
+
27
+ # ==================== 浏览器操作 ====================
28
+
29
+ def driver_get(self, *, content: str) -> None:
30
+ """导航到 URL
31
+
32
+ Args:
33
+ content: 目标 URL
34
+ """
35
+ INFO(f"导航到: {content}")
36
+ self.page.goto(content, wait_until='domcontentloaded')
37
+ self.wait_for_element_plus_loading()
38
+
39
+ def driver_back(self) -> None:
40
+ """浏览器后退"""
41
+ INFO("浏览器后退")
42
+ self.page.go_back()
43
+
44
+ def driver_forward(self) -> None:
45
+ """浏览器前进"""
46
+ INFO("浏览器前进")
47
+ self.page.go_forward()
48
+
49
+ def driver_refresh(self) -> None:
50
+ """刷新页面"""
51
+ INFO("刷新页面")
52
+ self.page.reload()
53
+ self.wait_for_element_plus_loading()
54
+
55
+ def driver_close(self) -> None:
56
+ """关闭当前页面"""
57
+ INFO("关闭当前页面")
58
+ self.page.close()
59
+
60
+ def driver_maximize(self) -> None:
61
+ """最大化窗口(设置视口为屏幕大小)"""
62
+ INFO("最大化窗口")
63
+ # Playwright 使用视口而非窗口,设置一个较大的尺寸
64
+ self.page.set_viewport_size({'width': 1920, 'height': 1080})
65
+
66
+ # ==================== 输入操作 ====================
67
+
68
+ def input_text(
69
+ self,
70
+ targeting: str,
71
+ element: str,
72
+ index: Optional[int] = None,
73
+ parent: Optional[Locator] = None,
74
+ *,
75
+ content: str
76
+ ) -> None:
77
+ """输入文本(自动清除后输入)
78
+
79
+ Args:
80
+ targeting: 定位类型
81
+ element: 定位表达式
82
+ index: 索引
83
+ parent: 父元素定位器
84
+ content: 输入内容
85
+ """
86
+ INFO(f"输入文本: {content}")
87
+ loc = self.locator(targeting, element, index, parent)
88
+ loc.fill(str(content))
89
+
90
+ def input_append(
91
+ self,
92
+ targeting: str,
93
+ element: str,
94
+ index: Optional[int] = None,
95
+ parent: Optional[Locator] = None,
96
+ *,
97
+ content: str
98
+ ) -> None:
99
+ """追加输入文本
100
+
101
+ Args:
102
+ targeting: 定位类型
103
+ element: 定位表达式
104
+ index: 索引
105
+ parent: 父元素定位器
106
+ content: 追加内容
107
+ """
108
+ INFO(f"追加输入: {content}")
109
+ loc = self.locator(targeting, element, index, parent)
110
+ loc.press_sequentially(str(content))
111
+
112
+ def input_clear(
113
+ self,
114
+ targeting: str,
115
+ element: str,
116
+ index: Optional[int] = None,
117
+ parent: Optional[Locator] = None
118
+ ) -> None:
119
+ """清除输入框内容
120
+
121
+ Args:
122
+ targeting: 定位类型
123
+ element: 定位表达式
124
+ index: 索引
125
+ parent: 父元素定位器
126
+ """
127
+ INFO("清除输入框")
128
+ loc = self.locator(targeting, element, index, parent)
129
+ loc.clear()
130
+
131
+ # ==================== 点击操作 ====================
132
+
133
+ def click_btn(
134
+ self,
135
+ targeting: str,
136
+ element: str,
137
+ index: Optional[int] = None,
138
+ parent: Optional[Locator] = None,
139
+ *,
140
+ force: bool = False,
141
+ js_click: bool = False,
142
+ content: str = None
143
+ ) -> None:
144
+ """点击元素
145
+
146
+ Args:
147
+ targeting: 定位类型
148
+ element: 定位表达式
149
+ index: 索引
150
+ parent: 父元素定位器
151
+ force: 是否强制点击
152
+ js_click: 是否使用 JS 点击
153
+ content: 预留参数(兼容性)
154
+ """
155
+ INFO(f"点击元素: {element}")
156
+ loc = self.locator(targeting, element, index, parent)
157
+
158
+ if js_click or content == 'js_click':
159
+ loc.evaluate('el => el.click()')
160
+ else:
161
+ loc.click(force=force)
162
+
163
+ def double_click(
164
+ self,
165
+ targeting: str,
166
+ element: str,
167
+ index: Optional[int] = None,
168
+ parent: Optional[Locator] = None
169
+ ) -> None:
170
+ """双击元素
171
+
172
+ Args:
173
+ targeting: 定位类型
174
+ element: 定位表达式
175
+ index: 索引
176
+ parent: 父元素定位器
177
+ """
178
+ INFO(f"双击元素: {element}")
179
+ loc = self.locator(targeting, element, index, parent)
180
+ loc.dblclick()
181
+
182
+ def right_click(
183
+ self,
184
+ targeting: str,
185
+ element: str,
186
+ index: Optional[int] = None,
187
+ parent: Optional[Locator] = None
188
+ ) -> None:
189
+ """右键点击
190
+
191
+ Args:
192
+ targeting: 定位类型
193
+ element: 定位表达式
194
+ index: 索引
195
+ parent: 父元素定位器
196
+ """
197
+ INFO(f"右键点击: {element}")
198
+ loc = self.locator(targeting, element, index, parent)
199
+ loc.click(button='right')
200
+
201
+ def click_and_hold(
202
+ self,
203
+ targeting: str,
204
+ element: str,
205
+ index: Optional[int] = None,
206
+ parent: Optional[Locator] = None
207
+ ) -> None:
208
+ """点击并按住
209
+
210
+ Args:
211
+ targeting: 定位类型
212
+ element: 定位表达式
213
+ index: 索引
214
+ parent: 父元素定位器
215
+ """
216
+ INFO(f"点击并按住: {element}")
217
+ loc = self.locator(targeting, element, index, parent)
218
+ box = loc.bounding_box()
219
+ if box:
220
+ self.page.mouse.move(box['x'] + box['width'] / 2, box['y'] + box['height'] / 2)
221
+ self.page.mouse.down()
222
+
223
+ def release_click(self) -> None:
224
+ """释放点击"""
225
+ INFO("释放点击")
226
+ self.page.mouse.up()
227
+
228
+ # ==================== 鼠标操作 ====================
229
+
230
+ def hover(
231
+ self,
232
+ targeting: str,
233
+ element: str,
234
+ index: Optional[int] = None,
235
+ parent: Optional[Locator] = None
236
+ ) -> None:
237
+ """鼠标悬停
238
+
239
+ Args:
240
+ targeting: 定位类型
241
+ element: 定位表达式
242
+ index: 索引
243
+ parent: 父元素定位器
244
+ """
245
+ INFO(f"悬停: {element}")
246
+ loc = self.locator(targeting, element, index, parent)
247
+ loc.hover()
248
+
249
+ def drag_and_drop(
250
+ self,
251
+ source_targeting: str,
252
+ source_element: str,
253
+ target_targeting: str,
254
+ target_element: str,
255
+ *,
256
+ content: str = None
257
+ ) -> None:
258
+ """拖拽元素
259
+
260
+ Args:
261
+ source_targeting: 源元素定位类型
262
+ source_element: 源元素定位表达式
263
+ target_targeting: 目标元素定位类型
264
+ target_element: 目标元素定位表达式
265
+ content: 预留参数
266
+ """
267
+ INFO(f"拖拽: {source_element} -> {target_element}")
268
+ source = self.locator(source_targeting, source_element)
269
+ target = self.locator(target_targeting, target_element)
270
+ source.drag_to(target)
271
+
272
+ def scroll_to_element(
273
+ self,
274
+ targeting: str,
275
+ element: str,
276
+ index: Optional[int] = None,
277
+ parent: Optional[Locator] = None
278
+ ) -> None:
279
+ """滚动到元素位置
280
+
281
+ Args:
282
+ targeting: 定位类型
283
+ element: 定位表达式
284
+ index: 索引
285
+ parent: 父元素定位器
286
+ """
287
+ INFO(f"滚动到元素: {element}")
288
+ loc = self.locator(targeting, element, index, parent)
289
+ loc.scroll_into_view_if_needed()
290
+
291
+ def scroll_by(self, *, content: str) -> None:
292
+ """滚动页面
293
+
294
+ Args:
295
+ content: 滚动距离,格式 "x,y" 或 "y"
296
+ """
297
+ parts = str(content).split(',')
298
+ if len(parts) == 2:
299
+ x, y = int(parts[0]), int(parts[1])
300
+ else:
301
+ x, y = 0, int(parts[0])
302
+
303
+ INFO(f"滚动页面: ({x}, {y})")
304
+ self.page.mouse.wheel(x, y)
305
+
306
+ # ==================== 键盘操作 ====================
307
+
308
+ def keyboard_events(
309
+ self,
310
+ targeting: str,
311
+ element: str,
312
+ index: Optional[int] = None,
313
+ parent: Optional[Locator] = None,
314
+ *,
315
+ content: str
316
+ ) -> None:
317
+ """键盘事件
318
+
319
+ Args:
320
+ targeting: 定位类型
321
+ element: 定位表达式
322
+ index: 索引
323
+ parent: 父元素定位器
324
+ content: 按键 (如 Keys.ENTER, Keys.TAB)
325
+ """
326
+ INFO(f"键盘事件: {content}")
327
+ loc = self.locator(targeting, element, index, parent)
328
+
329
+ # 转换按键
330
+ key = KEY_MAP.get(content, content)
331
+ loc.press(key)
332
+
333
+ def press_key(self, *, content: str) -> None:
334
+ """全局按键
335
+
336
+ Args:
337
+ content: 按键
338
+ """
339
+ INFO(f"按键: {content}")
340
+ key = KEY_MAP.get(content, content)
341
+ self.page.keyboard.press(key)
342
+
343
+ def type_text(self, *, content: str) -> None:
344
+ """全局键盘输入
345
+
346
+ Args:
347
+ content: 输入内容
348
+ """
349
+ INFO(f"键盘输入: {content}")
350
+ self.page.keyboard.type(str(content))
351
+
352
+ # ==================== 下拉选择(原生 select) ====================
353
+
354
+ def selector_operation(
355
+ self,
356
+ targeting: str,
357
+ element: str,
358
+ index: Optional[int] = None,
359
+ parent: Optional[Locator] = None,
360
+ *,
361
+ content: str,
362
+ by: str = 'label'
363
+ ) -> None:
364
+ """原生 select 下拉框选择
365
+
366
+ Args:
367
+ targeting: 定位类型
368
+ element: 定位表达式
369
+ index: 索引
370
+ parent: 父元素定位器
371
+ content: 选项值
372
+ by: 选择方式 (label, value, index)
373
+ """
374
+ INFO(f"选择下拉框: {content}")
375
+ loc = self.locator(targeting, element, index, parent)
376
+
377
+ if by == 'label':
378
+ loc.select_option(label=content)
379
+ elif by == 'value':
380
+ loc.select_option(value=content)
381
+ elif by == 'index':
382
+ loc.select_option(index=int(content))
383
+ else:
384
+ loc.select_option(label=content)
385
+
386
+ # ==================== 复选框/单选框 ====================
387
+
388
+ def checkbox_operation(
389
+ self,
390
+ targeting: str,
391
+ element: str,
392
+ index: Optional[int] = None,
393
+ parent: Optional[Locator] = None,
394
+ *,
395
+ content: str = 'true'
396
+ ) -> None:
397
+ """复选框操作
398
+
399
+ Args:
400
+ targeting: 定位类型
401
+ element: 定位表达式
402
+ index: 索引
403
+ parent: 父元素定位器
404
+ content: 是否选中 (true/false)
405
+ """
406
+ checked = str(content).lower() in ('true', '1', 'yes', 'on')
407
+ INFO(f"复选框: {'选中' if checked else '取消'}")
408
+ loc = self.locator(targeting, element, index, parent)
409
+ loc.set_checked(checked)
410
+
411
+ def radio_select(
412
+ self,
413
+ targeting: str,
414
+ element: str,
415
+ index: Optional[int] = None,
416
+ parent: Optional[Locator] = None
417
+ ) -> None:
418
+ """单选框选择
419
+
420
+ Args:
421
+ targeting: 定位类型
422
+ element: 定位表达式
423
+ index: 索引
424
+ parent: 父元素定位器
425
+ """
426
+ INFO(f"选择单选框: {element}")
427
+ loc = self.locator(targeting, element, index, parent)
428
+ loc.check()
429
+
430
+ # ==================== 文件上传 ====================
431
+
432
+ def file_upload(
433
+ self,
434
+ targeting: str,
435
+ element: str,
436
+ index: Optional[int] = None,
437
+ parent: Optional[Locator] = None,
438
+ *,
439
+ content: str
440
+ ) -> None:
441
+ """文件上传
442
+
443
+ Args:
444
+ targeting: 定位类型
445
+ element: 定位表达式
446
+ index: 索引
447
+ parent: 父元素定位器
448
+ content: 文件路径(多个文件用逗号分隔)
449
+ """
450
+ INFO(f"上传文件: {content}")
451
+ loc = self.locator(targeting, element, index, parent)
452
+
453
+ # 支持多文件
454
+ files = [f.strip() for f in str(content).split(',')]
455
+ if len(files) == 1:
456
+ loc.set_input_files(files[0])
457
+ else:
458
+ loc.set_input_files(files)
459
+
460
+ # ==================== iframe 操作 ====================
461
+
462
+ def switch_to_frame(
463
+ self,
464
+ targeting: str,
465
+ element: str,
466
+ index: Optional[int] = None,
467
+ parent: Optional[Locator] = None
468
+ ) -> None:
469
+ """切换到 iframe
470
+
471
+ Args:
472
+ targeting: 定位类型
473
+ element: 定位表达式
474
+ index: 索引
475
+ parent: 父元素定位器
476
+ """
477
+ self.switch_frame(targeting, element)
478
+
479
+ def switch_to_default(self) -> None:
480
+ """切换到主文档"""
481
+ self.frame_default()
482
+
483
+ def switch_to_parent_frame(self) -> None:
484
+ """切换到父 frame"""
485
+ self.switch_frame_parent()
486
+
487
+ # ==================== 窗口/标签页操作 ====================
488
+
489
+ def get_handle(self, *, content: str = None) -> str:
490
+ """获取当前页面标识并缓存
491
+
492
+ Args:
493
+ content: 缓存键名
494
+
495
+ Returns:
496
+ str: 页面 URL
497
+ """
498
+ url = self.page.url
499
+ PRIVATEDATA['PAGES'].append(self.page)
500
+ if content:
501
+ set_element_value(content, url)
502
+ INFO(f"页面句柄: {url}")
503
+ return url
504
+
505
+ def switch_to_new_page(self, *, content: str = None) -> None:
506
+ """切换到新打开的页面
507
+
508
+ Args:
509
+ content: 预留参数
510
+ """
511
+ INFO("切换到新页面")
512
+ pages = self.page.context.pages
513
+ if len(pages) > 1:
514
+ new_page = pages[-1]
515
+ GSTORE['page'] = new_page
516
+ self.page = new_page
517
+
518
+ def switch_to_page(self, *, content: str) -> None:
519
+ """切换到指定索引的页面
520
+
521
+ Args:
522
+ content: 页面索引
523
+ """
524
+ index = int(content)
525
+ INFO(f"切换到页面: {index}")
526
+ pages = self.page.context.pages
527
+ if 0 <= index < len(pages):
528
+ self.page = pages[index]
529
+ GSTORE['page'] = self.page
530
+
531
+ def switch_to_page_by_title(self, *, content: str) -> None:
532
+ """根据标题切换页面
533
+
534
+ Args:
535
+ content: 标题模式
536
+ """
537
+ INFO(f"切换到标题包含: {content}")
538
+ pages = self.page.context.pages
539
+ for page in pages:
540
+ if content in page.title():
541
+ self.page = page
542
+ GSTORE['page'] = self.page
543
+ return
544
+ INFO(f"未找到标题包含 '{content}' 的页面", "WARNING")
545
+
546
+ def switch_to_page_by_url(self, *, content: str) -> None:
547
+ """根据 URL 切换页面
548
+
549
+ Args:
550
+ content: URL 模式
551
+ """
552
+ INFO(f"切换到 URL 包含: {content}")
553
+ pages = self.page.context.pages
554
+ for page in pages:
555
+ if content in page.url:
556
+ self.page = page
557
+ GSTORE['page'] = self.page
558
+ return
559
+ INFO(f"未找到 URL 包含 '{content}' 的页面", "WARNING")
560
+
561
+ def close_other_pages(self) -> None:
562
+ """关闭其他页面"""
563
+ INFO("关闭其他页面")
564
+ current = self.page
565
+ pages = self.page.context.pages
566
+ for page in pages:
567
+ if page != current:
568
+ page.close()
569
+
570
+ # ==================== 弹窗处理 ====================
571
+
572
+ def alert_accept(self) -> None:
573
+ """接受弹窗"""
574
+ INFO("接受弹窗")
575
+ self.page.on('dialog', lambda dialog: dialog.accept())
576
+
577
+ def alert_dismiss(self) -> None:
578
+ """取消弹窗"""
579
+ INFO("取消弹窗")
580
+ self.page.on('dialog', lambda dialog: dialog.dismiss())
581
+
582
+ def alert_text(self, *, content: str = None) -> str:
583
+ """获取弹窗文本
584
+
585
+ Args:
586
+ content: 缓存键名
587
+
588
+ Returns:
589
+ str: 弹窗文本
590
+ """
591
+ text = ''
592
+
593
+ def handle_dialog(dialog):
594
+ nonlocal text
595
+ text = dialog.message
596
+ dialog.accept()
597
+
598
+ self.page.on('dialog', handle_dialog)
599
+ if content:
600
+ set_element_value(content, text)
601
+ return text
602
+
603
+ def alert_input(self, *, content: str) -> None:
604
+ """prompt 弹窗输入
605
+
606
+ Args:
607
+ content: 输入内容
608
+ """
609
+ INFO(f"弹窗输入: {content}")
610
+ self.page.on('dialog', lambda dialog: dialog.accept(content))
611
+
612
+ # ==================== 断言关键字 ====================
613
+
614
+ def title_assert(self, *, content: str, mode: str = 'equals') -> bool:
615
+ """页面标题断言
616
+
617
+ Args:
618
+ content: 期望值
619
+ mode: 断言模式 (equals, contains, regex)
620
+
621
+ Returns:
622
+ bool: 断言结果
623
+ """
624
+ title = self.page.title()
625
+ result = self._assert_text(title, content, mode)
626
+ INFO(f"标题断言: {title} {mode} {content} -> {result}")
627
+ return result
628
+
629
+ def url_assert(self, *, content: str, mode: str = 'equals') -> bool:
630
+ """页面 URL 断言
631
+
632
+ Args:
633
+ content: 期望值
634
+ mode: 断言模式
635
+
636
+ Returns:
637
+ bool: 断言结果
638
+ """
639
+ url = self.page.url
640
+ result = self._assert_text(url, content, mode)
641
+ INFO(f"URL 断言: {url} {mode} {content} -> {result}")
642
+ return result
643
+
644
+ def text_assert(
645
+ self,
646
+ targeting: str,
647
+ element: str,
648
+ index: Optional[int] = None,
649
+ parent: Optional[Locator] = None,
650
+ *,
651
+ content: str,
652
+ mode: str = 'equals'
653
+ ) -> bool:
654
+ """元素文本断言
655
+
656
+ Args:
657
+ targeting: 定位类型
658
+ element: 定位表达式
659
+ index: 索引
660
+ parent: 父元素定位器
661
+ content: 期望值
662
+ mode: 断言模式
663
+
664
+ Returns:
665
+ bool: 断言结果
666
+ """
667
+ loc = self.locator(targeting, element, index, parent)
668
+ actual = loc.text_content() or ''
669
+ actual = actual.strip()
670
+ result = self._assert_text(actual, content, mode)
671
+ INFO(f"文本断言: '{actual}' {mode} '{content}' -> {result}")
672
+ return result
673
+
674
+ def value_assert(
675
+ self,
676
+ targeting: str,
677
+ element: str,
678
+ index: Optional[int] = None,
679
+ parent: Optional[Locator] = None,
680
+ *,
681
+ content: str,
682
+ mode: str = 'equals'
683
+ ) -> bool:
684
+ """元素 value 属性断言
685
+
686
+ Args:
687
+ targeting: 定位类型
688
+ element: 定位表达式
689
+ index: 索引
690
+ parent: 父元素定位器
691
+ content: 期望值
692
+ mode: 断言模式
693
+
694
+ Returns:
695
+ bool: 断言结果
696
+ """
697
+ loc = self.locator(targeting, element, index, parent)
698
+ actual = loc.input_value() or ''
699
+ result = self._assert_text(actual, content, mode)
700
+ INFO(f"Value 断言: '{actual}' {mode} '{content}' -> {result}")
701
+ return result
702
+
703
+ def attribute_assert(
704
+ self,
705
+ targeting: str,
706
+ element: str,
707
+ index: Optional[int] = None,
708
+ parent: Optional[Locator] = None,
709
+ *,
710
+ attribute: str,
711
+ content: str,
712
+ mode: str = 'equals'
713
+ ) -> bool:
714
+ """元素属性断言
715
+
716
+ Args:
717
+ targeting: 定位类型
718
+ element: 定位表达式
719
+ index: 索引
720
+ parent: 父元素定位器
721
+ attribute: 属性名
722
+ content: 期望值
723
+ mode: 断言模式
724
+
725
+ Returns:
726
+ bool: 断言结果
727
+ """
728
+ loc = self.locator(targeting, element, index, parent)
729
+ actual = loc.get_attribute(attribute) or ''
730
+ result = self._assert_text(actual, content, mode)
731
+ INFO(f"属性断言: {attribute}='{actual}' {mode} '{content}' -> {result}")
732
+ return result
733
+
734
+ def element_visible_assert(
735
+ self,
736
+ targeting: str,
737
+ element: str,
738
+ index: Optional[int] = None,
739
+ parent: Optional[Locator] = None,
740
+ *,
741
+ content: str = 'true'
742
+ ) -> bool:
743
+ """元素可见性断言
744
+
745
+ Args:
746
+ targeting: 定位类型
747
+ element: 定位表达式
748
+ index: 索引
749
+ parent: 父元素定位器
750
+ content: 期望是否可见 (true/false)
751
+
752
+ Returns:
753
+ bool: 断言结果
754
+ """
755
+ expected = str(content).lower() in ('true', '1', 'yes')
756
+ loc = self.locator(targeting, element, index, parent)
757
+ is_visible = loc.is_visible()
758
+ result = is_visible == expected
759
+ INFO(f"可见性断言: {is_visible} == {expected} -> {result}")
760
+ return result
761
+
762
+ def element_enabled_assert(
763
+ self,
764
+ targeting: str,
765
+ element: str,
766
+ index: Optional[int] = None,
767
+ parent: Optional[Locator] = None,
768
+ *,
769
+ content: str = 'true'
770
+ ) -> bool:
771
+ """元素可用性断言
772
+
773
+ Args:
774
+ targeting: 定位类型
775
+ element: 定位表达式
776
+ index: 索引
777
+ parent: 父元素定位器
778
+ content: 期望是否可用 (true/false)
779
+
780
+ Returns:
781
+ bool: 断言结果
782
+ """
783
+ expected = str(content).lower() in ('true', '1', 'yes')
784
+ loc = self.locator(targeting, element, index, parent)
785
+ is_enabled = loc.is_enabled()
786
+ result = is_enabled == expected
787
+ INFO(f"可用性断言: {is_enabled} == {expected} -> {result}")
788
+ return result
789
+
790
+ def element_count_assert(
791
+ self,
792
+ targeting: str,
793
+ element: str,
794
+ *,
795
+ content: str,
796
+ mode: str = 'equals'
797
+ ) -> bool:
798
+ """元素数量断言
799
+
800
+ Args:
801
+ targeting: 定位类型
802
+ element: 定位表达式
803
+ content: 期望数量
804
+ mode: 断言模式 (equals, gt, lt, gte, lte)
805
+
806
+ Returns:
807
+ bool: 断言结果
808
+ """
809
+ loc = self.locator(targeting, element)
810
+ actual = loc.count()
811
+ expected = int(content)
812
+
813
+ mode_map = {
814
+ 'equals': actual == expected,
815
+ 'gt': actual > expected,
816
+ 'lt': actual < expected,
817
+ 'gte': actual >= expected,
818
+ 'lte': actual <= expected,
819
+ }
820
+ result = mode_map.get(mode, actual == expected)
821
+ INFO(f"数量断言: {actual} {mode} {expected} -> {result}")
822
+ return result
823
+
824
+ def _assert_text(self, actual: str, expected: str, mode: str) -> bool:
825
+ """文本断言辅助方法
826
+
827
+ Args:
828
+ actual: 实际值
829
+ expected: 期望值
830
+ mode: 断言模式
831
+
832
+ Returns:
833
+ bool: 断言结果
834
+ """
835
+ if mode == 'equals':
836
+ return actual == expected
837
+ elif mode == 'contains':
838
+ return expected in actual
839
+ elif mode == 'regex':
840
+ return bool(re.search(expected, actual))
841
+ elif mode == 'startswith':
842
+ return actual.startswith(expected)
843
+ elif mode == 'endswith':
844
+ return actual.endswith(expected)
845
+ elif mode == 'not_equals':
846
+ return actual != expected
847
+ elif mode == 'not_contains':
848
+ return expected not in actual
849
+ return False
850
+
851
+ # ==================== 取值操作 ====================
852
+
853
+ def get_element_text(
854
+ self,
855
+ targeting: str,
856
+ element: str,
857
+ index: Optional[int] = None,
858
+ parent: Optional[Locator] = None,
859
+ *,
860
+ content: str
861
+ ) -> str:
862
+ """获取元素文本并缓存
863
+
864
+ Args:
865
+ targeting: 定位类型
866
+ element: 定位表达式
867
+ index: 索引
868
+ parent: 父元素定位器
869
+ content: 缓存键名
870
+
871
+ Returns:
872
+ str: 元素文本
873
+ """
874
+ loc = self.locator(targeting, element, index, parent)
875
+ text = loc.text_content() or ''
876
+ text = text.strip()
877
+ set_element_value(content, text)
878
+ INFO(f"获取文本: {content} = '{text}'")
879
+ return text
880
+
881
+ def get_element_value(
882
+ self,
883
+ targeting: str,
884
+ element: str,
885
+ index: Optional[int] = None,
886
+ parent: Optional[Locator] = None,
887
+ *,
888
+ content: str
889
+ ) -> str:
890
+ """获取元素 value 属性并缓存
891
+
892
+ Args:
893
+ targeting: 定位类型
894
+ element: 定位表达式
895
+ index: 索引
896
+ parent: 父元素定位器
897
+ content: 缓存键名
898
+
899
+ Returns:
900
+ str: value 值
901
+ """
902
+ loc = self.locator(targeting, element, index, parent)
903
+ value = loc.input_value() or ''
904
+ set_element_value(content, value)
905
+ INFO(f"获取 value: {content} = '{value}'")
906
+ return value
907
+
908
+ def get_element_attribute(
909
+ self,
910
+ targeting: str,
911
+ element: str,
912
+ index: Optional[int] = None,
913
+ parent: Optional[Locator] = None,
914
+ *,
915
+ attribute: str,
916
+ content: str
917
+ ) -> str:
918
+ """获取元素属性并缓存
919
+
920
+ Args:
921
+ targeting: 定位类型
922
+ element: 定位表达式
923
+ index: 索引
924
+ parent: 父元素定位器
925
+ attribute: 属性名
926
+ content: 缓存键名
927
+
928
+ Returns:
929
+ str: 属性值
930
+ """
931
+ loc = self.locator(targeting, element, index, parent)
932
+ value = loc.get_attribute(attribute) or ''
933
+ set_element_value(content, value)
934
+ INFO(f"获取属性: {content} = {attribute}='{value}'")
935
+ return value
936
+
937
+ def get_page_title(self, *, content: str) -> str:
938
+ """获取页面标题并缓存
939
+
940
+ Args:
941
+ content: 缓存键名
942
+
943
+ Returns:
944
+ str: 页面标题
945
+ """
946
+ title = self.page.title()
947
+ set_element_value(content, title)
948
+ INFO(f"获取标题: {content} = '{title}'")
949
+ return title
950
+
951
+ def get_page_url(self, *, content: str) -> str:
952
+ """获取页面 URL 并缓存
953
+
954
+ Args:
955
+ content: 缓存键名
956
+
957
+ Returns:
958
+ str: 页面 URL
959
+ """
960
+ url = self.page.url
961
+ set_element_value(content, url)
962
+ INFO(f"获取 URL: {content} = '{url}'")
963
+ return url
964
+
965
+ # ==================== 等待操作 ====================
966
+
967
+ def time_sleep(self, *, content: str) -> None:
968
+ """显式等待
969
+
970
+ Args:
971
+ content: 等待秒数
972
+ """
973
+ seconds = float(content)
974
+ INFO(f"等待 {seconds} 秒")
975
+ self.page.wait_for_timeout(int(seconds * 1000))
976
+
977
+ def wait_element_visible(
978
+ self,
979
+ targeting: str,
980
+ element: str,
981
+ index: Optional[int] = None,
982
+ *,
983
+ content: str = '30000'
984
+ ) -> None:
985
+ """等待元素可见
986
+
987
+ Args:
988
+ targeting: 定位类型
989
+ element: 定位表达式
990
+ index: 索引
991
+ content: 超时时间 (毫秒)
992
+ """
993
+ timeout = int(content)
994
+ INFO(f"等待元素可见: {element}")
995
+ loc = self.locator(targeting, element, index)
996
+ loc.wait_for(state='visible', timeout=timeout)
997
+
998
+ def wait_element_hidden(
999
+ self,
1000
+ targeting: str,
1001
+ element: str,
1002
+ index: Optional[int] = None,
1003
+ *,
1004
+ content: str = '30000'
1005
+ ) -> None:
1006
+ """等待元素隐藏
1007
+
1008
+ Args:
1009
+ targeting: 定位类型
1010
+ element: 定位表达式
1011
+ index: 索引
1012
+ content: 超时时间 (毫秒)
1013
+ """
1014
+ timeout = int(content)
1015
+ INFO(f"等待元素隐藏: {element}")
1016
+ loc = self.locator(targeting, element, index)
1017
+ loc.wait_for(state='hidden', timeout=timeout)
1018
+
1019
+ def wait_page_load(self, *, content: str = '30000') -> None:
1020
+ """等待页面加载完成
1021
+
1022
+ Args:
1023
+ content: 超时时间 (毫秒)
1024
+ """
1025
+ timeout = int(content)
1026
+ INFO("等待页面加载")
1027
+ self.page.wait_for_load_state('networkidle', timeout=timeout)
1028
+
1029
+ # ==================== 截图 ====================
1030
+
1031
+ def screenshot(self, *, content: str, full_page: bool = False) -> None:
1032
+ """截图
1033
+
1034
+ Args:
1035
+ content: 保存路径
1036
+ full_page: 是否全页截图
1037
+ """
1038
+ INFO(f"截图: {content}")
1039
+ self.page.screenshot(path=content, full_page=full_page)
1040
+
1041
+ def screenshot_full(self, *, content: str) -> None:
1042
+ """全页截图
1043
+
1044
+ Args:
1045
+ content: 保存路径
1046
+ """
1047
+ INFO(f"全页截图: {content}")
1048
+ self.page.screenshot(path=content, full_page=True)
1049
+
1050
+ def element_screenshot(
1051
+ self,
1052
+ targeting: str,
1053
+ element: str,
1054
+ index: Optional[int] = None,
1055
+ *,
1056
+ content: str
1057
+ ) -> None:
1058
+ """元素截图
1059
+
1060
+ Args:
1061
+ targeting: 定位类型
1062
+ element: 定位表达式
1063
+ index: 索引
1064
+ content: 保存路径
1065
+ """
1066
+ INFO(f"元素截图: {content}")
1067
+ loc = self.locator(targeting, element, index)
1068
+ loc.screenshot(path=content)
1069
+
1070
+ # ==================== 其他操作 ====================
1071
+
1072
+ def execute_js(self, *, content: str) -> any:
1073
+ """执行 JavaScript
1074
+
1075
+ Args:
1076
+ content: JavaScript 代码
1077
+
1078
+ Returns:
1079
+ 执行结果
1080
+ """
1081
+ INFO(f"执行 JS: {content[:50]}...")
1082
+ return self.page.evaluate(content)
1083
+
1084
+ def execute_js_on_element(
1085
+ self,
1086
+ targeting: str,
1087
+ element: str,
1088
+ index: Optional[int] = None,
1089
+ *,
1090
+ content: str
1091
+ ) -> any:
1092
+ """在元素上执行 JavaScript
1093
+
1094
+ Args:
1095
+ targeting: 定位类型
1096
+ element: 定位表达式
1097
+ index: 索引
1098
+ content: JavaScript 代码
1099
+
1100
+ Returns:
1101
+ 执行结果
1102
+ """
1103
+ INFO(f"元素 JS: {content[:50]}...")
1104
+ loc = self.locator(targeting, element, index)
1105
+ return loc.evaluate(content)
1106
+
1107
+ def highlight_element(
1108
+ self,
1109
+ targeting: str,
1110
+ element: str,
1111
+ index: Optional[int] = None,
1112
+ *,
1113
+ content: str = '2'
1114
+ ) -> None:
1115
+ """高亮元素
1116
+
1117
+ Args:
1118
+ targeting: 定位类型
1119
+ element: 定位表达式
1120
+ index: 索引
1121
+ content: 高亮秒数
1122
+ """
1123
+ loc = self.locator(targeting, element, index)
1124
+ loc.evaluate('''el => {
1125
+ const original = el.style.outline;
1126
+ el.style.outline = '3px solid red';
1127
+ setTimeout(() => el.style.outline = original, arguments[0] * 1000);
1128
+ }''', float(content))
1129
+ INFO(f"高亮元素: {element}")