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.
- dp_cli-0.7.0/LICENSE +29 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/PKG-INFO +6 -3
- {dp_cli-0.6.3 → dp_cli-0.7.0}/README.md +3 -2
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/bridge.py +16 -16
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/bridge_manager.py +18 -16
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/__init__.py +3 -1
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/_utils.py +45 -5
- dp_cli-0.7.0/dp_cli/commands/assert_cmd.py +172 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/element.py +43 -22
- dp_cli-0.7.0/dp_cli/commands/emulate.py +372 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/keyboard.py +3 -2
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/misc.py +5 -5
- dp_cli-0.7.0/dp_cli/commands/network.py +371 -0
- dp_cli-0.7.0/dp_cli/commands/observe.py +167 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/page.py +145 -2
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/record.py +3 -3
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/snapshot_cmd.py +9 -9
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/storage.py +34 -4
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/tab.py +4 -4
- dp_cli-0.7.0/dp_cli/logs.py +38 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/main.py +6 -1
- dp_cli-0.7.0/dp_cli/output.py +173 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/recorder.py +4 -2
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/session.py +18 -8
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/a11y.py +8 -6
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/clickable.py +4 -2
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/stealth.py +4 -2
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/PKG-INFO +6 -3
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/SOURCES.txt +7 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/pyproject.toml +1 -1
- dp_cli-0.7.0/tests/test_assert_cmd.py +314 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_bridge_integration.py +1 -0
- dp_cli-0.7.0/tests/test_browser_smoke.py +59 -0
- dp_cli-0.7.0/tests/test_commands.py +2009 -0
- dp_cli-0.6.3/dp_cli/commands/network.py +0 -175
- dp_cli-0.6.3/dp_cli/output.py +0 -64
- dp_cli-0.6.3/tests/test_commands.py +0 -889
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/__init__.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/commands/browser.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/locators/__init__.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/locators/playwright.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/locators/pw_js.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/__init__.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/clickable_js.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/extract.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/js_scripts.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli/snapshot/utils.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/dependency_links.txt +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/entry_points.txt +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/requires.txt +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/dp_cli.egg-info/top_level.txt +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/setup.cfg +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_a11y.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_bridge_manager.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_clickable.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_pw_locator.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_recorder.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_resolve_locator.py +0 -0
- {dp_cli-0.6.3 → dp_cli-0.7.0}/tests/test_session.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|