From ba74930ca51da2dc17f8fe4e48b24ee2ad8f27b4 Mon Sep 17 00:00:00 2001 From: Dhruv Raajeev Date: Wed, 22 Jul 2026 08:28:22 -0500 Subject: [PATCH] feat: add connect() API for remote CDP instances (#435) Attach to an already-running CloakBrowser instance over CDP (e.g. one started via cloakserve in Docker) while keeping the wrapper conveniences that stock connect_over_cdp() drops: humanize and the no-viewport default. - Python: connect() + connect_async() (shared _finalize_connected tail) - JS: connect() for Playwright and Puppeteer (http or ws endpoint) - .NET: CloakLauncher.ConnectAsync + ConnectOptions - Fingerprint/timezone/locale selected via the endpoint URL query string, forwarded verbatim; closing detaches without terminating the remote. - Tests (Python real-browser; JS SLOW-gated; .NET wiring) + README/CHANGELOG. Closes #435 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + README.md | 31 +++- cloakbrowser/__init__.py | 4 +- cloakbrowser/browser.py | 155 ++++++++++++++++++ dotnet/src/CloakBrowser/CloakLauncher.cs | 54 ++++++ dotnet/src/CloakBrowser/LaunchOptions.cs | 22 +++ .../tests/CloakBrowser.Tests/ConnectTests.cs | 29 ++++ js/README.md | 16 ++ js/src/index.ts | 4 +- js/src/playwright.ts | 34 +++- js/src/puppeteer.ts | 51 +++++- js/src/types.ts | 16 ++ js/tests/connect.test.ts | 116 +++++++++++++ tests/test_connect.py | 136 +++++++++++++++ 14 files changed, 658 insertions(+), 11 deletions(-) create mode 100644 dotnet/tests/CloakBrowser.Tests/ConnectTests.cs create mode 100644 js/tests/connect.test.ts create mode 100644 tests/test_connect.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f7392b..1b79e834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi ## [Unreleased] +- **[wrapper]** Add `connect()` / `connect_async()` — attach to an already-running CloakBrowser instance over CDP (e.g. one started via `cloakserve` in Docker) and keep the wrapper conveniences that stock `connect_over_cdp()` drops: `humanize=True` and the no-viewport default. Select a fingerprint/timezone/locale via the endpoint URL's query string (forwarded verbatim); closing the returned browser detaches from the remote instance without terminating it (#435). Python (sync + async), JavaScript (Playwright + Puppeteer), and .NET (`CloakLauncher.ConnectAsync`). - **[wrapper]** Fix `geoip=True` overriding a timezone or locale that was passed explicitly as a raw flag via `args=` (`--fingerprint-timezone`, `--lang`, `--fingerprint-locale`). A raw flag now counts as explicit and is preserved, matching the behavior of the `timezone=`/`locale=` parameters; GeoIP only fills the values you did not set. Python, JavaScript, and .NET. ## [0.4.13] — 2026-07-22 diff --git a/README.md b/README.md index 2b6ab53e..6c03eecb 100644 --- a/README.md +++ b/README.md @@ -818,7 +818,14 @@ browser = await launch_async(args=["--remote-debugging-port=9242"]) # Note: humanize requires the wrapper (see below) ``` -> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization: +> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. The simplest way to keep it when attaching to a running instance is `connect()` (see [CDP server mode](#cdp-server-mode)): +> +> ```python +> from cloakbrowser import connect +> browser = connect("http://127.0.0.1:9242", humanize=True) +> ``` +> +> If you already hold a Playwright/Puppeteer browser from your own code, apply the layer directly instead: > > ```js > import { patchBrowser, resolveConfig } from 'cloakbrowser/human'; @@ -904,19 +911,31 @@ Start a persistent stealth browser and connect to it remotely via Chrome DevTool docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve ``` -Then connect from your host machine: +Then connect from your host machine with `connect()` — same API as `launch()`, but it attaches to the running instance and keeps `humanize` and the no-viewport default working: ```python -from playwright.sync_api import sync_playwright +from cloakbrowser import connect -pw = sync_playwright().start() -browser = pw.chromium.connect_over_cdp("http://localhost:9222") +browser = connect("http://localhost:9222", humanize=True) page = browser.new_page() page.goto("https://example.com") print(page.title()) -browser.close() +browser.close() # detaches; the remote instance keeps running +``` + +```javascript +import { connect } from 'cloakbrowser'; // or 'cloakbrowser/puppeteer' + +const browser = await connect('http://localhost:9222', { humanize: true }); +const page = await browser.newPage(); +await page.goto('https://example.com'); +await browser.close(); ``` +Select a fingerprint/timezone/locale via the endpoint URL's query string — it's forwarded verbatim: `connect("http://localhost:9222?fingerprint=11111&timezone=America/New_York")`. Async: `connect_async(...)` (Python); .NET: `await CloakLauncher.ConnectAsync("http://localhost:9222")`. + +Prefer plain Playwright? `pw.chromium.connect_over_cdp("http://localhost:9222")` still works — you just don't get `humanize` or the no-viewport default. + If your framework needs a direct WebSocket endpoint, fetch Chrome's discovery document and use the rewritten `webSocketDebuggerUrl`. The URL points back through `cloakserve` so the CDP proxy can keep per-seed routing intact: ```bash diff --git a/cloakbrowser/__init__.py b/cloakbrowser/__init__.py index bb8febf2..072653c0 100644 --- a/cloakbrowser/__init__.py +++ b/cloakbrowser/__init__.py @@ -11,7 +11,7 @@ browser.close() """ -from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip +from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, connect, connect_async, ProxySettings, build_args, maybe_resolve_geoip from .config import CHROMIUM_VERSION, get_default_stealth_args from .download import binary_info, check_for_update, clear_cache, ensure_binary from .license import CloakBrowserLicenseError, LicenseInfo, validate_license @@ -36,6 +36,8 @@ def __getattr__(name): "launch_context_async", "launch_persistent_context", "launch_persistent_context_async", + "connect", + "connect_async", "ensure_binary", "clear_cache", "binary_info", diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 4360b2b3..2c767873 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -395,6 +395,161 @@ async def _close_with_cleanup() -> None: return browser +def _finalize_connected( + browser: Any, + pw: Any, + humanize: bool, + human_preset: HumanPreset, + human_config: HumanConfigOverrides | None, + default_no_viewport: bool, +) -> Any: + """Shared tail for connect(): wrap close() for driver cleanup, default the + viewport, and apply humanize. Mirrors the tail of launch(). ``browser.close()`` + detaches from the remote instance (it does not terminate it — connect_over_cdp + semantics) and stops our local Playwright driver. + """ + _original_close = browser.close + + def _close_with_cleanup() -> None: + try: + _original_close() + finally: + pw.stop() + + browser.close = _close_with_cleanup + + if default_no_viewport: + _default_no_viewport(browser) + + if humanize: + from .human import patch_browser + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_browser(browser, cfg) + + return browser + + +async def _finalize_connected_async( + browser: Any, + pw: Any, + humanize: bool, + human_preset: HumanPreset, + human_config: HumanConfigOverrides | None, + default_no_viewport: bool, +) -> Any: + """Async variant of :func:`_finalize_connected`.""" + _original_close = browser.close + + async def _close_with_cleanup() -> None: + try: + await _original_close() + finally: + await pw.stop() + + browser.close = _close_with_cleanup + + if default_no_viewport: + _default_no_viewport_async(browser) + + if humanize: + from .human import patch_browser_async + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_browser_async(browser, cfg) + + return browser + + +def connect( + endpoint_url: str, + *, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + default_no_viewport: bool = True, + **kwargs: Any, +) -> Any: + """Connect to an already-running CloakBrowser instance over CDP. + + Unlike ``launch()``, this does not start a browser — it attaches to one that is + already running (e.g. started via ``cloakserve`` in Docker) and layers on the + same conveniences ``launch()`` provides: the no-viewport default and, optionally, + the humanize behavioral layer. The C++ fingerprint patches live in the running + binary and persist across the connection, so nothing fingerprint-related happens + here — select a fingerprint/timezone/locale via the endpoint URL's query string + (e.g. ``http://host:9222?fingerprint=12345&timezone=America/New_York``), which is + forwarded verbatim. + + Args: + endpoint_url: CDP endpoint of the running instance, e.g. + ``"http://localhost:9222"`` (optionally with a ``?fingerprint=...`` query). + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config mapping to override preset values. + default_no_viewport: Default ``new_page()``/``new_context()`` to + ``no_viewport=True`` (default True). Set False to keep Playwright's + emulated viewport. + **kwargs: Passed directly to ``playwright.chromium.connect_over_cdp()`` + (e.g. ``headers``, ``timeout``, ``slow_mo``). + + Returns: + Playwright Browser object. ``browser.close()`` detaches from the remote + instance (it does not terminate it) and stops the local driver. + + Example: + >>> from cloakbrowser import connect + >>> browser = connect("http://localhost:9222", humanize=True) + >>> page = browser.new_page() + >>> page.goto("https://bot.incolumitas.com") + >>> browser.close() + """ + from playwright.sync_api import sync_playwright + + pw = sync_playwright().start() + try: + browser = pw.chromium.connect_over_cdp(endpoint_url, **kwargs) + except Exception: + try: + pw.stop() + except Exception as stop_exc: + logger.warning("Playwright cleanup after connect failure failed: %s", stop_exc) + raise + + logger.debug("Connected to CloakBrowser over CDP (%s)", endpoint_url) + return _finalize_connected( + browser, pw, humanize, human_preset, human_config, default_no_viewport + ) + + +async def connect_async( + endpoint_url: str, + *, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + default_no_viewport: bool = True, + **kwargs: Any, +) -> Any: + """Async variant of :func:`connect`.""" + from playwright.async_api import async_playwright + + pw = await async_playwright().start() + try: + browser = await pw.chromium.connect_over_cdp(endpoint_url, **kwargs) + except Exception: + try: + await pw.stop() + except Exception as stop_exc: + logger.warning("Playwright cleanup after connect failure failed: %s", stop_exc) + raise + + logger.debug("Connected to CloakBrowser over CDP async (%s)", endpoint_url) + return await _finalize_connected_async( + browser, pw, humanize, human_preset, human_config, default_no_viewport + ) + + def launch_persistent_context( user_data_dir: str | os.PathLike, headless: bool = True, diff --git a/dotnet/src/CloakBrowser/CloakLauncher.cs b/dotnet/src/CloakBrowser/CloakLauncher.cs index 325ba097..d429bbe7 100644 --- a/dotnet/src/CloakBrowser/CloakLauncher.cs +++ b/dotnet/src/CloakBrowser/CloakLauncher.cs @@ -86,6 +86,60 @@ public static async Task LaunchAsync(LaunchOptions? options } } + // ----------------------------------------------------------------------- + // connect - attach to an already-running instance over CDP + // ----------------------------------------------------------------------- + + /// + /// Connect to an already-running CloakBrowser instance over CDP (e.g. one started + /// via cloakserve in Docker) and apply the same conveniences + /// provides — the no-viewport default and, optionally, the humanize behavioral layer. + /// The C++ fingerprint patches live in the running binary and persist across the + /// connection, so select a fingerprint/timezone/locale via the endpoint URL's query + /// string (e.g. http://host:9222?fingerprint=12345), which is forwarded verbatim. + /// detaches from the remote instance; it + /// does not terminate it. + /// + public static async Task ConnectAsync(string endpointUrl, ConnectOptions? options = null) + { + options ??= new ConnectOptions(); + + CloakLog.Debug($"Connecting to CloakBrowser over CDP ({endpointUrl})"); + + var playwright = await Playwright.CreateAsync().ConfigureAwait(false); + IBrowser browser; + try + { + browser = await playwright.Chromium.ConnectOverCDPAsync(endpointUrl).ConfigureAwait(false); + } + catch + { + playwright.Dispose(); + throw; + } + + try + { + var humanCfg = options.Humanize + ? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig) + : null; + // Map DefaultNoViewport onto the handle's headed/headlessNoViewport gate: + // ApplyHeadedNoViewport applies NoViewport unless (headless && !headlessNoViewport), + // so headless=false forces the no-viewport default; headless=true keeps the + // emulated viewport. (We can't know the remote's real headless state.) + return new CloakBrowserHandle( + playwright, browser, options.Humanize, humanCfg, + headless: !options.DefaultNoViewport, headlessNoViewport: false); + } + catch + { + try { await browser.CloseAsync().ConfigureAwait(false); } + catch (Exception closeEx) { CloakLog.Warning($"browser cleanup after connect failure failed: {closeEx.Message}"); } + playwright.Dispose(); + throw; + } + } + // ----------------------------------------------------------------------- // launch_context - returns a Context handle (browser owned) // ----------------------------------------------------------------------- diff --git a/dotnet/src/CloakBrowser/LaunchOptions.cs b/dotnet/src/CloakBrowser/LaunchOptions.cs index 7426c5f3..dd2e3ead 100644 --- a/dotnet/src/CloakBrowser/LaunchOptions.cs +++ b/dotnet/src/CloakBrowser/LaunchOptions.cs @@ -65,6 +65,28 @@ public class LaunchOptions internal bool SuppressMaximize { get; set; } } +/// +/// Options for . +/// Mirrors the keyword arguments of the Python connect(). +/// +public class ConnectOptions +{ + /// Enable the human-like behavior layer when creating pages via . + public bool Humanize { get; set; } + + /// Humanize preset (default ). + public HumanPreset HumanPreset { get; set; } = HumanPreset.Default; + + /// Custom humanize config overrides (snake_case or PascalCase keys). + public IReadOnlyDictionary? HumanConfig { get; set; } + + /// + /// Default NewPageAsync/NewContextAsync to NoViewport (default true). + /// Set false to keep Playwright's emulated viewport. + /// + public bool DefaultNoViewport { get; set; } = true; +} + /// Options for context-producing launchers (adds context-level emulation settings). public class LaunchContextOptions : LaunchOptions { diff --git a/dotnet/tests/CloakBrowser.Tests/ConnectTests.cs b/dotnet/tests/CloakBrowser.Tests/ConnectTests.cs new file mode 100644 index 00000000..ec660d87 --- /dev/null +++ b/dotnet/tests/CloakBrowser.Tests/ConnectTests.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using CloakBrowser; +using CloakBrowser.Human; +using Xunit; + +namespace CloakBrowser.Tests; + +public class ConnectTests +{ + [Fact] + public void ConnectOptions_Defaults() + { + var o = new ConnectOptions(); + Assert.False(o.Humanize); + Assert.Equal(HumanPreset.Default, o.HumanPreset); + Assert.True(o.DefaultNoViewport); // no-viewport on by default (parity with Python/JS) + Assert.Null(o.HumanConfig); + } + + [Fact] + public async Task ConnectAsync_UnreachableEndpoint_Throws() + { + // No browser is listening on this port; ConnectOverCDPAsync fails, and + // ConnectAsync disposes Playwright and rethrows rather than leaking the driver. + await Assert.ThrowsAnyAsync( + () => CloakLauncher.ConnectAsync("http://127.0.0.1:1")); + } +} diff --git a/js/README.md b/js/README.md index d92fe07b..31e075bf 100644 --- a/js/README.md +++ b/js/README.md @@ -131,6 +131,22 @@ await page.goto('https://example.com'); await ctx.close(); // profile saved — reuse same path to restore state ``` +### Connect to a running instance + +Attach to an already-running CloakBrowser (e.g. one started via `cloakserve` in Docker) over CDP, keeping `humanize` and the no-viewport default — unlike stock `connectOverCDP`: + +```javascript +import { connect } from 'cloakbrowser'; // Playwright +// import { connect } from 'cloakbrowser/puppeteer'; // Puppeteer + +const browser = await connect('http://localhost:9222', { humanize: true }); +const page = await browser.newPage(); +await page.goto('https://example.com'); +await browser.close(); // detaches; the remote instance keeps running +``` + +Select a fingerprint via the endpoint URL's query string — it's forwarded verbatim: `connect('http://localhost:9222?fingerprint=11111')`. Pass a `ws(s)://` URL directly, or an `http(s)://` one (the ws endpoint is resolved for you). + ### Auto Timezone/Locale from Proxy IP When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's location. Install `mmdb-lib` to enable auto-detection from an offline GeoIP database (~70 MB, downloaded on first use): diff --git a/js/src/index.ts b/js/src/index.ts index b108814a..56855b08 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -16,7 +16,7 @@ */ // Launch functions (Playwright API) -export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js"; +export { launch, launchContext, launchPersistentContext, connect, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js"; // Binary management export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js"; @@ -28,5 +28,5 @@ export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js"; export { validateLicense, CloakBrowserLicenseError } from "./license.js"; // Types -export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js"; +export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, ConnectOptions, BinaryInfo } from "./types.js"; export type { LicenseInfo } from "./license.js"; diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 14fa04a7..b2b45c4e 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -4,7 +4,7 @@ */ import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions as PlaywrightLaunchOptions } from "playwright-core"; -import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; +import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, ConnectOptions } from "./types.js"; import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, @@ -195,6 +195,38 @@ export async function launch(options: LaunchOptions = {}): Promise { return browser; } +/** + * Connect to an already-running CloakBrowser instance over CDP (e.g. one started + * via cloakserve in Docker) and apply the same conveniences launch() provides — + * the no-viewport default and, optionally, the humanize behavioral layer. + * + * The C++ fingerprint patches live in the running binary and persist across the + * connection, so select a fingerprint/timezone/locale via the endpoint URL's query + * string (e.g. "http://host:9222?fingerprint=12345"), which is forwarded verbatim. + * `browser.close()` detaches from the remote instance; it does not terminate it. + * + * @example + * ```ts + * import { connect } from 'cloakbrowser'; + * const browser = await connect('http://localhost:9222', { humanize: true }); + * const page = await browser.newPage(); + * await page.goto('https://bot.incolumitas.com'); + * await browser.close(); + * ``` + */ +export async function connect( + endpoint: string, + options: ConnectOptions = {} +): Promise { + const { chromium } = await import("playwright-core"); + const browser = await chromium.connectOverCDP(endpoint, options.connectOptions); + if (options.defaultNoViewport !== false) { + applyDefaultNoViewport(browser); + } + await humanizeBrowser(browser, options); + return browser; +} + /** * Wrap a Browser's newContext()/newPage() to default to viewport:null (no * emulation) when the caller didn't specify a viewport. setdefault-style: an diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index 8b05dedb..d6374302 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -5,7 +5,7 @@ */ import type { Browser } from "puppeteer-core"; -import type { LaunchOptions } from "./types.js"; +import type { LaunchOptions, ConnectOptions } from "./types.js"; import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, @@ -184,6 +184,55 @@ export async function launch(options: LaunchOptions = {}): Promise { return browser; } +/** + * Connect to an already-running CloakBrowser instance over CDP via Puppeteer + * (e.g. one started via cloakserve in Docker) and apply the humanize behavioral + * layer. Accepts an http(s):// endpoint (Puppeteer resolves the ws URL from + * /json/version) or a ws(s):// endpoint directly. + * + * The C++ fingerprint patches live in the running binary and persist across the + * connection; select a fingerprint via the endpoint URL's query string + * (e.g. "http://host:9222?fingerprint=12345"). `browser.disconnect()` detaches + * from the remote instance; it does not terminate it. + * + * @example + * ```ts + * import { connect } from 'cloakbrowser/puppeteer'; + * const browser = await connect('http://localhost:9222', { humanize: true }); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.disconnect(); + * ``` + */ +export async function connect( + endpoint: string, + options: ConnectOptions = {} +): Promise { + const puppeteer = await import("puppeteer-core"); + const target = /^wss?:\/\//i.test(endpoint) + ? { browserWSEndpoint: endpoint } + : { browserURL: endpoint }; + const browser = await puppeteer.default.connect({ + ...target, + // null disables Puppeteer's 800x600 emulated viewport (bot tell); undefined + // keeps its default. defaultNoViewport defaults to true. + defaultViewport: options.defaultNoViewport === false ? undefined : null, + ...(options.connectOptions ?? {}), + }); + + if (options.humanize) { + const { patchBrowser } = await import('./human-puppeteer/index.js'); + const { resolveConfig } = await import('./human/config.js'); + const cfg = resolveConfig( + options.humanPreset ?? 'default', + options.humanConfig, + ); + patchBrowser(browser, cfg); + } + + return browser; +} + /** * Launch stealth Chromium with a persistent user profile via Puppeteer. * Passes `userDataDir` to Puppeteer's launch options so cookies, diff --git a/js/src/types.ts b/js/src/types.ts index 01a5ef39..ef40f502 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -41,6 +41,22 @@ export interface LaunchOptions { humanConfig?: Partial; } +export interface ConnectOptions { + /** Enable human-like mouse, keyboard, and scroll behavior. */ + humanize?: boolean; + /** Human behavior preset: 'default' or 'careful'. */ + humanPreset?: HumanPreset; + /** Override individual human behavior parameters. */ + humanConfig?: Partial; + /** + * Default newPage()/newContext() to viewport:null (default: true). Set false + * to keep Playwright's emulated viewport. + */ + defaultNoViewport?: boolean; + /** Raw options forwarded to connectOverCDP() — e.g. headers, timeout, slowMo. */ + connectOptions?: Record; +} + export interface LaunchContextOptions extends LaunchOptions { /** Custom user agent string. */ userAgent?: string; diff --git a/js/tests/connect.test.ts b/js/tests/connect.test.ts new file mode 100644 index 00000000..704e4c4c --- /dev/null +++ b/js/tests/connect.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// SLOW TESTS — require a real browser (run with: SLOW=1 vitest run --testTimeout=60000). +// Strategy: start the CloakBrowser binary with a debug port (what cloakserve does +// under the hood), then connect() back to it over CDP — no Pro license, no live sites. +const SLOW = process.env.SLOW === "1"; +const describeIfSlow = SLOW ? describe : describe.skip; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + srv.on("error", reject); + }); +} + +async function remoteAlive(port: number): Promise { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`); + return res.ok; + } catch { + return false; + } +} + +async function waitForCdp(port: number, timeoutMs = 30000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await remoteAlive(port)) return; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`CDP endpoint on :${port} not ready within ${timeoutMs}ms`); +} + +async function startBrowser(): Promise<{ endpoint: string; port: number; proc: ChildProcess }> { + const { ensureBinary } = await import("../src/download.js"); + const binary = await ensureBinary(); + const port = await freePort(); + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-connect-")); + const proc = spawn( + binary, + [ + "--headless=new", + `--remote-debugging-port=${port}`, + `--user-data-dir=${dataDir}`, + "--no-first-run", + "--no-default-browser-check", + "--no-sandbox", + ], + { stdio: "ignore" } + ); + await waitForCdp(port); + return { endpoint: `http://127.0.0.1:${port}`, port, proc }; +} + +describeIfSlow("connect() — Playwright", () => { + it("returns a usable browser and honors humanize + no-viewport", async () => { + const { connect } = await import("../src/index.js"); + const { endpoint, proc } = await startBrowser(); + try { + const browser = await connect(endpoint, { humanize: true }); + try { + expect(browser.isConnected()).toBe(true); + const page = await browser.newPage(); + await page.goto("data:text/html,hi"); + expect(await page.title()).toBe("hi"); + // defaultNoViewport: true → no emulated viewport. + expect(page.viewportSize()).toBeNull(); + } finally { + await browser.close(); + } + } finally { + proc.kill(); + } + }, 60000); + + it("close() detaches without killing the remote instance", async () => { + const { connect } = await import("../src/index.js"); + const { endpoint, port, proc } = await startBrowser(); + try { + const browser = await connect(endpoint); + await browser.close(); + expect(browser.isConnected()).toBe(false); + expect(await remoteAlive(port)).toBe(true); + } finally { + proc.kill(); + } + }, 60000); +}); + +describeIfSlow("connect() — Puppeteer", () => { + it("connects over an http endpoint and applies humanize", async () => { + const { connect } = await import("../src/puppeteer.js"); + const { endpoint, proc } = await startBrowser(); + try { + const browser = await connect(endpoint, { humanize: true }); + try { + const page = await browser.newPage(); + await page.goto("data:text/html,hey"); + expect(await page.title()).toBe("hey"); + } finally { + await browser.disconnect(); + } + } finally { + proc.kill(); + } + }, 60000); +}); diff --git a/tests/test_connect.py b/tests/test_connect.py new file mode 100644 index 00000000..90827fbd --- /dev/null +++ b/tests/test_connect.py @@ -0,0 +1,136 @@ +"""Integration tests for connect() / connect_async(). + +Strategy: start the CloakBrowser binary directly with a remote-debugging port +(the same thing cloakserve does under the hood), then connect() back to it over +CDP. This exercises the full wrapper path — endpoint -> connect_over_cdp -> no +viewport -> humanize -> close cleanup — with no Pro license and no live sites. +""" + +import json +import socket +import subprocess +import time +import urllib.request + +import pytest + +from cloakbrowser import connect, connect_async +from cloakbrowser.download import ensure_binary + +pytest.importorskip("playwright", reason="connect() requires playwright") + + +def _free_port() -> int: + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +def _remote_alive(port: int) -> bool: + try: + urllib.request.urlopen( + f"http://127.0.0.1:{port}/json/version", timeout=1 + ).close() + return True + except Exception: + return False + + +def _wait_for_cdp(port: int, timeout: float = 30.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if _remote_alive(port): + return + time.sleep(0.25) + raise RuntimeError(f"CDP endpoint on :{port} not ready within {timeout}s") + + +@pytest.fixture +def cdp(tmp_path): + """Start the binary with a debug port; yield (http_endpoint, port).""" + binary = ensure_binary() + port = _free_port() + proc = subprocess.Popen( + [ + str(binary), + "--headless=new", + f"--remote-debugging-port={port}", + f"--user-data-dir={tmp_path / 'profile'}", + "--no-first-run", + "--no-default-browser-check", + "--no-sandbox", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + _wait_for_cdp(port) + yield f"http://127.0.0.1:{port}", port + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_connect_returns_usable_browser(cdp): + endpoint, _ = cdp + browser = connect(endpoint) + try: + assert browser.is_connected() + page = browser.new_page() + page.goto("data:text/html,hi") + assert page.title() == "hi" + finally: + browser.close() + + +def test_connect_humanize_patches_pages(cdp): + endpoint, _ = cdp + browser = connect(endpoint, humanize=True) + try: + page = browser.new_page() + # patch_page tags humanized pages with `_original`. + assert hasattr(page, "_original") + finally: + browser.close() + + +def test_connect_default_no_viewport(cdp): + endpoint, _ = cdp + browser = connect(endpoint) # default_no_viewport=True + try: + assert browser.new_page().viewport_size is None + finally: + browser.close() + + browser = connect(endpoint, default_no_viewport=False) + try: + assert browser.new_page().viewport_size is not None + finally: + browser.close() + + +def test_close_detaches_without_killing_remote(cdp): + endpoint, port = cdp + browser = connect(endpoint) + browser.new_page().goto("data:text/html,x") + browser.close() + assert not browser.is_connected() + # close() detaches our driver; the remote instance keeps running. + assert _remote_alive(port) + + +async def test_connect_async(cdp): + endpoint, _ = cdp + browser = await connect_async(endpoint) + try: + assert browser.is_connected() + page = await browser.new_page() + await page.goto("data:text/html,hey") + assert await page.title() == "hey" + finally: + await browser.close()