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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ A universal browser automation library with a unified API across multiple browse
| --------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| JavaScript/TypeScript | Playwright, Puppeteer | Uses the official Node.js packages directly. |
| Rust | Chromiumoxide, Playwright, Puppeteer | Chromiumoxide is native Rust/CDP. Playwright and Puppeteer run through a Node.js bridge to the official packages. Fantoccini remains available as an engine type for compatibility, but managed launch is not implemented yet. |
| Python | Playwright, Selenium | Python support is maintained separately from the JS/Rust parity work. |
| Python | Playwright, Selenium | Uses the official Python integrations and supports attaching to an existing Chrome-family browser over CDP. |

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.

## Core Concept: Page State Machine

Browser Commander manages the browser as a state machine with two states:
Expand Down
3 changes: 3 additions & 0 deletions docs/feature-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This matrix tracks the shared API surface across the maintained language impleme
| Capability | JavaScript Playwright | JavaScript Puppeteer | Rust Chromiumoxide | Rust Playwright bridge | Rust Puppeteer bridge |
| --------------------------------------- | --------------------- | -------------------- | ------------------ | ---------------------- | --------------------- |
| Launch browser | Supported | Supported | Supported | Supported | Supported |
| Connect to running browser over CDP | Supported | Supported | Supported | Bridge | Bridge |
| Persistent user data directory | Supported | Supported | Supported | Supported | Supported |
| Portable cookie/localStorage state | Supported | Supported | Not implemented | Not implemented | Not implemented |
| Custom Chrome args | Supported | Supported | Supported | Supported | Supported |
Expand Down Expand Up @@ -48,3 +49,5 @@ This matrix tracks the shared API surface across the maintained language impleme
- Existing Rust aliases remain compatible: `chromiumoxide` and `cdp` parse as `EngineType::Chromiumoxide`; `fantoccini` and `webdriver` parse as `EngineType::Fantoccini`.
- `playwright` and `puppeteer` now parse as distinct Rust engine types instead of silently mapping to a different backend.
- Rust Playwright/Puppeteer support requires Node.js plus the matching package in `node_working_dir` or normal Node module resolution.
- Python exposes the same CDP attach operation as `connect_browser()` for its Playwright and Selenium engines.
- Chrome 136 and newer require a non-default user data directory before honoring remote-debugging switches.
96 changes: 96 additions & 0 deletions experiments/connect-browser-python-smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Attach the Python Playwright API to a system Chrome over CDP."""

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 (
ConnectOptions,
connect_browser,
make_browser_commander,
)


async def wait_for_cdp_port(profile: Path, process: asyncio.subprocess.Process) -> int:
active_port = profile / "DevToolsActivePort"
for _ in range(200):
if process.returncode is not None:
msg = f"Chrome exited before CDP was ready: {process.returncode}"
raise RuntimeError(msg)
try:
return int(active_port.read_text().splitlines()[0])
except (FileNotFoundError, IndexError, ValueError):
await asyncio.sleep(0.1)
msg = f"Timed out waiting for {active_port}"
raise TimeoutError(msg)


async def main() -> None:
if len(sys.argv) != 2:
msg = "Usage: python experiments/connect-browser-python-smoke.py <browser>"
raise RuntimeError(msg)

profile = Path(tempfile.mkdtemp(prefix="browser-commander-python-cdp-"))
process = await asyncio.create_subprocess_exec(
sys.argv[1],
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=0",
f"--user-data-dir={profile}",
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
"about:blank",
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)

try:
port = await wait_for_cdp_port(profile, process)
result = await connect_browser(
ConnectOptions(
engine="playwright",
cdp_endpoint=f"http://127.0.0.1:{port}",
timeout=20_000,
seed_cookies=[
{
"name": "attached",
"value": "python",
"url": "https://example.com",
}
],
)
)
try:
await result.page.goto(
"data:text/html,<main id=connected>CDP connection works</main>"
)
commander = make_browser_commander(
result.page,
enable_network_tracking=False,
enable_navigation_manager=False,
enable_dialog_manager=False,
)
assert await commander.count("#connected") == 1
cookies = await result.page.context.cookies("https://example.com")
assert any(cookie["name"] == "attached" for cookie in cookies)
await commander.destroy()
print("python playwright real-browser CDP smoke test passed")
finally:
await result.browser.close()
finally:
if process.returncode is None:
process.terminate()
await process.wait()
shutil.rmtree(profile, ignore_errors=True)


if __name__ == "__main__":
asyncio.run(main())
86 changes: 86 additions & 0 deletions experiments/connect-real-browser-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";

import {
launchAndConnectRealBrowser,
makeBrowserCommander,
} from "../js/src/index.js";

const browserExecutable = process.argv[2];
if (!browserExecutable) {
throw new Error(
"Usage: node experiments/connect-real-browser-smoke.mjs <browser-executable>",
);
}

const server = createServer((request, response) => {
response.setHeader("content-type", "text/html");
response.end('<main id="connected">CDP connection works</main>');
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
const origin = `http://127.0.0.1:${port}`;
const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), "browser-commander-cdp-connect-"),
);

async function waitForExit(browserProcess) {
if (browserProcess.exitCode !== null) return;
await new Promise((resolve) => {
browserProcess.once("exit", resolve);
if (browserProcess.exitCode !== null) resolve();
});
}

try {
for (const engine of ["playwright", "puppeteer"]) {
const connection = await launchAndConnectRealBrowser({
engine,
executablePath: browserExecutable,
userDataDir: path.join(temporaryDirectory, engine),
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
seedCookies: [
{
name: "attached",
value: engine,
url: origin,
},
],
});

try {
await connection.page.goto(origin);
const commander = makeBrowserCommander({
page: connection.page,
enableNetworkTracking: false,
enableNavigationManager: false,
enableDialogManager: false,
});
assert.equal(await commander.count({ selector: "#connected" }), 1);
await commander.destroy();
assert.match(
await connection.page.evaluate(() => document.cookie),
new RegExp(`attached=${engine}`),
);
console.log(`${engine} real-browser CDP smoke test passed`);
} finally {
await connection.browser.close();
if (connection.browserProcess.exitCode === null) {
connection.browserProcess.kill();
}
await waitForExit(connection.browserProcess);
}
}
} finally {
server.close();
await rm(temporaryDirectory, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
}
5 changes: 5 additions & 0 deletions js/.changeset/real-browser-cdp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'browser-commander': minor
---

Add CDP attachment through `connectBrowser()` for Playwright and Puppeteer, plus `launchAndConnectRealBrowser()` for starting an installed Chrome-family browser with a dedicated automation profile.
67 changes: 67 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,41 @@ const { browser, page } = await launchBrowser({
});
```

Attach to a Chrome-family browser that is already listening for CDP connections:

```javascript
import { connectBrowser, makeBrowserCommander } from 'browser-commander';

const { browser, page } = await connectBrowser({
engine: 'playwright', // or 'puppeteer'
cdpEndpoint: 'http://127.0.0.1:9222',
});
const commander = makeBrowserCommander({ page });
```

`launchAndConnectRealBrowser()` 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';

const connection = await launchAndConnectRealBrowser({
engine: 'puppeteer',
channel: 'chrome',
userDataDir: '/tmp/my-automation-profile',
seedCookies: [
{ name: 'session', value: 'saved', url: 'https://example.com' },
],
});
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.

Reuse a saved authenticated session by passing Playwright-compatible storage
state as a JSON file path or object. Cookies and localStorage are restored for
both engines:
Expand Down Expand Up @@ -303,6 +338,38 @@ The `storageState` option accepts a Playwright-compatible JSON path or object.
Each engine restores its cookies and origin-specific localStorage before
navigation, including when Playwright uses a persistent context.

### connectBrowser(options)

Connect to an existing Chrome-family browser over an HTTP or WebSocket CDP
endpoint. Exactly one of `cdpEndpoint` and `wsEndpoint` is required. The raw
`browser` and `page` work with both the underlying engine API and
`makeBrowserCommander({ page })`.

```javascript
const { browser, page } = await connectBrowser({
engine: 'playwright',
wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/<id>',
timeout: 30_000,
seedCookies: [
{ name: 'session', value: 'saved', url: 'https://example.com' },
],
});
```

Playwright accepts `timeout` and Puppeteer accepts `protocolTimeout`.
`storageState` can also seed Playwright-compatible cookies and localStorage.

### launchAndConnectRealBrowser(options)

Start an installed browser and connect through `connectBrowser()`. Use
`channel` (`chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`,
`msedge-beta`, `msedge-dev`, `msedge-canary`, `brave`, or `chromium`) or an
explicit `executablePath`. The helper defaults to
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 }`.

### saveStorageState(page, filePath)

Save the current cookies and localStorage in Playwright's portable storage
Expand Down
Loading
Loading