drissionpage-cli 0.1.2__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.
Files changed (23) hide show
  1. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/PKG-INFO +1 -1
  2. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/__init__.py +37 -16
  3. drissionpage_cli-0.1.4/drissionpage_cli/__main__.py +3 -0
  4. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/PKG-INFO +1 -1
  5. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/SOURCES.txt +1 -0
  6. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/pyproject.toml +1 -1
  7. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/tests/test_integration.py +31 -17
  8. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/tests/test_unit.py +51 -5
  9. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/LICENSE +0 -0
  10. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/README.md +0 -0
  11. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/SKILL.md +0 -0
  12. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/dual-mode.md +0 -0
  13. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/element-locators.md +0 -0
  14. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/network-listening.md +0 -0
  15. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/running-code.md +0 -0
  16. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/screenshots-pdf.md +0 -0
  17. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/session-management.md +0 -0
  18. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli/skills/drissionpage-cli/references/storage-state.md +0 -0
  19. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/dependency_links.txt +0 -0
  20. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/entry_points.txt +0 -0
  21. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/requires.txt +0 -0
  22. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/drissionpage_cli.egg-info/top_level.txt +0 -0
  23. {drissionpage_cli-0.1.2 → drissionpage_cli-0.1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drissionpage-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: DrissionPage CLI - Token-efficient browser automation for coding agents
5
5
  Author: DrissionPage-MCP Contributors
6
6
  License: Apache-2.0
@@ -15,7 +15,7 @@ import time
15
15
  import traceback
16
16
  from pathlib import Path
17
17
 
18
- __version__ = "0.1.2"
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
- # Try to reconnect to existing session
81
- co = ChromiumOptions()
82
- co.set_address(info["address"])
83
- try:
84
- page = ChromiumPage(addr_or_opts=co)
85
- if not create:
86
- return page
87
- # create=True but session is alive — close old one first
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.quit()
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
- pass
92
- _kill_session(sessions, session_name)
93
- sessions = _load_sessions()
94
- except Exception:
95
- # Browser is dead — clean up stale entry
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:
@@ -111,7 +129,9 @@ def _get_page(session_name, create=False, options=None):
111
129
 
112
130
  use_system_user_path = options and options.get("system_user_path")
113
131
  if use_system_user_path:
114
- # use_system_user_path is incompatible with auto_port (auto_port creates a temp profile)
132
+ # auto_port picks a free port; use_system_user_path then causes DrissionPage
133
+ # to strip the --user-data-dir arg at launch, so Chrome uses the system profile.
134
+ co.auto_port()
115
135
  co.use_system_user_path(True)
116
136
  else:
117
137
  co.auto_port() # pick a free port to avoid conflicts with stale browsers
@@ -140,6 +160,7 @@ def _get_page(session_name, create=False, options=None):
140
160
  "address": page.address,
141
161
  "pid": page.process_id,
142
162
  "started": time.time(),
163
+ "headless": options.get("headless", True) if options else True,
143
164
  }
144
165
  _save_sessions(sessions)
145
166
  return page
@@ -0,0 +1,3 @@
1
+ from drissionpage_cli import main
2
+
3
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drissionpage-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: DrissionPage CLI - Token-efficient browser automation for coding agents
5
5
  Author: DrissionPage-MCP Contributors
6
6
  License: Apache-2.0
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  drissionpage_cli/__init__.py
5
+ drissionpage_cli/__main__.py
5
6
  drissionpage_cli.egg-info/PKG-INFO
6
7
  drissionpage_cli.egg-info/SOURCES.txt
7
8
  drissionpage_cli.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "drissionpage-cli"
7
- version = "0.1.2"
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
- CLI_PATH = Path(__file__).resolve().parent.parent / "drissionpage_cli.py"
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, str(CLI_PATH)] + list(args)
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.0" in stdout
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
- html = "data:text/html,<p>Storage Test</p>"
221
- run_cli("open", html, env_extra=isolated_env)
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
- html = "data:text/html,<p>Session Storage</p>"
256
- run_cli("open", html, env_extra=isolated_env)
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
- html = "data:text/html,<p>State Test</p>"
277
- run_cli("open", html, env_extra=isolated_env)
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 to trigger alert, then dismiss
379
- run_cli("click", "@id=a", env_extra=isolated_env)
380
- rc, stdout, _ = run_cli("dialog-dismiss", env_extra=isolated_env)
381
- # This may fail if alert wasn't caught, which is ok for basic test
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.2"
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.0" in result.output
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."""
@@ -619,11 +619,11 @@ class TestGetPageOptions:
619
619
  self._run_get_page({"system_user_path": True}, tmp_path, mock_co, mock_page)
620
620
  mock_co.use_system_user_path.assert_called_once_with(True)
621
621
 
622
- def test_system_user_path_skips_auto_port(self, tmp_path):
622
+ def test_system_user_path_also_calls_auto_port(self, tmp_path):
623
623
  mock_co = self._make_mock_co()
624
624
  mock_page = MagicMock()
625
625
  self._run_get_page({"system_user_path": True}, tmp_path, mock_co, mock_page)
626
- mock_co.auto_port.assert_not_called()
626
+ mock_co.auto_port.assert_called_once()
627
627
 
628
628
  def test_no_system_user_path_calls_auto_port(self, tmp_path):
629
629
  mock_co = self._make_mock_co()
@@ -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(as_dict=False, all_info=True)
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()