Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 90 additions & 34 deletions packages/sdk-python/src/stagehand/browser_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import socket
import sys
import tempfile
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath

from .client_models import LocalBrowserSource, StagehandClientInitParams

Expand Down Expand Up @@ -41,6 +41,37 @@
"--propagate-iph-for-testing",
)

_MAC_APPLICATIONS = (
("Google Chrome.app", "Google Chrome"),
("Google Chrome Beta.app", "Google Chrome Beta"),
("Google Chrome Dev.app", "Google Chrome Dev"),
("Google Chrome Canary.app", "Google Chrome Canary"),
("Chromium.app", "Chromium"),
)

_WINDOWS_APPLICATIONS = (
("Google", "Chrome"),
("Google", "Chrome Beta"),
("Google", "Chrome Dev"),
("Google", "Chrome SxS"),
("Chromium",),
)

_LINUX_EXECUTABLES = (
"google-chrome-stable",
"google-chrome",
"google-chrome-beta",
"google-chrome-unstable",
"chromium-browser",
"chromium",
)

_CHROME_NOT_FOUND_MESSAGE = (
"No supported local browser installation was found. Stagehand supports Chrome Stable, "
"Beta, Dev, Canary, and Chromium. Set browser.executable_path or CHROME_PATH to use a "
"custom executable."
)


@dataclass
class ResolvedBrowserSource:
Expand Down Expand Up @@ -166,44 +197,69 @@ async def close() -> None:
)


def _find_chrome_path() -> str:
configured = os.environ.get("CHROME_PATH")
if configured and Path(configured).is_file():
return configured

