dp-cli 0.6.3__tar.gz → 0.7.0__tar.gz

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 (60) hide show
  1. dp_cli-0.7.0/LICENSE +29 -0
  2. {dp_cli-0.6.3 → dp_cli-0.7.0}/PKG-INFO +6 -3
  3. {dp_cli-0.6.3 → dp_cli-0.7.0}/README.md +3 -2
  4. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/bridge.py +16 -16
  5. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/bridge_manager.py +18 -16
  6. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/__init__.py +3 -1
  7. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/_utils.py +45 -5
  8. dp_cli-0.7.0/dp_cli/commands/assert_cmd.py +172 -0
  9. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/element.py +43 -22
  10. dp_cli-0.7.0/dp_cli/commands/emulate.py +372 -0
  11. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/keyboard.py +3 -2
  12. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/misc.py +5 -5
  13. dp_cli-0.7.0/dp_cli/commands/network.py +371 -0
  14. dp_cli-0.7.0/dp_cli/commands/observe.py +167 -0
  15. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/page.py +145 -2
  16. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/record.py +3 -3
  17. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/snapshot_cmd.py +9 -9
  18. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/storage.py +34 -4
  19. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/tab.py +4 -4
  20. dp_cli-0.7.0/dp_cli/logs.py +38 -0
  21. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/main.py +6 -1
  22. dp_cli-0.7.0/dp_cli/output.py +173 -0
  23. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/recorder.py +4 -2
  24. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/session.py +18 -8
  25. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/a11y.py +8 -6
  26. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/clickable.py +4 -2
  27. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/stealth.py +4 -2
  28. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/PKG-INFO +6 -3
  29. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/SOURCES.txt +7 -0
  30. {dp_cli-0.6.3 → dp_cli-0.7.0}/pyproject.toml +1 -1
  31. dp_cli-0.7.0/tests/test_assert_cmd.py +314 -0
  32. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_bridge_integration.py +1 -0
  33. dp_cli-0.7.0/tests/test_browser_smoke.py +59 -0
  34. dp_cli-0.7.0/tests/test_commands.py +2009 -0
  35. dp_cli-0.6.3/dp_cli/commands/network.py +0 -175
  36. dp_cli-0.6.3/dp_cli/output.py +0 -64
  37. dp_cli-0.6.3/tests/test_commands.py +0 -889
  38. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/__init__.py +0 -0
  39. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/browser.py +0 -0
  40. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/locators/__init__.py +0 -0
  41. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/locators/playwright.py +0 -0
  42. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/locators/pw_js.py +0 -0
  43. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/__init__.py +0 -0
  44. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/clickable_js.py +0 -0
  45. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/extract.py +0 -0
  46. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/js_scripts.py +0 -0
  47. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/utils.py +0 -0
  48. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/dependency_links.txt +0 -0
  49. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/entry_points.txt +0 -0
  50. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/requires.txt +0 -0
  51. {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/top_level.txt +0 -0
  52. {dp_cli-0.6.3 → dp_cli-0.7.0}/setup.cfg +0 -0
  53. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_a11y.py +0 -0
  54. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_bridge_manager.py +0 -0
  55. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_clickable.py +0 -0
  56. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_pw_locator.py +0 -0
  57. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_recorder.py +0 -0
  58. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_resolve_locator.py +0 -0
  59. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_session.py +0 -0
  60. {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_snapshot_small.py +0 -0
dp_cli-0.7.0/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, mofanx
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice,
10
+ this list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dp-cli
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: A powerful CLI for DrissionPage — browser automation, structured data extraction, network listening and more.
5
5
  License: BSD-3-Clause
6
6
  Project-URL: Homepage, https://github.com/mofanx/dp-cli
@@ -13,6 +13,7 @@ Classifier: Topic :: Utilities
13
13
  Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
14
14
  Requires-Python: >=3.8
15
15
  Description-Content-Type: text/markdown
16
+ License-File: LICENSE
16
17
  Requires-Dist: DrissionPage>=4.0
17
18
  Requires-Dist: click>=8.0
18
19
  Requires-Dist: aiohttp>=3.9
@@ -23,6 +24,7 @@ Requires-Dist: pytest>=7.4.0; extra == "test"
23
24
  Requires-Dist: pytest-cov>=4.1.0; extra == "test"
24
25
  Requires-Dist: pytest-timeout>=2.1.0; extra == "test"
25
26
  Requires-Dist: pytest-mock>=3.11.0; extra == "test"
27
+ Dynamic: license-file
26
28
 
27
29
  # dp-cli
28
30
 
@@ -278,7 +280,8 @@ dp_cli/
278
280
  ├── snapshot_cmd.py # snapshot / extract / query / find / inspect
279
281
  ├── element.py # click / fill / select / hover / drag / check / upload / count
280
282
  ├── keyboard.py # press / type / scroll / scroll-to / autoscroll
281
- ├── page.py # screenshot / pdf / eval / wait (idle/loaded/url/title) / dialog
283
+ ├── page.py # screenshot / pdf / eval / wait / console / dialog
284
+ ├── assert_cmd.py # assert-text / assert-url / assert-visible / assert-count
282
285
  ├── tab.py # tab-list / tab-new / tab-select / tab-close
283
286
  ├── storage.py # cookie-* / localstorage-* / sessionstorage-*
284
287
  ├── network.py # listen / listen-stop / http-get / http-post
@@ -287,7 +290,7 @@ dp_cli/
287
290
 
288
291
  ## Documentation
289
292
 
290
- See [`skills/SKILL.md`](skills/SKILL.md) for full workflow guide and [`skills/references/commands.md`](skills/references/commands.md) for complete command reference.
293
+ See [`skills/dp-cli/SKILL.md`](skills/dp-cli/SKILL.md) for full workflow guide and [`skills/dp-cli/references/commands.md`](skills/dp-cli/references/commands.md) for complete command reference. Exit-code contract: [`ERROR_CODES.md`](ERROR_CODES.md).
291
294
 
292
295
  ## License
293
296
 
@@ -252,7 +252,8 @@ dp_cli/
252
252
  ├── snapshot_cmd.py # snapshot / extract / query / find / inspect
253
253
  ├── element.py # click / fill / select / hover / drag / check / upload / count
254
254
  ├── keyboard.py # press / type / scroll / scroll-to / autoscroll
255
- ├── page.py # screenshot / pdf / eval / wait (idle/loaded/url/title) / dialog
255
+ ├── page.py # screenshot / pdf / eval / wait / console / dialog
256
+ ├── assert_cmd.py # assert-text / assert-url / assert-visible / assert-count
256
257
  ├── tab.py # tab-list / tab-new / tab-select / tab-close
257
258
  ├── storage.py # cookie-* / localstorage-* / sessionstorage-*
258
259
  ├── network.py # listen / listen-stop / http-get / http-post
@@ -261,7 +262,7 @@ dp_cli/
261
262
 
262
263
  ## Documentation
263
264
 
264
- See [`skills/SKILL.md`](skills/SKILL.md) for full workflow guide and [`skills/references/commands.md`](skills/references/commands.md) for complete command reference.
265
+ See [`skills/dp-cli/SKILL.md`](skills/dp-cli/SKILL.md) for full workflow guide and [`skills/dp-cli/references/commands.md`](skills/dp-cli/references/commands.md) for complete command reference. Exit-code contract: [`ERROR_CODES.md`](ERROR_CODES.md).
265
266
 
266
267
  ## License
267
268
 
@@ -97,14 +97,14 @@ class CDPBridge:
97
97
  for ws in list(self._browser_clients):
98
98
  try:
99
99
  await ws.close(code=1011, message=b'upstream closed')
100
- except Exception:
101
- pass
100
+ except Exception as e:
101
+ log.debug('close downstream ws failed: %s', e)
102
102
  self._browser_clients.clear()
103
103
  for sid, ws in list(self._session_to_client.items()):
104
104
  try:
105
105
  await ws.close(code=1011, message=b'upstream closed')
106
- except Exception:
107
- pass
106
+ except Exception as e:
107
+ log.debug('close session ws failed: %s', e)
108
108
  self._session_to_client.clear()
109
109
  self.ws = None
110
110
  self._connected_evt.clear()
@@ -144,8 +144,8 @@ class CDPBridge:
144
144
  try:
145
145
  if not client_ws.closed:
146
146
  await client_ws.send_str(json.dumps(ev))
147
- except Exception:
148
- pass
147
+ except Exception as e:
148
+ log.debug('forward event to client failed: %s', e)
149
149
  return
150
150
 
151
151
  if not sess_id:
@@ -154,8 +154,8 @@ class CDPBridge:
154
154
  try:
155
155
  if not c.closed:
156
156
  await c.send_str(ev_str)
157
- except Exception:
158
- pass
157
+ except Exception as e:
158
+ log.debug('broadcast event failed: %s', e)
159
159
 
160
160
  async def call(self, method: str, params: dict | None = None,
161
161
  session_id: str | None = None) -> dict:
@@ -200,8 +200,8 @@ class CDPBridge:
200
200
  err = {'id': client_id, 'error': {'code': -32000, 'message': str(e)}}
201
201
  try:
202
202
  await client_ws.send_str(json.dumps(err))
203
- except Exception:
204
- pass
203
+ except Exception as e2:
204
+ log.debug('send error reply failed: %s', e2)
205
205
 
206
206
  async def attach_page_client(self, target_id: str,
207
207
  client_ws: web.WebSocketResponse) -> str | None:
@@ -386,8 +386,8 @@ async def handle_ws(request: web.Request) -> web.StreamResponse:
386
386
  try:
387
387
  payload = json.loads(msg.data)
388
388
  await bridge.forward_from_client(payload, client_ws, session_id)
389
- except Exception:
390
- pass
389
+ except Exception as e:
390
+ log.debug('forward client message failed: %s', e)
391
391
  elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED,
392
392
  aiohttp.WSMsgType.ERROR):
393
393
  break
@@ -479,14 +479,14 @@ async def main_async(user_data_dir: Path, host: str, port: int) -> None:
479
479
  continue
480
480
  try:
481
481
  signal.signal(sig, _win_signal_handler)
482
- except (ValueError, OSError):
483
- pass
482
+ except (ValueError, OSError) as e:
483
+ log.debug('register signal handler failed: %s', e)
484
484
  else:
485
485
  for sig in (signal.SIGINT, signal.SIGTERM):
486
486
  try:
487
487
  loop.add_signal_handler(sig, stop_evt.set)
488
- except NotImplementedError:
489
- pass
488
+ except NotImplementedError as e:
489
+ log.debug('add_signal_handler unsupported: %s', e)
490
490
  try:
491
491
  await stop_evt.wait()
492
492
  finally:
@@ -24,6 +24,8 @@ import sys
24
24
  import time
25
25
  from pathlib import Path
26
26
 
27
+ from dp_cli.logs import debug
28
+
27
29
  IS_WINDOWS = sys.platform == 'win32'
28
30
 
29
31
 
@@ -74,8 +76,8 @@ def _win_terminate(pid: int) -> None:
74
76
  check=False,
75
77
  timeout=5,
76
78
  )
