From 833d07432289f16dca3e635c50a3463c15f112c7 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 03:39:46 +0000 Subject: [PATCH 1/6] 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/66 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index 3811e7a..f6598af 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-02T03:39:46.457Z \ No newline at end of file From ae806d4b6450d12c369b6f797a50024dc1f3ab04 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 04:06:11 +0000 Subject: [PATCH 2/6] Revert "Initial commit with task details" This reverts commit 833d07432289f16dca3e635c50a3463c15f112c7. --- .gitkeep | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitkeep b/.gitkeep index f6598af..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-02T03:39:46.457Z \ No newline at end of file +# Updated: 2026-08-01T16:08:23.376Z \ No newline at end of file From 46d0049815458d9bc703ce3b3adf278a4ccbab44 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 04:12:41 +0000 Subject: [PATCH 3/6] feat(js): connect to real browsers over CDP --- experiments/connect-real-browser-smoke.mjs | 86 ++++ js/.changeset/real-browser-cdp.md | 5 + js/README.md | 67 ++++ js/src/browser/connector.js | 163 ++++++++ js/src/browser/real-browser.js | 439 +++++++++++++++++++++ js/src/exports.js | 2 + js/tests/unit/browser/connector.test.js | 129 ++++++ js/tests/unit/browser/real-browser.test.js | 165 ++++++++ 8 files changed, 1056 insertions(+) create mode 100644 experiments/connect-real-browser-smoke.mjs create mode 100644 js/.changeset/real-browser-cdp.md create mode 100644 js/src/browser/connector.js create mode 100644 js/src/browser/real-browser.js create mode 100644 js/tests/unit/browser/connector.test.js create mode 100644 js/tests/unit/browser/real-browser.test.js diff --git a/experiments/connect-real-browser-smoke.mjs b/experiments/connect-real-browser-smoke.mjs new file mode 100644 index 0000000..52d8c48 --- /dev/null +++ b/experiments/connect-real-browser-smoke.mjs @@ -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 ", + ); +} + +const server = createServer((request, response) => { + response.setHeader("content-type", "text/html"); + response.end('
CDP connection works
'); +}); +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, + }); +} diff --git a/js/.changeset/real-browser-cdp.md b/js/.changeset/real-browser-cdp.md new file mode 100644 index 0000000..20ef12b --- /dev/null +++ b/js/.changeset/real-browser-cdp.md @@ -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. diff --git a/js/README.md b/js/README.md index 3f97a34..de34134 100644 --- a/js/README.md +++ b/js/README.md @@ -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: @@ -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/', + 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 diff --git a/js/src/browser/connector.js b/js/src/browser/connector.js new file mode 100644 index 0000000..606e599 --- /dev/null +++ b/js/src/browser/connector.js @@ -0,0 +1,163 @@ +import { + loadStorageState, + restorePlaywrightStorageState, + restorePuppeteerStorageState, +} from './storage-state.js'; + +function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) { + if (!['playwright', 'puppeteer'].includes(engine)) { + throw new Error( + `Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'` + ); + } + + if (Boolean(cdpEndpoint) === Boolean(wsEndpoint)) { + throw new Error( + 'connectBrowser requires exactly one of cdpEndpoint or wsEndpoint' + ); + } +} + +function addDefinedOptions(options, values) { + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + options[key] = value; + } + } + return options; +} + +function buildPlaywrightConnectOptions({ slowMo, timeout, headers }) { + return addDefinedOptions({}, { slowMo, timeout, headers }); +} + +function buildPuppeteerConnectOptions({ + cdpEndpoint, + wsEndpoint, + slowMo, + protocolTimeout, + headers, +}) { + return addDefinedOptions( + { + ...(cdpEndpoint + ? { browserURL: cdpEndpoint } + : { browserWSEndpoint: wsEndpoint }), + defaultViewport: null, + }, + { slowMo, protocolTimeout, headers } + ); +} + +async function prepareStorageState({ storageState, seedCookies }) { + if (seedCookies !== undefined && !Array.isArray(seedCookies)) { + throw new TypeError('seedCookies must be an array'); + } + + const resolvedStorageState = await loadStorageState(storageState); + if (!resolvedStorageState && seedCookies === undefined) { + return undefined; + } + + return { + ...(resolvedStorageState ?? {}), + cookies: [...(resolvedStorageState?.cookies ?? []), ...(seedCookies ?? [])], + }; +} + +async function connectPlaywright({ options, loadPlaywright, storageState }) { + const { chromium } = await loadPlaywright(); + const endpoint = options.cdpEndpoint ?? options.wsEndpoint; + const browser = await chromium.connectOverCDP( + endpoint, + buildPlaywrightConnectOptions(options) + ); + const context = browser.contexts()[0]; + if (!context) { + throw new Error('Connected Playwright browser has no default context'); + } + const page = context.pages()[0] ?? (await context.newPage()); + + await restorePlaywrightStorageState({ context, storageState }); + return { browser, page }; +} + +async function connectPuppeteer({ options, loadPuppeteer, storageState }) { + const puppeteerModule = await loadPuppeteer(); + const puppeteer = puppeteerModule.default ?? puppeteerModule; + const browser = await puppeteer.connect( + buildPuppeteerConnectOptions(options) + ); + const pages = await browser.pages(); + const page = pages[0] ?? (await browser.newPage()); + + await restorePuppeteerStorageState({ page, storageState }); + return { browser, page }; +} + +/** + * Attach to an already-running Chromium-family browser over CDP. + * + * @param {Object} options - Connection options + * @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine + * @param {string} [options.cdpEndpoint] - HTTP DevTools endpoint, such as http://127.0.0.1:9222 + * @param {string} [options.wsEndpoint] - DevTools browser WebSocket endpoint + * @param {number} [options.slowMo] - Delay engine operations by this many milliseconds + * @param {number} [options.timeout] - Playwright connection timeout in milliseconds + * @param {number} [options.protocolTimeout] - Puppeteer CDP call timeout in milliseconds + * @param {Object} [options.headers] - Additional connection headers + * @param {Object[]|string|Object} [options.storageState] - Playwright-compatible state path or object + * @param {Object[]} [options.seedCookies] - Cookies to seed after connecting + * @param {boolean} [options.verbose=false] - Enable connection logging + * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles + */ +export async function connectBrowser(options = {}) { + return await connectBrowserWithDependencies(options); +} + +/** + * Dependency-injected implementation used by the public connector and tests. + * + * @param {Object} options - See {@link connectBrowser} + * @param {Object} dependencies - Optional engine module loaders + * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles + */ +export async function connectBrowserWithDependencies( + options = {}, + dependencies = {} +) { + const normalizedOptions = { + engine: 'playwright', + verbose: false, + ...options, + }; + validateConnectionOptions(normalizedOptions); + + const storageState = await prepareStorageState(normalizedOptions); + const { engine, verbose } = normalizedOptions; + if (verbose) { + console.log(`Connecting to browser with ${engine} engine...`); + } + + const result = + engine === 'playwright' + ? await connectPlaywright({ + options: normalizedOptions, + loadPlaywright: + dependencies.loadPlaywright ?? (() => import('playwright')), + storageState, + }) + : await connectPuppeteer({ + options: normalizedOptions, + loadPuppeteer: + dependencies.loadPuppeteer ?? (() => import('puppeteer')), + storageState, + }); + + if (verbose) { + console.log(`Connected to browser with ${engine} engine`); + } + return result; +} + +export { buildPlaywrightConnectOptions, buildPuppeteerConnectOptions }; diff --git a/js/src/browser/real-browser.js b/js/src/browser/real-browser.js new file mode 100644 index 0000000..240a838 --- /dev/null +++ b/js/src/browser/real-browser.js @@ -0,0 +1,439 @@ +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { access, mkdir, readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { connectBrowser } from './connector.js'; + +const MANAGED_ARGUMENTS = [ + '--remote-debugging-address', + '--remote-debugging-port', + '--user-data-dir', +]; + +const 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'], +}; + +function platformPath(platform) { + return platform === 'win32' ? path.win32 : path; +} + +function defaultUserDataDir(channel, homeDir = os.homedir()) { + const directoryName = channel.replace(/[^a-z0-9_.-]/gi, '-'); + return path.join( + homeDir, + '.browser-commander', + 'real-browser', + directoryName + ); +} + +function knownDefaultUserDataDirs({ + platform = process.platform, + homeDir = os.homedir(), + environment = process.env, +} = {}) { + const pathApi = platformPath(platform); + if (platform === 'darwin') { + const applicationSupport = pathApi.join( + homeDir, + 'Library', + 'Application Support' + ); + return [ + pathApi.join(applicationSupport, 'Google', 'Chrome'), + pathApi.join(applicationSupport, 'Google', 'Chrome Beta'), + pathApi.join(applicationSupport, 'Google', 'Chrome Canary'), + pathApi.join(applicationSupport, 'Google', 'Chrome Dev'), + pathApi.join(applicationSupport, 'Chromium'), + pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser'), + pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser-Beta'), + pathApi.join( + applicationSupport, + 'BraveSoftware', + 'Brave-Browser-Nightly' + ), + pathApi.join(applicationSupport, 'Microsoft Edge'), + pathApi.join(applicationSupport, 'Microsoft Edge Beta'), + pathApi.join(applicationSupport, 'Microsoft Edge Canary'), + pathApi.join(applicationSupport, 'Microsoft Edge Dev'), + ]; + } + if (platform === 'win32') { + const localAppData = + environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local'); + return [ + pathApi.join(localAppData, 'Google', 'Chrome', 'User Data'), + pathApi.join(localAppData, 'Google', 'Chrome Beta', 'User Data'), + pathApi.join(localAppData, 'Google', 'Chrome Dev', 'User Data'), + pathApi.join(localAppData, 'Google', 'Chrome SxS', 'User Data'), + pathApi.join(localAppData, 'Chromium', 'User Data'), + pathApi.join(localAppData, 'BraveSoftware', 'Brave-Browser', 'User Data'), + pathApi.join( + localAppData, + 'BraveSoftware', + 'Brave-Browser-Beta', + 'User Data' + ), + pathApi.join( + localAppData, + 'BraveSoftware', + 'Brave-Browser-Nightly', + 'User Data' + ), + pathApi.join(localAppData, 'Microsoft', 'Edge', 'User Data'), + pathApi.join(localAppData, 'Microsoft', 'Edge Beta', 'User Data'), + pathApi.join(localAppData, 'Microsoft', 'Edge Dev', 'User Data'), + pathApi.join(localAppData, 'Microsoft', 'Edge SxS', 'User Data'), + ]; + } + return [ + pathApi.join(homeDir, '.config', 'google-chrome'), + pathApi.join(homeDir, '.config', 'google-chrome-beta'), + pathApi.join(homeDir, '.config', 'google-chrome-unstable'), + pathApi.join(homeDir, '.config', 'chromium'), + pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'), + pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Beta'), + pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Nightly'), + pathApi.join(homeDir, '.config', 'microsoft-edge'), + pathApi.join(homeDir, '.config', 'microsoft-edge-beta'), + pathApi.join(homeDir, '.config', 'microsoft-edge-dev'), + ]; +} + +/** Ensure Chrome is not asked to expose the user's default profile over CDP. */ +export function assertDedicatedUserDataDir(userDataDir, platformOptions = {}) { + if (!userDataDir) { + throw new Error('launchAndConnectRealBrowser requires a userDataDir'); + } + const platform = platformOptions.platform ?? process.platform; + const pathApi = platformPath(platform); + const normalize = (value) => { + const resolved = pathApi.resolve(value).replace(/[\\/]+$/, ''); + return platform === 'win32' ? resolved.toLowerCase() : resolved; + }; + const requested = normalize(userDataDir); + const isDefault = knownDefaultUserDataDirs(platformOptions).some( + (directory) => normalize(directory) === requested + ); + if (isDefault) { + throw new Error( + 'launchAndConnectRealBrowser requires a dedicated userDataDir, not a browser default profile' + ); + } +} + +function browserInstallCandidates({ + channel, + platform = process.platform, + environment = process.env, +}) { + const candidates = []; + const names = CHANNEL_EXECUTABLE_NAMES[channel]; + if (!names) { + throw new Error( + `Unknown browser channel: ${channel}. Expected one of ${Object.keys(CHANNEL_EXECUTABLE_NAMES).join(', ')}` + ); + } + + if (platform === 'darwin') { + const 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', + }; + candidates.push(path.join('/Applications', applications[channel])); + } else if (platform === 'win32') { + const roots = [ + environment.PROGRAMFILES, + environment['PROGRAMFILES(X86)'], + environment.LOCALAPPDATA, + ].filter(Boolean); + const relativePaths = { + 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'], + }; + for (const root of roots) { + candidates.push(path.win32.join(root, ...relativePaths[channel])); + } + } else { + for (const name of names) { + candidates.push(`/usr/bin/${name}`, `/usr/local/bin/${name}`); + } + if (channel === 'chrome') { + candidates.push('/opt/google/chrome/google-chrome'); + } + } + + const pathApi = platformPath(platform); + const delimiter = platform === 'win32' ? ';' : path.delimiter; + for (const directory of (environment.PATH ?? '').split(delimiter)) { + if (!directory) { + continue; + } + for (const name of names) { + candidates.push( + pathApi.join(directory, platform === 'win32' ? `${name}.exe` : name) + ); + } + } + return [...new Set(candidates)]; +} + +/** Resolve a genuine installed Chrome-family browser executable. */ +export async function resolveSystemBrowserExecutable({ + channel = 'chrome', + executablePath, + platform = process.platform, + environment = process.env, +} = {}) { + const candidates = executablePath + ? [path.resolve(executablePath)] + : browserInstallCandidates({ channel, platform, environment }); + for (const candidate of candidates) { + try { + await access(candidate, constants.X_OK); + return candidate; + } catch { + // Continue through known locations and PATH entries. + } + } + throw new Error( + executablePath + ? `Browser executable is not accessible: ${executablePath}` + : `Could not find an installed ${channel} browser; provide executablePath` + ); +} + +/** Build the protected command line used for a real browser CDP process. */ +export function buildRealBrowserArgs({ + userDataDir, + remoteDebuggingPort = 0, + headless = false, + args = [], +}) { + if ( + !Number.isInteger(remoteDebuggingPort) || + remoteDebuggingPort < 0 || + remoteDebuggingPort > 65_535 + ) { + throw new RangeError( + 'remoteDebuggingPort must be an integer from 0 to 65535' + ); + } + const conflictingArgument = args.find((argument) => + MANAGED_ARGUMENTS.some( + (managed) => argument === managed || argument.startsWith(`${managed}=`) + ) + ); + if (conflictingArgument) { + throw new Error( + `${conflictingArgument} is managed by launchAndConnectRealBrowser` + ); + } + + return [ + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${remoteDebuggingPort}`, + `--user-data-dir=${userDataDir}`, + '--no-first-run', + '--no-default-browser-check', + ...(headless ? ['--headless=new'] : []), + ...args, + ]; +} + +async function fetchCdpVersion(endpoint, fetchImplementation, timeout) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.min(timeout, 500)); + try { + const response = await fetchImplementation(`${endpoint}/json/version`, { + signal: controller.signal, + }); + if (!response.ok) { + return false; + } + const version = await response.json(); + return Boolean(version.webSocketDebuggerUrl); + } finally { + clearTimeout(timer); + } +} + +/** Wait until Chrome publishes a usable DevTools endpoint. */ +export async function waitForCdpEndpoint({ + remoteDebuggingPort, + userDataDir, + browserProcess, + timeout = 30_000, + fetchImplementation = globalThis.fetch, +}) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (browserProcess.exitCode !== null) { + throw new Error( + `Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})` + ); + } + + let port = remoteDebuggingPort; + if (port === 0) { + try { + const activePort = await readFile( + path.join(userDataDir, 'DevToolsActivePort'), + 'utf8' + ); + port = Number.parseInt(activePort.split(/\r?\n/, 1)[0], 10); + } catch { + port = 0; + } + } + + if (port > 0) { + const endpoint = `http://127.0.0.1:${port}`; + try { + if ( + await fetchCdpVersion( + endpoint, + fetchImplementation, + Math.max(1, deadline - Date.now()) + ) + ) { + return endpoint; + } + } catch { + // Chrome may have allocated the port before /json/version is ready. + } + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `Timed out after ${timeout}ms waiting for the DevTools endpoint` + ); +} + +/** + * Spawn a genuine installed Chrome-family browser with an isolated profile, + * wait for its loopback CDP endpoint, and attach through {@link connectBrowser}. + * + * @param {Object} options - Launch and connection options + * @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine + * @param {string} [options.channel='chrome'] - Installed Chrome-family channel + * @param {string} [options.executablePath] - Explicit installed browser executable + * @param {string} [options.userDataDir] - Dedicated non-default browser profile + * @param {number} [options.remoteDebuggingPort=0] - Loopback CDP port; zero lets Chrome choose + * @param {boolean} [options.headless=false] - Run the installed browser headlessly + * @param {string[]} [options.args] - Additional browser arguments + * @param {number} [options.startupTimeout=30000] - CDP readiness timeout in milliseconds + * @param {Object[]} [options.seedCookies] - Cookies to seed after connecting + * @param {boolean} [options.verbose=false] - Show browser and connection logs + * @returns {Promise<{browser: Object, page: Object, browserProcess: Object, cdpEndpoint: string, executablePath: string, userDataDir: string}>} Connected handles and process metadata + */ +export async function launchAndConnectRealBrowser(options = {}) { + return await launchAndConnectRealBrowserWithDependencies(options); +} + +/** Dependency-injected implementation used by the public helper and tests. */ +export async function launchAndConnectRealBrowserWithDependencies( + options = {}, + dependencies = {} +) { + const { + engine = 'playwright', + channel = 'chrome', + executablePath: requestedExecutablePath, + userDataDir = defaultUserDataDir(channel), + remoteDebuggingPort = 0, + headless = false, + args = [], + startupTimeout = 30_000, + verbose = false, + cdpEndpoint, + wsEndpoint, + ...connectionOptions + } = options; + if (cdpEndpoint || wsEndpoint) { + throw new Error( + 'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint' + ); + } + + assertDedicatedUserDataDir(userDataDir); + await mkdir(userDataDir, { recursive: true }); + const resolveExecutable = + dependencies.resolveExecutable ?? resolveSystemBrowserExecutable; + const resolvedExecutablePath = await resolveExecutable({ + channel, + executablePath: requestedExecutablePath, + }); + const browserArgs = buildRealBrowserArgs({ + userDataDir, + remoteDebuggingPort, + headless, + args, + }); + const spawnBrowser = dependencies.spawnBrowser ?? spawn; + const browserProcess = spawnBrowser(resolvedExecutablePath, browserArgs, { + stdio: verbose ? 'inherit' : 'ignore', + }); + + try { + const waitForEndpoint = dependencies.waitForEndpoint ?? waitForCdpEndpoint; + const resolvedCdpEndpoint = await waitForEndpoint({ + remoteDebuggingPort, + userDataDir, + browserProcess, + timeout: startupTimeout, + }); + const connect = dependencies.connect ?? connectBrowser; + const connection = await connect({ + engine, + cdpEndpoint: resolvedCdpEndpoint, + ...connectionOptions, + verbose, + }); + return { + ...connection, + browserProcess, + cdpEndpoint: resolvedCdpEndpoint, + executablePath: resolvedExecutablePath, + userDataDir, + }; + } catch (error) { + if (browserProcess.exitCode === null) { + browserProcess.kill(); + } + throw error; + } +} + +export { defaultUserDataDir, knownDefaultUserDataDirs }; diff --git a/js/src/exports.js b/js/src/exports.js index 2429c42..93f891f 100644 --- a/js/src/exports.js +++ b/js/src/exports.js @@ -42,6 +42,8 @@ export { } from './core/page-trigger-manager.js'; // Re-export browser management +export { connectBrowser } from './browser/connector.js'; +export { launchAndConnectRealBrowser } 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/connector.test.js b/js/tests/unit/browser/connector.test.js new file mode 100644 index 0000000..39d5abe --- /dev/null +++ b/js/tests/unit/browser/connector.test.js @@ -0,0 +1,129 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { + connectBrowser, + connectBrowserWithDependencies, +} from '../../../src/browser/connector.js'; +import { connectBrowser as publicConnectBrowser } from '../../../src/index.js'; + +describe('connectBrowser', () => { + it('is exported from the package API', () => { + assert.equal(publicConnectBrowser, connectBrowser); + }); + + it('attaches Playwright over an HTTP CDP endpoint and reuses its first page', async () => { + const calls = []; + const page = { id: 'playwright-page' }; + const context = { + pages: () => [page], + addCookies: async (cookies) => calls.push(['cookies', cookies]), + }; + const browser = { contexts: () => [context] }; + const chromium = { + connectOverCDP: async (endpoint, options) => { + calls.push(['connect', endpoint, options]); + return browser; + }, + }; + const seedCookies = [ + { name: 'SID', value: 'saved', domain: '.example.com', path: '/' }, + ]; + + const result = await connectBrowserWithDependencies( + { + engine: 'playwright', + cdpEndpoint: 'http://127.0.0.1:9222', + slowMo: 25, + timeout: 5_000, + seedCookies, + }, + { loadPlaywright: async () => ({ chromium }) } + ); + + assert.equal(result.browser, browser); + assert.equal(result.page, page); + assert.deepEqual(calls, [ + ['connect', 'http://127.0.0.1:9222', { slowMo: 25, timeout: 5_000 }], + ['cookies', seedCookies], + ]); + }); + + it('maps a WebSocket endpoint to Puppeteer and creates a page when needed', async () => { + const calls = []; + const page = { + id: 'new-puppeteer-page', + setCookie: async (...cookies) => calls.push(['cookies', cookies]), + }; + const browser = { + pages: async () => [], + newPage: async () => { + calls.push(['newPage']); + return page; + }, + }; + const puppeteer = { + connect: async (options) => { + calls.push(['connect', options]); + return browser; + }, + }; + const seedCookies = [ + { name: 'session', value: 'saved', domain: '.example.com', path: '/' }, + ]; + + const result = await connectBrowserWithDependencies( + { + engine: 'puppeteer', + wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/id', + slowMo: 10, + protocolTimeout: 30_000, + seedCookies, + }, + { loadPuppeteer: async () => ({ default: puppeteer }) } + ); + + assert.equal(result.browser, browser); + assert.equal(result.page, page); + assert.deepEqual(calls, [ + [ + 'connect', + { + browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser/id', + defaultViewport: null, + slowMo: 10, + protocolTimeout: 30_000, + }, + ], + ['newPage'], + ['cookies', seedCookies], + ]); + }); + + it('requires exactly one endpoint and a supported engine', async () => { + await assert.rejects( + () => connectBrowserWithDependencies({ engine: 'playwright' }, {}), + /exactly one of cdpEndpoint or wsEndpoint/ + ); + await assert.rejects( + () => + connectBrowserWithDependencies( + { + engine: 'playwright', + cdpEndpoint: 'http://127.0.0.1:9222', + wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/id', + }, + {} + ), + /exactly one of cdpEndpoint or wsEndpoint/ + ); + await assert.rejects( + () => + connectBrowserWithDependencies( + { engine: 'invalid', cdpEndpoint: 'http://127.0.0.1:9222' }, + {} + ), + /Invalid engine: invalid/ + ); + }); +}); diff --git a/js/tests/unit/browser/real-browser.test.js b/js/tests/unit/browser/real-browser.test.js new file mode 100644 index 0000000..6b07b38 --- /dev/null +++ b/js/tests/unit/browser/real-browser.test.js @@ -0,0 +1,165 @@ +import assert from 'node:assert'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + assertDedicatedUserDataDir, + buildRealBrowserArgs, + launchAndConnectRealBrowser, + launchAndConnectRealBrowserWithDependencies, +} from '../../../src/browser/real-browser.js'; +import { launchAndConnectRealBrowser as publicHelper } from '../../../src/index.js'; + +describe('launchAndConnectRealBrowser', () => { + let temporaryDirectory; + + afterEach(async () => { + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = undefined; + } + }); + + it('is exported from the package API', () => { + assert.equal(publicHelper, launchAndConnectRealBrowser); + }); + + it('builds a loopback-only CDP command with a dedicated profile', () => { + const args = buildRealBrowserArgs({ + userDataDir: '/tmp/browser-commander-dedicated', + remoteDebuggingPort: 9333, + headless: true, + args: ['--lang=en-US'], + }); + + assert.deepEqual(args, [ + '--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', + ]); + }); + + it('rejects custom arguments that could bypass protected CDP settings', () => { + for (const argument of [ + '--remote-debugging-address=0.0.0.0', + '--remote-debugging-port=9222', + '--user-data-dir=/tmp/other', + ]) { + assert.throws( + () => + buildRealBrowserArgs({ + userDataDir: '/tmp/browser-commander-dedicated', + remoteDebuggingPort: 0, + args: [argument], + }), + /managed by launchAndConnectRealBrowser/ + ); + } + }); + + it('rejects Chrome default user-data directories', () => { + for (const profile of ['google-chrome', 'google-chrome-beta']) { + assert.throws( + () => + assertDedicatedUserDataDir( + path.join(os.homedir(), '.config', profile), + { + platform: 'linux', + homeDir: os.homedir(), + environment: {}, + } + ), + /dedicated userDataDir/ + ); + } + }); + + it('spawns, waits, connects, and returns process metadata', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-real-browser-test-') + ); + const calls = []; + const browserProcess = { exitCode: null, kill: () => calls.push(['kill']) }; + const browser = { id: 'browser' }; + const page = { id: 'page' }; + + const result = await launchAndConnectRealBrowserWithDependencies( + { + engine: 'puppeteer', + channel: 'chrome', + userDataDir: temporaryDirectory, + remoteDebuggingPort: 0, + seedCookies: [{ name: 'SID', value: 'saved' }], + }, + { + resolveExecutable: async () => '/opt/google/chrome', + spawnBrowser: (executablePath, args) => { + calls.push(['spawn', executablePath, args]); + return browserProcess; + }, + waitForEndpoint: async (options) => { + calls.push(['wait', options.remoteDebuggingPort]); + return 'http://127.0.0.1:9444'; + }, + connect: async (options) => { + calls.push(['connect', options]); + return { browser, page }; + }, + } + ); + + assert.equal(result.browser, browser); + assert.equal(result.page, page); + assert.equal(result.browserProcess, browserProcess); + assert.equal(result.cdpEndpoint, 'http://127.0.0.1:9444'); + assert.equal(result.executablePath, '/opt/google/chrome'); + assert.equal(result.userDataDir, temporaryDirectory); + assert.deepEqual(calls[1], ['wait', 0]); + assert.deepEqual(calls[2], [ + 'connect', + { + engine: 'puppeteer', + cdpEndpoint: 'http://127.0.0.1:9444', + seedCookies: [{ name: 'SID', value: 'saved' }], + verbose: false, + }, + ]); + }); + + it('terminates the spawned browser when connection fails', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-real-browser-test-') + ); + let killed = false; + const browserProcess = { + exitCode: null, + kill: () => { + killed = true; + }, + }; + + await assert.rejects( + () => + launchAndConnectRealBrowserWithDependencies( + { userDataDir: temporaryDirectory }, + { + resolveExecutable: async () => '/opt/google/chrome', + spawnBrowser: () => browserProcess, + waitForEndpoint: async () => 'http://127.0.0.1:9222', + connect: async () => { + throw new Error('connection failed'); + }, + } + ), + /connection failed/ + ); + + assert.equal(killed, true); + }); +}); From 887516c43e16f7ae8eb4d9275896f701266bebdc Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 04:12:48 +0000 Subject: [PATCH 4/6] feat(rust): attach browser engines over CDP --- rust/README.md | 28 ++ rust/changelog.d/66.cdp-browser-connection.md | 7 + rust/src/browser/connector.rs | 264 ++++++++++++++++++ rust/src/browser/mod.rs | 2 + rust/src/browser/node_bridge.rs | 86 +++++- rust/src/browser/node_engine_bridge.js | 63 ++++- rust/src/lib.rs | 11 +- rust/tests/connect_smoke.rs | 130 +++++++++ 8 files changed, 572 insertions(+), 19 deletions(-) create mode 100644 rust/changelog.d/66.cdp-browser-connection.md create mode 100644 rust/src/browser/connector.rs create mode 100644 rust/tests/connect_smoke.rs diff --git a/rust/README.md b/rust/README.md index 2b35ad4..c8a8bd4 100644 --- a/rust/README.md +++ b/rust/README.md @@ -133,6 +133,34 @@ let options = LaunchOptions::playwright() `LaunchOptions::fantoccini()` is still accepted for source compatibility, but `launch_browser()` does not yet start a managed WebDriver process. +### Connect to a Running Browser over CDP + +`connect_browser()` attaches to an externally managed Chrome-family browser +and returns the same `LaunchResult` page adapter as `launch_browser()`. Use +Chromiumoxide natively, or the Playwright/Puppeteer Node.js bridges: + +```rust +use browser_commander::prelude::*; + +let native = connect_browser( + ConnectOptions::chromiumoxide() + .cdp_endpoint("http://127.0.0.1:9222"), +).await?; + +let playwright = connect_browser( + ConnectOptions::playwright() + .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/") + .node_working_dir("./js"), +).await?; + +native.page.goto("https://example.com").await?; +``` + +Exactly one endpoint is required. When starting Chrome 136 or newer yourself, +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()`. + ### Navigation ```rust diff --git a/rust/changelog.d/66.cdp-browser-connection.md b/rust/changelog.d/66.cdp-browser-connection.md new file mode 100644 index 0000000..1403de7 --- /dev/null +++ b/rust/changelog.d/66.cdp-browser-connection.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- Added `connect_browser()` and `ConnectOptions` for attaching Chromiumoxide, Playwright, or Puppeteer to an externally managed Chrome-family browser over CDP. diff --git a/rust/src/browser/connector.rs b/rust/src/browser/connector.rs new file mode 100644 index 0000000..72a44af --- /dev/null +++ b/rust/src/browser/connector.rs @@ -0,0 +1,264 @@ +//! Connect to an already-running Chromium-family browser over CDP. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use chromiumoxide::browser::Browser as CdpBrowser; +use chromiumoxide::cdp::browser_protocol::network::CookieParam; +use futures::StreamExt; +use serde_json::Value; + +use crate::browser::chromiumoxide_adapter::ChromiumoxidePage; +use crate::browser::launcher::{Browser, LaunchResult}; +use crate::browser::node_bridge::NodeBridgePage; +use crate::core::engine::EngineType; + +/// Options for attaching to a running browser over CDP. +#[derive(Debug, Clone)] +pub struct ConnectOptions { + /// Browser automation engine used for the connection. + pub engine: EngineType, + /// HTTP DevTools endpoint, for example `http://127.0.0.1:9222`. + pub cdp_endpoint: Option, + /// DevTools browser WebSocket endpoint. + pub ws_endpoint: Option, + /// Slow down 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 after attaching, in CDP/Playwright cookie format. + pub seed_cookies: Vec, + /// Enable verbose bridge logging. + pub verbose: bool, + /// Node.js executable for Playwright/Puppeteer bridge engines. + pub node_executable: Option, + /// Directory where Node resolves the Playwright/Puppeteer package. + pub node_working_dir: Option, +} + +impl Default for ConnectOptions { + fn default() -> Self { + Self { + engine: EngineType::Chromiumoxide, + cdp_endpoint: None, + ws_endpoint: None, + slow_mo: 0, + timeout: None, + protocol_timeout: None, + seed_cookies: Vec::new(), + verbose: false, + node_executable: None, + node_working_dir: None, + } + } +} + +impl ConnectOptions { + /// Create Chromiumoxide connection options. + pub fn chromiumoxide() -> Self { + Self::default() + } + + /// Create Playwright bridge connection options. + pub fn playwright() -> Self { + Self { + engine: EngineType::Playwright, + ..Self::default() + } + } + + /// Create Puppeteer bridge connection options. + pub fn puppeteer() -> Self { + Self { + engine: EngineType::Puppeteer, + ..Self::default() + } + } + + /// Select an HTTP DevTools endpoint. + pub fn cdp_endpoint(mut self, endpoint: impl Into) -> Self { + self.cdp_endpoint = Some(endpoint.into()); + self + } + + /// Select a DevTools browser WebSocket endpoint. + pub fn ws_endpoint(mut self, endpoint: impl Into) -> Self { + self.ws_endpoint = Some(endpoint.into()); + 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 immediately after the connection is established. + pub fn seed_cookies(mut self, cookies: Vec) -> Self { + self.seed_cookies = cookies; + self + } + + /// Enable verbose 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 or Puppeteer. + pub fn node_working_dir(mut self, directory: impl Into) -> Self { + self.node_working_dir = Some(directory.into()); + self + } + + pub(crate) fn endpoint(&self) -> Result<&str, anyhow::Error> { + match (&self.cdp_endpoint, &self.ws_endpoint) { + (Some(endpoint), None) | (None, Some(endpoint)) if !endpoint.is_empty() => Ok(endpoint), + _ => Err(anyhow::anyhow!( + "connect_browser requires exactly one of cdp_endpoint or ws_endpoint" + )), + } + } +} + +/// Attach to a running Chromium-family browser over CDP. +/// +/// Chromiumoxide connects natively. Playwright and Puppeteer use the same +/// official Node.js packages as [`launch_browser`](super::launcher::launch_browser). +/// The returned page implements the crate's shared [`EngineAdapter`](crate::core::EngineAdapter) +/// API. The browser's profile and process remain externally managed. +pub async fn connect_browser(options: ConnectOptions) -> Result { + let endpoint = options.endpoint()?.to_string(); + if options.verbose { + tracing::info!(engine = %options.engine, %endpoint, "connecting to browser"); + } + + match options.engine { + EngineType::Chromiumoxide => connect_chromiumoxide(options, endpoint).await, + EngineType::Playwright | EngineType::Puppeteer => { + let engine = options.engine; + let timeout = options.timeout; + let connection = NodeBridgePage::connect(options); + let page = if let Some(timeout) = timeout { + tokio::time::timeout(timeout, connection) + .await + .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))?? + } else { + connection.await? + }; + Ok(LaunchResult { + browser: Browser { + engine, + user_data_dir: PathBuf::new(), + headless: false, + }, + page: Arc::new(page), + }) + } + EngineType::Fantoccini => Err(anyhow::anyhow!( + "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer" + )), + } +} + +async fn connect_chromiumoxide( + options: ConnectOptions, + endpoint: String, +) -> Result { + let connection = CdpBrowser::connect(endpoint); + let (browser, mut handler) = if let Some(timeout) = options.timeout { + tokio::time::timeout(timeout, connection) + .await + .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))?? + } else { + connection.await? + }; + + let handler_task = tokio::spawn(async move { + while let Some(event) = handler.next().await { + if let Err(error) = event { + tracing::debug!(%error, "chromiumoxide handler event error"); + } + } + }); + + if !options.seed_cookies.is_empty() { + let cookies = options + .seed_cookies + .iter() + .cloned() + .map(serde_json::from_value::) + .collect::, _>>() + .map_err(|error| anyhow::anyhow!("invalid seed cookie: {error}"))?; + browser.set_cookies(cookies).await?; + } + + let page = match browser.pages().await?.into_iter().next() { + Some(page) => page, + None => browser.new_page("about:blank").await?, + }; + let engine = options.engine; + let adapter = ChromiumoxidePage::new(page, browser, handler_task, PathBuf::new()); + + Ok(LaunchResult { + browser: Browser { + engine, + user_data_dir: PathBuf::new(), + headless: false, + }, + page: Arc::new(adapter), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn connect_options_builders_preserve_endpoints_and_cookies() { + let cookies = vec![json!({"name": "SID", "value": "saved", "domain": ".example.com"})]; + let options = ConnectOptions::playwright() + .cdp_endpoint("http://127.0.0.1:9222") + .slow_mo(25) + .seed_cookies(cookies.clone()) + .node_working_dir("../js"); + + assert_eq!(options.engine, EngineType::Playwright); + assert_eq!(options.endpoint().unwrap(), "http://127.0.0.1:9222"); + assert_eq!(options.slow_mo, 25); + assert_eq!(options.seed_cookies, cookies); + assert_eq!(options.node_working_dir, Some(PathBuf::from("../js"))); + } + + #[test] + fn connect_options_require_exactly_one_endpoint() { + assert!(ConnectOptions::default().endpoint().is_err()); + assert!(ConnectOptions::puppeteer() + .cdp_endpoint("http://127.0.0.1:9222") + .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id") + .endpoint() + .is_err()); + } +} diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs index 05b4a31..e7bfd85 100644 --- a/rust/src/browser/mod.rs +++ b/rust/src/browser/mod.rs @@ -5,12 +5,14 @@ //! - Navigation operations pub mod chromiumoxide_adapter; +pub mod connector; pub mod launcher; pub mod media; pub mod navigation_ops; pub mod node_bridge; pub use chromiumoxide_adapter::ChromiumoxidePage; +pub use connector::{connect_browser, ConnectOptions}; pub use launcher::{launch_browser, Browser, LaunchOptions, LaunchResult}; pub use media::{emulate_media, ColorScheme, EmulateMediaOptions}; pub use navigation_ops::{ diff --git a/rust/src/browser/node_bridge.rs b/rust/src/browser/node_bridge.rs index 27647d1..eb52bf2 100644 --- a/rust/src/browser/node_bridge.rs +++ b/rust/src/browser/node_bridge.rs @@ -17,6 +17,7 @@ use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::Mutex; use tokio::task::JoinHandle; +use crate::browser::connector::ConnectOptions; use crate::browser::launcher::LaunchOptions; use crate::core::engine::{ElementInfo, EngineAdapter, EngineError, EngineType, PdfOptions}; @@ -51,16 +52,48 @@ impl NodeBridgePage { options: LaunchOptions, user_data_dir: PathBuf, ) -> Result { - let engine = options.engine; + let page = Self::start( + options.engine, + options.node_executable.as_deref(), + options.node_working_dir.as_deref(), + ) + .await?; + + page.request("launch", launch_params(&options, &user_data_dir)) + .await + .map_err(|err| anyhow::anyhow!("{}", err))?; + + Ok(page) + } + + pub(crate) async fn connect(options: ConnectOptions) -> Result { + let page = Self::start( + options.engine, + options.node_executable.as_deref(), + options.node_working_dir.as_deref(), + ) + .await?; + + page.request("connect", connect_params(&options)) + .await + .map_err(|err| anyhow::anyhow!("{}", err))?; + + Ok(page) + } + + async fn start( + engine: EngineType, + node_executable: Option<&Path>, + node_working_dir: Option<&Path>, + ) -> Result { if !matches!(engine, EngineType::Playwright | EngineType::Puppeteer) { return Err(anyhow::anyhow!( "Node bridge only supports playwright and puppeteer engines" )); } - let node = options - .node_executable - .clone() + let node = node_executable + .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from("node")); let mut command = Command::new(node); command @@ -71,7 +104,7 @@ impl NodeBridgePage { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if let Some(ref working_dir) = options.node_working_dir { + if let Some(working_dir) = node_working_dir { command.current_dir(working_dir); } @@ -98,7 +131,7 @@ impl NodeBridgePage { }) }); - let page = Self { + Ok(Self { engine, inner: Arc::new(Mutex::new(NodeBridgeProcess { child, @@ -107,13 +140,7 @@ impl NodeBridgePage { next_id: 0, })), stderr_task: Arc::new(Mutex::new(stderr_task)), - }; - - page.request("launch", launch_params(&options, &user_data_dir)) - .await - .map_err(|err| anyhow::anyhow!("{}", err))?; - - Ok(page) + }) } /// Close the browser subprocess. Dropping the adapter also terminates it. @@ -222,6 +249,23 @@ fn launch_params(options: &LaunchOptions, user_data_dir: &Path) -> Value { }) } +fn connect_params(options: &ConnectOptions) -> Value { + json!({ + "engine": options.engine.to_string(), + "cdpEndpoint": options.cdp_endpoint, + "wsEndpoint": options.ws_endpoint, + "slowMo": options.slow_mo, + "timeout": duration_millis(options.timeout), + "protocolTimeout": duration_millis(options.protocol_timeout), + "seedCookies": options.seed_cookies, + "verbose": options.verbose, + }) +} + +fn duration_millis(duration: Option) -> Option { + duration.map(|value| value.as_millis().min(u64::MAX as u128) as u64) +} + impl std::fmt::Debug for NodeBridgePage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("NodeBridgePage") @@ -495,4 +539,20 @@ mod tests { assert!(params["channel"].is_null()); assert!(params["executablePath"].is_null()); } + + #[test] + fn connect_params_forward_endpoint_timeouts_and_cookies() { + let options = ConnectOptions::puppeteer() + .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id") + .timeout(std::time::Duration::from_millis(1_500)) + .protocol_timeout(std::time::Duration::from_secs(2)) + .seed_cookies(vec![json!({"name": "SID", "value": "saved"})]); + + let params = connect_params(&options); + + assert_eq!(params["wsEndpoint"], options.ws_endpoint.unwrap()); + assert_eq!(params["timeout"], 1_500); + assert_eq!(params["protocolTimeout"], 2_000); + assert_eq!(params["seedCookies"][0]["name"], "SID"); + } } diff --git a/rust/src/browser/node_engine_bridge.js b/rust/src/browser/node_engine_bridge.js index f892813..e4e1ff3 100644 --- a/rust/src/browser/node_engine_bridge.js +++ b/rust/src/browser/node_engine_bridge.js @@ -47,7 +47,7 @@ function send(response) { function ensurePage() { if (!page) { - throw new Error("Browser page is not initialized. Send launch first."); + throw new Error("Browser page is not initialized. Send launch or connect first."); } return page; } @@ -141,6 +141,44 @@ async function launchPuppeteer(params) { } } +async function connectPlaywright(params) { + const { chromium } = await import("playwright"); + browser = await chromium.connectOverCDP( + params.cdpEndpoint ?? params.wsEndpoint, + compactObject({ + slowMo: params.slowMo, + timeout: params.timeout, + }), + ); + context = browser.contexts()[0]; + if (!context) { + throw new Error("Connected browser did not expose a default context"); + } + const pages = context.pages(); + page = pages[0] ?? (await context.newPage()); + if (params.seedCookies?.length) { + await context.addCookies(params.seedCookies); + } +} + +async function connectPuppeteer(params) { + const puppeteer = await import("puppeteer"); + browser = await puppeteer.default.connect( + compactObject({ + browserURL: params.cdpEndpoint, + browserWSEndpoint: params.wsEndpoint, + defaultViewport: null, + slowMo: params.slowMo, + protocolTimeout: params.protocolTimeout, + }), + ); + const pages = await browser.pages(); + page = pages[0] ?? (await browser.newPage()); + if (params.seedCookies?.length) { + await page.setCookie(...params.seedCookies); + } +} + async function applyColorScheme(colorScheme) { const currentPage = ensurePage(); if (engineName === "playwright") { @@ -179,10 +217,33 @@ async function handleLaunch(params) { return { engine: engineName }; } +async function handleConnect(params) { + engineName = params.engine; + verbose = Boolean(params.verbose); + + if (engineName === "playwright") { + await connectPlaywright(params); + } else if (engineName === "puppeteer") { + await connectPuppeteer(params); + } else { + throw new Error(`Unsupported bridge engine: ${engineName}`); + } + + try { + await page.bringToFront(); + } catch (error) { + log(`bringToFront failed: ${error.message}`); + } + + return { engine: engineName }; +} + async function handleCommand(method, params) { switch (method) { case "launch": return await handleLaunch(params); + case "connect": + return await handleConnect(params); case "close": if (browser) { await browser.close(); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c5766e8..3126a85 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -50,8 +50,8 @@ pub mod utilities; // Re-export commonly used items at crate root pub use browser::{ - emulate_media, launch_browser, Browser, ChromiumoxidePage, ColorScheme, EmulateMediaOptions, - LaunchOptions, LaunchResult, NodeBridgePage, + connect_browser, emulate_media, launch_browser, Browser, ChromiumoxidePage, ColorScheme, + ConnectOptions, EmulateMediaOptions, LaunchOptions, LaunchResult, NodeBridgePage, }; pub use core::{ DialogEvent, DialogManager, DialogType, EngineAdapter, EngineError, EngineType, Logger, @@ -66,9 +66,10 @@ pub use core::{ /// ``` pub mod prelude { pub use crate::browser::{ - emulate_media, goto, launch_browser, verify_navigation, wait_for_navigation, - wait_for_url_stabilization, Browser, ColorScheme, EmulateMediaOptions, LaunchOptions, - LaunchResult, NavigationOptions, NavigationResult, WaitUntil, + 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, }; pub use crate::core::{ is_navigation_error, is_timeout_error, DialogEvent, DialogManager, DialogType, diff --git a/rust/tests/connect_smoke.rs b/rust/tests/connect_smoke.rs new file mode 100644 index 0000000..106798c --- /dev/null +++ b/rust/tests/connect_smoke.rs @@ -0,0 +1,130 @@ +//! Smoke test that starts a system Chrome with a dedicated CDP profile and +//! attaches every supported Rust engine. Run explicitly with: +//! +//! ```sh +//! BROWSER_COMMANDER_CHROME=/usr/bin/google-chrome \ +//! cargo test --test connect_smoke -- --ignored --nocapture +//! ``` + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use browser_commander::{connect_browser, ConnectOptions}; + +#[tokio::test] +#[ignore] +async fn attach_all_cdp_engines_to_system_chrome() -> 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 child = ChildGuard( + Command::new(chrome) + .arg("--remote-debugging-address=127.0.0.1") + .arg("--remote-debugging-port=0") + .arg(format!( + "--user-data-dir={}", + temporary_directory.path().display() + )) + .args([ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "about:blank", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?, + ); + + let endpoint = wait_for_endpoint(temporary_directory.path(), &mut child).await?; + let node_working_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../js"); + let options = [ + ConnectOptions::chromiumoxide() + .cdp_endpoint(&endpoint) + .timeout(Duration::from_secs(20)), + ConnectOptions::playwright() + .cdp_endpoint(&endpoint) + .timeout(Duration::from_secs(20)) + .node_working_dir(&node_working_dir), + ConnectOptions::puppeteer() + .cdp_endpoint(&endpoint) + .timeout(Duration::from_secs(20)) + .node_working_dir(&node_working_dir), + ]; + + for option in options { + let engine = option.engine; + let result = connect_browser(option).await?; + result + .page + .goto("data:text/html,

attached

") + .await?; + assert_eq!( + result + .page + .evaluate("document.querySelector('#connected').textContent") + .await? + .as_str(), + Some("attached"), + "{engine} should operate through the shared page adapter" + ); + } + + Ok(()) +} + +async fn wait_for_endpoint(profile: &Path, child: &mut ChildGuard) -> anyhow::Result { + let active_port = profile.join("DevToolsActivePort"); + for _ in 0..200 { + if let Some(status) = child.0.try_wait()? { + anyhow::bail!("Chrome exited before CDP was ready: {status}"); + } + if let Ok(contents) = std::fs::read_to_string(&active_port) { + if let Some(port) = contents.lines().next() { + return Ok(format!("http://127.0.0.1:{port}")); + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + anyhow::bail!("timed out waiting for {}", active_port.display()) +} + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +struct TempDir(PathBuf); + +impl TempDir { + fn path(&self) -> &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-connect-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 3b8daeff6108688ec8ff68066d37b101990021fe Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 04:12:54 +0000 Subject: [PATCH 5/6] feat(python): attach browser engines over CDP --- experiments/connect-browser-python-smoke.py | 96 +++++++++++++ python/README.md | 26 ++++ python/changelog.d/66.added.md | 3 + python/src/browser_commander/__init__.py | 4 + .../src/browser_commander/browser/__init__.py | 3 + .../browser_commander/browser/connector.py | 131 ++++++++++++++++++ python/src/browser_commander/exports.py | 3 + python/tests/unit/browser/test_connector.py | 98 +++++++++++++ 8 files changed, 364 insertions(+) create mode 100644 experiments/connect-browser-python-smoke.py create mode 100644 python/changelog.d/66.added.md create mode 100644 python/src/browser_commander/browser/connector.py create mode 100644 python/tests/unit/browser/test_connector.py diff --git a/experiments/connect-browser-python-smoke.py b/experiments/connect-browser-python-smoke.py new file mode 100644 index 0000000..7211e17 --- /dev/null +++ b/experiments/connect-browser-python-smoke.py @@ -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 " + 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,
CDP 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 + 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()) diff --git a/python/README.md b/python/README.md index 8630864..3ffeb35 100644 --- a/python/README.md +++ b/python/README.md @@ -150,6 +150,32 @@ result = await launch_browser(options) browser, page = result.browser, result.page ``` +### connect_browser(options) + +Attach Playwright or Selenium to an already-running Chrome-family browser over +CDP. Exactly one HTTP `cdp_endpoint` or browser `ws_endpoint` is required: + +```python +from browser_commander import ConnectOptions, connect_browser + +result = await connect_browser( + ConnectOptions( + engine="playwright", # or "selenium" + cdp_endpoint="http://127.0.0.1:9222", + seed_cookies=[ + {"name": "session", "value": "saved", "url": "https://example.com"} + ], + ) +) +browser, page = result.browser, result.page +``` + +The returned page is the raw engine page/driver and can be passed directly to +`make_browser_commander()`. For Chrome 136 and newer, start the browser with a +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. + The `color_scheme` option emulates `prefers-color-scheme` at launch time: ```python diff --git a/python/changelog.d/66.added.md b/python/changelog.d/66.added.md new file mode 100644 index 0000000..1f18db9 --- /dev/null +++ b/python/changelog.d/66.added.md @@ -0,0 +1,3 @@ +### Added + +- Added `connect_browser()` and `ConnectOptions` for attaching Playwright or Selenium to an externally managed Chrome-family browser over CDP. diff --git a/python/src/browser_commander/__init__.py b/python/src/browser_commander/__init__.py index db75175..547c77f 100644 --- a/python/src/browser_commander/__init__.py +++ b/python/src/browser_commander/__init__.py @@ -20,6 +20,7 @@ ActionStoppedError, ClickResult, ClickVerificationResult, + ConnectOptions, # Engine adapter EngineAdapter, EngineType, @@ -52,6 +53,7 @@ click_button, # Click interactions click_element, + connect_browser, count, create_engine_adapter, create_logger, @@ -130,6 +132,7 @@ "BrowserCommander", "ClickResult", "ClickVerificationResult", + "ConnectOptions", # Engine adapter "EngineAdapter", "EngineType", @@ -162,6 +165,7 @@ "click_button", # Click interactions "click_element", + "connect_browser", "count", "create_engine_adapter", "create_logger", diff --git a/python/src/browser_commander/browser/__init__.py b/python/src/browser_commander/browser/__init__.py index 43a730f..eeb1284 100644 --- a/python/src/browser_commander/browser/__init__.py +++ b/python/src/browser_commander/browser/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from browser_commander.browser.connector import ConnectOptions, connect_browser from browser_commander.browser.launcher import ( LaunchOptions, LaunchResult, @@ -23,11 +24,13 @@ from browser_commander.browser.pdf import pdf __all__ = [ + "ConnectOptions", "GotoResult", "LaunchOptions", "LaunchResult", "NavigationVerificationResult", "WaitAfterActionResult", + "connect_browser", "default_navigation_verification", "emulate_media", "goto", diff --git a/python/src/browser_commander/browser/connector.py b/python/src/browser_commander/browser/connector.py new file mode 100644 index 0000000..181e707 --- /dev/null +++ b/python/src/browser_commander/browser/connector.py @@ -0,0 +1,131 @@ +"""Attach to an already-running Chromium-family browser over CDP.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse + +from browser_commander.browser.launcher import LaunchResult +from browser_commander.core.engine_detection import EngineType + + +@dataclass +class ConnectOptions: + """Configuration for a CDP browser connection.""" + + engine: EngineType = "playwright" + cdp_endpoint: str | None = None + ws_endpoint: str | None = None + 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 + + +def _validate_options(options: ConnectOptions) -> str: + if options.engine not in ("playwright", "selenium"): + msg = f"Invalid engine: {options.engine}. Expected 'playwright' or 'selenium'" + raise ValueError(msg) + if bool(options.cdp_endpoint) == bool(options.ws_endpoint): + msg = "connect_browser requires exactly one of cdp_endpoint or ws_endpoint" + raise ValueError(msg) + return options.cdp_endpoint or options.ws_endpoint or "" + + +def _debugger_address(endpoint: str) -> str: + parsed = urlparse(endpoint) + if parsed.hostname is None or parsed.port is None: + msg = f"CDP endpoint must include a host and port: {endpoint}" + raise ValueError(msg) + host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + return f"{host}:{parsed.port}" + + +async def _default_start_playwright() -> Any: + from playwright.async_api import async_playwright + + return await async_playwright().start() + + +def _default_create_selenium(chrome_options: Any) -> Any: + from selenium import webdriver + + return webdriver.Chrome(options=chrome_options) + + +async def _connect_playwright( + options: ConnectOptions, + endpoint: str, + start_playwright: Any, +) -> LaunchResult: + playwright = await start_playwright() + connect_options: dict[str, Any] = {} + if options.slow_mo is not None: + connect_options["slow_mo"] = options.slow_mo + if options.timeout is not None: + connect_options["timeout"] = options.timeout + if options.headers is not None: + connect_options["headers"] = options.headers + + browser = await playwright.chromium.connect_over_cdp(endpoint, **connect_options) + if not browser.contexts: + msg = "Connected Playwright browser has no default context" + raise RuntimeError(msg) + context = browser.contexts[0] + page = context.pages[0] if context.pages else await context.new_page() + if options.seed_cookies: + await context.add_cookies(options.seed_cookies) + return LaunchResult(browser=browser, page=page) + + +async def _connect_selenium( + options: ConnectOptions, + endpoint: str, + create_selenium: Any, +) -> LaunchResult: + from selenium.webdriver.chrome.options import Options + + chrome_options = Options() + chrome_options.debugger_address = _debugger_address(endpoint) + browser = create_selenium(chrome_options) + for cookie in options.seed_cookies: + browser.execute_cdp_cmd("Network.setCookie", cookie) + return LaunchResult(browser=browser, page=browser) + + +async def connect_browser(options: ConnectOptions) -> LaunchResult: + """Attach to a running browser over an HTTP or WebSocket CDP endpoint.""" + + return await connect_browser_with_dependencies(options) + + +async def connect_browser_with_dependencies( + options: ConnectOptions, + *, + start_playwright: Any | None = None, + create_selenium: Any | None = None, +) -> LaunchResult: + """Dependency-injected connector implementation used by tests.""" + + endpoint = _validate_options(options) + if options.verbose: + print(f"Connecting to browser with {options.engine} engine...") + + if options.engine == "playwright": + result = await _connect_playwright( + options, + endpoint, + start_playwright or _default_start_playwright, + ) + else: + result = await _connect_selenium( + options, + endpoint, + create_selenium or _default_create_selenium, + ) + + if options.verbose: + print(f"Connected to browser with {options.engine} engine") + return result diff --git a/python/src/browser_commander/exports.py b/python/src/browser_commander/exports.py index 164aabb..d5ca2b6 100644 --- a/python/src/browser_commander/exports.py +++ b/python/src/browser_commander/exports.py @@ -7,6 +7,7 @@ # Re-export core utilities # Re-export browser management +from browser_commander.browser.connector import ConnectOptions, connect_browser from browser_commander.browser.launcher import ( LaunchOptions, LaunchResult, @@ -151,6 +152,7 @@ "ActionStoppedError", "ClickResult", "ClickVerificationResult", + "ConnectOptions", "DialogManager", # Engine adapter "EngineAdapter", @@ -184,6 +186,7 @@ "click_button", # Click interactions "click_element", + "connect_browser", "count", "create_engine_adapter", "create_logger", diff --git a/python/tests/unit/browser/test_connector.py b/python/tests/unit/browser/test_connector.py new file mode 100644 index 0000000..18d5e8a --- /dev/null +++ b/python/tests/unit/browser/test_connector.py @@ -0,0 +1,98 @@ +"""Unit tests for attaching to an existing browser over CDP.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from browser_commander import connect_browser as public_connect_browser +from browser_commander.browser import connect_browser as browser_connect_browser +from browser_commander.browser.connector import ( + ConnectOptions, + connect_browser, + connect_browser_with_dependencies, +) + + +def test_connect_browser_is_exported() -> None: + assert public_connect_browser is connect_browser + assert browser_connect_browser is connect_browser + + +@pytest.mark.asyncio +async def test_connects_playwright_and_seeds_cookies() -> None: + page = object() + context = MagicMock() + context.pages = [page] + context.add_cookies = AsyncMock() + browser = MagicMock() + browser.contexts = [context] + chromium = MagicMock() + chromium.connect_over_cdp = AsyncMock(return_value=browser) + playwright = MagicMock(chromium=chromium) + cookies = [{"name": "SID", "value": "saved", "domain": ".example.com"}] + + result = await connect_browser_with_dependencies( + ConnectOptions( + engine="playwright", + cdp_endpoint="http://127.0.0.1:9222", + slow_mo=25, + timeout=5_000, + seed_cookies=cookies, + ), + start_playwright=AsyncMock(return_value=playwright), + ) + + assert result.browser is browser + assert result.page is page + chromium.connect_over_cdp.assert_awaited_once_with( + "http://127.0.0.1:9222", slow_mo=25, timeout=5_000 + ) + context.add_cookies.assert_awaited_once_with(cookies) + + +@pytest.mark.asyncio +async def test_connects_selenium_and_seeds_cookies_over_cdp() -> None: + driver = MagicMock() + create_selenium = MagicMock(return_value=driver) + cookies = [{"name": "SID", "value": "saved", "domain": ".example.com"}] + + result = await connect_browser_with_dependencies( + ConnectOptions( + engine="selenium", + ws_endpoint="ws://127.0.0.1:9333/devtools/browser/id", + seed_cookies=cookies, + ), + create_selenium=create_selenium, + ) + + assert result.browser is driver + assert result.page is driver + chrome_options = create_selenium.call_args.args[0] + assert chrome_options.debugger_address == "127.0.0.1:9333" + driver.execute_cdp_cmd.assert_called_once_with("Network.setCookie", cookies[0]) + + +@pytest.mark.asyncio +async def test_requires_exactly_one_endpoint() -> None: + with pytest.raises(ValueError, match="exactly one of cdp_endpoint or ws_endpoint"): + await connect_browser_with_dependencies(ConnectOptions()) + + with pytest.raises(ValueError, match="exactly one of cdp_endpoint or ws_endpoint"): + await connect_browser_with_dependencies( + ConnectOptions( + cdp_endpoint="http://127.0.0.1:9222", + ws_endpoint="ws://127.0.0.1:9222/devtools/browser/id", + ) + ) + + +@pytest.mark.asyncio +async def test_rejects_invalid_engine() -> None: + with pytest.raises(ValueError, match="Invalid engine: invalid"): + await connect_browser_with_dependencies( + ConnectOptions( # type: ignore[arg-type] + engine="invalid", cdp_endpoint="http://127.0.0.1:9222" + ) + ) From ab5d6cb31603958e5d6c3a4a6f4241d2a222a000 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 04:13:03 +0000 Subject: [PATCH 6/6] docs: record cross-language CDP attach parity --- README.md | 7 ++++++- docs/feature-parity.md | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 18fd5a4..2bfba47 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/feature-parity.md b/docs/feature-parity.md index e3fcdbd..40936ed 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -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 | @@ -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.