diff --git a/packages/sdk-python/src/stagehand/browser_source.py b/packages/sdk-python/src/stagehand/browser_source.py index a844556af..53bf6ea2a 100644 --- a/packages/sdk-python/src/stagehand/browser_source.py +++ b/packages/sdk-python/src/stagehand/browser_source.py @@ -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 @@ -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: @@ -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: diff --git a/packages/sdk-python/tests/test_browser_source.py b/packages/sdk-python/tests/test_browser_source.py index cc16b195c..417b86585 100644 --- a/packages/sdk-python/tests/test_browser_source.py +++ b/packages/sdk-python/tests/test_browser_source.py @@ -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 @@ -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" diff --git a/packages/sdk-ts/src/browserSource.ts b/packages/sdk-ts/src/browserSource.ts index 3fbf63950..96bc18a24 100644 --- a/packages/sdk-ts/src/browserSource.ts +++ b/packages/sdk-ts/src/browserSource.ts @@ -6,6 +6,7 @@ import { type BrowserbaseSessionClient, type BrowserbaseSessionClientFactory, } from "./browserbaseSession.js"; +import { findChromeExecutable, type ChromeExecutableResolverOptions } from "./chromeExecutable.js"; export type { BrowserbaseSessionClient, BrowserbaseSessionClientFactory }; @@ -31,6 +32,10 @@ export type BrowserSourceResolverDependencies = { createBrowserbaseSessionClient?: BrowserbaseSessionClientFactory; }; +export type LocalBrowserLauncherDependencies = { + findChromeExecutable?: (options: ChromeExecutableResolverOptions) => string; +}; + export async function resolveBrowserSource( input: unknown, dependencies: BrowserSourceResolverDependencies = {}, @@ -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: [ diff --git a/packages/sdk-ts/src/chromeExecutable.ts b/packages/sdk-ts/src/chromeExecutable.ts new file mode 100644 index 000000000..0443cb6c3 --- /dev/null +++ b/packages/sdk-ts/src/chromeExecutable.ts @@ -0,0 +1,136 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +const CHROME_NOT_FOUND_MESSAGE = + "No supported local browser installation was found. Stagehand supports Chrome Stable, Beta, Dev, Canary, and Chromium. Set browser.executablePath or CHROME_PATH to use a custom executable."; + +const 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"], +] as const; + +const WINDOWS_APPLICATIONS = [ + ["Google", "Chrome"], + ["Google", "Chrome Beta"], + ["Google", "Chrome Dev"], + ["Google", "Chrome SxS"], + ["Chromium"], +] as const; + +const LINUX_EXECUTABLES = [ + "google-chrome-stable", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "chromium-browser", + "chromium", +] as const; + +const CHANNEL_ORDER = ["stable", "beta", "dev", "canary", "chromium", "unknown"] as const; + +type ChromeChannel = (typeof CHANNEL_ORDER)[number]; + +export type ChromeExecutableResolverOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDirectory?: string; + isExecutable?: (candidate: string) => boolean; + legacyInstallations?: () => readonly string[]; +}; + +export function chromeExecutableCandidates( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + homeDirectory: string, +): string[] { + if (platform === "darwin") { + const applicationDirectories = [ + "/Applications", + path.posix.join(homeDirectory, "Applications"), + ]; + return MAC_APPLICATIONS.flatMap(([bundle, executable]) => + applicationDirectories.map((directory) => + path.posix.join(directory, bundle, "Contents", "MacOS", executable), + ), + ); + } + + if (platform === "win32") { + const roots = [env.LOCALAPPDATA, env.PROGRAMFILES, env["PROGRAMFILES(X86)"]].filter( + (root): root is string => root !== undefined && root.length > 0, + ); + return WINDOWS_APPLICATIONS.flatMap((segments) => + roots.map((root) => path.win32.join(root, ...segments, "Application", "chrome.exe")), + ); + } + + const pathDirectories = (env.PATH ?? "").split(":").filter(Boolean); + return LINUX_EXECUTABLES.flatMap((executable) => + pathDirectories.map((directory) => path.posix.join(directory, executable)), + ); +} + +export function findChromeExecutable(options: ChromeExecutableResolverOptions = {}): string { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDirectory = options.homeDirectory ?? homedir(); + const isExecutable = options.isExecutable ?? canExecute; + + const configuredPath = env.CHROME_PATH; + if (configuredPath !== undefined && isExecutable(configuredPath)) { + return configuredPath; + } + + const candidates = chromeExecutableCandidates(platform, env, homeDirectory); + if (options.legacyInstallations !== undefined) { + try { + candidates.push(...options.legacyInstallations()); + } catch { + // Stagehand reports its own actionable error if neither discovery strategy succeeds. + } + } + + const candidate = orderChromeExecutableCandidates(candidates).find(isExecutable); + if (candidate !== undefined) { + return candidate; + } + + throw new Error(CHROME_NOT_FOUND_MESSAGE); +} + +function orderChromeExecutableCandidates(candidates: readonly string[]): string[] { + const uniqueCandidates = [...new Set(candidates)]; + return CHANNEL_ORDER.flatMap((channel) => + uniqueCandidates.filter((candidate) => chromeChannel(candidate) === channel), + ); +} + +function chromeChannel(candidate: string): ChromeChannel { + const normalized = candidate.toLowerCase().replaceAll("\\", "/"); + const executable = path.posix.basename(normalized); + if (executable.includes("chromium") || normalized.includes("/chromium/")) return "chromium"; + if (executable.includes("canary") || normalized.includes("/chrome sxs/")) return "canary"; + if (executable.includes("beta") || normalized.includes("/chrome beta/")) return "beta"; + if ( + executable.includes(" dev") || + executable.includes("unstable") || + normalized.includes("/chrome dev/") + ) { + return "dev"; + } + if (executable.includes("chrome")) return "stable"; + return "unknown"; +} + +function canExecute(candidate: string): boolean { + try { + accessSync(candidate, constants.X_OK); + return statSync(candidate).isFile(); + } catch { + return false; + } +} diff --git a/packages/sdk-ts/tests/browserSource.test.ts b/packages/sdk-ts/tests/browserSource.test.ts index e3a089869..5327b31ec 100644 --- a/packages/sdk-ts/tests/browserSource.test.ts +++ b/packages/sdk-ts/tests/browserSource.test.ts @@ -1,5 +1,23 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveBrowserSource, type BrowserbaseSessionClient } from "../src/browserSource.js"; +import { + launchLocalBrowser, + resolveBrowserSource, + type BrowserbaseSessionClient, +} from "../src/browserSource.js"; + +const chromeLauncher = vi.hoisted(() => ({ + launch: vi.fn(), + defaultFlags: vi.fn(() => [] as string[]), + getInstallations: vi.fn(() => [] as string[]), +})); + +vi.mock("chrome-launcher", () => ({ + launch: chromeLauncher.launch, + Launcher: { + defaultFlags: chromeLauncher.defaultFlags, + getInstallations: chromeLauncher.getInstallations, + }, +})); describe("resolveBrowserSource", () => { it("creates a Browserbase session from the default browser source", async () => { @@ -141,3 +159,38 @@ describe("resolveBrowserSource", () => { expect(launchLocalBrowser).not.toHaveBeenCalled(); }); }); + +describe("launchLocalBrowser", () => { + it("passes an explicit executable path to chrome-launcher without discovery", async () => { + const kill = vi.fn(); + chromeLauncher.launch.mockResolvedValueOnce({ port: 9333, kill }); + + const browser = await launchLocalBrowser({ executablePath: "/custom/chrome" }); + + expect(chromeLauncher.launch).toHaveBeenCalledWith( + expect.objectContaining({ chromePath: "/custom/chrome" }), + ); + expect(browser.cdpUrl).toBe("http://127.0.0.1:9333"); + browser.close(); + expect(kill).toHaveBeenCalledOnce(); + }); + + it("resolves a browser automatically with chrome-launcher's compatibility discoveries", async () => { + const kill = vi.fn(); + const findChromeExecutable = vi.fn( + (options: { legacyInstallations?: () => readonly string[] }) => { + return options.legacyInstallations?.()[0] ?? "/missing/chrome"; + }, + ); + chromeLauncher.getInstallations.mockReturnValueOnce(["/opt/google/chrome-wrapper"]); + chromeLauncher.launch.mockResolvedValueOnce({ port: 9444, kill }); + + await launchLocalBrowser({}, { findChromeExecutable }); + + expect(findChromeExecutable).toHaveBeenCalledOnce(); + expect(chromeLauncher.getInstallations).toHaveBeenCalledOnce(); + expect(chromeLauncher.launch).toHaveBeenCalledWith( + expect.objectContaining({ chromePath: "/opt/google/chrome-wrapper" }), + ); + }); +}); diff --git a/packages/sdk-ts/tests/chromeExecutable.test.ts b/packages/sdk-ts/tests/chromeExecutable.test.ts new file mode 100644 index 000000000..98bc702f9 --- /dev/null +++ b/packages/sdk-ts/tests/chromeExecutable.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { chromeExecutableCandidates, findChromeExecutable } from "../src/chromeExecutable.js"; + +describe("Chrome executable discovery", () => { + it("orders macOS system and user installations by release channel", () => { + const candidates = chromeExecutableCandidates("darwin", {}, "/Users/tester"); + + expect(candidates).toEqual([ + "/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", + ]); + }); + + it("orders Windows installations by release channel across known roots", () => { + const candidates = chromeExecutableCandidates( + "win32", + { + LOCALAPPDATA: "C:\\Users\\tester\\AppData\\Local", + PROGRAMFILES: "C:\\Program Files", + }, + "C:\\Users\\tester", + ); + + expect(candidates.slice(0, 6)).toEqual([ + "C:\\Users\\tester\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Users\\tester\\AppData\\Local\\Google\\Chrome Beta\\Application\\chrome.exe", + "C:\\Program Files\\Google\\Chrome Beta\\Application\\chrome.exe", + "C:\\Users\\tester\\AppData\\Local\\Google\\Chrome Dev\\Application\\chrome.exe", + "C:\\Program Files\\Google\\Chrome Dev\\Application\\chrome.exe", + ]); + expect(candidates).toContain( + "C:\\Users\\tester\\AppData\\Local\\Google\\Chrome SxS\\Application\\chrome.exe", + ); + }); + + it("searches Linux PATH in release-channel order", () => { + const candidates = chromeExecutableCandidates( + "linux", + { PATH: "/opt/bin:/usr/bin" }, + "/home/tester", + ); + + expect(candidates.slice(0, 6)).toEqual([ + "/opt/bin/google-chrome-stable", + "/usr/bin/google-chrome-stable", + "/opt/bin/google-chrome", + "/usr/bin/google-chrome", + "/opt/bin/google-chrome-beta", + "/usr/bin/google-chrome-beta", + ]); + }); + + it("prefers a valid CHROME_PATH", () => { + expect( + findChromeExecutable({ + platform: "linux", + env: { CHROME_PATH: "/custom/chrome", PATH: "/usr/bin" }, + isExecutable: (candidate) => candidate === "/custom/chrome", + }), + ).toBe("/custom/chrome"); + }); + + it.each([ + ["Chrome Dev", "/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev"], + ["Chrome Canary", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ])("falls back to an installation containing only %s", (_name, installed) => { + expect( + findChromeExecutable({ + platform: "darwin", + env: {}, + homeDirectory: "/Users/tester", + isExecutable: (candidate) => candidate === installed, + }), + ).toBe(installed); + }); + + it("skips an invalid CHROME_PATH and prefers stable among installed channels", () => { + const stable = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + const canary = "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"; + + expect( + findChromeExecutable({ + platform: "darwin", + env: { CHROME_PATH: "/missing/chrome" }, + homeDirectory: "/Users/tester", + isExecutable: (candidate) => candidate === stable || candidate === canary, + }), + ).toBe(stable); + }); + + it("accepts Windows Chrome discovered through chrome-launcher's WSL support", () => { + const windowsChrome = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"; + + expect( + findChromeExecutable({ + platform: "linux", + env: { PATH: "/usr/bin" }, + isExecutable: (candidate) => candidate === windowsChrome, + legacyInstallations: () => [windowsChrome], + }), + ).toBe(windowsChrome); + }); + + it("accepts a macOS installation discovered through Launch Services", () => { + const registeredChrome = + "/Enterprise/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + + expect( + findChromeExecutable({ + platform: "darwin", + env: {}, + homeDirectory: "/Users/tester", + isExecutable: (candidate) => candidate === registeredChrome, + legacyInstallations: () => [registeredChrome], + }), + ).toBe(registeredChrome); + }); + + it("accepts a Linux chrome-wrapper discovered through desktop entries", () => { + const chromeWrapper = "/opt/google/chrome-wrapper"; + + expect( + findChromeExecutable({ + platform: "linux", + env: { PATH: "/usr/bin" }, + isExecutable: (candidate) => candidate === chromeWrapper, + legacyInstallations: () => [chromeWrapper], + }), + ).toBe(chromeWrapper); + }); + + it("applies Stagehand channel order to legacy discoveries", () => { + const stable = "/Users/dev/custom/Google Chrome.app/Contents/MacOS/Google Chrome"; + const canary = "/custom/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"; + + expect( + findChromeExecutable({ + platform: "darwin", + env: {}, + homeDirectory: "/Users/tester", + isExecutable: (candidate) => candidate === stable || candidate === canary, + legacyInstallations: () => [canary, stable], + }), + ).toBe(stable); + }); + + it("throws a Stagehand-specific error when no browser is found", () => { + expect(() => + findChromeExecutable({ + platform: "linux", + env: { PATH: "/usr/bin" }, + isExecutable: () => false, + }), + ).toThrow(/Chrome Stable, Beta, Dev, Canary, and Chromium.*executablePath.*CHROME_PATH/u); + }); +});