drissionpage-cli 0.1.3__tar.gz → 0.1.4__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.
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/PKG-INFO +1 -1
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/__init__.py +34 -15
- drissionpage_cli-0.1.4/drissionpage_cli/__main__.py +3 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/PKG-INFO +1 -1
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/SOURCES.txt +1 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/pyproject.toml +1 -1
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/tests/test_integration.py +31 -17
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/tests/test_unit.py +49 -3
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/LICENSE +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/README.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/SKILL.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/dual-mode.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/element-locators.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/network-listening.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/running-code.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/screenshots-pdf.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/session-management.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/storage-state.md +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/dependency_links.txt +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/entry_points.txt +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/requires.txt +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/top_level.txt +0 -0
- {drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ import time
|
|
|
15
15
|
import traceback
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
-
__version__ = "0.1.
|
|
18
|
+
__version__ = "0.1.4"
|
|
19
19
|
|
|
20
20
|
# Session storage directory
|
|
21
21
|
CLI_DIR = Path(os.environ.get("DRISSIONPAGE_CLI_DIR", ".drissionpage-cli"))
|
|
@@ -72,27 +72,45 @@ def _get_page(session_name, create=False, options=None):
|
|
|
72
72
|
so that multiple named sessions can coexist.
|
|
73
73
|
"""
|
|
74
74
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
75
|
+
from DrissionPage._functions.tools import port_is_using
|
|
75
76
|
|
|
76
77
|
sessions = _load_sessions()
|
|
77
78
|
info = sessions.get(session_name)
|
|
78
79
|
|
|
79
80
|
if info:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
#
|
|
81
|
+
address = info["address"]
|
|
82
|
+
ip, port_str = address.split(":")
|
|
83
|
+
if port_is_using(ip, port_str):
|
|
84
|
+
# Browser is alive — connect to it.
|
|
85
|
+
# We must restore the headless state that was used when the session was
|
|
86
|
+
# created. If we don't, DrissionPage detects a headless/headed mismatch
|
|
87
|
+
# and calls Browser.close() to kill Chrome before relaunching it —
|
|
88
|
+
# which then fails for a reconnect-only scenario.
|
|
89
|
+
co = ChromiumOptions()
|
|
90
|
+
co.set_address(address)
|
|
91
|
+
co.headless(info.get("headless", True))
|
|
88
92
|
try:
|
|
89
|
-
page
|
|
93
|
+
page = ChromiumPage(addr_or_opts=co)
|
|
94
|
+
if not create:
|
|
95
|
+
return page
|
|
96
|
+
# create=True but session is alive — close old one first
|
|
97
|
+
try:
|
|
98
|
+
page.quit()
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
_kill_session(sessions, session_name)
|
|
102
|
+
sessions = _load_sessions()
|
|
90
103
|
except Exception:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
_kill_session(sessions, session_name)
|
|
105
|
+
sessions = _load_sessions()
|
|
106
|
+
if not create:
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
f"Session '{session_name}' is no longer running. "
|
|
109
|
+
f"Use 'open' to start a new one."
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
# Port not in use — browser is dead, clean up without trying to connect
|
|
113
|
+
# (avoids DrissionPage launching a phantom Chrome on the stale port).
|
|
96
114
|
_kill_session(sessions, session_name)
|
|
97
115
|
sessions = _load_sessions()
|
|
98
116
|
if not create:
|
|
@@ -142,6 +160,7 @@ def _get_page(session_name, create=False, options=None):
|
|
|
142
160
|
"address": page.address,
|
|
143
161
|
"pid": page.process_id,
|
|
144
162
|
"started": time.time(),
|
|
163
|
+
"headless": options.get("headless", True) if options else True,
|
|
145
164
|
}
|
|
146
165
|
_save_sessions(sessions)
|
|
147
166
|
return page
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "drissionpage-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "DrissionPage CLI - Token-efficient browser automation for coding agents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
|
|
22
22
|
import pytest
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
CLI_ROOT = Path(__file__).resolve().parent.parent
|
|
25
25
|
|
|
26
26
|
# Skip all integration tests if no browser or explicitly skipped
|
|
27
27
|
SKIP_INTEGRATION = os.environ.get("SKIP_INTEGRATION", "0") == "1"
|
|
@@ -33,10 +33,11 @@ pytestmark = pytest.mark.skipif(
|
|
|
33
33
|
def run_cli(*args, timeout=60, cwd=None, env_extra=None):
|
|
34
34
|
"""Run drissionpage-cli and return (returncode, stdout, stderr)."""
|
|
35
35
|
env = os.environ.copy()
|
|
36
|
+
env["PYTHONPATH"] = str(CLI_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
|
|
36
37
|
if env_extra:
|
|
37
38
|
env.update(env_extra)
|
|
38
39
|
|
|
39
|
-
cmd = [sys.executable,
|
|
40
|
+
cmd = [sys.executable, "-m", "drissionpage_cli"] + list(args)
|
|
40
41
|
proc = subprocess.run(
|
|
41
42
|
cmd, capture_output=True, text=True, timeout=timeout, env=env, cwd=cwd
|
|
42
43
|
)
|
|
@@ -53,6 +54,15 @@ def isolated_env(tmp_path):
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def storage_url(tmp_path):
|
|
59
|
+
"""A file:// URL that supports localStorage and sessionStorage.
|
|
60
|
+
data: URLs are null-origin in Chrome and block storage access."""
|
|
61
|
+
html_file = tmp_path / "storage_test.html"
|
|
62
|
+
html_file.write_text("<p>Storage Test</p>")
|
|
63
|
+
return html_file.as_uri()
|
|
64
|
+
|
|
65
|
+
|
|
56
66
|
@pytest.fixture(autouse=True)
|
|
57
67
|
def cleanup_after_test(isolated_env):
|
|
58
68
|
"""Ensure browser is closed after each test."""
|
|
@@ -73,7 +83,7 @@ class TestIntegrationBasic:
|
|
|
73
83
|
def test_version(self):
|
|
74
84
|
rc, stdout, stderr = run_cli("--version")
|
|
75
85
|
assert rc == 0
|
|
76
|
-
assert "0.1.
|
|
86
|
+
assert "0.1.4" in stdout
|
|
77
87
|
|
|
78
88
|
def test_help(self):
|
|
79
89
|
rc, stdout, stderr = run_cli("--help")
|
|
@@ -215,10 +225,10 @@ class TestIntegrationBrowser:
|
|
|
215
225
|
rc, stdout, stderr = run_cli("reload", env_extra=isolated_env)
|
|
216
226
|
assert rc == 0
|
|
217
227
|
|
|
218
|
-
def test_localstorage_operations(self, isolated_env):
|
|
228
|
+
def test_localstorage_operations(self, isolated_env, storage_url):
|
|
219
229
|
"""Test localStorage set, get, list, delete, clear."""
|
|
220
|
-
|
|
221
|
-
run_cli("open",
|
|
230
|
+
# data: URLs are null-origin and block localStorage; use file:// instead.
|
|
231
|
+
run_cli("open", storage_url, env_extra=isolated_env)
|
|
222
232
|
|
|
223
233
|
# Set
|
|
224
234
|
rc, stdout, _ = run_cli(
|
|
@@ -250,10 +260,10 @@ class TestIntegrationBrowser:
|
|
|
250
260
|
rc, stdout, _ = run_cli("localstorage-clear", env_extra=isolated_env)
|
|
251
261
|
assert rc == 0
|
|
252
262
|
|
|
253
|
-
def test_sessionstorage_operations(self, isolated_env):
|
|
263
|
+
def test_sessionstorage_operations(self, isolated_env, storage_url):
|
|
254
264
|
"""Test sessionStorage set, get, clear."""
|
|
255
|
-
|
|
256
|
-
run_cli("open",
|
|
265
|
+
# data: URLs are null-origin and block sessionStorage; use file:// instead.
|
|
266
|
+
run_cli("open", storage_url, env_extra=isolated_env)
|
|
257
267
|
|
|
258
268
|
rc, stdout, _ = run_cli(
|
|
259
269
|
"sessionstorage-set", "step", "5", env_extra=isolated_env
|
|
@@ -271,10 +281,10 @@ class TestIntegrationBrowser:
|
|
|
271
281
|
)
|
|
272
282
|
assert rc == 0
|
|
273
283
|
|
|
274
|
-
def test_state_save_and_load(self, isolated_env, tmp_path):
|
|
284
|
+
def test_state_save_and_load(self, isolated_env, storage_url, tmp_path):
|
|
275
285
|
"""Save and load browser state."""
|
|
276
|
-
|
|
277
|
-
run_cli("open",
|
|
286
|
+
# data: URLs are null-origin and block localStorage; use file:// instead.
|
|
287
|
+
run_cli("open", storage_url, env_extra=isolated_env)
|
|
278
288
|
|
|
279
289
|
# Set some storage
|
|
280
290
|
run_cli("localstorage-set", "saved_key", "saved_val", env_extra=isolated_env)
|
|
@@ -371,14 +381,18 @@ class TestIntegrationBrowser:
|
|
|
371
381
|
assert "resized" in stdout.lower()
|
|
372
382
|
|
|
373
383
|
def test_dialog_handling(self, isolated_env):
|
|
374
|
-
"""Test dialog accept/dismiss."""
|
|
384
|
+
"""Test dialog accept/dismiss in a single subprocess to avoid losing dialog state."""
|
|
375
385
|
html = "data:text/html,<button id='a' onclick='alert(\"hi\")'>Alert</button>"
|
|
376
386
|
run_cli("open", html, env_extra=isolated_env)
|
|
377
387
|
|
|
378
|
-
# Click
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
388
|
+
# Click and dismiss must happen in the same subprocess — the alert dialog is
|
|
389
|
+
# lost between separate CLI invocations because headless Chrome doesn't persist
|
|
390
|
+
# pending dialog state across CDP reconnections.
|
|
391
|
+
rc, stdout, _ = run_cli(
|
|
392
|
+
"run-code",
|
|
393
|
+
"import time; page.ele('@id=a').click(); time.sleep(0.3); page.handle_alert(accept=False)",
|
|
394
|
+
env_extra=isolated_env,
|
|
395
|
+
)
|
|
382
396
|
assert rc == 0
|
|
383
397
|
|
|
384
398
|
def test_hover(self, isolated_env):
|
|
@@ -26,7 +26,7 @@ import drissionpage_cli as cli
|
|
|
26
26
|
|
|
27
27
|
class TestVersionAndHelp:
|
|
28
28
|
def test_version_string(self):
|
|
29
|
-
assert cli.__version__ == "0.1.
|
|
29
|
+
assert cli.__version__ == "0.1.4"
|
|
30
30
|
|
|
31
31
|
def test_build_parser(self):
|
|
32
32
|
parser = cli.build_parser()
|
|
@@ -473,7 +473,7 @@ class TestCliInvocation:
|
|
|
473
473
|
|
|
474
474
|
result = run_cli("--version")
|
|
475
475
|
assert result.exit_code == 0
|
|
476
|
-
assert "0.1.
|
|
476
|
+
assert "0.1.4" in result.output
|
|
477
477
|
|
|
478
478
|
def test_no_command_shows_help(self):
|
|
479
479
|
"""No command shows help text."""
|
|
@@ -639,6 +639,52 @@ class TestGetPageOptions:
|
|
|
639
639
|
mock_co.use_system_user_path.assert_called_once_with(True)
|
|
640
640
|
mock_co.headless.assert_called_once_with(False)
|
|
641
641
|
|
|
642
|
+
def test_stale_session_dead_port_cleaned_without_connect(self, tmp_path):
|
|
643
|
+
"""When session port is not in use, we must clean up WITHOUT calling
|
|
644
|
+
ChromiumPage (which would launch a phantom Chrome on the stale port)."""
|
|
645
|
+
sessions_file = tmp_path / "sessions.json"
|
|
646
|
+
cli_dir_path = tmp_path / ".drissionpage-cli"
|
|
647
|
+
cli_dir_path.mkdir()
|
|
648
|
+
sessions_file.write_text(
|
|
649
|
+
'{"default": {"address": "127.0.0.1:9222", "pid": 99999, "started": 0}}'
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
mock_page = MagicMock()
|
|
653
|
+
with patch.object(cli, "SESSIONS_FILE", sessions_file), \
|
|
654
|
+
patch.object(cli, "CLI_DIR", cli_dir_path), \
|
|
655
|
+
patch("DrissionPage._functions.tools.port_is_using", return_value=False), \
|
|
656
|
+
patch("DrissionPage.ChromiumPage") as mock_chromium_page:
|
|
657
|
+
try:
|
|
658
|
+
cli._get_page("default", create=False, options=None)
|
|
659
|
+
except RuntimeError:
|
|
660
|
+
pass
|
|
661
|
+
# ChromiumPage must NOT be called — no phantom Chrome launch
|
|
662
|
+
mock_chromium_page.assert_not_called()
|
|
663
|
+
|
|
664
|
+
def test_reconnect_restores_headless_state(self, tmp_path):
|
|
665
|
+
"""When reconnecting to a live session, headless state from session info
|
|
666
|
+
must be passed to ChromiumOptions to prevent DrissionPage restarting Chrome."""
|
|
667
|
+
sessions_file = tmp_path / "sessions.json"
|
|
668
|
+
cli_dir_path = tmp_path / ".drissionpage-cli"
|
|
669
|
+
cli_dir_path.mkdir()
|
|
670
|
+
sessions_file.write_text(
|
|
671
|
+
'{"default": {"address": "127.0.0.1:9333", "pid": 111, "started": 0, "headless": true}}'
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
mock_co = MagicMock()
|
|
675
|
+
mock_page = MagicMock()
|
|
676
|
+
mock_page.address = "127.0.0.1:9333"
|
|
677
|
+
mock_page.process_id = 111
|
|
678
|
+
|
|
679
|
+
with patch.object(cli, "SESSIONS_FILE", sessions_file), \
|
|
680
|
+
patch.object(cli, "CLI_DIR", cli_dir_path), \
|
|
681
|
+
patch("DrissionPage._functions.tools.port_is_using", return_value=True), \
|
|
682
|
+
patch("DrissionPage.ChromiumOptions", return_value=mock_co), \
|
|
683
|
+
patch("DrissionPage.ChromiumPage", return_value=mock_page):
|
|
684
|
+
cli._get_page("default", create=False, options=None)
|
|
685
|
+
|
|
686
|
+
mock_co.headless.assert_called_once_with(True)
|
|
687
|
+
|
|
642
688
|
|
|
643
689
|
# ---------------------------------------------------------------------------
|
|
644
690
|
# Command handlers (mocked browser)
|
|
@@ -942,7 +988,7 @@ class TestCommandHandlersMocked:
|
|
|
942
988
|
args.session = None
|
|
943
989
|
args.domain = None
|
|
944
990
|
cli.cmd_cookie_list(args)
|
|
945
|
-
mock_page.cookies.assert_called_once_with(
|
|
991
|
+
mock_page.cookies.assert_called_once_with(all_info=True)
|
|
946
992
|
|
|
947
993
|
def test_cmd_cookie_list_filtered(self, tmp_path):
|
|
948
994
|
mock_page = self._make_mock_page()
|
|
File without changes
|
|
File without changes
|
{drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{drissionpage_cli-0.1.3 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|