stagehand 4.0.3a0.dev1491__tar.gz → 4.0.3a0.dev1493__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 (35) hide show
  1. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/PKG-INFO +1 -1
  2. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/pyproject.toml +1 -1
  3. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/service-worker.js +2 -2
  4. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_generated/protocol_version.py +1 -1
  5. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/browser.py +331 -63
  6. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/README.md +0 -0
  7. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/__init__.py +0 -0
  8. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/blank.html +0 -0
  9. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/content-script.js +0 -0
  10. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/manifest.json +0 -0
  11. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/offscreen/service-worker-heartbeat.html +0 -0
  12. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/offscreen/service-worker-heartbeat.js +0 -0
  13. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/wake-service-worker.html +0 -0
  14. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_extension/wake-service-worker.js +0 -0
  15. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_generated/__init__.py +0 -0
  16. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_generated/input_types.py +0 -0
  17. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_generated/models.py +0 -0
  18. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_sdk_identity.py +0 -0
  19. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/_validation.py +0 -0
  20. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/browser_clipboard.py +0 -0
  21. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/browser_context.py +0 -0
  22. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/browserbase_session.py +0 -0
  23. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/cdp_client.py +0 -0
  24. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/client_models.py +0 -0
  25. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/client_types.py +0 -0
  26. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/extension_assets.py +0 -0
  27. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/file_upload.py +0 -0
  28. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/locator.py +0 -0
  29. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/page.py +0 -0
  30. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/py.typed +0 -0
  31. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/response.py +0 -0
  32. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/rpc_client.py +0 -0
  33. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/stagehand.py +0 -0
  34. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/timeouts.py +0 -0
  35. {stagehand-4.0.3a0.dev1491 → stagehand-4.0.3a0.dev1493}/src/stagehand/webmcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stagehand
3
- Version: 4.0.3a0.dev1491
3
+ Version: 4.0.3a0.dev1493
4
4
  Summary: Browserbase's SDK for building browser agents
5
5
  Keywords: ai,browser,automation,web-scraping,testing
6
6
  Author: Browserbase
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stagehand"
3
- version = "4.0.3a0.dev1491"
3
+ version = "4.0.3a0.dev1493"
4
4
  description = "Browserbase's SDK for building browser agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -7359,7 +7359,7 @@ function isRecord$1(value) {
7359
7359
  }