if sys.platform == "darwin":
candidates = (
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
def _chrome_file_candidates(
platform: str,
environ: Mapping[str, str],
home_directory: str,
) -> tuple[str, ...]:
if platform == "darwin":
application_directories = (
PurePosixPath("/Applications"),
PurePosixPath(home_directory) / "Applications",
)
elif sys.platform == "win32":
roots = filter(
None,
(
os.environ.get("LOCALAPPDATA"),
os.environ.get("PROGRAMFILES"),
os.environ.get("PROGRAMFILES(X86)"),
),
return tuple(
str(directory / bundle / "Contents" / "MacOS" / executable)
for bundle, executable in _MAC_APPLICATIONS
for directory in application_directories
)
candidates = tuple(
str(Path(root) / "Google" / "Chrome" / "Application" / "chrome.exe") for root in roots

if platform == "win32":
roots = tuple(
root
for key in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)")
if (root := environ.get(key))
)
else:
candidates = tuple(
path
for name in (
"google-chrome-stable",
"google-chrome",
"chromium-browser",
"chromium",
)
if (path := shutil.which(name)) is not None
return tuple(
str(PureWindowsPath(root, *segments, "Application", "chrome.exe"))
for segments in _WINDOWS_APPLICATIONS
for root in roots
)

for candidate in candidates:
if Path(candidate).is_file():
return ()


def _find_chrome_path(
*,
platform: str | None = None,
environ: Mapping[str, str] | None = None,
home_directory: str | None = None,
is_file: Callable[[str], bool] | None = None,
which: Callable[[str], str | None] | None = None,
) -> str:
current_platform = platform or sys.platform
current_environ = os.environ if environ is None else environ
current_home = str(Path.home()) if home_directory is None else home_directory
file_exists = (lambda candidate: Path(candidate).is_file()) if is_file is None else is_file
find_on_path = (
(lambda name: shutil.which(name, path=current_environ.get("PATH", "")))
if which is None
else which
)

configured = current_environ.get("CHROME_PATH")
if configured and file_exists(configured):
return configured

for candidate in _chrome_file_candidates(current_platform, current_environ, current_home):
if file_exists(candidate):
return candidate
raise RuntimeError("Chrome installation not found; set CHROME_PATH")

if current_platform not in ("darwin", "win32"):
for executable in _LINUX_EXECUTABLES:
if candidate := find_on_path(executable):
return candidate

raise RuntimeError(_CHROME_NOT_FOUND_MESSAGE)


def _available_port() -> int:
Expand Down
176 changes: 175 additions & 1 deletion packages/sdk-python/tests/test_browser_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import pytest

from stagehand.browser_source import ResolvedBrowserSource, resolve_browser_source
from stagehand.browser_source import (
ResolvedBrowserSource,
_chrome_file_candidates,
_find_chrome_path,
resolve_browser_source,
)
from stagehand.client_models import StagehandClientInitParams


Expand Down Expand Up @@ -78,3 +83,172 @@ async def launch(options: object) -> ResolvedBrowserSource:

assert launched_headless is True
assert source.cdp_url == "http://localhost:9333"


def test_macos_browser_candidates_are_ordered_by_release_channel() -> None:
candidates = _chrome_file_candidates("darwin", {}, "/Users/tester")

assert candidates == (
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Users/tester/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Users/tester/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
"/Users/tester/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Users/tester/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Users/tester/Applications/Chromium.app/Contents/MacOS/Chromium",
)


def test_windows_browser_candidates_are_ordered_by_release_channel() -> None:
candidates = _chrome_file_candidates(
"win32",
{
"LOCALAPPDATA": r"C:\Users\tester\AppData\Local",
"PROGRAMFILES": r"C:\Program Files",
},
r"C:\Users\tester",
)

assert candidates[:6] == (
r"C:\Users\tester\AppData\Local\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Users\tester\AppData\Local\Google\Chrome Beta\Application\chrome.exe",
r"C:\Program Files\Google\Chrome Beta\Application\chrome.exe",
r"C:\Users\tester\AppData\Local\Google\Chrome Dev\Application\chrome.exe",
r"C:\Program Files\Google\Chrome Dev\Application\chrome.exe",
)
assert r"C:\Users\tester\AppData\Local\Google\Chrome SxS\Application\chrome.exe" in candidates


def test_linux_browser_lookup_uses_release_channel_order() -> None:
attempts: list[str] = []

def which(name: str) -> str | None:
attempts.append(name)
return "/usr/bin/google-chrome-unstable" if name == "google-chrome-unstable" else None

assert (
_find_chrome_path(
platform="linux", environ={}, is_file=lambda _candidate: False, which=which
)
== "/usr/bin/google-chrome-unstable"
)
assert attempts == [
"google-chrome-stable",
"google-chrome",
"google-chrome-beta",
"google-chrome-unstable",
]


def test_linux_browser_lookup_does_not_use_process_path_when_omitted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
paths: list[str | None] = []

def which(_name: str, *, path: str | None = None) -> str | None:
paths.append(path)
return "/host/google-chrome" if path is None else None

monkeypatch.setattr("stagehand.browser_source.shutil.which", which)

with pytest.raises(RuntimeError):
_find_chrome_path(platform="linux", environ={}, is_file=lambda _candidate: False)

assert paths
assert set(paths) == {""}


@pytest.mark.parametrize(
"installed",
[
"/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
],
)
def test_browser_lookup_falls_back_to_the_only_installed_variant(installed: str) -> None:
assert (
_find_chrome_path(
platform="darwin",
environ={},
home_directory="/Users/tester",
is_file=lambda candidate: candidate == installed,
)
== installed
)


def test_browser_lookup_prefers_valid_chrome_path() -> None:
assert (
_find_chrome_path(
platform="linux",
environ={"CHROME_PATH": "/custom/chrome"},
is_file=lambda candidate: candidate == "/custom/chrome",
)
== "/custom/chrome"
)


def test_browser_lookup_skips_invalid_chrome_path_and_prefers_stable() -> None:
stable = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
canary = "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"

assert (
_find_chrome_path(
platform="darwin",
environ={"CHROME_PATH": "/missing/chrome"},
home_directory="/Users/tester",
is_file=lambda candidate: candidate in {stable, canary},
)
== stable
)


def test_browser_lookup_raises_stagehand_specific_error() -> None:
with pytest.raises(
RuntimeError,
match=r"Chrome Stable, Beta, Dev, Canary, and Chromium.*executable_path.*CHROME_PATH",
):
_find_chrome_path(
platform="linux",
environ={},
is_file=lambda _candidate: False,
which=lambda _name: None,
)


@pytest.mark.asyncio
async def test_explicit_executable_path_bypasses_discovery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
launched: tuple[object, ...] | None = None

class Process:
returncode = 0

async def create_process(*args: object, **_kwargs: object) -> Process:
nonlocal launched
launched = args
return Process()

def fail_discovery() -> str:
raise AssertionError("automatic discovery should not run")

monkeypatch.setattr("stagehand.browser_source.asyncio.create_subprocess_exec", create_process)
monkeypatch.setattr("stagehand.browser_source._find_chrome_path", fail_discovery)
params = StagehandClientInitParams.model_validate({
"browser": {
"type": "local",
"executable_path": "/custom/chrome",
"user_data_dir": "/tmp/stagehand-test-profile",
}
})

await resolve_browser_source(params)

assert launched is not None
assert launched[0] == "/custom/chrome"
17 changes: 14 additions & 3 deletions packages/sdk-ts/src/browserSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type BrowserbaseSessionClient,
type BrowserbaseSessionClientFactory,
} from "./browserbaseSession.js";
import { findChromeExecutable, type ChromeExecutableResolverOptions } from "./chromeExecutable.js";

export type { BrowserbaseSessionClient, BrowserbaseSessionClientFactory };

Expand All @@ -31,6 +32,10 @@ export type BrowserSourceResolverDependencies = {
createBrowserbaseSessionClient?: BrowserbaseSessionClientFactory;
};

export type LocalBrowserLauncherDependencies = {
findChromeExecutable?: (options: ChromeExecutableResolverOptions) => string;
};

export async function resolveBrowserSource(
input: unknown,
dependencies: BrowserSourceResolverDependencies = {},
Expand Down Expand Up @@ -74,12 +79,18 @@ export async function resolveBrowserSource(
};
}

async function launchLocalBrowser(
export async function launchLocalBrowser(
options: LocalBrowserLaunchOptions,
dependencies: LocalBrowserLauncherDependencies = {},
): Promise<{ cdpUrl: string; close: () => void }> {
const { getChromePath, launch, Launcher } = await import("chrome-launcher");
const { launch, Launcher } = await import("chrome-launcher");
const chromePath =
options.executablePath ??
(dependencies.findChromeExecutable ?? findChromeExecutable)({
legacyInstallations: () => Launcher.getInstallations(),
});
const chrome = await launch({
chromePath: getChromePath(),
chromePath,
startingUrl: "about:blank",
ignoreDefaultFlags: true,
chromeFlags: [
Expand Down
Loading
Loading