77
- except Exception:
78
- pass
79
+ except Exception as e:
80
+ debug(f'taskkill pid={pid} 失败', e)
79
81
 
80
82
 
81
83
  _READY_RE = re.compile(r'^BRIDGE_READY host=(?P<host>\S+) port=(?P<port>\d+)\s*$')
@@ -113,8 +115,8 @@ def detect_inspect_mode(port: int, timeout: float = 2.0) -> bool:
113
115
  # 经典模式 /json/version 返回 dict,含 Browser 字段
114
116
  if isinstance(data, dict) and 'Browser' in data:
115
117
  return False
116
- except Exception:
117
- pass
118
+ except Exception as e:
119
+ debug(f'/json/version 响应解析失败,按 inspect 模式处理: port={port}', e)
118
120
  return True
119
121
 
120
122
 
@@ -166,8 +168,8 @@ def start_bridge(user_data_dir: str | os.PathLike,
166
168
  for line in proc.stderr:
167
169
  with stderr_lock:
168
170
  stderr_buf.append(line)
169
- except Exception:
170
- pass
171
+ except Exception as e:
172
+ debug('bridge stderr 读取线程结束异常', e)
171
173
 
172
174
  stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
173
175
  stderr_thread.start()
@@ -207,8 +209,8 @@ def start_bridge(user_data_dir: str | os.PathLike,
207
209
  except Exception:
208
210
  try:
209
211
  proc.terminate()
210
- except Exception:
211
- pass
212
+ except Exception as e:
213
+ debug('bridge 启动失败后 terminate 失败', e)
212
214
  raise
213
215
 
214
216
  # ready 后让 bridge 的 stdout 继续被 drain,避免管道阻塞。
@@ -217,8 +219,8 @@ def start_bridge(user_data_dir: str | os.PathLike,
217
219
  try:
218
220
  for _ in stream:
219
221
  pass
220
- except Exception:
221
- pass
222
+ except Exception as e:
223
+ debug('stdout drain 线程结束异常', e)
222
224
 
223
225
  if proc.stdout:
224
226
  threading.Thread(target=_drain, args=(proc.stdout,), daemon=True).start()
@@ -256,8 +258,8 @@ def stop_bridge(pid: int, timeout: float = 2.0) -> bool:
256
258
  # 1) 优雅: 给整个进程组发 CTRL_BREAK_EVENT
257
259
  try:
258
260
  os.kill(pid, signal.CTRL_BREAK_EVENT)
259
- except Exception:
260
- pass
261
+ except Exception as e:
262
+ debug(f'CTRL_BREAK_EVENT pid={pid} 失败', e)
261
263
 
262
264
  deadline = time.monotonic() + timeout
263
265
  while time.monotonic() < deadline:
@@ -276,8 +278,8 @@ def stop_bridge(pid: int, timeout: float = 2.0) -> bool:
276
278
  except (ProcessLookupError, PermissionError, OSError):
277
279
  try:
278
280
  os.kill(pid, signal.SIGTERM)
279
- except Exception:
280
- pass
281
+ except Exception as e:
282
+ debug(f'SIGTERM pid={pid} 失败', e)
281
283
 
282
284
  deadline = time.monotonic() + timeout
283
285
  while time.monotonic() < deadline:
@@ -291,7 +293,7 @@ def stop_bridge(pid: int, timeout: float = 2.0) -> bool:
291
293
  except Exception:
292
294
  try:
293
295
  os.kill(pid, signal.SIGKILL)
294
- except Exception:
295
- pass
296
+ except Exception as e:
297
+ debug(f'SIGKILL pid={pid} 失败', e)
296
298
  time.sleep(0.1)
297
299
  return not is_bridge_alive(pid)
@@ -2,9 +2,11 @@
2
2
  from dp_cli.commands import (
3
3
  browser, snapshot_cmd, element, keyboard,
4
4
  page, tab, storage, network, record, misc,
5
+ assert_cmd, emulate, observe,
5
6
  )
6
7
 
7
- _MODULES = [browser, snapshot_cmd, element, keyboard, page, tab, storage, network, record, misc]
8
+ _MODULES = [browser, snapshot_cmd, element, keyboard, page, tab, storage, network, record, misc,
9
+ assert_cmd, emulate, observe]
8
10
 
9
11
 
10
12
  def register_all(cli):
@@ -8,7 +8,7 @@ from time import perf_counter, sleep as _sleep
8
8
  import click
9
9
 
10
10
  from dp_cli.session import get_browser, load_refs, load_session, save_session
11
- from dp_cli.output import error
11
+ from dp_cli.output import error, debug
12
12
 
13
13
 
14
14
  def normalize_url(url: str) -> str:
@@ -25,6 +25,30 @@ def session_option(f):
25
25
  help='会话名称,默认 default', show_default=True)(f)
26
26
 
27
27
 
28
+ def frame_option(f):
29
+ return click.option('--frame', default=None,
30
+ help='限定在 iframe 内查找元素(值为 iframe 的定位器,'
31
+ '支持 css:/xpath:/pw:/ref:),如 --frame "css:iframe"')(f)
32
+
33
+
34
+ def frame_ctx(page, frame_loc: str = None, session: str = 'default'):
35
+ """frame_loc 为 None 时返回 page 本身,否则返回该 iframe 的 frame 上下文。
36
+
37
+ 返回对象(ChromiumFrame)同样提供 ele()/eles(),可直接替换 page 使用。
38
+ 找不到 iframe 时 error(ELEMENT_NOT_FOUND) 退出。
39
+ """
40
+ if not frame_loc:
41
+ return page
42
+ loc = resolve_locator(frame_loc, session, page)
43
+ fr = page.get_frame(loc, timeout=3)
44
+ # 按类名判定:真实 NoneElement.__getattr__ 对任意属性抛
45
+ # ElementNotFoundError(非 AttributeError),getattr 默认值挡不住
46
+ if fr is None or fr.__class__.__name__ == 'NoneElement':
47
+ error(f'iframe 未找到: {frame_loc}', code='ELEMENT_NOT_FOUND')
48
+ return page
49
+ return fr
50
+
51
+
28
52
  def _get_page(session: str, raw: bool = False, inject_recording: bool = True):
29
53
  """获取页面对象,失败则 error 退出。
30
54
 
@@ -72,15 +96,31 @@ def _get_page(session: str, raw: bool = False, inject_recording: bool = True):
72
96
  langs=stealth_cfg.get('langs'),
73
97
  webgl_vendor=stealth_cfg.get('webgl_vendor'),
74
98
  webgl_renderer=stealth_cfg.get('webgl_renderer'))
75
- except Exception:
76
- pass # 不能让 stealth 失败阻塞常规命令
99
+ except Exception as e:
100
+ # 不能让 stealth 失败阻塞常规命令
101
+ debug('stealth 重注入失败', e)
77
102
 
78
103
  if inject_recording and sess.get('recording'):
79
104
  try:
80
105
  from dp_cli.recorder import inject_recorder
81
106
  inject_recorder(target)
82
- except Exception:
83
- pass
107
+ except Exception as e:
108
+ debug('录制器重注入失败', e)
109
+
110
+ # 会话级 CDP override 重放(emulate/intercept 每条命令重新应用)
111
+ from dp_cli.commands.emulate import _apply_emulate, _apply_intercept
112
+ emulate_cfg = sess.get('emulate')
113
+ if emulate_cfg:
114
+ try:
115
+ _apply_emulate(target, emulate_cfg)
116
+ except Exception as e:
117
+ debug('emulate 重放失败', e)
118
+ intercept_cfg = sess.get('intercept')
119
+ if intercept_cfg:
120
+ try:
121
+ _apply_intercept(target, intercept_cfg)
122
+ except Exception as e:
123
+ debug('intercept 重放失败', e)
84
124
 
85
125
  return target
86
126
 
@@ -0,0 +1,172 @@
1
+ # -*- coding:utf-8 -*-
2
+ """断言命令: assert-text / assert-url / assert-visible / assert-count
3
+
4
+ 面向脚本与 agent 自检闭环:断言失败输出 JSON error 并以非零码退出,
5
+ 可直接用于 `&&` 串联或 CI 检查。
6
+ """
7
+ import click
8
+
9
+ from dp_cli.output import ok, error
10
+ from dp_cli.commands._utils import (session_option, frame_option,
11
+ _get_page, resolve_locator, frame_ctx)
12
+
13
+
14
+ def _is_missing(ele) -> bool:
15
+ return not ele or ele.__class__.__name__ == 'NoneElement'
16
+
17
+
18
+ def register(cli):
19
+
20
+ @cli.command('assert-text')
21
+ @click.argument('text')
22
+ @session_option
23
+ @click.option('--locator', default=None, help='限定断言范围到指定元素')
24
+ @click.option('--exact', is_flag=True, help='精确匹配(默认包含匹配;建议配合 --locator)')
25
+ @frame_option
26
+ @click.option('--timeout', default=5, type=float, help='元素等待超时秒数', show_default=True)
27
+ def assert_text(text, session, locator, exact, frame, timeout):
28
+ """断言页面或指定元素包含文本。
29
+
30
+ \b
31
+ 示例:
32
+ dp assert-text "登录成功"
33
+ dp assert-text "订单已提交" --locator "#msg"
34
+ dp assert-text "Hello" --locator "#title" --exact
35
+ """
36
+ page = _get_page(session)
37
+ ctx = frame_ctx(page, frame, session)
38
+ try:
39
+ if locator:
40
+ real_loc = resolve_locator(locator, session)
41
+ ele = ctx.ele(real_loc, timeout=timeout)
42
+ if _is_missing(ele):
43
+ error(f'断言失败:元素不存在 {real_loc}', code='ASSERT_FAILED')
44
+ return
45
+ haystack = (ele.raw_text or '').strip()
46
+ scope = f'元素 {real_loc}'
47
+ else:
48
+ haystack = page.html or ''
49
+ scope = '页面'
50
+ matched = haystack == text if exact else text in haystack
51
+ if matched:
52
+ ok({'text': text, 'scope': scope, 'exact': exact}, msg='断言通过')
53
+ else:
54
+ error(f'断言失败:{scope}不包含文本 "{text}"', code='ASSERT_FAILED',
55
+ detail=(haystack[:200] if haystack else '(空)'))
56
+ except Exception as e:
57
+ error('断言执行失败', code='ASSERT_FAILED', detail=str(e))
58
+
59
+ @cli.command('assert-url')
60
+ @click.argument('text')
61
+ @session_option
62
+ @click.option('--exact', is_flag=True, help='精确匹配(默认包含匹配)')
63
+ @click.option('--title', 'check_title', is_flag=True, help='断言标题而非 URL')
64
+ def assert_url(text, session, exact, check_title):
65
+ """断言当前 URL(或 --title 断言标题)包含文本。
66
+
67
+ \b
68
+ 示例:
69
+ dp assert-url "dashboard"
70
+ dp assert-url "https://example.com/home" --exact
71
+ dp assert-url "搜索结果" --title
72
+ """
73
+ page = _get_page(session)
74
+ try:
75
+ field = 'title' if check_title else 'url'
76
+ actual = page.title if check_title else page.url
77
+ actual = actual or ''
78
+ matched = actual == text if exact else text in actual
79
+ if matched:
80
+ ok({'field': field, 'actual': actual, 'exact': exact}, msg='断言通过')
81
+ else:
82
+ error(f'断言失败:{field} 不包含 "{text}"', code='ASSERT_FAILED',
83
+ detail=f'实际值: {actual[:200]}')
84
+ except Exception as e:
85
+ error('断言执行失败', code='ASSERT_FAILED', detail=str(e))
86
+
87
+ @cli.command('assert-visible')
88
+ @click.argument('locator')
89
+ @session_option
90
+ @click.option('--hidden', 'expect_hidden', is_flag=True,
91
+ help='断言元素不可见或不存在')
92
+ @frame_option
93
+ @click.option('--timeout', default=5, type=float, help='元素等待超时秒数', show_default=True)
94
+ def assert_visible(locator, session, expect_hidden, frame, timeout):
95
+ """断言元素可见(--hidden 断言不可见/不存在)。
96
+
97
+ \b
98
+ 示例:
99
+ dp assert-visible "#result"
100
+ dp assert-visible "ref:5"
101
+ dp assert-visible ".loading" --hidden # 等待加载指示器消失后断言
102
+ """
103
+ page = _get_page(session)
104
+ ctx = frame_ctx(page, frame, session)
105
+ try:
106
+ real_loc = resolve_locator(locator, session)
107
+ ele = ctx.ele(real_loc, timeout=timeout)
108
+ missing = _is_missing(ele)
109
+ visible = False if missing else bool(ele.states.is_displayed)
110
+ if expect_hidden:
111
+ if visible:
112
+ error(f'断言失败:元素仍可见 {real_loc}', code='ASSERT_FAILED')
113
+ else:
114
+ ok({'locator': real_loc, 'hidden': True}, msg='断言通过:元素不可见')
115
+ elif missing:
116
+ error(f'断言失败:元素不存在 {real_loc}', code='ASSERT_FAILED')
117
+ elif not visible:
118
+ error(f'断言失败:元素存在但不可见 {real_loc}', code='ASSERT_FAILED')
119
+ else:
120
+ ok({'locator': real_loc}, msg='断言通过:元素可见')
121
+ except Exception as e:
122
+ error('断言执行失败', code='ASSERT_FAILED', detail=str(e))
123
+
124
+ @cli.command('assert-count')
125
+ @click.argument('locator')
126
+ @click.argument('expected', required=False, type=int, default=None)
127
+ @session_option
128
+ @click.option('--min', 'min_count', type=int, default=None, help='最小数量(含)')
129
+ @click.option('--max', 'max_count', type=int, default=None, help='最大数量(含)')
130
+ @frame_option
131
+ @click.option('--timeout', default=0, type=float,
132
+ help='等待元素出现的超时秒数(默认 0 即时返回)', show_default=True)
133
+ def assert_count(locator, expected, session, min_count, max_count, frame, timeout):
134
+ """断言匹配元素的数量。
135
+
136
+ \b
137
+ 示例:
138
+ dp assert-count ".item" 20 # 恰好 20 个
139
+ dp assert-count ".item" --min 1 # 至少 1 个
140
+ dp assert-count ".error" 0 # 无错误元素
141
+ dp assert-count "li" --min 3 --max 10
142
+ """
143
+ if expected is not None and (min_count is not None
144
+ or max_count is not None):
145
+ error('位置参数 expected 与 --min/--max 互斥,请二选一',
146
+ code='INVALID_ARGS')
147
+ return
148
+ if expected is None and min_count is None and max_count is None:
149
+ error('请指定期望数量或 --min/--max', code='INVALID_ARGS')
150
+ return
151
+ page = _get_page(session)
152
+ try:
153
+ ctx = frame_ctx(page, frame, session)
154
+ real_loc = resolve_locator(locator, session)
155
+ eles = ctx.eles(real_loc, timeout=timeout)
156
+ n = len(eles)
157
+ if expected is not None:
158
+ passed = n == expected
159
+ desc = f'期望 {expected} 个,实际 {n} 个'
160
+ else:
161
+ lo = min_count if min_count is not None else 0
162
+ hi = max_count if max_count is not None else n
163
+ passed = lo <= n <= hi
164
+ lo_s = str(min_count) if min_count is not None else '-∞'
165
+ hi_s = str(max_count) if max_count is not None else '+∞'
166
+ desc = f'期望 [{lo_s}, {hi_s}],实际 {n} 个'
167
+ if passed:
168
+ ok({'locator': real_loc, 'count': n}, msg='断言通过')
169
+ else:
170
+ error(f'断言失败:{desc}', code='ASSERT_FAILED')
171
+ except Exception as e:
172
+ error('断言执行失败', code='ASSERT_FAILED', detail=str(e))