7360
7360
  var package_default$1 = {
7361
7361
  name: "@browserbasehq/stagehand-protocol",
7362
- version: "2.0.0-alpha-13c845ea089219d07e3f24571aa793648b544b62",
7362
+ version: "2.0.0-alpha-89c0fb83caedebc7f674561fc6405a03579842bd",
7363
7363
  "private": true,
7364
7364
  type: "module",
7365
7365
  exports: {
@@ -53444,7 +53444,7 @@ var WebTracerProvider = class extends BasicTracerProvider {
53444
53444
  };
53445
53445
  var package_default = {
53446
53446
  name: "@browserbasehq/stagehand-extension",
53447
- version: "1.0.2-alpha-13c845ea089219d07e3f24571aa793648b544b62",
53447
+ version: "1.0.2-alpha-89c0fb83caedebc7f674561fc6405a03579842bd",
53448
53448
  "private": true,
53449
53449
  type: "module",
53450
53450
  scripts: {
@@ -1,4 +1,4 @@
1
1
  """Generated from packages/protocol/package.json. Do not edit."""
2
2
 
3
3
  PROTOCOL_SEMVER_PATTERN = '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$'
4
- STAGEHAND_PROTOCOL_VERSION = '2.0.0-alpha-13c845ea089219d07e3f24571aa793648b544b62'
4
+ STAGEHAND_PROTOCOL_VERSION = '2.0.0-alpha-89c0fb83caedebc7f674561fc6405a03579842bd'
@@ -1,15 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import errno
5
+ import json
6
+ import math
4
7
  import os
5
8
  import shutil
6
9
  import signal
7
10
  import socket
8
11
  import sys
9
12
  import tempfile
13
+ import urllib.request
10
14
  from collections.abc import Awaitable, Callable, Mapping, Sequence
15
+ from contextlib import suppress
11
16
  from dataclasses import dataclass, field
12
- from pathlib import Path
17
+ from pathlib import Path, PureWindowsPath
13
18
  from typing import TYPE_CHECKING, Any, Literal, Protocol
14
19
 
15
20
  if TYPE_CHECKING:
@@ -65,6 +70,8 @@ _DEFAULT_CHROME_FLAGS = (
65
70
  )
66
71
 
67
72
  _BROWSER_TOKEN = object()
73
+ _CHROME_POLL_INTERVAL_SECONDS = 0.1
74
+ _CHROME_REQUEST_TIMEOUT_SECONDS = 0.1
68
75
 
69
76
 
70
77
  @dataclass(frozen=True)
@@ -84,14 +91,24 @@ class ResolvedBrowserSource:
84
91
  cdp_url: str
85
92
  keep_alive: bool
86
93
  _close_callback: Callable[[], Awaitable[None]] | None = field(default=None, repr=False)
87
- _closed: bool = field(default=False, init=False, repr=False)
94
+ _close_task: asyncio.Task[None] | None = field(default=None, init=False, repr=False)
88
95
 
89
96
  async def close(self) -> None:
90
- if self._closed:
97
+ if self._close_callback is None:
91
98
  return
92
- self._closed = True
93
- if self._close_callback is not None:
94
- await self._close_callback()
99
+ if self._close_task is None:
100
+ self._close_task = asyncio.create_task(_invoke_close(self._close_callback))
101
+ await asyncio.shield(self._close_task)
102
+
103
+
104
+ async def _invoke_close(callback: Callable[[], Awaitable[None]]) -> None:
105
+ await callback()
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class _ChromeProfile:
110
+ path: Path
111
+ remove: bool
95
112
 
96
113
 
97
114
  class StagehandBrowser:
@@ -258,6 +275,22 @@ class _LocalBrowserOptions(Protocol):
258
275
  keep_alive: bool | None
259
276
 
260
277
 
278
+ class _WaitableChromeProcess(Protocol):
279
+ returncode: int | None
280
+
281
+ async def wait(self) -> int: ...
282
+
283
+
284
+ class _ChromeProcess(_WaitableChromeProcess, Protocol):
285
+ pid: int
286
+
287
+
288
+ class _TaskkillError(RuntimeError):
289
+ def __init__(self, returncode: int) -> None:
290
+ self.returncode = returncode
291
+ super().__init__(f"taskkill exited with code {returncode}")
292
+
293
+
261
294
  async def _connect_browser(
262
295
  *,
263
296
  provider: Literal["local", "browserbase"],
@@ -622,54 +655,45 @@ browserbase = BrowserbaseBrowser()
622
655
 
623
656
 
624
657
  async def _launch_local_browser(options: _LocalBrowserOptions) -> ResolvedBrowserSource:
625
- chrome_path = options.executable_path or _find_chrome_path()
626
- port = options.port or _available_port()
627
- temporary_profile = options.user_data_dir is None
628
- user_data_dir = Path(options.user_data_dir or tempfile.mkdtemp(prefix="stagehand-chrome-"))
658
+ _validate_local_browser_options(options)
659
+ chrome_path = _find_chrome_path(options.executable_path)
660
+ port = _resolve_chrome_port(options.port)
661
+ profile = _resolve_chrome_profile(options)
629
662
  flags = _local_browser_flags(
630
663
  options,
631
664
  port=port,
632
- user_data_dir=user_data_dir,
633
- is_ci=bool(os.environ.get("CI")),
665
+ user_data_dir=profile.path,
666
+ disable_sandbox=_should_disable_chromium_sandbox(options),
634
667
  )
668
+ process: _ChromeProcess | None = None
635
669
  try:
636
- process = await asyncio.create_subprocess_exec(
637
- chrome_path,
638
- *flags,
639
- stdin=asyncio.subprocess.DEVNULL,
640
- stdout=asyncio.subprocess.DEVNULL,
641
- stderr=asyncio.subprocess.DEVNULL,
642
- start_new_session=sys.platform != "win32",
643
- )
644
- except BaseException:
645
- if temporary_profile and options.preserve_user_data_dir is not True:
646
- await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
670
+ try:
671
+ process = await asyncio.create_subprocess_exec(
672
+ chrome_path,
673
+ *flags,
674
+ stdin=asyncio.subprocess.DEVNULL,
675
+ stdout=asyncio.subprocess.DEVNULL,
676
+ stderr=asyncio.subprocess.DEVNULL,
677
+ start_new_session=sys.platform != "win32",
678
+ )
679
+ except OSError as error:
680
+ raise RuntimeError(f"Failed to start Chrome: {error}") from error
681
+ await _wait_for_chrome(f"http://127.0.0.1:{port}", process)
682
+ except BaseException as launch_error:
683
+ try:
684
+ if process is not None:
685
+ await _close_local_chrome(process, profile)
686
+ elif profile.remove:
687
+ await _remove_chrome_profile(profile.path)
688
+ except BaseException as cleanup_error:
689
+ raise _combined_error(
690
+ "Chrome launch failed and browser cleanup also failed",
691
+ [launch_error, cleanup_error],
692
+ ) from launch_error
647
693
  raise
648
694
 
649
695
  async def close() -> None:
650
- try:
651
- if process.returncode is None:
652
- try:
653
- if sys.platform == "win32":
654
- process.terminate()
655
- else:
656
- os.killpg(process.pid, signal.SIGTERM)
657
- except ProcessLookupError:
658
- pass
659
- try:
660
- await asyncio.wait_for(process.wait(), timeout=3)
661
- except TimeoutError:
662
- try:
663
- if sys.platform == "win32":
664
- process.kill()
665
- else:
666
- os.killpg(process.pid, signal.SIGKILL)
667
- except ProcessLookupError:
668
- pass
669
- await process.wait()
670
- finally:
671
- if temporary_profile and options.preserve_user_data_dir is not True:
672
- await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
696
+ await _close_local_chrome(process, profile)
673
697
 
674
698
  return ResolvedBrowserSource(
675
699
  cdp_url=f"http://127.0.0.1:{port}",
@@ -678,12 +702,189 @@ async def _launch_local_browser(options: _LocalBrowserOptions) -> ResolvedBrowse
678
702
  )
679
703
 
680
704
 
705
+ def _validate_local_browser_options(options: _LocalBrowserOptions) -> None:
706
+ if options.viewport is not None and (
707
+ options.viewport.width <= 0 or options.viewport.height <= 0
708
+ ):
709
+ raise ValueError("Chrome viewport dimensions must be positive integers")
710
+ if options.device_scale_factor is not None and (
711
+ not math.isfinite(options.device_scale_factor) or options.device_scale_factor <= 0
712
+ ):
713
+ raise ValueError("Chrome device scale factor must be positive and finite")
714
+ if options.proxy is not None:
715
+ if not options.proxy.server:
716
+ raise ValueError("Chrome proxy server is required")
717
+ if options.proxy.username is not None or options.proxy.password is not None:
718
+ raise NotImplementedError("Authenticated local browser proxies are not implemented yet")
719
+
720
+
721
+ def _resolve_chrome_profile(options: _LocalBrowserOptions) -> _ChromeProfile:
722
+ if options.user_data_dir not in (None, ""):
723
+ path = Path(options.user_data_dir)
724
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
725
+ return _ChromeProfile(path=path, remove=False)
726
+ path = Path(tempfile.mkdtemp(prefix="stagehand-chrome-"))
727
+ return _ChromeProfile(path=path, remove=options.preserve_user_data_dir is not True)
728
+
729
+
730
+ async def _wait_for_chrome(
731
+ cdp_url: str,
732
+ process: _WaitableChromeProcess,
733
+ ) -> None:
734
+ exited = asyncio.create_task(process.wait())
735
+ ready: asyncio.Task[bool] | None = None
736
+ delay: asyncio.Task[None] | None = None
737
+ try:
738
+ while True:
739
+ if process.returncode is not None:
740
+ raise _chrome_exited_before_ready_error(process.returncode)
741
+
742
+ ready = asyncio.create_task(_chrome_debugging_ready(cdp_url))
743
+ done, _ = await asyncio.wait((ready, exited), return_when=asyncio.FIRST_COMPLETED)
744
+ if exited in done:
745
+ ready.cancel()
746
+ with suppress(asyncio.CancelledError):
747
+ await ready
748
+ raise _chrome_exited_before_ready_error(exited.result())
749
+ if ready.result():
750
+ if process.returncode is not None or exited.done():
751
+ raise _chrome_exited_before_ready_error(process.returncode)
752
+ return
753
+
754
+ delay = asyncio.create_task(asyncio.sleep(_CHROME_POLL_INTERVAL_SECONDS))
755
+ done, _ = await asyncio.wait((delay, exited), return_when=asyncio.FIRST_COMPLETED)
756
+ if exited in done:
757
+ delay.cancel()
758
+ with suppress(asyncio.CancelledError):
759
+ await delay
760
+ raise _chrome_exited_before_ready_error(exited.result())
761
+ finally:
762
+ for pending in (ready, delay):
763
+ if pending is not None and not pending.done():
764
+ pending.cancel()
765
+ with suppress(asyncio.CancelledError):
766
+ await pending
767
+ if not exited.done():
768
+ exited.cancel()
769
+ with suppress(asyncio.CancelledError):
770
+ await exited
771
+
772
+
773
+ async def _chrome_debugging_ready(cdp_url: str) -> bool:
774
+ try:
775
+ version = await asyncio.to_thread(_read_chrome_version, cdp_url)
776
+ except Exception:
777
+ return False
778
+ debugger_url = version.get("webSocketDebuggerUrl")
779
+ return isinstance(debugger_url, str) and bool(debugger_url.strip())
780
+
781
+
782
+ def _read_chrome_version(cdp_url: str) -> dict[str, object]:
783
+ url = f"{cdp_url.rstrip('/')}/json/version"
784
+ with urllib.request.urlopen( # noqa: S310 -- The launcher owns this loopback URL.
785
+ url,
786
+ timeout=_CHROME_REQUEST_TIMEOUT_SECONDS,
787
+ ) as response:
788
+ value: Any = json.load(response)
789
+ if not isinstance(value, dict):
790
+ raise RuntimeError("Chrome version endpoint returned invalid JSON")
791
+ return value
792
+
793
+
794
+ def _chrome_exited_before_ready_error(returncode: int | None) -> RuntimeError:
795
+ detail = "unknown" if returncode is None else str(returncode)
796
+ return RuntimeError(f"Chrome exited before its debugging port was ready with code {detail}")
797
+
798
+
799
+ async def _close_local_chrome(
800
+ process: _ChromeProcess,
801
+ profile: _ChromeProfile,
802
+ ) -> None:
803
+ errors: list[BaseException] = []
804
+ try:
805
+ await _close_chrome_process(process)
806
+ except BaseException as error:
807
+ errors.append(error)
808
+ if profile.remove:
809
+ try:
810
+ await _remove_chrome_profile(profile.path)
811
+ except BaseException as error:
812
+ errors.append(error)
813
+ if errors:
814
+ raise _combined_error("Chrome termination and profile cleanup failed", errors)
815
+
816
+
817
+ async def _close_chrome_process(process: _ChromeProcess) -> None:
818
+ if process.returncode is not None:
819
+ return
820
+ try:
821
+ await _terminate_chrome_process(process.pid, force=False)
822
+ except BaseException as error:
823
+ if not _is_finished_process_error(error):
824
+ raise
825
+ try:
826
+ await asyncio.wait_for(process.wait(), timeout=3)
827
+ return
828
+ except TimeoutError:
829
+ pass
830
+ try:
831
+ await _terminate_chrome_process(process.pid, force=True)
832
+ except BaseException as error:
833
+ if not _is_finished_process_error(error):
834
+ raise
835
+ await process.wait()
836
+
837
+
838
+ async def _terminate_chrome_process(pid: int, *, force: bool) -> None:
839
+ if sys.platform == "win32":
840
+ await _run_taskkill(pid, force=force)
841
+ return
842
+ os.killpg(pid, signal.SIGKILL if force else signal.SIGTERM)
843
+
844
+
845
+ async def _run_taskkill(pid: int, *, force: bool) -> None:
846
+ taskkill = await asyncio.create_subprocess_exec(
847
+ "taskkill",
848
+ "/PID",
849
+ str(pid),
850
+ "/T",
851
+ *(["/F"] if force else []),
852
+ stdin=asyncio.subprocess.DEVNULL,
853
+ stdout=asyncio.subprocess.DEVNULL,
854
+ stderr=asyncio.subprocess.DEVNULL,
855
+ )
856
+ returncode = await taskkill.wait()
857
+ if returncode != 0:
858
+ raise _TaskkillError(returncode)
859
+
860
+
861
+ def _is_finished_process_error(error: BaseException) -> bool:
862
+ return isinstance(error, ProcessLookupError) or (
863
+ isinstance(error, _TaskkillError) and error.returncode == 128
864
+ )
865
+
866
+
867
+ def _combined_error(message: str, errors: list[BaseException]) -> BaseException:
868
+ if len(errors) == 1:
869
+ return errors[0]
870
+ if all(isinstance(error, Exception) for error in errors):
871
+ return ExceptionGroup(
872
+ message,
873
+ [error for error in errors if isinstance(error, Exception)],
874
+ )
875
+ return BaseExceptionGroup(message, errors)
876
+
877
+
878
+ async def _remove_chrome_profile(path: Path) -> None:
879
+ await asyncio.to_thread(shutil.rmtree, path)
880
+
881
+
681
882
  def _local_browser_flags(
682
883
  options: _LocalBrowserOptions,
683
884
  *,
684
885
  port: int,
685
886
  user_data_dir: Path,
686
- is_ci: bool,
887
+ disable_sandbox: bool,
687
888
  ) -> list[str]:
688
889
  ignored_default_args = options.ignore_default_args
689
890
  ignored_flags = set(ignored_default_args) if isinstance(ignored_default_args, list) else set()
@@ -709,7 +910,7 @@ def _local_browser_flags(
709
910
  f"--user-data-dir={user_data_dir}",
710
911
  *(["--headless"] if options.headless is True else []),
711
912
  *(["--auto-open-devtools-for-tabs"] if options.devtools is True else []),
712
- *(["--no-sandbox"] if is_ci or options.chromium_sandbox is False else []),
913
+ *(["--no-sandbox"] if disable_sandbox else []),
713
914
  *([f"--proxy-server={options.proxy.server}"] if options.proxy else []),
714
915
  *(
715
916
  [f"--proxy-bypass-list={options.proxy.bypass}"]
@@ -729,29 +930,69 @@ def _local_browser_flags(
729
930
  ]
730
931
 
731
932
 
732
- def _find_chrome_path() -> str:
733
- configured = os.environ.get("CHROME_PATH")
734
- if configured and Path(configured).is_file():
933
+ def _should_disable_chromium_sandbox(
934
+ options: _LocalBrowserOptions,
935
+ *,
936
+ platform: str | None = None,
937
+ environment: Mapping[str, str] | None = None,
938
+ getuid: Callable[[], int] | None = None,
939
+ ) -> bool:
940
+ platform = sys.platform if platform is None else platform
941
+ environment = os.environ if environment is None else environment
942
+ getuid = getattr(os, "getuid", None) if getuid is None else getuid
943
+ return (
944
+ bool(environment.get("CI"))
945
+ or options.chromium_sandbox is False
946
+ or (platform == "linux" and getuid is not None and getuid() == 0)
947
+ )
948
+
949
+
950
+ def _find_chrome_path(
951
+ explicit_path: str | None = None,
952
+ *,
953
+ platform: str | None = None,
954
+ environment: Mapping[str, str] | None = None,
955
+ which: Callable[[str], str | None] = shutil.which,
956
+ is_executable: Callable[[str, str], bool] | None = None,
957
+ ) -> str:
958
+ platform = sys.platform if platform is None else platform
959
+ environment = os.environ if environment is None else environment
960
+ is_executable = _is_executable_file if is_executable is None else is_executable
961
+
962
+ if explicit_path is not None:
963
+ if is_executable(explicit_path, platform):
964
+ return explicit_path
965
+ raise RuntimeError(f"Chrome executable {json.dumps(explicit_path)} does not exist")
966
+
967
+ configured = environment.get("CHROME_PATH")
968
+ if configured and is_executable(configured, platform):
735
969
  return configured
736
970
 
737
- if sys.platform == "darwin":
971
+ if platform == "darwin":
738
972
  candidates = (
739
973
  "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
740
974
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
975
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
976
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
741
977
  )
742
- elif sys.platform == "win32":
978
+ elif platform == "win32":
743
979
  roots = filter(
744
980
  None,
745
981
  (
746
- os.environ.get("LOCALAPPDATA"),
747
- os.environ.get("PROGRAMFILES"),
748
- os.environ.get("PROGRAMFILES(X86)"),
982
+ environment.get(name)
983
+ for name in (
984
+ "LOCALAPPDATA",
985
+ "PROGRAMFILES",
986
+ "PROGRAMFILES(X86)",
987
+ )
749
988
  ),
750
989
  )
751
990
  candidates = tuple(
752
- str(Path(root) / "Google" / "Chrome" / "Application" / "chrome.exe") for root in roots
991
+ str(PureWindowsPath(root) / "Google" / product / "Application" / "chrome.exe")
992
+ for root in roots
993
+ for product in ("Chrome SxS", "Chrome")
753
994
  )
754
- else:
995
+ elif platform == "linux":
755
996
  candidates = tuple(
756
997
  path
757
998
  for name in (
@@ -760,16 +1001,43 @@ def _find_chrome_path() -> str:
760
1001
  "chromium-browser",
761
1002
  "chromium",
762
1003
  )
763
- if (path := shutil.which(name)) is not None
1004
+ if (path := which(name)) is not None
764
1005
  )
1006
+ else:
1007
+ raise RuntimeError(f"Chrome launching is not supported on {platform}")
765
1008
 
766
1009
  for candidate in candidates:
767
- if Path(candidate).is_file():
1010
+ if is_executable(candidate, platform):
768
1011
  return candidate
769
1012
  raise RuntimeError("Chrome installation not found; set CHROME_PATH")
770
1013
 
771
1014
 
772
- def _available_port() -> int:
773
- with socket.socket() as candidate:
774
- candidate.bind(("127.0.0.1", 0))
1015
+ def _is_executable_file(path: str, platform: str) -> bool:
1016
+ candidate = Path(path)
1017
+ return candidate.is_file() and (platform == "win32" or os.access(candidate, os.X_OK))
1018
+
1019
+
1020
+ def _resolve_chrome_port(requested_port: int | None) -> int:
1021
+ if requested_port is None:
1022
+ return _available_port()
1023
+ if requested_port < 1 or requested_port > 65_535:
1024
+ raise ValueError("Chrome port must be between 1 and 65535")
1025
+ try:
1026
+ _inspect_chrome_port(requested_port)
1027
+ except OSError as error:
1028
+ if error.errno == errno.EADDRINUSE:
1029
+ raise RuntimeError(
1030
+ f"Chrome debugging port {requested_port} is already in use"
1031
+ ) from error
1032
+ raise
1033
+ return requested_port
1034
+
1035
+
1036
+ def _inspect_chrome_port(port: int) -> int:
1037
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as candidate:
1038
+ candidate.bind(("127.0.0.1", port))
775
1039
  return int(candidate.getsockname()[1])
1040
+
1041
+
1042
+ def _available_port() -> int:
1043
+ return _inspect_chrome_port(0)