From a7c1543c2f3195ecaa71b9e446ed3fa30e5a6dd2 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 04:54:00 +0000 Subject: [PATCH 1/7] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/browser-commander/issues/68 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index 3811e7a..9c45842 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,3 +1,4 @@ # .gitkeep file auto-generated at 2026-05-10T11:15:41.449Z for PR creation at branch issue-51-f2a446afa3b9 for issue https://github.com/link-foundation/browser-commander/issues/51 # Updated: 2026-06-28T20:40:17.765Z -# Updated: 2026-08-01T16:08:23.376Z \ No newline at end of file +# Updated: 2026-08-01T16:08:23.376Z +# Updated: 2026-08-02T04:53:59.885Z \ No newline at end of file From d22b80e98593e4877d78f949cc3842bb18c566c3 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:25:40 +0000 Subject: [PATCH 2/7] feat(js): expose launchRealBrowser alias --- experiments/connect-real-browser-smoke.mjs | 4 ++-- js/.changeset/real-browser-launch-parity.md | 5 +++++ js/README.md | 10 ++++++---- js/src/browser/real-browser.js | 9 +++++++++ js/src/exports.js | 5 ++++- js/tests/unit/browser/real-browser.test.js | 8 +++++++- 6 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 js/.changeset/real-browser-launch-parity.md diff --git a/experiments/connect-real-browser-smoke.mjs b/experiments/connect-real-browser-smoke.mjs index 52d8c48..7e8f69c 100644 --- a/experiments/connect-real-browser-smoke.mjs +++ b/experiments/connect-real-browser-smoke.mjs @@ -5,7 +5,7 @@ import os from "node:os"; import path from "node:path"; import { - launchAndConnectRealBrowser, + launchRealBrowser, makeBrowserCommander, } from "../js/src/index.js"; @@ -37,7 +37,7 @@ async function waitForExit(browserProcess) { try { for (const engine of ["playwright", "puppeteer"]) { - const connection = await launchAndConnectRealBrowser({ + const connection = await launchRealBrowser({ engine, executablePath: browserExecutable, userDataDir: path.join(temporaryDirectory, engine), diff --git a/js/.changeset/real-browser-launch-parity.md b/js/.changeset/real-browser-launch-parity.md new file mode 100644 index 0000000..0eea024 --- /dev/null +++ b/js/.changeset/real-browser-launch-parity.md @@ -0,0 +1,5 @@ +--- +'browser-commander': minor +--- + +Add the `launchRealBrowser()` API name for launching and attaching to a genuine installed Chrome-family browser. diff --git a/js/README.md b/js/README.md index de34134..1f05d6f 100644 --- a/js/README.md +++ b/js/README.md @@ -118,16 +118,16 @@ const { browser, page } = await connectBrowser({ const commander = makeBrowserCommander({ page }); ``` -`launchAndConnectRealBrowser()` can find and start a genuine installed Chrome, +`launchRealBrowser()` can find and start a genuine installed Chrome, Edge, Brave, or Chromium with a loopback CDP endpoint and then attach to it. It always uses a dedicated profile; Chrome 136 and newer do not honor remote debugging switches for the default profile. See the [Chrome remote-debugging security change](https://developer.chrome.com/blog/remote-debugging-port). ```javascript -import { launchAndConnectRealBrowser } from 'browser-commander'; +import { launchRealBrowser } from 'browser-commander'; -const connection = await launchAndConnectRealBrowser({ +const connection = await launchRealBrowser({ engine: 'puppeteer', channel: 'chrome', userDataDir: '/tmp/my-automation-profile', @@ -140,6 +140,7 @@ await connection.browser.close(); Cookie seeding copies only cookies you explicitly provide; the helper does not read, decrypt, or expose cookies from a browser's default profile. +`launchAndConnectRealBrowser()` remains available as a descriptive alias. Reuse a saved authenticated session by passing Playwright-compatible storage state as a JSON file path or object. Cookies and localStorage are restored for @@ -359,7 +360,7 @@ const { browser, page } = await connectBrowser({ Playwright accepts `timeout` and Puppeteer accepts `protocolTimeout`. `storageState` can also seed Playwright-compatible cookies and localStorage. -### launchAndConnectRealBrowser(options) +### launchRealBrowser(options) Start an installed browser and connect through `connectBrowser()`. Use `channel` (`chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, @@ -369,6 +370,7 @@ a managed directory under `~/.browser-commander/real-browser/`, rejects known default browser-profile paths, protects its remote-debugging arguments, and returns the spawned `browserProcess`, resolved `cdpEndpoint`, executable path, and profile path alongside `{ browser, page }`. +`launchAndConnectRealBrowser()` is an alias with identical behavior. ### saveStorageState(page, filePath) diff --git a/js/src/browser/real-browser.js b/js/src/browser/real-browser.js index 240a838..01a4c14 100644 --- a/js/src/browser/real-browser.js +++ b/js/src/browser/real-browser.js @@ -362,6 +362,15 @@ export async function launchAndConnectRealBrowser(options = {}) { return await launchAndConnectRealBrowserWithDependencies(options); } +/** + * Short Playwright-style name for {@link launchAndConnectRealBrowser}. + * + * Both names are the same function so existing callers can keep using the + * descriptive name while new code can use the API proposed for real-browser + * launch. + */ +export const launchRealBrowser = launchAndConnectRealBrowser; + /** Dependency-injected implementation used by the public helper and tests. */ export async function launchAndConnectRealBrowserWithDependencies( options = {}, diff --git a/js/src/exports.js b/js/src/exports.js index 93f891f..c0614e9 100644 --- a/js/src/exports.js +++ b/js/src/exports.js @@ -43,7 +43,10 @@ export { // Re-export browser management export { connectBrowser } from './browser/connector.js'; -export { launchAndConnectRealBrowser } from './browser/real-browser.js'; +export { + launchAndConnectRealBrowser, + launchRealBrowser, +} from './browser/real-browser.js'; export { launchBrowser } from './browser/launcher.js'; export { saveStorageState } from './browser/storage-state.js'; export { emulateMedia } from './browser/media.js'; diff --git a/js/tests/unit/browser/real-browser.test.js b/js/tests/unit/browser/real-browser.test.js index 6b07b38..763a0f1 100644 --- a/js/tests/unit/browser/real-browser.test.js +++ b/js/tests/unit/browser/real-browser.test.js @@ -9,8 +9,12 @@ import { buildRealBrowserArgs, launchAndConnectRealBrowser, launchAndConnectRealBrowserWithDependencies, + launchRealBrowser, } from '../../../src/browser/real-browser.js'; -import { launchAndConnectRealBrowser as publicHelper } from '../../../src/index.js'; +import { + launchAndConnectRealBrowser as publicHelper, + launchRealBrowser as publicShortHelper, +} from '../../../src/index.js'; describe('launchAndConnectRealBrowser', () => { let temporaryDirectory; @@ -24,6 +28,8 @@ describe('launchAndConnectRealBrowser', () => { it('is exported from the package API', () => { assert.equal(publicHelper, launchAndConnectRealBrowser); + assert.equal(launchRealBrowser, launchAndConnectRealBrowser); + assert.equal(publicShortHelper, launchRealBrowser); }); it('builds a loopback-only CDP command with a dedicated profile', () => { From f936f92e752e3a2d7c88e9af0fa537154a1f4d66 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:26:57 +0000 Subject: [PATCH 3/7] feat(python): launch installed browsers over CDP --- .../launch-real-browser-python-smoke.py | 63 +++ python/README.md | 30 + python/changelog.d/68.added.md | 3 + python/src/browser_commander/__init__.py | 8 + .../src/browser_commander/browser/__init__.py | 10 + .../browser_commander/browser/real_browser.py | 511 ++++++++++++++++++ python/src/browser_commander/exports.py | 10 + .../tests/unit/browser/test_real_browser.py | 186 +++++++ 8 files changed, 821 insertions(+) create mode 100644 experiments/launch-real-browser-python-smoke.py create mode 100644 python/changelog.d/68.added.md create mode 100644 python/src/browser_commander/browser/real_browser.py create mode 100644 python/tests/unit/browser/test_real_browser.py diff --git a/experiments/launch-real-browser-python-smoke.py b/experiments/launch-real-browser-python-smoke.py new file mode 100644 index 0000000..833e290 --- /dev/null +++ b/experiments/launch-real-browser-python-smoke.py @@ -0,0 +1,63 @@ +"""Launch system Chrome through the Python real-browser lifecycle helper.""" + +from __future__ import annotations + +import asyncio +import shutil +import sys +import tempfile +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPOSITORY_ROOT / "python" / "src")) + +from browser_commander import ( # noqa: E402 + RealBrowserOptions, + launch_real_browser, + make_browser_commander, +) + + +async def main() -> None: + executable = sys.argv[1] if len(sys.argv) > 1 else "/usr/bin/google-chrome" + profile = Path(tempfile.mkdtemp(prefix="browser-commander-python-real-")) + result = await launch_real_browser( + RealBrowserOptions( + engine="playwright", + executable_path=executable, + user_data_dir=str(profile), + headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"], + seed_cookies=[ + { + "name": "attached", + "value": "python", + "url": "https://example.com", + } + ], + ) + ) + + try: + await result.page.goto( + "data:text/html,
Real browser connection works
" + ) + commander = make_browser_commander( + result.page, + enable_network_tracking=False, + enable_navigation_manager=False, + enable_dialog_manager=False, + ) + assert await commander.count("#connected") == 1 + await commander.destroy() + print("python real-browser launch-and-connect smoke test passed") + finally: + await result.browser.close() + if result.browser_process.returncode is None: + result.browser_process.terminate() + await result.browser_process.wait() + shutil.rmtree(profile, ignore_errors=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/README.md b/python/README.md index 3ffeb35..fa60955 100644 --- a/python/README.md +++ b/python/README.md @@ -176,6 +176,36 @@ non-default `--user-data-dir`; remote debugging is intentionally disabled for the default Chrome profile. Cookie seeding uses only values supplied by the caller and does not read the default profile. +### launch_real_browser(options) + +Discover and start a genuine installed Chrome, Edge, Brave, or Chromium with a +dedicated profile, wait for its loopback CDP endpoint, and attach with +Playwright or Selenium: + +```python +from browser_commander import RealBrowserOptions, launch_real_browser + +result = await launch_real_browser( + RealBrowserOptions( + engine="playwright", # or "selenium" + channel="chrome", # chrome, msedge, brave, or chromium + user_data_dir="/tmp/browser-commander-profile", + seed_cookies=[ + {"name": "session", "value": "saved", "url": "https://example.com"} + ], + ) +) + +browser, page = result.browser, result.page +print(result.cdp_endpoint, result.executable_path) +``` + +The helper also supports beta/dev/canary channels and an explicit +`executable_path`. It rejects known default browser profiles and prevents +custom arguments from overriding its loopback address, debugging port, or +profile. The returned `browser_process` can be terminated explicitly after +closing the browser. `launch_and_connect_real_browser()` is an alias. + The `color_scheme` option emulates `prefers-color-scheme` at launch time: ```python diff --git a/python/changelog.d/68.added.md b/python/changelog.d/68.added.md new file mode 100644 index 0000000..3cc44b0 --- /dev/null +++ b/python/changelog.d/68.added.md @@ -0,0 +1,3 @@ +### Added + +- Added `launch_real_browser()` for discovering and starting an installed Chrome-family browser with a dedicated profile before attaching over CDP. diff --git a/python/src/browser_commander/__init__.py b/python/src/browser_commander/__init__.py index 547c77f..4ca7bf5 100644 --- a/python/src/browser_commander/__init__.py +++ b/python/src/browser_commander/__init__.py @@ -37,6 +37,8 @@ # Page trigger system PageTriggerManager, PlaywrightAdapter, + RealBrowserOptions, + RealBrowserResult, ScrollResult, ScrollVerificationResult, SeleniumAdapter, @@ -83,7 +85,9 @@ is_verbose_enabled, # Element visibility is_visible, + launch_and_connect_real_browser, launch_browser, + launch_real_browser, locator, log_element_info, make_url_condition, @@ -149,6 +153,8 @@ # Page trigger system "PageTriggerManager", "PlaywrightAdapter", + "RealBrowserOptions", + "RealBrowserResult", "ScrollResult", "ScrollVerificationResult", "SeleniumAdapter", @@ -195,7 +201,9 @@ "is_verbose_enabled", # Element visibility "is_visible", + "launch_and_connect_real_browser", "launch_browser", + "launch_real_browser", "locator", "log_element_info", "make_browser_commander", diff --git a/python/src/browser_commander/browser/__init__.py b/python/src/browser_commander/browser/__init__.py index eeb1284..8902637 100644 --- a/python/src/browser_commander/browser/__init__.py +++ b/python/src/browser_commander/browser/__init__.py @@ -22,6 +22,12 @@ wait_for_url_stabilization, ) from browser_commander.browser.pdf import pdf +from browser_commander.browser.real_browser import ( + RealBrowserOptions, + RealBrowserResult, + launch_and_connect_real_browser, + launch_real_browser, +) __all__ = [ "ConnectOptions", @@ -29,12 +35,16 @@ "LaunchOptions", "LaunchResult", "NavigationVerificationResult", + "RealBrowserOptions", + "RealBrowserResult", "WaitAfterActionResult", "connect_browser", "default_navigation_verification", "emulate_media", "goto", + "launch_and_connect_real_browser", "launch_browser", + "launch_real_browser", # PDF generation "pdf", "verify_navigation", diff --git a/python/src/browser_commander/browser/real_browser.py b/python/src/browser_commander/browser/real_browser.py new file mode 100644 index 0000000..1fbde36 --- /dev/null +++ b/python/src/browser_commander/browser/real_browser.py @@ -0,0 +1,511 @@ +"""Launch a genuine installed Chrome-family browser and attach over CDP.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import ntpath +import os +import posixpath +import re +import sys +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.request import urlopen + +from browser_commander.browser.connector import ConnectOptions, connect_browser +from browser_commander.browser.launcher import LaunchResult +from browser_commander.core.engine_detection import EngineType + +_MANAGED_ARGUMENTS = ( + "--remote-debugging-address", + "--remote-debugging-port", + "--user-data-dir", +) + +_CHANNEL_EXECUTABLE_NAMES = { + "brave": ("brave-browser", "brave-browser-stable", "brave"), + "chrome": ("google-chrome", "google-chrome-stable", "chrome"), + "chrome-beta": ("google-chrome-beta",), + "chrome-canary": ("google-chrome-canary",), + "chrome-dev": ("google-chrome-unstable",), + "chromium": ("chromium", "chromium-browser"), + "msedge": ("microsoft-edge", "microsoft-edge-stable", "msedge"), + "msedge-beta": ("microsoft-edge-beta",), + "msedge-canary": ("microsoft-edge-canary",), + "msedge-dev": ("microsoft-edge-dev",), +} + + +@dataclass +class RealBrowserOptions: + """Configuration for starting an installed browser and attaching over CDP.""" + + engine: EngineType = "playwright" + channel: str = "chrome" + executable_path: str | None = None + user_data_dir: str | None = None + remote_debugging_port: int = 0 + headless: bool = False + args: list[str] = field(default_factory=list) + startup_timeout: int = 30_000 + slow_mo: int | None = None + timeout: int | None = None + headers: dict[str, str] | None = None + seed_cookies: list[dict[str, Any]] = field(default_factory=list) + verbose: bool = False + + +@dataclass +class RealBrowserResult(LaunchResult): + """Connected browser handles plus spawned-process metadata.""" + + browser_process: Any + cdp_endpoint: str + executable_path: str + user_data_dir: str + + +def _path_module(platform: str) -> Any: + return ntpath if platform == "win32" else posixpath + + +def default_real_browser_user_data_dir( + channel: str, + *, + home_dir: str | os.PathLike[str] | None = None, + platform: str | None = None, +) -> str: + """Return Browser Commander's managed profile path for a channel.""" + + selected_platform = platform or sys.platform + path_module = _path_module(selected_platform) + home = os.fspath(home_dir) if home_dir is not None else str(Path.home()) + directory_name = re.sub(r"[^a-z0-9_.-]", "-", channel, flags=re.IGNORECASE) + return path_module.join( + home, + ".browser-commander", + "real-browser", + directory_name, + ) + + +def known_default_user_data_dirs( + *, + platform: str | None = None, + home_dir: str | os.PathLike[str] | None = None, + environment: Mapping[str, str] | None = None, +) -> list[str]: + """Return known default Chrome-family profile roots for an OS.""" + + selected_platform = platform or sys.platform + path_module = _path_module(selected_platform) + home = os.fspath(home_dir) if home_dir is not None else str(Path.home()) + selected_environment = os.environ if environment is None else environment + + if selected_platform == "darwin": + support = path_module.join(home, "Library", "Application Support") + return [ + path_module.join(support, "Google", name) + for name in ("Chrome", "Chrome Beta", "Chrome Canary", "Chrome Dev") + ] + [ + path_module.join(support, *parts) + for parts in ( + ("Chromium",), + ("BraveSoftware", "Brave-Browser"), + ("BraveSoftware", "Brave-Browser-Beta"), + ("BraveSoftware", "Brave-Browser-Nightly"), + ("Microsoft Edge",), + ("Microsoft Edge Beta",), + ("Microsoft Edge Canary",), + ("Microsoft Edge Dev",), + ) + ] + + if selected_platform == "win32": + local_app_data = selected_environment.get( + "LOCALAPPDATA", + path_module.join(home, "AppData", "Local"), + ) + return [ + path_module.join(local_app_data, *parts) + for parts in ( + ("Google", "Chrome", "User Data"), + ("Google", "Chrome Beta", "User Data"), + ("Google", "Chrome Dev", "User Data"), + ("Google", "Chrome SxS", "User Data"), + ("Chromium", "User Data"), + ("BraveSoftware", "Brave-Browser", "User Data"), + ("BraveSoftware", "Brave-Browser-Beta", "User Data"), + ("BraveSoftware", "Brave-Browser-Nightly", "User Data"), + ("Microsoft", "Edge", "User Data"), + ("Microsoft", "Edge Beta", "User Data"), + ("Microsoft", "Edge Dev", "User Data"), + ("Microsoft", "Edge SxS", "User Data"), + ) + ] + + return [ + path_module.join(home, ".config", *parts) + for parts in ( + ("google-chrome",), + ("google-chrome-beta",), + ("google-chrome-unstable",), + ("chromium",), + ("BraveSoftware", "Brave-Browser"), + ("BraveSoftware", "Brave-Browser-Beta"), + ("BraveSoftware", "Brave-Browser-Nightly"), + ("microsoft-edge",), + ("microsoft-edge-beta",), + ("microsoft-edge-dev",), + ) + ] + + +def assert_dedicated_user_data_dir( + user_data_dir: str | os.PathLike[str], + *, + platform: str | None = None, + home_dir: str | os.PathLike[str] | None = None, + environment: Mapping[str, str] | None = None, +) -> None: + """Reject known default browser profiles before enabling remote debugging.""" + + selected_platform = platform or sys.platform + path_module = _path_module(selected_platform) + + def normalize(value: str | os.PathLike[str]) -> str: + normalized = path_module.normcase(path_module.abspath(os.fspath(value))) + return normalized.rstrip("\\/") + + requested = normalize(user_data_dir) + defaults = known_default_user_data_dirs( + platform=selected_platform, + home_dir=home_dir, + environment=environment, + ) + if any(normalize(directory) == requested for directory in defaults): + msg = ( + "launch_real_browser requires a dedicated user_data_dir, " + "not a browser default profile" + ) + raise ValueError(msg) + + +def _browser_install_candidates( + channel: str, + *, + platform: str | None = None, + environment: Mapping[str, str] | None = None, + home_dir: str | os.PathLike[str] | None = None, +) -> list[str]: + selected_platform = platform or sys.platform + selected_environment = os.environ if environment is None else environment + names = _CHANNEL_EXECUTABLE_NAMES.get(channel) + if names is None: + expected = ", ".join(_CHANNEL_EXECUTABLE_NAMES) + msg = f"Unknown browser channel: {channel}. Expected one of {expected}" + raise ValueError(msg) + + candidates: list[str] = [] + if selected_platform == "darwin": + applications = { + "brave": "Brave Browser.app/Contents/MacOS/Brave Browser", + "chrome": "Google Chrome.app/Contents/MacOS/Google Chrome", + "chrome-beta": "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", + "chrome-canary": "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "chrome-dev": "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev", + "chromium": "Chromium.app/Contents/MacOS/Chromium", + "msedge": "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "msedge-beta": "Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta", + "msedge-canary": "Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary", + "msedge-dev": "Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev", + } + relative = applications[channel] + candidates.append(posixpath.join("/Applications", relative)) + home = os.fspath(home_dir) if home_dir is not None else str(Path.home()) + candidates.append(posixpath.join(home, "Applications", relative)) + elif selected_platform == "win32": + relative_paths = { + "brave": ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + "chrome": ("Google", "Chrome", "Application", "chrome.exe"), + "chrome-beta": ("Google", "Chrome Beta", "Application", "chrome.exe"), + "chrome-canary": ("Google", "Chrome SxS", "Application", "chrome.exe"), + "chrome-dev": ("Google", "Chrome Dev", "Application", "chrome.exe"), + "chromium": ("Chromium", "Application", "chrome.exe"), + "msedge": ("Microsoft", "Edge", "Application", "msedge.exe"), + "msedge-beta": ("Microsoft", "Edge Beta", "Application", "msedge.exe"), + "msedge-canary": ("Microsoft", "Edge SxS", "Application", "msedge.exe"), + "msedge-dev": ("Microsoft", "Edge Dev", "Application", "msedge.exe"), + } + roots = ( + selected_environment.get("PROGRAMFILES"), + selected_environment.get("PROGRAMFILES(X86)"), + selected_environment.get("LOCALAPPDATA"), + ) + candidates.extend( + ntpath.join(root, *relative_paths[channel]) for root in roots if root + ) + else: + for name in names: + candidates.extend((f"/usr/bin/{name}", f"/usr/local/bin/{name}")) + if channel == "chrome": + candidates.append("/opt/google/chrome/google-chrome") + + path_module = _path_module(selected_platform) + separator = ";" if selected_platform == "win32" else os.pathsep + for directory in selected_environment.get("PATH", "").split(separator): + if not directory: + continue + for name in names: + executable_name = f"{name}.exe" if selected_platform == "win32" else name + candidates.append(path_module.join(directory, executable_name)) + return list(dict.fromkeys(candidates)) + + +def resolve_system_browser_executable( + *, + channel: str = "chrome", + executable_path: str | os.PathLike[str] | None = None, +) -> str: + """Resolve a genuine installed Chrome-family browser executable.""" + + if executable_path is not None: + candidates = [str(Path(executable_path).expanduser().resolve())] + else: + candidates = _browser_install_candidates(channel) + + for candidate in candidates: + if Path(candidate).is_file() and os.access(candidate, os.X_OK): + return candidate + + if executable_path is not None: + msg = f"Browser executable is not accessible: {executable_path}" + else: + msg = f"Could not find an installed {channel} browser; provide executable_path" + raise FileNotFoundError(msg) + + +def build_real_browser_args( + *, + user_data_dir: str | os.PathLike[str], + remote_debugging_port: int = 0, + headless: bool = False, + args: list[str] | None = None, +) -> list[str]: + """Build the protected command line for the installed browser process.""" + + if ( + isinstance(remote_debugging_port, bool) + or not isinstance(remote_debugging_port, int) + or not 0 <= remote_debugging_port <= 65_535 + ): + msg = "remote_debugging_port must be an integer from 0 to 65535" + raise ValueError(msg) + + extra_args = args or [] + for argument in extra_args: + if any( + argument == managed or argument.startswith(f"{managed}=") + for managed in _MANAGED_ARGUMENTS + ): + msg = f"{argument} is managed by launch_real_browser" + raise ValueError(msg) + + return [ + "--remote-debugging-address=127.0.0.1", + f"--remote-debugging-port={remote_debugging_port}", + f"--user-data-dir={os.fspath(user_data_dir)}", + "--no-first-run", + "--no-default-browser-check", + *(["--headless=new"] if headless else []), + *extra_args, + ] + + +def _fetch_cdp_version(endpoint: str, timeout_seconds: float) -> bool: + with urlopen( + f"{endpoint.rstrip('/')}/json/version", + timeout=timeout_seconds, + ) as response: + if response.status != 200: + return False + payload = json.load(response) + return bool(payload.get("webSocketDebuggerUrl")) + + +async def wait_for_cdp_endpoint( + *, + remote_debugging_port: int, + user_data_dir: str | os.PathLike[str], + browser_process: Any, + startup_timeout: int = 30_000, +) -> str: + """Wait until the spawned browser publishes a usable CDP endpoint.""" + + if startup_timeout <= 0: + msg = "startup_timeout must be greater than zero" + raise ValueError(msg) + + loop = asyncio.get_running_loop() + deadline = loop.time() + startup_timeout / 1000 + active_port_path = Path(user_data_dir) / "DevToolsActivePort" + + while loop.time() < deadline: + if browser_process.returncode is not None: + msg = ( + "Browser exited before its DevTools endpoint was ready " + f"(exit {browser_process.returncode})" + ) + raise RuntimeError(msg) + + port = remote_debugging_port + if port == 0: + try: + port = int(active_port_path.read_text(encoding="utf-8").splitlines()[0]) + except (OSError, IndexError, ValueError): + port = 0 + + if port > 0: + endpoint = f"http://127.0.0.1:{port}" + remaining = max(0.001, deadline - loop.time()) + try: + ready = await asyncio.to_thread( + _fetch_cdp_version, + endpoint, + min(remaining, 0.5), + ) + if ready: + return endpoint + except (OSError, TimeoutError, ValueError, json.JSONDecodeError): + pass + await asyncio.sleep(0.1) + + msg = f"Timed out after {startup_timeout}ms waiting for the DevTools endpoint" + raise TimeoutError(msg) + + +async def _spawn_browser( + executable: str, + arguments: list[str], + *, + verbose: bool, +) -> asyncio.subprocess.Process: + output = None if verbose else asyncio.subprocess.DEVNULL + return await asyncio.create_subprocess_exec( + executable, + *arguments, + stdin=asyncio.subprocess.DEVNULL, + stdout=output, + stderr=output, + ) + + +async def _resolve(value: Any) -> Any: + return await value if inspect.isawaitable(value) else value + + +async def _terminate_process(process: Any) -> None: + if process.returncode is not None: + return + process.terminate() + wait = getattr(process, "wait", None) + if wait is None: + return + pending = wait() + if not inspect.isawaitable(pending): + return + try: + await asyncio.wait_for(pending, timeout=5) + except asyncio.TimeoutError: + kill = getattr(process, "kill", None) + if kill is not None: + kill() + await wait() + + +async def launch_real_browser( + options: RealBrowserOptions | None = None, +) -> RealBrowserResult: + """Start an installed browser with a dedicated profile and attach over CDP.""" + + return await launch_real_browser_with_dependencies(options or RealBrowserOptions()) + + +async def launch_real_browser_with_dependencies( + options: RealBrowserOptions, + *, + resolve_executable: Any = resolve_system_browser_executable, + spawn_browser: Any = _spawn_browser, + wait_for_endpoint: Any = wait_for_cdp_endpoint, + connect: Any = connect_browser, +) -> RealBrowserResult: + """Dependency-injected implementation used by the public helper and tests.""" + + if options.engine not in ("playwright", "selenium"): + msg = f"Invalid engine: {options.engine}. Expected 'playwright' or 'selenium'" + raise ValueError(msg) + + user_data_dir = options.user_data_dir or default_real_browser_user_data_dir( + options.channel + ) + assert_dedicated_user_data_dir(user_data_dir) + Path(user_data_dir).mkdir(parents=True, exist_ok=True) + + executable_path = await _resolve( + resolve_executable( + channel=options.channel, + executable_path=options.executable_path, + ) + ) + arguments = build_real_browser_args( + user_data_dir=user_data_dir, + remote_debugging_port=options.remote_debugging_port, + headless=options.headless, + args=options.args, + ) + browser_process = await _resolve( + spawn_browser(executable_path, arguments, verbose=options.verbose) + ) + + try: + cdp_endpoint = await _resolve( + wait_for_endpoint( + remote_debugging_port=options.remote_debugging_port, + user_data_dir=user_data_dir, + browser_process=browser_process, + startup_timeout=options.startup_timeout, + ) + ) + connection = await _resolve( + connect( + ConnectOptions( + engine=options.engine, + cdp_endpoint=cdp_endpoint, + slow_mo=options.slow_mo, + timeout=options.timeout, + headers=options.headers, + seed_cookies=options.seed_cookies, + verbose=options.verbose, + ) + ) + ) + except BaseException: + await _terminate_process(browser_process) + raise + + return RealBrowserResult( + browser=connection.browser, + page=connection.page, + browser_process=browser_process, + cdp_endpoint=cdp_endpoint, + executable_path=str(executable_path), + user_data_dir=str(user_data_dir), + ) + + +# Retain the descriptive helper name introduced by the JavaScript API. +launch_and_connect_real_browser = launch_real_browser diff --git a/python/src/browser_commander/exports.py b/python/src/browser_commander/exports.py index d5ca2b6..e1ba1ad 100644 --- a/python/src/browser_commander/exports.py +++ b/python/src/browser_commander/exports.py @@ -28,6 +28,12 @@ wait_for_url_stabilization, ) from browser_commander.browser.pdf import pdf +from browser_commander.browser.real_browser import ( + RealBrowserOptions, + RealBrowserResult, + launch_and_connect_real_browser, + launch_real_browser, +) from browser_commander.core.constants import CHROME_ARGS, TIMING # Re-export new core components @@ -170,6 +176,8 @@ # Page trigger system "PageTriggerManager", "PlaywrightAdapter", + "RealBrowserOptions", + "RealBrowserResult", "ScrollResult", "ScrollVerificationResult", "SeleniumAdapter", @@ -219,7 +227,9 @@ "key_down", "key_up", # Browser management + "launch_and_connect_real_browser", "launch_browser", + "launch_real_browser", "locator", "log_element_info", "make_url_condition", diff --git a/python/tests/unit/browser/test_real_browser.py b/python/tests/unit/browser/test_real_browser.py new file mode 100644 index 0000000..b4d1e4e --- /dev/null +++ b/python/tests/unit/browser/test_real_browser.py @@ -0,0 +1,186 @@ +"""Tests for launching and attaching to genuine installed browsers.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from browser_commander import ( + RealBrowserOptions, + launch_and_connect_real_browser, + launch_real_browser, +) +from browser_commander.browser.real_browser import ( + _browser_install_candidates, + assert_dedicated_user_data_dir, + build_real_browser_args, + launch_real_browser_with_dependencies, +) + + +def test_public_api_exports_compatible_helper_names() -> None: + assert launch_and_connect_real_browser is launch_real_browser + + +def test_builds_protected_loopback_command() -> None: + arguments = build_real_browser_args( + user_data_dir="/tmp/browser-commander-dedicated", + remote_debugging_port=9333, + headless=True, + args=["--lang=en-US"], + ) + + assert arguments == [ + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=9333", + "--user-data-dir=/tmp/browser-commander-dedicated", + "--no-first-run", + "--no-default-browser-check", + "--headless=new", + "--lang=en-US", + ] + + +def test_rejects_default_profiles_and_managed_arguments(tmp_path: Any) -> None: + fake_home = tmp_path / "home" + with pytest.raises(ValueError, match="dedicated user_data_dir"): + assert_dedicated_user_data_dir( + fake_home / ".config" / "google-chrome", + platform="linux", + home_dir=fake_home, + environment={}, + ) + + with pytest.raises(ValueError, match="managed by launch_real_browser"): + build_real_browser_args( + user_data_dir=tmp_path / "dedicated", + args=["--remote-debugging-port=9222"], + ) + + +@pytest.mark.parametrize( + ("platform", "channel", "environment", "expected"), + [ + ( + "darwin", + "chrome", + {"PATH": ""}, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ), + ( + "win32", + "msedge", + {"PROGRAMFILES": r"C:\Program Files", "PATH": ""}, + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", + ), + ( + "linux", + "brave", + {"PATH": "/custom/bin"}, + "/custom/bin/brave-browser", + ), + ], +) +def test_discovers_standard_browser_locations_on_each_platform( + platform: str, + channel: str, + environment: dict[str, str], + expected: str, +) -> None: + candidates = _browser_install_candidates( + channel, + platform=platform, + environment=environment, + home_dir="/Users/tester", + ) + + assert expected in candidates + + +@pytest.mark.asyncio +async def test_spawns_waits_connects_and_returns_process_metadata( + tmp_path: Any, +) -> None: + calls: list[Any] = [] + + class FakeProcess: + returncode = None + + def terminate(self) -> None: + calls.append(("terminate",)) + + process = FakeProcess() + browser = object() + page = object() + + async def resolve_executable(**options: Any) -> str: + calls.append(("resolve", options)) + return "/opt/google/chrome" + + async def spawn_browser( + executable: str, arguments: list[str], **options: Any + ) -> Any: + calls.append(("spawn", executable, arguments, options)) + return process + + async def wait_for_endpoint(**options: Any) -> str: + calls.append(("wait", options)) + return "http://127.0.0.1:9444" + + async def connect(options: Any) -> Any: + calls.append(("connect", options)) + from browser_commander.browser.launcher import LaunchResult + + return LaunchResult(browser=browser, page=page) + + result = await launch_real_browser_with_dependencies( + RealBrowserOptions( + engine="selenium", + channel="chrome", + user_data_dir=str(tmp_path / "profile"), + remote_debugging_port=0, + seed_cookies=[{"name": "SID", "value": "saved"}], + ), + resolve_executable=resolve_executable, + spawn_browser=spawn_browser, + wait_for_endpoint=wait_for_endpoint, + connect=connect, + ) + + assert result.browser is browser + assert result.page is page + assert result.browser_process is process + assert result.cdp_endpoint == "http://127.0.0.1:9444" + assert result.executable_path == "/opt/google/chrome" + assert result.user_data_dir == str(tmp_path / "profile") + connect_options = calls[-1][1] + assert connect_options.engine == "selenium" + assert connect_options.cdp_endpoint == "http://127.0.0.1:9444" + assert connect_options.seed_cookies == [{"name": "SID", "value": "saved"}] + + +@pytest.mark.asyncio +async def test_terminates_spawned_browser_when_connection_fails(tmp_path: Any) -> None: + terminated = False + + class FakeProcess: + returncode = None + + def terminate(self) -> None: + nonlocal terminated + terminated = True + + async def connect(_options: Any) -> Any: + raise RuntimeError("connection failed") + + with pytest.raises(RuntimeError, match="connection failed"): + await launch_real_browser_with_dependencies( + RealBrowserOptions(user_data_dir=str(tmp_path / "profile")), + resolve_executable=lambda **_options: "/opt/google/chrome", + spawn_browser=lambda *_args, **_options: FakeProcess(), + wait_for_endpoint=lambda **_options: "http://127.0.0.1:9222", + connect=connect, + ) + + assert terminated From 093034c611609d60f931279db6fc1f3c863480a7 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:27:18 +0000 Subject: [PATCH 4/7] feat(rust): launch installed browsers over CDP --- rust/README.md | 32 + rust/changelog.d/68.real-browser-launch.md | 7 + rust/src/browser/mod.rs | 6 + rust/src/browser/real_browser.rs | 830 +++++++++++++++++++++ rust/src/lib.rs | 13 +- rust/tests/real_browser_api.rs | 35 + rust/tests/real_browser_smoke.rs | 75 ++ 7 files changed, 993 insertions(+), 5 deletions(-) create mode 100644 rust/changelog.d/68.real-browser-launch.md create mode 100644 rust/src/browser/real_browser.rs create mode 100644 rust/tests/real_browser_api.rs create mode 100644 rust/tests/real_browser_smoke.rs diff --git a/rust/README.md b/rust/README.md index c8a8bd4..1f2faa0 100644 --- a/rust/README.md +++ b/rust/README.md @@ -161,6 +161,38 @@ pass a non-default `--user-data-dir` together with the remote-debugging flag; Chrome intentionally disables remote debugging for its default data directory. Cookies can be supplied explicitly with `ConnectOptions::seed_cookies()`. +### Launch and Connect to an Installed Browser + +`launch_real_browser()` discovers and starts genuine installed Chrome, Edge, +Brave, or Chromium with a dedicated profile, waits for its loopback CDP +endpoint, and attaches with Chromiumoxide or the Playwright/Puppeteer bridges: + +```rust +use browser_commander::prelude::*; +use serde_json::json; + +let result = launch_real_browser( + RealBrowserOptions::playwright() + .channel("chrome") + .user_data_dir("/tmp/browser-commander-profile") + .seed_cookies(vec![json!({ + "name": "session", + "value": "saved", + "url": "https://example.com" + })]) + .node_working_dir("./js"), +).await?; + +result.page.goto("https://example.com").await?; +println!("CDP endpoint: {}", result.cdp_endpoint); +``` + +An explicit `executable_path` can replace channel discovery. Known default +profiles and custom arguments that override the loopback address, debugging +port, or profile are rejected. `RealBrowserLaunchResult` owns a +`browser_process` handle and terminates the spawned browser when dropped. +`launch_and_connect_real_browser()` is an alias. + ### Navigation ```rust diff --git a/rust/changelog.d/68.real-browser-launch.md b/rust/changelog.d/68.real-browser-launch.md new file mode 100644 index 0000000..5c65605 --- /dev/null +++ b/rust/changelog.d/68.real-browser-launch.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- Added `launch_real_browser()` for discovering and starting an installed Chrome-family browser with a dedicated profile before attaching over CDP. diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs index e7bfd85..b18b520 100644 --- a/rust/src/browser/mod.rs +++ b/rust/src/browser/mod.rs @@ -10,6 +10,7 @@ pub mod launcher; pub mod media; pub mod navigation_ops; pub mod node_bridge; +pub mod real_browser; pub use chromiumoxide_adapter::ChromiumoxidePage; pub use connector::{connect_browser, ConnectOptions}; @@ -20,3 +21,8 @@ pub use navigation_ops::{ NavigationResult, NavigationVerificationResult, WaitUntil, }; pub use node_bridge::NodeBridgePage; +pub use real_browser::{ + assert_dedicated_user_data_dir, build_real_browser_args, default_real_browser_user_data_dir, + launch_and_connect_real_browser, launch_real_browser, resolve_system_browser_executable, + BrowserProcess, RealBrowserLaunchResult, RealBrowserOptions, +}; diff --git a/rust/src/browser/real_browser.rs b/rust/src/browser/real_browser.rs new file mode 100644 index 0000000..6369d1d --- /dev/null +++ b/rust/src/browser/real_browser.rs @@ -0,0 +1,830 @@ +//! Launch genuine installed Chrome-family browsers and attach over CDP. + +use std::collections::HashSet; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::browser::connector::{connect_browser, ConnectOptions}; +use crate::browser::launcher::{Browser, LaunchResult}; +use crate::core::engine::{EngineAdapter, EngineType}; + +const MANAGED_ARGUMENTS: [&str; 3] = [ + "--remote-debugging-address", + "--remote-debugging-port", + "--user-data-dir", +]; + +/// Options for launching an installed browser and attaching over CDP. +#[derive(Debug, Clone)] +pub struct RealBrowserOptions { + /// Browser Commander engine used after the browser starts. + pub engine: EngineType, + /// Installed Chrome-family channel to discover. + pub channel: String, + /// Explicit installed-browser executable, bypassing channel discovery. + pub executable_path: Option, + /// Dedicated, non-default browser profile. + pub user_data_dir: Option, + /// Loopback CDP port. Zero lets Chrome choose an available port. + pub remote_debugging_port: u16, + /// Run the installed browser headlessly. + pub headless: bool, + /// Additional browser arguments. + pub args: Vec, + /// Maximum time to wait for Chrome's `/json/version` endpoint. + pub startup_timeout: Duration, + /// Delay Playwright/Puppeteer operations by this many milliseconds. + pub slow_mo: u64, + /// Optional connection timeout. + pub timeout: Option, + /// Optional Puppeteer timeout for individual CDP calls. + pub protocol_timeout: Option, + /// Cookies to seed immediately after attaching. + pub seed_cookies: Vec, + /// Enable browser and connector logging. + pub verbose: bool, + /// Node.js executable for Playwright/Puppeteer bridge engines. + pub node_executable: Option, + /// Directory where Node resolves Playwright/Puppeteer. + pub node_working_dir: Option, +} + +impl Default for RealBrowserOptions { + fn default() -> Self { + Self { + engine: EngineType::Chromiumoxide, + channel: "chrome".to_string(), + executable_path: None, + user_data_dir: None, + remote_debugging_port: 0, + headless: false, + args: Vec::new(), + startup_timeout: Duration::from_secs(30), + slow_mo: 0, + timeout: None, + protocol_timeout: None, + seed_cookies: Vec::new(), + verbose: false, + node_executable: None, + node_working_dir: None, + } + } +} + +impl RealBrowserOptions { + /// Create native Chromiumoxide options. + pub fn chromiumoxide() -> Self { + Self::default() + } + + /// Create Playwright bridge options. + pub fn playwright() -> Self { + Self { + engine: EngineType::Playwright, + slow_mo: 150, + ..Self::default() + } + } + + /// Create Puppeteer bridge options. + pub fn puppeteer() -> Self { + Self { + engine: EngineType::Puppeteer, + ..Self::default() + } + } + + /// Select an installed browser channel. + pub fn channel(mut self, channel: impl Into) -> Self { + self.channel = channel.into(); + self + } + + /// Select an explicit installed-browser executable. + pub fn executable_path(mut self, executable_path: impl Into) -> Self { + self.executable_path = Some(executable_path.into()); + self + } + + /// Select a dedicated browser profile. + pub fn user_data_dir(mut self, user_data_dir: impl Into) -> Self { + self.user_data_dir = Some(user_data_dir.into()); + self + } + + /// Select a loopback CDP port. Zero asks Chrome to allocate one. + pub fn remote_debugging_port(mut self, port: u16) -> Self { + self.remote_debugging_port = port; + self + } + + /// Enable or disable headless mode. + pub fn headless(mut self, headless: bool) -> Self { + self.headless = headless; + self + } + + /// Set additional browser arguments. + pub fn with_args(mut self, args: Vec) -> Self { + self.args = args; + self + } + + /// Set the CDP readiness timeout. + pub fn startup_timeout(mut self, timeout: Duration) -> Self { + self.startup_timeout = timeout; + self + } + + /// Set the engine operation delay. + pub fn slow_mo(mut self, milliseconds: u64) -> Self { + self.slow_mo = milliseconds; + self + } + + /// Set the connection timeout. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Set Puppeteer's timeout for individual CDP calls. + pub fn protocol_timeout(mut self, timeout: Duration) -> Self { + self.protocol_timeout = Some(timeout); + self + } + + /// Seed cookies after attaching. + pub fn seed_cookies(mut self, cookies: Vec) -> Self { + self.seed_cookies = cookies; + self + } + + /// Enable launch and connection logging. + pub fn verbose(mut self, verbose: bool) -> Self { + self.verbose = verbose; + self + } + + /// Override the Node.js executable for bridge engines. + pub fn node_executable(mut self, executable: impl Into) -> Self { + self.node_executable = Some(executable.into()); + self + } + + /// Set the directory where Node resolves Playwright/Puppeteer. + pub fn node_working_dir(mut self, directory: impl Into) -> Self { + self.node_working_dir = Some(directory.into()); + self + } + + /// Resolve the configured or managed dedicated profile path. + pub fn get_user_data_dir(&self) -> PathBuf { + self.user_data_dir + .clone() + .unwrap_or_else(|| default_real_browser_user_data_dir(&self.channel)) + } +} + +/// Owned installed-browser process. Dropping it terminates the spawned browser. +pub struct BrowserProcess { + child: Child, +} + +impl BrowserProcess { + fn new(child: Child) -> Self { + Self { child } + } + + /// Operating-system process identifier. + pub fn id(&self) -> u32 { + self.child.id() + } + + /// Return the exit status if the browser has stopped. + pub fn try_wait(&mut self) -> io::Result> { + self.child.try_wait() + } + + /// Terminate and reap the installed-browser process. + pub fn kill(&mut self) -> io::Result<()> { + if self.child.try_wait()?.is_some() { + return Ok(()); + } + self.child.kill()?; + self.child.wait()?; + Ok(()) + } +} + +impl std::fmt::Debug for BrowserProcess { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BrowserProcess") + .field("id", &self.id()) + .finish() + } +} + +impl Drop for BrowserProcess { + fn drop(&mut self) { + let _ = self.kill(); + } +} + +/// Browser/page handles plus metadata for the spawned installed browser. +pub struct RealBrowserLaunchResult { + /// Browser metadata matching [`LaunchResult`]. + pub browser: Browser, + /// Shared engine adapter matching [`LaunchResult`]. + pub page: Arc, + /// Resolved loopback DevTools endpoint. + pub cdp_endpoint: String, + /// Resolved installed-browser executable. + pub executable_path: PathBuf, + /// Dedicated profile used by the browser. + pub user_data_dir: PathBuf, + /// Owned process handle. Dropping the result terminates the browser. + pub browser_process: BrowserProcess, +} + +impl std::fmt::Debug for RealBrowserLaunchResult { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RealBrowserLaunchResult") + .field("browser", &self.browser) + .field("page", &"") + .field("cdp_endpoint", &self.cdp_endpoint) + .field("executable_path", &self.executable_path) + .field("user_data_dir", &self.user_data_dir) + .field("browser_process", &self.browser_process) + .finish() + } +} + +/// Return Browser Commander's managed dedicated profile for a channel. +pub fn default_real_browser_user_data_dir(channel: &str) -> PathBuf { + let directory_name: String = channel + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || "_.-".contains(character) { + character + } else { + '-' + } + }) + .collect(); + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".browser-commander") + .join("real-browser") + .join(directory_name) +} + +fn known_default_user_data_dirs() -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + #[cfg(target_os = "macos")] + { + let support = home.join("Library").join("Application Support"); + return vec![ + support.join("Google/Chrome"), + support.join("Google/Chrome Beta"), + support.join("Google/Chrome Canary"), + support.join("Google/Chrome Dev"), + support.join("Chromium"), + support.join("BraveSoftware/Brave-Browser"), + support.join("BraveSoftware/Brave-Browser-Beta"), + support.join("BraveSoftware/Brave-Browser-Nightly"), + support.join("Microsoft Edge"), + support.join("Microsoft Edge Beta"), + support.join("Microsoft Edge Canary"), + support.join("Microsoft Edge Dev"), + ]; + } + + #[cfg(target_os = "windows")] + { + let local = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Local")); + return vec![ + local.join("Google/Chrome/User Data"), + local.join("Google/Chrome Beta/User Data"), + local.join("Google/Chrome Dev/User Data"), + local.join("Google/Chrome SxS/User Data"), + local.join("Chromium/User Data"), + local.join("BraveSoftware/Brave-Browser/User Data"), + local.join("BraveSoftware/Brave-Browser-Beta/User Data"), + local.join("BraveSoftware/Brave-Browser-Nightly/User Data"), + local.join("Microsoft/Edge/User Data"), + local.join("Microsoft/Edge Beta/User Data"), + local.join("Microsoft/Edge Dev/User Data"), + local.join("Microsoft/Edge SxS/User Data"), + ]; + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + vec![ + home.join(".config/google-chrome"), + home.join(".config/google-chrome-beta"), + home.join(".config/google-chrome-unstable"), + home.join(".config/chromium"), + home.join(".config/BraveSoftware/Brave-Browser"), + home.join(".config/BraveSoftware/Brave-Browser-Beta"), + home.join(".config/BraveSoftware/Brave-Browser-Nightly"), + home.join(".config/microsoft-edge"), + home.join(".config/microsoft-edge-beta"), + home.join(".config/microsoft-edge-dev"), + ] + } +} + +fn normalize_for_comparison(path: &Path) -> PathBuf { + let normalized = std::fs::canonicalize(path).unwrap_or_else(|_| { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } + }); + + #[cfg(target_os = "windows")] + { + return PathBuf::from(normalized.to_string_lossy().to_lowercase()); + } + + #[cfg(not(target_os = "windows"))] + { + normalized + } +} + +/// Ensure Chrome is not asked to expose a known default profile over CDP. +pub fn assert_dedicated_user_data_dir(user_data_dir: &Path) -> Result<(), anyhow::Error> { + let requested = normalize_for_comparison(user_data_dir); + if known_default_user_data_dirs() + .iter() + .any(|default| normalize_for_comparison(default) == requested) + { + return Err(anyhow::anyhow!( + "launch_real_browser requires a dedicated user_data_dir, not a browser default profile" + )); + } + Ok(()) +} + +fn channel_executable_names(channel: &str) -> Result<&'static [&'static str], anyhow::Error> { + match channel { + "brave" => Ok(&["brave-browser", "brave-browser-stable", "brave"]), + "chrome" => Ok(&["google-chrome", "google-chrome-stable", "chrome"]), + "chrome-beta" => Ok(&["google-chrome-beta"]), + "chrome-canary" => Ok(&["google-chrome-canary"]), + "chrome-dev" => Ok(&["google-chrome-unstable"]), + "chromium" => Ok(&["chromium", "chromium-browser"]), + "msedge" => Ok(&["microsoft-edge", "microsoft-edge-stable", "msedge"]), + "msedge-beta" => Ok(&["microsoft-edge-beta"]), + "msedge-canary" => Ok(&["microsoft-edge-canary"]), + "msedge-dev" => Ok(&["microsoft-edge-dev"]), + _ => Err(anyhow::anyhow!( + "unknown browser channel: {channel}; expected chrome, chrome-beta, chrome-canary, chrome-dev, chromium, brave, msedge, msedge-beta, msedge-canary, or msedge-dev" + )), + } +} + +fn browser_install_candidates(channel: &str) -> Result, anyhow::Error> { + let names = channel_executable_names(channel)?; + let mut candidates = Vec::new(); + + #[cfg(target_os = "macos")] + { + let relative = match channel { + "brave" => "Brave Browser.app/Contents/MacOS/Brave Browser", + "chrome" => "Google Chrome.app/Contents/MacOS/Google Chrome", + "chrome-beta" => "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", + "chrome-canary" => "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "chrome-dev" => "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev", + "chromium" => "Chromium.app/Contents/MacOS/Chromium", + "msedge" => "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "msedge-beta" => "Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta", + "msedge-canary" => "Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary", + "msedge-dev" => "Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev", + _ => unreachable!("channel was validated above"), + }; + candidates.push(Path::new("/Applications").join(relative)); + if let Some(home) = dirs::home_dir() { + candidates.push(home.join("Applications").join(relative)); + } + } + + #[cfg(target_os = "windows")] + { + let relative: &[&str] = match channel { + "brave" => &["BraveSoftware", "Brave-Browser", "Application", "brave.exe"], + "chrome" => &["Google", "Chrome", "Application", "chrome.exe"], + "chrome-beta" => &["Google", "Chrome Beta", "Application", "chrome.exe"], + "chrome-canary" => &["Google", "Chrome SxS", "Application", "chrome.exe"], + "chrome-dev" => &["Google", "Chrome Dev", "Application", "chrome.exe"], + "chromium" => &["Chromium", "Application", "chrome.exe"], + "msedge" => &["Microsoft", "Edge", "Application", "msedge.exe"], + "msedge-beta" => &["Microsoft", "Edge Beta", "Application", "msedge.exe"], + "msedge-canary" => &["Microsoft", "Edge SxS", "Application", "msedge.exe"], + "msedge-dev" => &["Microsoft", "Edge Dev", "Application", "msedge.exe"], + _ => unreachable!("channel was validated above"), + }; + for key in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] { + if let Some(root) = std::env::var_os(key) { + let mut candidate = PathBuf::from(root); + candidate.extend(relative); + candidates.push(candidate); + } + } + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + for name in names { + candidates.push(Path::new("/usr/bin").join(name)); + candidates.push(Path::new("/usr/local/bin").join(name)); + } + if channel == "chrome" { + candidates.push(PathBuf::from("/opt/google/chrome/google-chrome")); + } + } + + if let Some(path) = std::env::var_os("PATH") { + for directory in std::env::split_paths(&path) { + for name in names { + #[cfg(target_os = "windows")] + let executable_name = format!("{name}.exe"); + #[cfg(not(target_os = "windows"))] + let executable_name = (*name).to_string(); + candidates.push(directory.join(executable_name)); + } + } + } + + let mut seen = HashSet::new(); + candidates.retain(|candidate| seen.insert(candidate.clone())); + Ok(candidates) +} + +fn is_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + + #[cfg(not(unix))] + { + true + } +} + +/// Resolve a genuine installed Chrome-family browser executable. +pub fn resolve_system_browser_executable( + options: &RealBrowserOptions, +) -> Result { + let candidates = if let Some(executable_path) = &options.executable_path { + vec![normalize_for_comparison(executable_path)] + } else { + browser_install_candidates(&options.channel)? + }; + + for candidate in candidates { + if is_executable(&candidate) { + return Ok(candidate); + } + } + + if let Some(executable_path) = &options.executable_path { + Err(anyhow::anyhow!( + "browser executable is not accessible: {}", + executable_path.display() + )) + } else { + Err(anyhow::anyhow!( + "could not find an installed {} browser; provide executable_path", + options.channel + )) + } +} + +/// Build the protected command line for an installed browser process. +pub fn build_real_browser_args(options: &RealBrowserOptions) -> Result, anyhow::Error> { + for argument in &options.args { + if MANAGED_ARGUMENTS + .iter() + .any(|managed| argument == managed || argument.starts_with(&format!("{managed}="))) + { + return Err(anyhow::anyhow!( + "{argument} is managed by launch_real_browser" + )); + } + } + + let mut arguments = vec![ + "--remote-debugging-address=127.0.0.1".to_string(), + format!("--remote-debugging-port={}", options.remote_debugging_port), + format!("--user-data-dir={}", options.get_user_data_dir().display()), + "--no-first-run".to_string(), + "--no-default-browser-check".to_string(), + ]; + if options.headless { + arguments.push("--headless=new".to_string()); + } + arguments.extend(options.args.clone()); + Ok(arguments) +} + +fn response_has_cdp_websocket(response: &[u8]) -> bool { + if !(response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200")) { + return false; + } + let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + serde_json::from_slice::(&response[header_end + 4..]) + .ok() + .and_then(|value| value.get("webSocketDebuggerUrl").cloned()) + .and_then(|value| value.as_str().map(str::to_owned)) + .is_some() +} + +async fn fetch_cdp_version(port: u16, timeout: Duration) -> bool { + let request = format!( + "GET /json/version HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + let request_future = async { + let mut stream = TcpStream::connect(("127.0.0.1", port)).await?; + stream.write_all(request.as_bytes()).await?; + let mut response = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let bytes_read = stream.read(&mut chunk).await?; + if bytes_read == 0 { + break; + } + response.extend_from_slice(&chunk[..bytes_read]); + if response_has_cdp_websocket(&response) { + return Ok::(true); + } + if response.len() > 1024 * 1024 { + return Ok(false); + } + } + Ok(response_has_cdp_websocket(&response)) + }; + matches!( + tokio::time::timeout(timeout, request_future).await, + Ok(Ok(true)) + ) +} + +async fn wait_for_cdp_endpoint( + options: &RealBrowserOptions, + user_data_dir: &Path, + browser_process: &mut BrowserProcess, +) -> Result { + let started = Instant::now(); + let active_port_path = user_data_dir.join("DevToolsActivePort"); + + while started.elapsed() < options.startup_timeout { + if let Some(status) = browser_process.try_wait()? { + return Err(anyhow::anyhow!( + "browser exited before its DevTools endpoint was ready ({status})" + )); + } + + let mut port = options.remote_debugging_port; + if port == 0 { + port = std::fs::read_to_string(&active_port_path) + .ok() + .and_then(|contents| contents.lines().next()?.parse::().ok()) + .unwrap_or(0); + } + + if port > 0 { + let remaining = options.startup_timeout.saturating_sub(started.elapsed()); + if fetch_cdp_version(port, remaining.min(Duration::from_millis(500))).await { + return Ok(format!("http://127.0.0.1:{port}")); + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Err(anyhow::anyhow!( + "timed out after {}ms waiting for the DevTools endpoint", + options.startup_timeout.as_millis() + )) +} + +fn connection_options( + options: &RealBrowserOptions, + endpoint: &str, +) -> Result { + let mut connection = match options.engine { + EngineType::Chromiumoxide => ConnectOptions::chromiumoxide(), + EngineType::Playwright => ConnectOptions::playwright(), + EngineType::Puppeteer => ConnectOptions::puppeteer(), + EngineType::Fantoccini => { + return Err(anyhow::anyhow!( + "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer" + )); + } + }; + connection.cdp_endpoint = Some(endpoint.to_string()); + connection.slow_mo = options.slow_mo; + connection.timeout = options.timeout; + connection.protocol_timeout = options.protocol_timeout; + connection.seed_cookies = options.seed_cookies.clone(); + connection.verbose = options.verbose; + connection.node_executable = options.node_executable.clone(); + connection.node_working_dir = options.node_working_dir.clone(); + Ok(connection) +} + +/// Launch a genuine installed browser with an isolated profile and attach. +/// +/// Chrome 136 and newer ignore remote-debugging switches for default profiles, +/// so this helper rejects known default profile roots. It only binds CDP to +/// loopback, verifies `/json/version`, and then delegates to [`connect_browser`]. +pub async fn launch_real_browser( + options: RealBrowserOptions, +) -> Result { + if options.engine == EngineType::Fantoccini { + return Err(anyhow::anyhow!( + "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer" + )); + } + + let user_data_dir = options.get_user_data_dir(); + assert_dedicated_user_data_dir(&user_data_dir)?; + std::fs::create_dir_all(&user_data_dir)?; + + let executable_path = resolve_system_browser_executable(&options)?; + let arguments = build_real_browser_args(&options)?; + let output = if options.verbose { + Stdio::inherit() + } else { + Stdio::null() + }; + let child = Command::new(&executable_path) + .args(arguments) + .stdin(Stdio::null()) + .stdout(output) + .stderr(if options.verbose { + Stdio::inherit() + } else { + Stdio::null() + }) + .spawn() + .map_err(|error| { + anyhow::anyhow!( + "failed to start installed browser {}: {error}", + executable_path.display() + ) + })?; + let mut browser_process = BrowserProcess::new(child); + + let cdp_endpoint = + match wait_for_cdp_endpoint(&options, &user_data_dir, &mut browser_process).await { + Ok(endpoint) => endpoint, + Err(error) => { + let _ = browser_process.kill(); + return Err(error); + } + }; + + let connect_options = connection_options(&options, &cdp_endpoint)?; + let LaunchResult { mut browser, page } = match connect_browser(connect_options).await { + Ok(connection) => connection, + Err(error) => { + let _ = browser_process.kill(); + return Err(error); + } + }; + browser.user_data_dir = user_data_dir.clone(); + browser.headless = options.headless; + + Ok(RealBrowserLaunchResult { + browser, + page, + cdp_endpoint, + executable_path, + user_data_dir, + browser_process, + }) +} + +/// Descriptive alias for [`launch_real_browser`]. +pub async fn launch_and_connect_real_browser( + options: RealBrowserOptions, +) -> Result { + launch_real_browser(options).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_options_use_native_engine_and_managed_profile() { + let options = RealBrowserOptions::default(); + assert_eq!(options.engine, EngineType::Chromiumoxide); + assert_eq!(options.channel, "chrome"); + assert_eq!(options.remote_debugging_port, 0); + assert!(options + .get_user_data_dir() + .to_string_lossy() + .contains("real-browser")); + } + + #[test] + fn rejects_the_current_platform_default_profiles() { + for profile in known_default_user_data_dirs() { + let error = assert_dedicated_user_data_dir(&profile).unwrap_err(); + assert!(error.to_string().contains("dedicated user_data_dir")); + } + } + + #[test] + fn all_required_channels_have_discovery_candidates() { + for channel in ["chrome", "chromium", "brave", "msedge"] { + assert!(!browser_install_candidates(channel).unwrap().is_empty()); + } + + let chrome = browser_install_candidates("chrome").unwrap(); + #[cfg(target_os = "macos")] + assert!(chrome.contains(&PathBuf::from( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + ))); + #[cfg(target_os = "windows")] + assert!(chrome.iter().any( + |candidate| candidate.ends_with(Path::new("Google/Chrome/Application/chrome.exe")) + )); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + assert!(chrome.contains(&PathBuf::from("/usr/bin/google-chrome"))); + } + + #[test] + fn rejects_fantoccini_before_connecting() { + let options = RealBrowserOptions { + engine: EngineType::Fantoccini, + ..RealBrowserOptions::default() + }; + assert!(connection_options(&options, "http://127.0.0.1:9222").is_err()); + } + + #[tokio::test] + async fn rejects_fantoccini_before_starting_a_browser() { + let options = RealBrowserOptions { + engine: EngineType::Fantoccini, + executable_path: Some(PathBuf::from("missing-browser")), + ..RealBrowserOptions::default() + }; + + let error = launch_real_browser(options).await.unwrap_err(); + assert!(error.to_string().contains("does not connect over CDP")); + } + + #[tokio::test] + async fn cdp_probe_does_not_wait_for_the_server_to_close_the_connection() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + let response_body = r#"{"webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser/id"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{response_body}", + response_body.len() + ); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request).await.unwrap(); + stream.write_all(response.as_bytes()).await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + }); + + assert!(fetch_cdp_version(port, Duration::from_millis(200)).await); + server.abort(); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3126a85..9d80ac3 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -50,8 +50,10 @@ pub mod utilities; // Re-export commonly used items at crate root pub use browser::{ - connect_browser, emulate_media, launch_browser, Browser, ChromiumoxidePage, ColorScheme, + build_real_browser_args, connect_browser, emulate_media, launch_and_connect_real_browser, + launch_browser, launch_real_browser, Browser, BrowserProcess, ChromiumoxidePage, ColorScheme, ConnectOptions, EmulateMediaOptions, LaunchOptions, LaunchResult, NodeBridgePage, + RealBrowserLaunchResult, RealBrowserOptions, }; pub use core::{ DialogEvent, DialogManager, DialogType, EngineAdapter, EngineError, EngineType, Logger, @@ -66,10 +68,11 @@ pub use core::{ /// ``` pub mod prelude { pub use crate::browser::{ - connect_browser, emulate_media, goto, launch_browser, verify_navigation, - wait_for_navigation, wait_for_url_stabilization, Browser, ColorScheme, ConnectOptions, - EmulateMediaOptions, LaunchOptions, LaunchResult, NavigationOptions, NavigationResult, - WaitUntil, + connect_browser, emulate_media, goto, launch_and_connect_real_browser, launch_browser, + launch_real_browser, verify_navigation, wait_for_navigation, wait_for_url_stabilization, + Browser, BrowserProcess, ColorScheme, ConnectOptions, EmulateMediaOptions, LaunchOptions, + LaunchResult, NavigationOptions, NavigationResult, RealBrowserLaunchResult, + RealBrowserOptions, WaitUntil, }; pub use crate::core::{ is_navigation_error, is_timeout_error, DialogEvent, DialogManager, DialogType, diff --git a/rust/tests/real_browser_api.rs b/rust/tests/real_browser_api.rs new file mode 100644 index 0000000..3b461a8 --- /dev/null +++ b/rust/tests/real_browser_api.rs @@ -0,0 +1,35 @@ +use browser_commander::{ + build_real_browser_args, launch_and_connect_real_browser, launch_real_browser, + RealBrowserOptions, +}; + +#[test] +fn real_browser_api_builds_protected_launch_arguments() { + let options = RealBrowserOptions::playwright() + .channel("chrome") + .user_data_dir("dedicated-profile") + .remote_debugging_port(9333) + .headless(true) + .with_args(vec!["--lang=en-US".to_string()]); + + let arguments = build_real_browser_args(&options).unwrap(); + + assert_eq!(arguments[0], "--remote-debugging-address=127.0.0.1"); + assert_eq!(arguments[1], "--remote-debugging-port=9333"); + assert_eq!(arguments[2], "--user-data-dir=dedicated-profile"); + assert!(arguments.contains(&"--headless=new".to_string())); + assert!(arguments.contains(&"--lang=en-US".to_string())); + + let _short_helper = launch_real_browser; + let _compatible_helper = launch_and_connect_real_browser; +} + +#[test] +fn real_browser_api_rejects_managed_arguments() { + let options = RealBrowserOptions::chromiumoxide() + .user_data_dir("dedicated-profile") + .with_args(vec!["--user-data-dir=other-profile".to_string()]); + + let error = build_real_browser_args(&options).unwrap_err(); + assert!(error.to_string().contains("managed by launch_real_browser")); +} diff --git a/rust/tests/real_browser_smoke.rs b/rust/tests/real_browser_smoke.rs new file mode 100644 index 0000000..4a46980 --- /dev/null +++ b/rust/tests/real_browser_smoke.rs @@ -0,0 +1,75 @@ +//! Smoke test for the installed-browser launch-and-connect lifecycle. Run with: +//! +//! ```sh +//! BROWSER_COMMANDER_CHROME=/usr/bin/google-chrome \ +//! cargo test --test real_browser_smoke -- --ignored --nocapture +//! ``` + +use std::path::PathBuf; +use std::time::Duration; + +use browser_commander::{launch_real_browser, RealBrowserOptions}; +use serde_json::json; + +#[tokio::test] +#[ignore] +async fn launch_system_chrome_and_attach() -> anyhow::Result<()> { + let temporary_directory = tempdir()?; + let chrome = std::env::var_os("BROWSER_COMMANDER_CHROME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/usr/bin/google-chrome")); + let mut result = launch_real_browser( + RealBrowserOptions::chromiumoxide() + .executable_path(chrome) + .user_data_dir(temporary_directory.path()) + .headless(true) + .verbose(true) + .startup_timeout(Duration::from_secs(20)) + .with_args(vec![ + "--no-sandbox".to_string(), + "--disable-dev-shm-usage".to_string(), + ]) + .seed_cookies(vec![json!({ + "name": "attached", + "value": "rust", + "domain": ".example.com", + })]), + ) + .await?; + + result + .page + .goto("data:text/html,
Real browser connection works
") + .await?; + assert_eq!(result.page.count("#connected").await?, 1); + result.browser_process.kill()?; + Ok(()) +} + +struct TempDir(PathBuf); + +impl TempDir { + fn path(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn tempdir() -> std::io::Result { + let unique = format!( + "bc-real-browser-smoke-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + ); + let path = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&path)?; + Ok(TempDir(path)) +} From 3afcf1999c353b3da3e2d9d250fba2d0249b31fb Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:27:32 +0000 Subject: [PATCH 5/7] docs: record real-browser launch parity --- README.md | 7 ++++--- docs/feature-parity.md | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2bfba47..7d87a6e 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ A universal browser automation library with a unified API across multiple browse See [docs/feature-parity.md](docs/feature-parity.md) for the cross-language feature matrix and [docs/case-studies/issue-51/README.md](docs/case-studies/issue-51/README.md) for the implementation notes. All three implementations can attach to a running Chrome-family browser over -CDP. The JavaScript package also provides `launchAndConnectRealBrowser()` to -find and start an installed Chrome, Edge, Brave, or Chromium with a safe, -dedicated automation profile before attaching. +CDP or find and start an installed Chrome, Edge, Brave, or Chromium with a +safe, dedicated automation profile before attaching. Use +`launchRealBrowser()` in JavaScript and `launch_real_browser()` in Python or +Rust. ## Core Concept: Page State Machine diff --git a/docs/feature-parity.md b/docs/feature-parity.md index 40936ed..c8ff8ef 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -44,6 +44,17 @@ This matrix tracks the shared API surface across the maintained language impleme | Rust API docs | `cd rust && cargo doc --no-deps --all-features` | Built by `.github/workflows/docs.yml` | | Combined Pages artifact | Generated from both outputs | Uploaded on PRs and deployed from `main` | +## Real-Browser Lifecycle Parity + +| Capability | JavaScript | Rust | Python | +| --------------------------------------- | --------------------- | ------------------------------------ | ----------------------- | +| Attach to an existing CDP endpoint | Playwright, Puppeteer | Chromiumoxide, Playwright, Puppeteer | Playwright, Selenium | +| Discover an installed Chrome-family app | Linux, macOS, Windows | Linux, macOS, Windows | Linux, macOS, Windows | +| Launch with a dedicated profile | `launchRealBrowser()` | `launch_real_browser()` | `launch_real_browser()` | +| Loopback-only CDP readiness probe | Supported | Supported | Supported | +| Seed cookies after connection | Supported | Supported | Supported | +| Return browser and page handles | Raw engine handles | Shared `EngineAdapter` | Raw engine handles | + ## Compatibility Notes - Existing Rust aliases remain compatible: `chromiumoxide` and `cdp` parse as `EngineType::Chromiumoxide`; `fantoccini` and `webdriver` parse as `EngineType::Fantoccini`. From 30d3fc82ac72dc99cefb4d097dd2db82b32789a2 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:34:26 +0000 Subject: [PATCH 6/7] Revert "Initial commit with task details" This reverts commit a7c1543c2f3195ecaa71b9e446ed3fa30e5a6dd2. --- .gitkeep | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitkeep b/.gitkeep index 9c45842..3811e7a 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,4 +1,3 @@ # .gitkeep file auto-generated at 2026-05-10T11:15:41.449Z for PR creation at branch issue-51-f2a446afa3b9 for issue https://github.com/link-foundation/browser-commander/issues/51 # Updated: 2026-06-28T20:40:17.765Z -# Updated: 2026-08-01T16:08:23.376Z -# Updated: 2026-08-02T04:53:59.885Z \ No newline at end of file +# Updated: 2026-08-01T16:08:23.376Z \ No newline at end of file From 33f697f85656a4410196ca5f6472ceeb3ab3b743 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:41:39 +0000 Subject: [PATCH 7/7] test(python): make profile guard portable --- python/tests/unit/browser/test_real_browser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/tests/unit/browser/test_real_browser.py b/python/tests/unit/browser/test_real_browser.py index b4d1e4e..01aa8b5 100644 --- a/python/tests/unit/browser/test_real_browser.py +++ b/python/tests/unit/browser/test_real_browser.py @@ -43,10 +43,11 @@ def test_builds_protected_loopback_command() -> None: def test_rejects_default_profiles_and_managed_arguments(tmp_path: Any) -> None: - fake_home = tmp_path / "home" + # Keep the simulated Linux paths independent of the host running the test. + fake_home = "/home/tester" with pytest.raises(ValueError, match="dedicated user_data_dir"): assert_dedicated_user_data_dir( - fake_home / ".config" / "google-chrome", + "/home/tester/.config/google-chrome", platform="linux", home_dir=fake_home, environment={},