Compare commits

..
Author SHA1 Message Date
CloakHQ 858c0d0e85 ci: fix publish version check — read _version.py without importing 2026-03-08 23:49:10 +01:00
Cloak-HQandGitHub e615349f1e Merge pull request #30 from evelaa123/feature/humanize
feat: add humanize option  human-like mouse, keyboard, scroll behavio…
2026-03-08 23:22:17 +01:00
CloakHQ 1c93951f23 release: v0.3.11 — Linux build 145.0.7632.159.3
- Version bump: 0.3.10 → 0.3.11 (Python + JS)
- Linux Chromium: 145.0.7632.159.2 → 159.3
- CHANGELOG: v0.3.11 entry
- README: patch count 26→31, humanize docs
- bin/cloakserve: use --remote-debugging-address, remove socat
- Dockerfile: remove socat dependency
2026-03-08 23:19:39 +01:00
lilos 7bf8836683 feat: add human-like behavioral layer (humanize option)
Bezier mouse curves, per-character typing with mistype simulation,
smooth micro-step scrolling, idle micro-movements between actions.

Supports both sync and async Playwright APIs. Patches page, frame,
context, browser, and Locator class methods.

Two presets: 'default' (normal speed) and 'careful' (slower, deliberate).
Configurable via HumanConfig dataclass / interface with full override support.

Bug fixes (from PR review):
- fill()/clear(): platform-aware select-all (Meta+a on macOS, Control+a elsewhere)
- sync Locator check()/uncheck(): wrap mouse_move in RawMouse-compatible object
- resolve_config(): raise error on unknown preset name
- Lazy-load human.config via __getattr__ in __init__.py
- humanPreset typed as 'default' | 'careful' literal union
- browser.newPage() patches implicit context

Tests: Python 36/36, JS Vitest 34/34, visual Python 17/17, JS 13/13
2026-03-08 12:49:42 +03:00
CloakHQ 23a9c4d4bd ci: add publish workflow, binary attestation, and dev extras
- publish.yml: automated PyPI/npm/Docker on v* tag push; OIDC trusted publishing for PyPI/npm; Docker signed with Cosign keyless + provenance attested
- attest-release.yml: manual workflow to attest binary release assets via Sigstore (actions/attest-build-provenance@v2)
- pyproject.toml: add dev extras (pytest, pytest-asyncio)
2026-03-08 02:32:33 +01:00
CloakHQ c8e09656aa release: v0.3.10 — Linux build 145.0.7632.159.2
Binary: fix detection regression (#16), fix fingerprint consistency in offline audio rendering.
Wrapper: bump version to 0.3.10, update Linux binary version to 145.0.7632.159.2.
2026-03-07 02:28:53 +01:00
CloakHQ 724d49f65b test: add wrapper regression tests for issues #9, #27
docs: add Docker Compose with healthcheck, persistent profiles
via volume mount, and resource usage numbers to README
2026-03-06 07:37:19 +01:00
CloakHQ ed79560e5f feat: add cloakserve CDP server mode for Docker
Add bin/cloakserve — launches stealth Chromium with remote debugging
enabled so users can connect via connect_over_cdp() from the host.
Uses socat to forward 0.0.0.0:9222 to Chrome's localhost-only CDP port.

Usage: docker run -d -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve

Tested: all stealth checks pass via CDP, bot.sannysoft.com 54/54,
reCAPTCHA 0.9, zero detection regressions.
2026-03-06 05:20:40 +01:00
CloakHQ 829e4b881f ci: add unit test workflow for Python and JavaScript
Runs pytest and vitest on every push/PR to main.
Only unit tests — no binary required.
2026-03-06 05:13:07 +01:00
Cloak-HQ 3880d30d0f fix: deduplicate CLI flags when user args overlap with stealth defaults
Bump version to 0.3.9. Extract shared buildArgs into js/src/args.ts (DRY),
guard console.debug behind DEBUG=cloakbrowser env var, strengthen caplog assertion.
2026-03-05 18:44:18 +01:00
Cloak-HQ 9c533e4120 feat: upgrade Chromium base to 145.0.7632.159 (Linux x64)
- Bump linux-x64 binary to 145.0.7632.159 (macOS/Windows stay at 145.0.7632.109.2)
- Wrapper version 0.3.8
- Fix rollback path examples to use correct per-platform versions
2026-03-05 18:44:17 +01:00
Cloak-HQ 98c216f07e feat: make patchright optional, default to stock playwright 2026-03-05 18:44:17 +01:00
Cloak-HQandGitHub ee953709b0 Merge pull request #29 from evelaa123/fix/python-download-timeout
fix(python): reduce download connect timeout to 10s, read to 60s for …
2026-03-05 12:08:04 +01:00
lilos a45fdc4d7e fix(python): reduce download connect timeout to 10s, read to 60s for faster fallback 2026-03-05 13:48:05 +03:00
Cloak-HQ e411f24cf3 feat: first-launch welcome message for all install methods
Show welcome banner once per install (Python, JS, Docker).
Uses marker file in ~/.cloakbrowser/ — resets on cache clear or update.
Replaces logger.info() calls that were invisible by default.

Bump to v0.3.8.
2026-03-05 07:49:08 +01:00
Cloak-HQ 0a99a1458a docs: update troubleshooting — persistent profiles for sites that challenge fresh sessions 2026-03-05 07:25:49 +01:00
Cloak-HQ f76dbdb044 feat: Docker Hub image, cloaktest CLI, and example UX improvements
Add cloakhq/cloakbrowser Docker Hub image with Node.js, JS wrapper,
Xvfb headed mode, and cloaktest shortcut. Add launch feedback and IP
display to all examples. Update README Docker section for Docker Hub.
2026-03-05 05:09:29 +01:00
Cloak-HQ 976f5ae534 docs: streamline READMEs for launch — remove repetition, reorder for conversion
- Hero: remove emojis, cut weak bullets, add auto-updating/free+OSS
- Latest: rename to v0.3.5 (Chromium 145), swap weaker items for CDP/audit/persistent
- Why: remove unverified AI agent claims, cut redundant lines
- Test Results: 30/30 → tested against 30+ detection sites
- Comparison: move up after proof images, Camoufox "Unstable"
- Fingerprint flags: collapse into <details> block
- Platforms: move up before Docker
- Headed Mode: merge into Troubleshooting
- Roadmap: move down after FAQ
- FAQ legal: rewrite to "do not condone illegal use"
- Add rollback instructions via CLOAKBROWSER_BINARY_PATH
- Examples: update descriptions, remove persistent-context.ts
- js/README.md: sync hero, platforms, test table, reCAPTCHA tips
2026-03-05 03:54:10 +01:00
Cloak-HQ 0719f750ef test: add comprehensive unit tests for all public APIs
Python (75 new tests):
- launch_context(): viewport, timezone bypass, geoip, close cleanup, error cleanup
- launch_persistent_context(): sync + async, args, proxy, close/pw.stop()
- config: binary paths, archive names, cache dir, stealth args profiles
- extract: tar/zip with path traversal protection, .app bundle preservation
- ensure_binary(), clear_cache(), check_for_update(), version markers
- geoip: private IP detection

JavaScript (26 new tests):
- puppeteer wrapper: stealth args, proxy string/dict, auth monkey-patch
- launchContext/launchPersistentContext: viewport, timezone, proxy, close
- ensureBinary, clearCache, checkForUpdate, archive helpers

Total: 169 Python + 88 JS tests (was 59 + 47)
2026-03-05 03:02:46 +01:00
Cloak-HQ 05fa1a052a refactor: unify timezone parameter naming across Python and JS wrappers
- Rename timezone_id → timezone in launch_context(), launch_persistent_context(),
  and launch_persistent_context_async() (Python)
- Extract _migrate_timezone_id() helper for deprecation compat (DRY)
- Always pop timezone_id from kwargs to prevent override via context_kwargs.update()
- Use FutureWarning (visible by default) instead of DeprecationWarning
- JS: deprecate timezoneId on LaunchContextOptions with runtime shim
- Extract migrateTimezoneId<T>() shared helper in playwright.ts (DRY)
- Bump version to 0.3.7 in _version.py and package.json
- Add 4 Python + 4 JS unit tests for deprecation compat behavior
2026-03-05 02:50:55 +01:00
Cloak-HQ 25acff23b7 docs: strengthen binary license — liability cap, cloud/CI use, acceptable use
Add Cloud/Container/Integration Use section clarifying internal Docker/CI
is permitted, dependency listing is not redistribution, OEM/SaaS requires
separate license. Add Limitation of Liability ($100 cap). Add prohibited
use cases (banking, credential stuffing, fraud). Clarify that flags,
extensions, and custom profiles are permitted configuration. Update README
and js/README with prohibition language and license link.
2026-03-04 22:40:25 +01:00
Cloak-HQ bd22e51bc2 feat: support proxy dict with bypass field (#24)
The `proxy` parameter now accepts a Playwright proxy dict
({server, bypass, username, password}) in addition to URL strings.
Dict proxies are passed directly to Playwright, enabling bypass
lists and other advanced proxy options.

- Add ProxySettings TypedDict for Python type safety
- Extract server URL from dict proxies for geoip resolution
- Handle dict proxy args/auth in Puppeteer wrapper
- Strip inline credentials from dict proxy server URL in Puppeteer
- Fix JS launchContext() double-setting timezone (binary flag + context)
- Remove unnecessary non-null assertions in TS geoip helpers
- Use nullish coalescing for password fallbacks
- Add unit tests for geoip with dict proxy input
2026-03-04 21:16:35 +01:00
CloakHQ ca5cce2222 release: v0.3.5 — persistent context, Windows zip fix, community PRs
- Add launch_persistent_context() Python + JS with examples
- Document persistent context API in both READMEs
- Bump version to 0.3.5
- Credit @evelaa123 and @yahooguntu in CHANGELOG
2026-03-04 19:23:16 +01:00
Cloak-HQandGitHub de54e67f74 Merge pull request #22 from evelaa123/feat/launch-persistent-context
feat: add launchPersistentContext() to avoid incognito detection
2026-03-04 19:10:23 +01:00
Cloak-HQandGitHub ef066fa091 Merge pull request #23 from evelaa123/fix/windows-zip-extraction
fix(windows): zip extraction fails when primary download server is down
2026-03-04 18:34:25 +01:00
lilos 5237065385 fix(windows): destroy fileStream on failed download to prevent zip lock 2026-03-04 13:38:09 +03:00
lilos 8e83b8c399 fix: LaunchPersistentContextOptions extends LaunchContextOptions 2026-03-04 12:31:33 +03:00
lilos c44b04a953 feat: add launch_persistent_context + async variant (Python), fix import os at module level 2026-03-04 12:19:56 +03:00
lilos 51c3f464a5 feat: add launchPersistentContext() to avoid incognito detection 2026-03-04 12:00:49 +03:00
CloakHQ ed0ecf0e48 docs: add macOS fingerprint profile troubleshooting note 2026-03-04 03:51:05 +01:00
CloakHQ 46049a15d3 feat: Windows .zip download support, binary v145.0.7632.109.2
- Add get_archive_ext() / get_archive_name() for platform-aware archive format (.zip on Windows, .tar.gz elsewhere)
- Add _extract_zip() / extractZip() with path traversal protection
- Python: zipfile module extraction
- JS: PowerShell Expand-Archive on Windows, system unzip on others
- Bump all platform versions to 145.0.7632.109.2 (4 platforms: linux-x64, darwin-arm64, darwin-x64, windows-x64)
- Update checksum lookup, temp file naming, and auto-update asset matching to use archive helpers
2026-03-04 01:23:07 +01:00
CloakHQ 11b3bcb701 release: v0.3.4 — 26 patches, auto-spoof, timezone fix, README refresh 2026-03-04 01:11:50 +01:00
CloakHQ 28de7bb147 refactor: simplify stealth args — rely on binary auto-generation (v14+)
Binary v14+ auto-generates hardware concurrency, device memory, screen
dimensions, and window size from the fingerprint seed. Remove these
explicit flags from Python/JS wrapper defaults and update README:

- Remove 5 flags from get_default_stealth_args() in both wrappers
- Move hardware-concurrency, device-memory, screen-width, screen-height
  to the Additional Flags table with auto-generated defaults documented
- Update code examples to use --fingerprint instead of --window-size
- Simplify fingerprint defaults table to show only wrapper-set flags
2026-03-03 21:27:01 +01:00
CloakHQ 0c64a32122 release: v0.3.3 — Windows x64, macOS v145, auto-spoof docs
Bump wrapper to 0.3.3. Update README fingerprint section to
document auto-spoof behavior (zero-config stealth). Improve
reCAPTCHA test with wait_for_selector instead of blind sleep.
2026-03-03 20:05:16 +01:00
CloakHQ f9887943c0 feat: add Windows x64 support, update macOS to v145 2026-03-03 08:57:07 +01:00
CloakHQ 55418add96 feat: macOS v145 wrapper prep — GPU flags, version bump, README update
- Add explicit Mac GPU flags (Apple M3 Metal renderer) to stealth args
- Bump macOS platform versions to 145.0.7632.109
- Update README fingerprint table to reflect actual Mac GPU defaults
- Add warning about binary requiring explicit flags without wrapper
- Fix stealth_test.py wait_until for reCAPTCHA page
2026-03-03 08:57:07 +01:00
68 changed files with 8865 additions and 374 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Attest Release Binary
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. chromium-v145.0.7632.159.2)'
required: true
jobs:
attest:
runs-on: ubuntu-latest
permissions:
id-token: write # Sigstore OIDC
attestations: write # GitHub attestation API
contents: write # Download release assets
steps:
- name: Download release binaries
run: gh release download ${{ github.event.inputs.tag }} --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
env:
GH_TOKEN: ${{ github.token }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: |
cloakbrowser-*.tar.gz
cloakbrowser-*.zip
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]" pytest pytest-asyncio
- name: Run tests
run: pytest tests/ -v -m "not slow"
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install and build
run: cd js && npm install && npm run build
- name: Typecheck
run: cd js && npm run typecheck
- name: Run tests
run: cd js && npm test
+132
View File
@@ -0,0 +1,132 @@
name: Publish
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
job:
description: 'Job to run (leave empty to run all)'
required: false
type: choice
options:
- ''
- publish-pypi
- publish-npm
- publish-docker
concurrency:
group: publish
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Python tests
run: |
pip install -e ".[dev]" pytest pytest-asyncio
pytest tests/ -v -m "not slow"
- uses: actions/setup-node@v4
with:
node-version: 22
- name: JavaScript tests
run: cd js && npm ci && npm run build && npm test
validate-version:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check tag matches package versions
run: |
TAG="${GITHUB_REF_NAME#v}"
PY=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
JS=$(python -c 'import json; print(json.load(open("js/package.json"))["version"])')
echo "Tag: $TAG | Python: $PY | npm: $JS"
[ "$TAG" = "$PY" ] || { echo "ERROR: tag v$TAG != _version.py $PY"; exit 1; }
[ "$TAG" = "$JS" ] || { echo "ERROR: tag v$TAG != package.json $JS"; exit 1; }
publish-pypi:
needs: [test, validate-version]
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build
run: |
pip install build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
publish-npm:
needs: [test, validate-version]
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Build
run: cd js && npm ci && npm run build
- name: Publish to npm
run: cd js && npm publish --provenance --access public
publish-docker:
needs: [test, validate-version]
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
runs-on: ubuntu-latest
permissions:
id-token: write # Cosign keyless signing + attestations
contents: read
attestations: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Extract version
run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
cloakhq/cloakbrowser:${{ env.VERSION }}
cloakhq/cloakbrowser:latest
provenance: true
sbom: true
- uses: sigstore/cosign-installer@v3
- name: Sign image
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-name: index.docker.io/cloakhq/cloakbrowser
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
-45
View File
@@ -1,45 +0,0 @@
name: Release Binary
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. chromium-v145.0.7718.0)'
required: true
title:
description: 'Release title (e.g. Chromium v145 — Stealth Build)'
required: true
default: 'Stealth Chromium Build'
patch_count:
description: 'Number of fingerprint patches'
required: true
default: '16'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
name: "${{ github.event.inputs.title }}"
body: |
## Stealth Chromium Build
Pre-built Chromium with ${{ github.event.inputs.patch_count }} source-level fingerprint patches.
### Install
```bash
pip install cloakbrowser # Python
npm install cloakbrowser # JavaScript
# Binary auto-downloads on first launch
```
> Binary integrity is verified automatically via SHA-256 checksums on download.
>
> Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD`
+1
View File
@@ -61,3 +61,4 @@ publish.sh
deploy.sh
.env
debug
publish-docker.sh
+22 -3
View File
@@ -26,13 +26,21 @@ You may NOT:
4. **Modify** the Binary or create derivative works based on it
5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary
Listing CloakBrowser as a dependency in your project (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution — end users download the Binary directly from official CloakHQ channels.
Normal use of the Binary with command-line flags, browser extensions, managed policies, custom profiles, or user data directories does not constitute modification or creation of derivative works.
Internal caching or mirroring (including via artifact repositories such as Artifactory or Nexus) of unmodified Binaries that were originally obtained from official CloakHQ distribution channels is permitted solely for internal operational purposes within your organization. This permission does not allow public redistribution or distribution to third parties.
## Cloud, Container & Integration Use
**Internal use** — You may store and run the unmodified Binary within internal infrastructure, including Docker images, VM templates, CI runners, container registries, and artifact repositories (e.g., Artifactory, Nexus), solely for your organization's internal operational purposes.
**Dependency listing** — Listing CloakBrowser as a dependency in your project or third-party framework (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution, as end users download the Binary directly from official CloakHQ channels. No commercial license is required for this.
**Using CloakBrowser for your own business is free** — no license beyond this one is needed, regardless of company size or revenue.
**OEM/SaaS license required** — Bundling, embedding, or pre-installing the Binary into a product, hosted service, or cloud artifact distributed to third parties requires a separate OEM license. This includes running the Binary on your infrastructure to serve third-party customers (e.g., browser-as-a-service). Contact cloakhq@pm.me for OEM/SaaS licensing.
## Official Distribution
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Restrictions section are not considered unauthorized sources.
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Cloud, Container & Integration Use section are not considered unauthorized sources.
## Trademark Notice
@@ -46,6 +54,13 @@ Attribution is appreciated but not required. If you'd like to credit CloakBrowse
You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use.
Without limiting the above, the following uses are expressly prohibited:
- Unauthorized access to financial, banking, healthcare, or government authentication systems
- Credential stuffing, brute-force login attempts, or automated account creation
- Circumventing authentication on systems you do not own or have authorization to test
- Any activity that constitutes fraud, identity theft, or unauthorized data collection
## Indemnification
You agree to indemnify and hold harmless CloakHQ and its contributors from any claims, damages, losses, liabilities, and expenses (including reasonable legal fees) arising from your unlawful use of the Binary or your violation of this license.
@@ -54,6 +69,10 @@ You agree to indemnify and hold harmless CloakHQ and its contributors from any c
THE BINARY IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE BINARY OR THE USE OR OTHER DEALINGS IN THE BINARY.
## Limitation of Liability
IN NO EVENT SHALL CLOAKHQ OR ITS CONTRIBUTORS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, BUSINESS OPPORTUNITIES, OR GOODWILL, ARISING OUT OF OR IN CONNECTION WITH THE USE OF THE BINARY, REGARDLESS OF THE THEORY OF LIABILITY. CLOAKHQ'S TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED ONE HUNDRED US DOLLARS (US $100).
## Data Collection
CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary. The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ.
+57
View File
@@ -6,6 +6,63 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [0.3.11] — 2026-03-08
- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123))
- **[binary]** CDP input stealth — 4 new source-level C++ patches removing automation signals from input events
- **[binary]** Support `--remote-debugging-address` flag for CDP bind address — eliminates the socat workaround in `cloakserve` Docker mode
- **[wrapper]** `cloakserve` updated to use `--remote-debugging-address=0.0.0.0` directly — socat dependency removed from Docker image
- **[binary]** GPU fingerprint accuracy improvements — renderer suffix strings now match real Chrome output across Windows and Linux profiles
- **[binary]** GPU capability accuracy fix for NVIDIA profiles — spoofed values now reflect actual hardware limits
- **[binary]** macOS GPU accuracy fix — GPU model database reference corrected for Apple Silicon profiles
- **[binary]** Fix CDP input synthesis — a guard condition prevented the patch from activating; now fires correctly on all input events
- **[binary]** Code quality hardening across patches — correctness and reliability fixes
## [0.3.10] — 2026-03-07
- **[binary]** Upgrade Linux build to 145.0.7632.159.2
- **[binary]** Fix detection regression caused by unnecessary browser flag (fixes #16)
- **[binary]** Fix fingerprint consistency in offline audio rendering
- **[wrapper]** Add `cloakserve` CDP server mode for Docker — exposes Chrome DevTools Protocol on `0.0.0.0:9222` for external tool integration
- **[wrapper]** Add wrapper regression tests: page.goto timing with stealth init (#9), add_init_script compatibility with proxy auth (#27)
## [0.3.9] — 2026-03-05
- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2
- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints
- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]`
- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
## [0.3.7] — 2026-03-05
- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`)
- **[wrapper]** Docker Hub image (`cloakhq/cloakbrowser`) — pre-built with Python + JS wrappers, Xvfb for headed mode, and `cloaktest` CLI shortcut. One-liner: `docker run --rm cloakhq/cloakbrowser cloaktest`
- **[wrapper]** Add "Launching stealth browser..." feedback to all examples for better UX in Docker/CI
- **[wrapper]** Comprehensive unit tests: 169 Python + 88 JS (up from 59 + 47)
- **[docs]** Streamline READMEs for launch — reorder for conversion, collapse fingerprint flags, update Docker section
## [0.3.6] — 2026-03-04
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
## [0.3.5] — 2026-03-04
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support
- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23)
## [0.3.4] — 2026-03-04
Binary v14: auto-spoof restored with seed, wrapper simplified to match.
- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently
- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags
- **[binary]** 26 source-level C++ patches (up from 25)
- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox`
- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip
- **[wrapper]** Clarify README platform detection behavior
## [0.3.3] — 2026-03-03
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
+32 -5
View File
@@ -1,6 +1,6 @@
FROM python:3.12-slim
# Chromium system deps (matches fingerprint-chromium 142+ requirements)
# Chromium system deps + Node.js
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
@@ -9,16 +9,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxcb1 libxext6 libxshmfence1 \
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
xvfb xdotool \
curl ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
# Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir .
# Pre-download stealth Chromium binary during build (not at runtime)
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()"
# JS wrapper
COPY js/ js/
RUN cd js && npm install && npm run build
# Examples
COPY examples/ examples/
# Pre-download stealth Chromium binary during build (not at runtime)
# Remove welcome marker so users see it on first container run
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
&& rm -f ~/.cloakbrowser/.welcome_shown
# CLI shortcuts
COPY bin/cloaktest /usr/local/bin/cloaktest
COPY bin/cloakserve /usr/local/bin/cloakserve
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve
EXPOSE 9222
# Xvfb entrypoint for headed mode support
COPY bin/docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV DISPLAY=:99
ENTRYPOINT ["/entrypoint.sh"]
CMD ["python"]
+393 -99
View File
@@ -5,11 +5,11 @@
<p align="center">
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/CloakHQ/CloakBrowser" alt="License"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/cloakhq/cloakbrowser?v=1" alt="License"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/cloakhq/cloakbrowser" alt="Last Commit"></a>
<br>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a>
<a href="https://pepy.tech/projects/cloakbrowser"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
</p>
@@ -35,13 +35,18 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p>
- 🔒 **25 source-level C++ patches**not JS injection, not config flags
- 🛡️ **CDP stealth built-in**uses [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) to reduce Playwright's automation footprint
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement**works with Playwright (Python & JS) and Puppeteer (JS)
- 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
- 💸 **Enterprise results, zero cost**anti-detect browsers charge $49299/month for the same results. CloakBrowser is free
- **31 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **Auto-updating binary** — background update checks, always on the latest stealth build
- **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
- **Free and open source** — no subscriptions, no usage limits
**Try it now** — no install needed:
```bash
docker run --rm cloakhq/cloakbrowser cloaktest
```
**Python:**
```python
@@ -104,14 +109,17 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## What's New in v0.3.0
## Latest: v0.3.11 (Chromium 145.0.7632.159.2)
- **Chromium 145** — latest stable, 25 fingerprint patches (up from 16). All platforms
- **9 new patches** — screen dimensions, device memory, audio, WebGL, and more
- **SHA-256 checksum verification** — binary downloads are verified for integrity
- **CDP hardening** — audited and patched known automation detection vectors
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior
- **`cloakserve` CDP server** — no longer requires socat. Chrome binds directly to `0.0.0.0:9222` via native flag support
- **31 fingerprint patches** (Linux x64) — 5 new patches since v0.3.10, plus GPU fingerprint accuracy fixes for NVIDIA and Apple Silicon profiles
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Playwright + Puppeteer from one package** — `import from 'cloakbrowser'` or `import from 'cloakbrowser/puppeteer'`. Same binary, your choice of API
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
See the full [CHANGELOG.md](CHANGELOG.md) for details.
@@ -119,12 +127,11 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
- **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting), while the Patchright driver defers Playwright's binding registration and randomizes internal world names. Most stealth tools only do one or the other.
- **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with AI browser agents** — drop-in stealth binary for [browser-use](https://github.com/browser-use/browser-use), [agent-browser](https://github.com/nichochar/agent-browser), Claude computer use, and OpenAI Operator
- **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services.
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. Antibot systems score it as a normal browser because it *is* a normal browser, just with your fingerprints instead of theirs. No CAPTCHA services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
## Test Results
@@ -146,7 +153,7 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
| | | **30/30 passed** | |
| | | **Tested against 30+ detection sites** | |
### Proof
@@ -170,6 +177,23 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
</p>
<p align="center">
<img src="https://i.imgur.com/srCcFtK.png" width="600" alt="deviceandbrowserinfo.com — You are human!">
<br><em>deviceandbrowserinfo.com behavioral bot detection — "You are human!" with humanize=True (24/24 signals passed)</em>
</p>
## Comparison
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|---|---|---|---|---|---|
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** |
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
| Maintained | Yes | Stale | Stale | Unstable | **Active** |
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
## How It Works
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
@@ -179,7 +203,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 25 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
The binary includes 31 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
@@ -201,6 +225,9 @@ browser = launch(headless=False)
# With proxy
browser = launch(proxy="http://user:pass@proxy:8080")
# With proxy dict (bypass, separate auth fields)
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
# With extra Chrome args
browser = launch(args=["--disable-gpu"])
@@ -213,6 +240,12 @@ browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# Human-like mouse, keyboard, and scroll behavior
browser = launch(humanize=True)
# With slower, more deliberate movements
browser = launch(humanize=True, human_preset="careful")
# Without default stealth args (bring your own fingerprint flags)
browser = launch(stealth_args=False, args=["--fingerprint=12345"])
```
@@ -237,7 +270,7 @@ asyncio.run(main())
### `launch_context()`
Convenience function that creates browser + context with common options:
Convenience function that creates browser + context in one call with user agent, viewport, locale, and timezone:
```python
from cloakbrowser import launch_context
@@ -246,11 +279,40 @@ context = launch_context(
user_agent="Custom UA",
viewport={"width": 1920, "height": 1080},
locale="en-US",
timezone_id="America/New_York",
timezone="America/New_York",
)
page = context.new_page()
page.goto("https://protected-site.com")
context.close()
```
### `launch_persistent_context()`
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan.
Use this when you need to:
- **Stay logged in** across runs (cookies/sessions survive restarts)
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
- **Load Chrome extensions** (extensions only work from a real user data dir)
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
```python
from cloakbrowser import launch_persistent_context
# First run — creates the profile
ctx = launch_persistent_context("./my-profile", headless=False)
page = ctx.new_page()
page.goto("https://protected-site.com")
ctx.close() # profile saved
# Next run — cookies, localStorage restored automatically
ctx = launch_persistent_context("./my-profile", headless=False)
```
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`.
Async version: `launch_persistent_context_async()`.
### Utility Functions
```python
@@ -258,7 +320,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status
print(binary_info())
# {'version': '145.0.7632.109', 'platform': 'linux-x64', 'installed': True, ...}
# {'version': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download
clear_cache()
@@ -274,7 +336,7 @@ CloakBrowser ships a TypeScript package with full type definitions. Choose Playw
### Playwright (default)
```javascript
import { launch, launchContext } from 'cloakbrowser';
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// Basic
const browser = await launch();
@@ -286,6 +348,7 @@ const browser = await launch({
args: ['--fingerprint=12345'],
timezone: 'America/New_York',
locale: 'en-US',
humanize: true,
});
// Convenience: browser + context in one call
@@ -293,9 +356,16 @@ const context = await launchContext({
userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York',
timezone: 'America/New_York',
});
const page = await context.newPage();
// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection
const ctx = await launchPersistentContext({
userDataDir: './chrome-profile',
headless: false,
proxy: 'http://user:pass@proxy:8080',
});
```
> **Note:** Each example above is standalone — not meant to run as one block.
@@ -330,6 +400,69 @@ console.log(binaryInfo());
clearCache();
```
## Human Behavior
Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls — `page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, and the full Locator API — are automatically replaced with human-like equivalents. No code changes needed.
```python
browser = launch(humanize=True)
page = browser.new_page()
page.goto("https://example.com")
page.locator("#email").fill("user@example.com") # per-character timing, thinking pauses
page.locator("button[type=submit]").click() # Bézier curve, realistic aim point
```
```javascript
const browser = await launch({ humanize: true });
```
**What changes:**
| Interaction | Default | With `humanize=True` |
|---|---|---|
| Mouse movement | Instant teleport | Bézier curve with easing and slight overshoot |
| Clicks | Instant | Realistic aim point + hold duration |
| Keyboard | Instant fill | Per-character timing, thinking pauses, occasional typos with self-correction |
| Scroll | Jump | Accelerate → cruise → decelerate micro-steps |
| `fill()` | Instant value set | Clears existing content, types character by character |
**Presets**`default` (normal speed) or `careful` (slower, more deliberate, idle micro-movements between actions):
```python
browser = launch(humanize=True, human_preset="careful")
```
```javascript
const browser = await launch({ humanize: true, humanPreset: 'careful' });
```
**Custom config** — override any parameter:
```python
browser = launch(humanize=True, human_config={
"mistype_chance": 0.05, # 5% typo rate with self-correction
"typing_delay": 100, # slower typing (ms per character)
"idle_between_actions": True, # micro-movements between clicks
"idle_between_duration": [0.3, 0.8], # idle duration range (seconds)
})
```
```javascript
const browser = await launch({
humanize: true,
humanConfig: {
mistype_chance: 0.05,
typing_delay: 100,
idle_between_actions: true,
idle_between_duration: [0.3, 0.8],
}
});
```
Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
## Configuration
| Env Variable | Default | Description |
@@ -352,7 +485,7 @@ The binary is **stealthy by default** — no flags needed. It auto-generates a r
| **`--fingerprint=seed`** | Deterministic identity from the seed. Same seed = same fingerprint across launches. Use this for session persistence (returning visitor). |
| **`--fingerprint=seed` + explicit flags** | Explicit flags override individual auto-generated values. The seed fills in everything else. |
The binary detects its platform at compile time — a macOS binary reports Apple GPU and macOS screen (1440x900), a Linux binary reports NVIDIA GPU and 1080p screen. Override with `--fingerprint-platform` for cross-platform spoofing (e.g. Linux binary appearing as Windows).
The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly.
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
> ```python
@@ -364,7 +497,7 @@ The binary detects its platform at compile time — a macOS binary reports Apple
### Default Fingerprint
Every `launch()` call sets these automatically. Defaults are **platform-aware** — macOS runs as a native Mac browser, Linux and Windows use the Windows fingerprint profile:
Every `launch()` call sets these automatically. The **wrapper** applies platform-aware defaults — on Linux it spoofs as Windows for a more common fingerprint, on macOS it runs as a native Mac browser:
| Flag | Linux/Windows Default | macOS Default | Controls |
|------|--------------|---------------|----------|
@@ -421,81 +554,61 @@ browser = launch(args=[
])
```
## Comparison
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|---|---|---|---|---|---|
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** |
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
| Maintained | Yes | Stale | Stale | Unstable (2026 beta) | **Active** |
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
## Platforms
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 25 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 25 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 25 | ✅ Latest |
| Windows x86_64 | 145 | 25 | ✅ Latest |
The wrapper auto-downloads the correct binary for your platform.
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
## Examples
**Python** — see [`examples/`](examples/):
- [`basic.py`](examples/basic.py) — Launch and load a page
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
- [`stealth_test.py`](examples/stealth_test.py) — Run against 6 detection sites
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
**JavaScript** — see [`js/examples/`](js/examples/):
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Full 6-site detection test suite
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
## Roadmap
## Platforms
| Feature | Status |
|---------|--------|
| Linux x64 — Chromium 145 (25 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (25 patches) | ✅ Released |
| Windows x64 — Chromium 145 (25 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned |
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 31 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest |
The wrapper auto-downloads the correct binary for your platform.
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
## Docker
A ready-to-use [`Dockerfile`](Dockerfile) is included. It installs system deps, the package, and pre-downloads the stealth binary during build:
Pre-built image on Docker Hub — no install, no setup.
### Quick test
```bash
docker build -t cloakbrowser .
docker run --rm cloakbrowser python examples/basic.py
docker run --rm cloakhq/cloakbrowser cloaktest
```
The key steps in the Dockerfile:
1. **System deps** — Chromium requires ~15 shared libraries (`libnss3`, `libgbm1`, etc.)
2. **`pip install .`** — installs CloakBrowser + Playwright
3. **`ensure_binary()`** — downloads the stealth Chromium binary at build time (~200MB), so containers start instantly
To extend with your own script, just add a `COPY` + `CMD`:
```dockerfile
FROM cloakbrowser
COPY your_script.py /app/
CMD ["python", "your_script.py"]
```
**With a proxy** (the most common production setup):
### Run a script
```bash
docker run --rm cloakbrowser python -c "
# Inline script
docker run --rm cloakhq/cloakbrowser python -c "
from cloakbrowser import launch
browser = launch()
page = browser.new_page()
page.goto('https://example.com')
print(page.title())
browser.close()
"
# Mount your own script
docker run --rm -v ./my_script.py:/app/my_script.py cloakhq/cloakbrowser python my_script.py
# With a proxy
docker run --rm cloakhq/cloakbrowser python -c "
from cloakbrowser import launch
browser = launch(proxy='http://user:pass@proxy:8080')
page = browser.new_page()
@@ -505,13 +618,108 @@ browser.close()
"
```
### CDP server mode
Start a persistent stealth browser and connect to it remotely via Chrome DevTools Protocol:
```bash
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve
```
Then connect from your host machine:
```python
from playwright.sync_api import sync_playwright
pw = sync_playwright().start()
browser = pw.chromium.connect_over_cdp("http://localhost:9222")
page = browser.new_page()
page.goto("https://example.com")
print(page.title())
browser.close()
```
Pass extra flags to the browser:
```bash
# With proxy
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --proxy-server=http://proxy:8080
# Headed mode (renders to Xvfb inside container)
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --headless=false
```
Stop the server:
```bash
docker stop cloak && docker rm cloak
```
> **Security:** CDP gives full control over the browser (execute JS, read pages, access files).
> The examples bind to `127.0.0.1` so only your machine can connect. Never expose port 9222
> to the public internet without additional authentication.
### Docker Compose
```yaml
services:
cloakbrowser:
image: cloakhq/cloakbrowser
command: cloakserve
restart: unless-stopped
ports:
- "127.0.0.1:9222:9222"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9222/json/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
```
Run multiple instances with different fingerprint seeds on different ports — each gets unique canvas noise, client rects, and other browser signals. Pass `--fingerprint=<seed>` in the command (e.g., `cloakserve --fingerprint=12345`).
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
```bash
docker run --rm -v ./my-profile:/profile cloakhq/cloakbrowser python -c "
from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context('/profile')
page = ctx.new_page()
page.goto('https://example.com')
ctx.close()
"
```
Run again with the same volume — cookies, localStorage, and cache are restored automatically.
**Resource usage:** ~190MB RAM idle, ~280MB with 3 tabs. ~30MB per additional tab.
### Extend with your own image
```dockerfile
FROM cloakhq/cloakbrowser
COPY your_script.py /app/
CMD ["python", "your_script.py"]
```
**Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image:
```bash
docker build -t cloakbrowser .
```
CloakBrowser works identically local, in Docker, and on VPS. No environment-specific config needed.
**Note:** If you run CloakBrowser inside a web server with uvloop (e.g., `uvicorn[standard]`), use `--loop asyncio` to avoid subprocess pipe hangs.
## Headed Mode (for aggressive bot detection)
## Troubleshooting
Some sites using advanced bot detection (e.g., DataDome, Cloudflare Turnstile) can detect headless mode even with our C++ patches. For these sites, run in **headed mode** with a virtual display:
**Still getting blocked on aggressive sites (DataDome, Turnstile)?**
Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display:
```bash
# Install Xvfb (virtual framebuffer)
@@ -532,31 +740,48 @@ page.goto("https://heavily-protected-site.com") # passes DataDome, etc.
browser.close()
```
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services.
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
> **Tip:** Datacenter IPs are often flagged by IP reputation databases regardless of browser fingerprint. For sites with strict bot detection, a residential proxy makes the difference.
**Sites challenge fresh sessions but work after first visit**
## Troubleshooting
**Reddit or similar sites show CAPTCHA / "Prove your humanity"**
Some sites (notably Reddit homepage) use HTTP/2 fingerprinting that detects Playwright's connection layer. Pass `--disable-http2` to fall back to HTTP/1.1:
Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions:
```python
browser = launch(args=["--disable-http2"])
from cloakbrowser import launch_persistent_context
# First run: warm up with --disable-http2
ctx = launch_persistent_context("./profile", args=["--disable-http2"])
page = ctx.new_page()
page.goto("https://example.com") # warms up cookies
ctx.close()
# Future runs — no --disable-http2 needed
ctx = launch_persistent_context("./profile")
page = ctx.new_page()
page.goto("https://example.com") # passes with saved cookies
```
```javascript
const browser = await launch({ args: ['--disable-http2'] });
import { launchPersistentContext } from 'cloakbrowser';
// First run: warm up with --disable-http2
let ctx = await launchPersistentContext({ userDataDir: './profile', args: ['--disable-http2'] });
let page = await ctx.newPage();
await page.goto('https://example.com');
await ctx.close();
// Future runs — no --disable-http2 needed
ctx = await launchPersistentContext({ userDataDir: './profile' });
```
Only use this flag for sites that require it — most sites work fine with HTTP/2.
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
**Something not working? Make sure you're on the latest wrapper**
**Something not working? Make sure you're on the latest version**
Older versions may use outdated stealth args or download an older binary:
```bash
pip install -U cloakbrowser # Python
npm install cloakbrowser@latest # JavaScript
docker pull cloakhq/cloakbrowser:latest # Docker
```
**Binary download fails / timeout**
@@ -565,6 +790,19 @@ Set a custom download URL or use a local binary:
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
```
**New update broke something? Roll back to the previous version**
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
```
**macOS: "App is damaged" or Gatekeeper blocks launch**
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
```bash
@@ -574,9 +812,35 @@ xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
**"playwright install" vs CloakBrowser binary**
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash
patchright install-deps chromium
playwright install-deps chromium
```
**macOS: Blocked on some sites that pass on Linux**
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
**Site detects incognito / private browsing mode**
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes:
```python
from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context("./my-profile", headless=False)
page = ctx.new_page()
```
```javascript
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
@@ -599,6 +863,7 @@ await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Try the Patchright backend** — suppresses additional CDP automation signals at the Playwright protocol layer. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it if you're still seeing low scores after trying the steps above
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
@@ -613,7 +878,7 @@ Other tips for maximizing reCAPTCHA scores:
## FAQ
**Q: Is this legal?**
A: CloakBrowser is a browser. Using it is legal. What you do with it is your responsibility, just like with Chrome, Firefox, or any browser. We do not endorse violating website terms of service.
A: CloakBrowser is a browser built on open-source Chromium. We do not condone illegal use. Automating systems without authorization, credential stuffing, and account creation abuse are expressly prohibited. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
**Q: How is this different from Camoufox?**
A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready.
@@ -624,6 +889,17 @@ A: Possibly. Bot detection is an arms race. Source-level patches are harder to d
**Q: Can I use my own proxy?**
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
## Roadmap
| Feature | Status |
|---------|--------|
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned |
## Links
- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md)
@@ -633,6 +909,19 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- 📧 **Contact** — cloakhq@pm.me
## Security
All binary releases are GPG-signed and include GitHub artifact attestations for supply chain verification.
```bash
# Verify GPG signature
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v145.0.7632.159.3
# Verify binary attestation
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
```
## License
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
@@ -641,3 +930,8 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
## Contributing
Issues and PRs welcome. If something isn't working, [open an issue](https://github.com/CloakHQ/CloakBrowser/issues) — we respond fast.
## Contributors
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Launch stealth Chromium as a CDP server for remote connections.
Usage:
cloakserve # headless on port 9222
cloakserve --headless=false # headed (uses Xvfb in Docker)
cloakserve --proxy-server=host:port # with proxy
Connect from host:
playwright.chromium.connect_over_cdp("http://localhost:9222")
"""
import signal
import subprocess
import sys
import time
from cloakbrowser.config import get_default_stealth_args
from cloakbrowser.download import ensure_binary
PORT = 9222
binary = ensure_binary()
chrome_args = [
binary,
f"--remote-debugging-port={PORT}",
"--remote-debugging-address=0.0.0.0",
# Sane defaults for running Chrome directly (outside Playwright)
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-popup-blocking",
"--disable-background-networking",
"--metrics-recording-only",
] + get_default_stealth_args() + sys.argv[1:]
chrome = subprocess.Popen(chrome_args)
time.sleep(2)
print(f"CloakBrowser CDP server ready on port {PORT}", flush=True)
def cleanup(sig, frame):
chrome.terminate()
sys.exit(0)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
chrome.wait()
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
# Run CloakBrowser stealth test suite
exec python -u /app/examples/stealth_test.py --no-screenshots "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1
exec "$@"
+19 -1
View File
@@ -11,20 +11,38 @@ Usage:
browser.close()
"""
from .browser import launch, launch_async, launch_context
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__
# Human-like behavioral layer (optional)
def __getattr__(name):
if name == "HumanConfig":
from .human.config import HumanConfig
globals()["HumanConfig"] = HumanConfig
return HumanConfig
if name == "resolve_human_config":
from .human.config import resolve_config
globals()["resolve_human_config"] = resolve_config
return resolve_config
raise AttributeError(f"module 'cloakbrowser' has no attribute {name}")
__all__ = [
"launch",
"launch_async",
"launch_context",
"launch_persistent_context",
"launch_persistent_context_async",
"ensure_binary",
"clear_cache",
"binary_info",
"check_for_update",
"CHROMIUM_VERSION",
"get_default_stealth_args",
"ProxySettings",
"HumanConfig",
"resolve_human_config",
"__version__",
]
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.3"
__version__ = "0.3.11"
+403 -28
View File
@@ -15,7 +15,9 @@ Usage:
from __future__ import annotations
import logging
from typing import Any, Literal
import os
import warnings
from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
@@ -24,21 +26,51 @@ from .download import ensure_binary
logger = logging.getLogger("cloakbrowser")
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone."""
if "timezone_id" in kwargs:
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
if timezone is None:
timezone = kwargs.pop("timezone_id")
else:
kwargs.pop("timezone_id")
return timezone
class _ProxySettingsRequired(TypedDict):
server: str
class ProxySettings(_ProxySettingsRequired, total=False):
"""Playwright-compatible proxy configuration."""
bypass: str
username: str
password: str
def launch(
headless: bool = True,
proxy: str | None = None,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
Args:
headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
proxy: Proxy URL string or Playwright proxy dict.
String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
— passed directly to Playwright.
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags.
@@ -48,6 +80,13 @@ def launch(
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results.
backend: Playwright backend — 'playwright' (default) or 'patchright'.
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
but breaks proxy auth and add_init_script.
Override globally with CLOAKBROWSER_BACKEND env var.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch().
Returns:
@@ -61,7 +100,7 @@ def launch(
>>> print(page.title())
>>> browser.close()
"""
from patchright.sync_api import sync_playwright
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -88,29 +127,44 @@ def launch(
browser.close = _close_with_cleanup
# Human-like behavioral patching
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 launch_async(
async def launch_async( # noqa: C901
headless: bool = True,
proxy: str | None = None,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch(). Returns a Playwright Browser object.
Args:
headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch().
Returns:
@@ -129,7 +183,7 @@ async def launch_async(
>>>
>>> asyncio.run(main())
"""
from patchright.async_api import async_playwright
async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -156,20 +210,259 @@ async def launch_async(
browser.close = _close_with_cleanup
# Human-like behavioral patching (async variant)
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 launch_context(
def launch_persistent_context(
user_data_dir: str | os.PathLike,
headless: bool = True,
proxy: str | None = None,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = None,
locale: str | None = None,
timezone_id: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext.
This persists cookies, localStorage, cache, and other browser state across
sessions by storing them in ``user_data_dir``. Also avoids incognito detection
by services like BrowserScan (-10% penalty).
Args:
user_data_dir: Path to the directory where browser profile data is stored.
Created automatically if it doesn't exist. Reuse the same path across
sessions to restore cookies, localStorage, cached credentials, etc.
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``.
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns:
Playwright BrowserContext object backed by a persistent profile.
Call ``.close()`` when done — this also stops the Playwright instance.
Example:
>>> from cloakbrowser import launch_persistent_context
>>> ctx = launch_persistent_context("./my-profile", headless=False)
>>> page = ctx.new_page()
>>> page.goto("https://protected-site.com")
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
"""
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
headless,
user_data_dir,
)
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
pw = sync_playwright().start()
context = pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
**_build_proxy_kwargs(proxy),
**context_kwargs,
)
# Patch close() to also stop the Playwright instance
_original_close = context.close
def _close_with_cleanup() -> None:
_original_close()
pw.stop()
context.close = _close_with_cleanup
# Human-like behavioral patching
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
return context
async def launch_persistent_context_async(
user_data_dir: str | os.PathLike,
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = None,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_persistent_context().
Launch stealth browser with a persistent profile and return a BrowserContext.
This persists cookies, localStorage, cache, and other browser state across
sessions by storing them in ``user_data_dir``.
Args:
user_data_dir: Path to the directory where browser profile data is stored.
Created automatically if it doesn't exist.
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns:
Playwright BrowserContext object backed by a persistent profile (async API).
Call ``await .close()`` when done.
Example:
>>> import asyncio
>>> from cloakbrowser import launch_persistent_context_async
>>>
>>> async def main():
... ctx = await launch_persistent_context_async("./my-profile", headless=False)
... page = await ctx.new_page()
... await page.goto("https://protected-site.com")
... await ctx.close()
>>>
>>> asyncio.run(main())
"""
async_playwright = _import_async_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
headless,
user_data_dir,
)
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
pw = await async_playwright().start()
context = await pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
executable_path=binary_path,
headless=headless,
args=chrome_args,
ignore_default_args=["--enable-automation"],
**_build_proxy_kwargs(proxy),
**context_kwargs,
)
# Patch close() to also stop the Playwright instance
_original_close = context.close
async def _close_with_cleanup() -> None:
await _original_close()
await pw.stop()
context.close = _close_with_cleanup
# Human-like behavioral patching (async variant)
if humanize:
from .human import patch_context_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context_async(context, cfg)
return context
def launch_context(
headless: bool = True,
proxy: str | ProxySettings | None = None,
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = None,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: str = "default",
human_config: dict | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -179,27 +472,35 @@ def launch_context(
Args:
headless: Run in headless mode (default True).
proxy: Proxy server URL.
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
locale: Browser locale, e.g. "en-US".
timezone_id: Timezone, e.g. "America/New_York".
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light').
Note: 'no-preference' doesn't work in Patchright (falls back to 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config dict to override preset values.
**kwargs: Passed to browser.new_context().
Returns:
Playwright BrowserContext object.
"""
timezone = _migrate_timezone_id(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to both binary flags AND context params
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Skip --fingerprint-timezone binary flag: it only applies to the default
# context and interferes with Playwright's timezone_id on new contexts.
# Timezone is set via browser.new_context(timezone_id=...) below instead.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone_id, locale=locale)
timezone=None, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {}
if user_agent:
@@ -207,8 +508,8 @@ def launch_context(
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone_id:
context_kwargs["timezone_id"] = timezone_id
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
@@ -228,9 +529,57 @@ def launch_context(
context.close = _close_context_with_cleanup
# Human-like behavioral patching
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
return context
# ---------------------------------------------------------------------------
# Backend resolution
# ---------------------------------------------------------------------------
def _resolve_backend(backend: str | None) -> str:
"""Resolve backend: param > env var > default ('playwright')."""
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
if b not in ("playwright", "patchright"):
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
return b
def _import_sync_playwright(backend: str):
"""Import sync_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.sync_api import sync_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return sync_playwright
from playwright.sync_api import sync_playwright
return sync_playwright
def _import_async_playwright(backend: str):
"""Import async_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.async_api import async_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return async_playwright
from playwright.async_api import async_playwright
return async_playwright
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -238,7 +587,7 @@ def launch_context(
def _maybe_resolve_geoip(
geoip: bool,
proxy: str | None,
proxy: str | ProxySettings | None,
timezone: str | None,
locale: str | None,
) -> tuple[str | None, str | None]:
@@ -248,7 +597,10 @@ def _maybe_resolve_geoip(
from .geoip import resolve_proxy_geo
geo_tz, geo_locale = resolve_proxy_geo(proxy)
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
if not proxy_url:
return timezone, locale
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
if timezone is None:
timezone = geo_tz
if locale is None:
@@ -262,18 +614,39 @@ def _build_args(
timezone: str | None = None,
locale: str | None = None,
) -> list[str]:
"""Combine stealth args with user-provided args and locale flags."""
result = []
"""Combine stealth args with user-provided args and locale flags.
Deduplicates by flag key (everything before '=').
Priority: stealth defaults < user args < dedicated params (timezone/locale).
"""
seen: dict[str, str] = {}
if stealth_args:
result.extend(get_default_stealth_args())
for arg in get_default_stealth_args():
seen[arg.split("=", 1)[0]] = arg
if extra_args:
result.extend(extra_args)
for arg in extra_args:
key = arg.split("=", 1)[0]
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], arg)
seen[key] = arg
# Timezone/locale flags are independent of stealth_args — always inject when set
if timezone:
result.append(f"--fingerprint-timezone={timezone}")
key = "--fingerprint-timezone"
flag = f"{key}={timezone}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if locale:
result.append(f"--lang={locale}")
return result
key = "--lang"
flag = f"{key}={locale}"
if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
return list(seen.values())
def _parse_proxy_url(proxy: str) -> dict[str, Any]:
@@ -302,8 +675,10 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
"""Build proxy kwargs for Playwright launch."""
if proxy is None:
return {}
if isinstance(proxy, dict):
return {"proxy": proxy}
return {"proxy": _parse_proxy_url(proxy)}
+18 -9
View File
@@ -15,13 +15,13 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version.
# ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.109"
CHROMIUM_VERSION = "145.0.7632.159.3"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "145.0.7632.109",
"darwin-x64": "145.0.7632.109",
"windows-x64": "145.0.7632.109",
"linux-x64": "145.0.7632.159.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
}
# ---------------------------------------------------------------------------
@@ -204,18 +204,27 @@ GITHUB_DOWNLOAD_BASE_URL = (
)
def get_archive_ext() -> str:
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
return ".zip" if platform.system() == "Windows" else ".tar.gz"
def get_archive_name(tag: str | None = None) -> str:
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
t = tag or get_platform_tag()
return f"cloakbrowser-{t}{get_archive_ext()}"
def get_download_url(version: str | None = None) -> str:
"""Return the full download URL for the current platform's binary archive."""
v = version or get_chromium_version()
tag = get_platform_tag()
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
def get_fallback_download_url(version: str | None = None) -> str:
"""Return the GitHub Releases fallback URL for the binary archive."""
v = version or get_chromium_version()
tag = get_platform_tag()
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
# ---------------------------------------------------------------------------
+65 -27
View File
@@ -28,6 +28,8 @@ from .config import (
GITHUB_DOWNLOAD_BASE_URL,
_version_newer,
check_platform_available,
get_archive_ext,
get_archive_name,
get_binary_dir,
get_binary_path,
get_cache_dir,
@@ -42,12 +44,31 @@ from .config import (
logger = logging.getLogger("cloakbrowser")
# Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = 600.0
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
# Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600
def _show_welcome() -> None:
"""Show welcome message on first launch. Uses a marker file to show only once."""
marker = get_cache_dir() / ".welcome_shown"
if marker.exists():
return
print()
print(" CloakBrowser — stealth Chromium for automation")
print(" https://github.com/CloakHQ/CloakBrowser")
print()
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues")
print(" Star us if CloakBrowser helps your project!")
print()
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("")
except OSError:
pass
def ensure_binary() -> str:
"""Ensure the stealth Chromium binary is available. Download if needed.
@@ -75,6 +96,7 @@ def ensure_binary() -> str:
if binary_path.exists() and _is_executable(binary_path):
logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
_show_welcome()
_maybe_trigger_update_check()
return str(binary_path)
@@ -123,7 +145,7 @@ def _download_and_extract(version: str | None = None) -> None:
binary_dir.parent.mkdir(parents=True, exist_ok=True)
# Download to temp file first (atomic — no partial downloads in cache)
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
@@ -144,9 +166,7 @@ def _download_and_extract(version: str | None = None) -> None:
_verify_download_checksum(tmp_path, version)
_extract_archive(tmp_path, binary_dir, binary_path)
logger.info("Visit https://cloakbrowser.dev for docs and release notifications.")
logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues")
logger.info("Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser")
_show_welcome()
finally:
# Clean up temp file
tmp_path.unlink(missing_ok=True)
@@ -155,7 +175,7 @@ def _download_and_extract(version: str | None = None) -> None:
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
checksums = _fetch_checksums(version)
tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
tarball_name = get_archive_name()
if checksums is None:
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
@@ -256,7 +276,7 @@ def _download_file(url: str, dest: Path) -> None:
def _extract_archive(
archive_path: Path, dest_dir: Path, binary_path: Path | None = None
) -> None:
"""Extract tar.gz archive to destination directory."""
"""Extract tar.gz or zip archive to destination directory."""
logger.info("Extracting to %s", dest_dir)
# Clean existing dir if partial download existed
@@ -266,26 +286,12 @@ def _extract_archive(
dest_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as tar:
# Security: prevent path traversal
safe_members = []
for member in tar.getmembers():
# Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk():
link_target = member.linkname
# Reject symlinks that escape the dest dir
if os.path.isabs(link_target) or ".." in link_target.split("/"):
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
continue
else:
member_path = (dest_dir / member.name).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise RuntimeError(f"Archive contains path traversal: {member.name}")
safe_members.append(member)
if str(archive_path).endswith(".zip"):
_extract_zip(archive_path, dest_dir)
else:
_extract_tar(archive_path, dest_dir)
tar.extractall(dest_dir, members=safe_members)
# If tar extracted into a single subdirectory, flatten it
# If extracted into a single subdirectory, flatten it
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
# But never flatten .app bundles — macOS needs the bundle structure intact
_flatten_single_subdir(dest_dir)
@@ -303,6 +309,38 @@ def _extract_archive(
logger.info("Binary ready: %s", bp)
def _extract_tar(archive_path: Path, dest_dir: Path) -> None:
"""Extract tar.gz archive with path traversal protection."""
with tarfile.open(archive_path, "r:gz") as tar:
safe_members = []
for member in tar.getmembers():
# Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk():
link_target = member.linkname
if os.path.isabs(link_target) or ".." in link_target.split("/"):
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
continue
else:
member_path = (dest_dir / member.name).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise RuntimeError(f"Archive contains path traversal: {member.name}")
safe_members.append(member)
tar.extractall(dest_dir, members=safe_members)
def _extract_zip(archive_path: Path, dest_dir: Path) -> None:
"""Extract zip archive with path traversal protection."""
import zipfile
with zipfile.ZipFile(archive_path, "r") as zf:
for info in zf.infolist():
member_path = (dest_dir / info.filename).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise RuntimeError(f"Archive contains path traversal: {info.filename}")
zf.extractall(dest_dir)
def _flatten_single_subdir(dest_dir: Path) -> None:
"""If extraction created a single subdirectory, move its contents up.
@@ -435,7 +473,7 @@ def _get_latest_chromium_version() -> str | None:
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
)
resp.raise_for_status()
platform_tarball = f"cloakbrowser-{get_platform_tag()}.tar.gz"
platform_tarball = get_archive_name()
for release in resp.json():
tag = release.get("tag_name", "")
if tag.startswith("chromium-v") and not release.get("draft"):
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
"""cloakbrowser-human — Configuration and presets.
All numeric parameters for human-like behavior are centralized here.
Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
"""
from __future__ import annotations
import math
import random
import time
from dataclasses import dataclass, field
from typing import Literal, Tuple
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
Range = Tuple[float, float]
HumanPreset = Literal["default", "careful"]
# ---------------------------------------------------------------------------
# Configuration dataclass
# ---------------------------------------------------------------------------
@dataclass
class HumanConfig:
"""All tunable parameters for human-like behavior."""
# Keyboard
typing_delay: float = 70
typing_delay_spread: float = 40
typing_pause_chance: float = 0.1
typing_pause_range: Range = (400, 1000)
shift_down_delay: Range = (30, 70)
shift_up_delay: Range = (20, 50)
key_hold: Range = (15, 35)
# Mistype (typo simulation)
mistype_chance: float = 0.02
mistype_delay_notice: Range = (100, 300)
mistype_delay_correct: Range = (50, 150)
field_switch_delay: Range = (800, 1500)
# Mouse — movement
mouse_steps_divisor: float = 8
mouse_min_steps: int = 25
mouse_max_steps: int = 80
mouse_wobble_max: float = 1.5
mouse_overshoot_chance: float = 0.15
mouse_overshoot_px: Range = (3, 6)
mouse_burst_size: Range = (3, 5)
mouse_burst_pause: Range = (8, 18)
# Mouse — clicks
click_aim_delay_input: Range = (60, 140)
click_aim_delay_button: Range = (80, 200)
click_hold_input: Range = (40, 100)
click_hold_button: Range = (60, 150)
click_input_x_range: Range = (0.05, 0.30)
# Mouse — idle
idle_drift_px: float = 3
idle_pause_range: Range = (300, 1000)
# Scroll
scroll_delta_base: Range = (80, 130)
scroll_delta_variance: float = 0.2
scroll_pause_fast: Range = (30, 80)
scroll_pause_slow: Range = (80, 200)
scroll_accel_steps: Range = (2, 3)
scroll_decel_steps: Range = (2, 3)
scroll_overshoot_chance: float = 0.1
scroll_overshoot_px: Range = (50, 150)
scroll_settle_delay: Range = (300, 600)
scroll_target_zone: Range = (0.20, 0.80)
scroll_pre_move_delay: Range = (100, 300)
# Initial cursor position (as if coming from the address bar area)
initial_cursor_x: Range = (400, 700)
initial_cursor_y: Range = (45, 60)
# Idle micro-movements between actions (opt-in, adds latency)
idle_between_actions: bool = False
idle_between_duration: Range = (0.3, 0.8)
# ---------------------------------------------------------------------------
# Presets
# ---------------------------------------------------------------------------
def _careful_config() -> HumanConfig:
"""Careful preset — everything slower and more deliberate."""
return HumanConfig(
# Keyboard — slower typing
typing_delay=100,
typing_delay_spread=50,
typing_pause_chance=0.15,
typing_pause_range=(500, 1200),
shift_down_delay=(40, 90),
shift_up_delay=(30, 70),
key_hold=(20, 45),
field_switch_delay=(1000, 2000),
# Mouse — slower, more precise
mouse_overshoot_chance=0.10,
mouse_burst_pause=(12, 25),
# Mouse — clicks (longer aiming and holding)
click_aim_delay_input=(80, 180),
click_aim_delay_button=(120, 280),
click_hold_input=(60, 140),
click_hold_button=(80, 200),
# Scroll — slower
scroll_pause_fast=(100, 200),
scroll_pause_slow=(250, 600),
scroll_settle_delay=(400, 800),
scroll_pre_move_delay=(150, 400),
# Idle between actions enabled for careful preset
idle_between_actions=True,
idle_between_duration=(0.4, 1.0),
)
_PRESETS: dict[str, HumanConfig] = {
"default": HumanConfig(),
"careful": _careful_config(),
}
def resolve_config(
preset: HumanPreset = "default",
overrides: dict | None = None,
) -> HumanConfig:
"""Resolve a preset name + optional overrides into a full HumanConfig.
Args:
preset: 'default' or 'careful'.
overrides: Dict of field names to override values.
Returns:
A new HumanConfig instance.
Raises:
ValueError: If preset is not a recognized name.
"""
if preset not in _PRESETS:
raise ValueError(
f"Unknown humanize preset {preset!r}. "
f"Valid presets: {', '.join(sorted(_PRESETS.keys()))}"
)
base = _PRESETS[preset]
if not overrides:
return HumanConfig(**{k: getattr(base, k) for k in base.__dataclass_fields__})
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
merged.update(overrides)
return HumanConfig(**merged)
# ---------------------------------------------------------------------------
# Utility functions
# ---------------------------------------------------------------------------
def rand(lo: float, hi: float) -> float:
"""Random float in [lo, hi]."""
return random.uniform(lo, hi)
def rand_int(lo: int, hi: int) -> int:
"""Random integer in [lo, hi] inclusive."""
return random.randint(lo, hi)
def rand_range(r: Range) -> float:
"""Random float from a (min, max) tuple."""
return random.uniform(r[0], r[1])
def rand_int_range(r: Range) -> int:
"""Random integer from a (min, max) tuple, inclusive."""
return random.randint(int(r[0]), int(r[1]))
def sleep_ms(ms: float) -> None:
"""Sleep for `ms` milliseconds."""
if ms > 0:
time.sleep(ms / 1000.0)
async def async_sleep_ms(ms: float) -> None:
"""Async sleep for `ms` milliseconds."""
if ms > 0:
import asyncio
await asyncio.sleep(ms / 1000.0)
+104
View File
@@ -0,0 +1,104 @@
"""cloakbrowser-human — Human-like keyboard input."""
from __future__ import annotations
import random
from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, sleep_ms
class RawKeyboard(Protocol):
def down(self, key: str) -> None: ...
def up(self, key: str) -> None: ...
def type(self, text: str) -> None: ...
def insert_text(self, text: str) -> None: ...
SHIFT_SYMBOLS = frozenset('@#!$%^&*()_+{}|:"<>?~')
NEARBY_KEYS = {
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'sfecx', 'e': 'wrsdf',
'f': 'dgrtcv', 'g': 'fhtyb', 'h': 'gjybn', 'i': 'ujko', 'j': 'hkunm',
'k': 'jloi', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'iklp',
'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu',
'z': 'asx',
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
}
def _get_nearby_key(ch: str) -> str:
"""Return a random adjacent key for the given character."""
lower = ch.lower()
if lower in NEARBY_KEYS:
neighbors = NEARBY_KEYS[lower]
wrong = random.choice(neighbors)
return wrong.upper() if ch.isupper() else wrong
return ch
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct
if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch)
_type_normal_char(raw, wrong, cfg)
sleep_ms(rand_range(cfg.mistype_delay_notice))
raw.down("Backspace")
sleep_ms(rand_range(cfg.key_hold))
raw.up("Backspace")
sleep_ms(rand_range(cfg.mistype_delay_correct))
if ch.isupper() and ch.isalpha():
_type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
_type_shift_symbol(page, raw, ch, cfg)
else:
_type_normal_char(raw, ch, cfg)
if i < len(text) - 1:
_inter_char_delay(cfg)
def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down(ch)
sleep_ms(rand_range(cfg.key_hold))
raw.up(ch)
def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.down(ch)
sleep_ms(rand_range(cfg.key_hold))
raw.up(ch)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.insert_text(ch)
page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _inter_char_delay(cfg: HumanConfig) -> None:
if random.random() < cfg.typing_pause_chance:
sleep_ms(rand_range(cfg.typing_pause_range))
else:
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
sleep_ms(max(10, delay))
+85
View File
@@ -0,0 +1,85 @@
"""cloakbrowser-human — Async human-like keyboard input.
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
"""
from __future__ import annotations
import random
from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, async_sleep_ms
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
class AsyncRawKeyboard(Protocol):
async def down(self, key: str) -> None: ...
async def up(self, key: str) -> None: ...
async def type(self, text: str) -> None: ...
async def insert_text(self, text: str) -> None: ...
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct
if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch)
await _type_normal_char(raw, wrong, cfg)
await async_sleep_ms(rand_range(cfg.mistype_delay_notice))
await raw.down("Backspace")
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.up("Backspace")
await async_sleep_ms(rand_range(cfg.mistype_delay_correct))
if ch.isupper() and ch.isalpha():
await _type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
await _type_shift_symbol(page, raw, ch, cfg)
else:
await _type_normal_char(raw, ch, cfg)
if i < len(text) - 1:
await _inter_char_delay(cfg)
async def _type_normal_char(raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down(ch)
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.up(ch)
async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.down(ch)
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.up(ch)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.insert_text(ch)
await page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _inter_char_delay(cfg: HumanConfig) -> None:
if random.random() < cfg.typing_pause_chance:
await async_sleep_ms(rand_range(cfg.typing_pause_range))
else:
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
await async_sleep_ms(max(10, delay))
+132
View File
@@ -0,0 +1,132 @@
"""cloakbrowser-human — Human-like mouse movement and clicking."""
from __future__ import annotations
import math
import random
from typing import Any, Protocol, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
class RawMouse(Protocol):
def move(self, x: float, y: float) -> None: ...
def down(self) -> None: ...
def up(self) -> None: ...
def wheel(self, delta_x: float, delta_y: float) -> None: ...
class Point:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def _ease_in_out(t: float) -> float:
if t < 0.5:
return 4 * t * t * t
return 1 - pow(-2 * t + 2, 3) / 2
def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
u = 1 - t
uu = u * u
uuu = uu * u
tt = t * t
ttt = tt * t
return Point(
uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
)
def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]:
dx = end.x - start.x
dy = end.y - start.y
dist = math.hypot(dx, dy) or 1
px = -dy / dist
py = dx / dist
bias1 = rand(-0.3, 0.3) * dist
bias2 = rand(-0.3, 0.3) * dist
return (
Point(start.x + dx * 0.25 + px * bias1, start.y + dy * 0.25 + py * bias1),
Point(start.x + dx * 0.75 + px * bias2, start.y + dy * 0.75 + py * bias2),
)
def human_move(
raw: RawMouse,
start_x: float, start_y: float,
end_x: float, end_y: float,
cfg: HumanConfig,
) -> None:
dist = math.hypot(end_x - start_x, end_y - start_y)
if dist < 1:
return
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
start = Point(start_x, start_y)
end = Point(end_x, end_y)
cp1, cp2 = _random_control_points(start, end)
burst_counter = 0
burst_size = rand_int_range(cfg.mouse_burst_size)
for i in range(steps + 1):
progress = i / steps
eased_t = _ease_in_out(progress)
pt = _bezier(start, cp1, cp2, end, eased_t)
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
raw.move(round(wx), round(wy))
burst_counter += 1
if burst_counter >= burst_size and i < steps:
sleep_ms(rand_range(cfg.mouse_burst_pause))
burst_counter = 0
if random.random() < cfg.mouse_overshoot_chance:
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
angle = math.atan2(end_y - start_y, end_x - start_x)
raw.move(round(end_x + math.cos(angle) * overshoot_dist),
round(end_y + math.sin(angle) * overshoot_dist))
sleep_ms(rand(30, 70))
raw.move(round(end_x + (random.random() - 0.5) * 4),
round(end_y + (random.random() - 0.5) * 4))
def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point:
if is_input:
x_frac = rand_range(cfg.click_input_x_range)
y_frac = rand(0.30, 0.70)
else:
x_frac = rand(0.35, 0.65)
y_frac = rand(0.35, 0.65)
return Point(round(box["x"] + box["width"] * x_frac),
round(box["y"] + box["height"] * y_frac))
def human_click(raw: RawMouse, is_input: bool, cfg: HumanConfig) -> None:
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
sleep_ms(aim_delay)
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
raw.down()
sleep_ms(hold_time)
raw.up()
def human_idle(raw: RawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
import time as _time
end_time = _time.monotonic() + seconds
x, y = cx, cy
while _time.monotonic() < end_time:
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
x += dx
y += dy
raw.move(round(x), round(y))
sleep_ms(rand_range(cfg.idle_pause_range))
+87
View File
@@ -0,0 +1,87 @@
"""cloakbrowser-human — Async human-like mouse movement and clicking.
Mirrors mouse.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
"""
from __future__ import annotations
import math
import random
from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse import Point, _ease_in_out, _bezier, _random_control_points, click_target # noqa: reuse pure math
class AsyncRawMouse(Protocol):
async def move(self, x: float, y: float) -> None: ...
async def down(self) -> None: ...
async def up(self) -> None: ...
async def wheel(self, delta_x: float, delta_y: float) -> None: ...
async def async_human_move(
raw: AsyncRawMouse,
start_x: float, start_y: float,
end_x: float, end_y: float,
cfg: HumanConfig,
) -> None:
dist = math.hypot(end_x - start_x, end_y - start_y)
if dist < 1:
return
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
start = Point(start_x, start_y)
end = Point(end_x, end_y)
cp1, cp2 = _random_control_points(start, end)
burst_counter = 0
burst_size = rand_int_range(cfg.mouse_burst_size)
for i in range(steps + 1):
progress = i / steps
eased_t = _ease_in_out(progress)
pt = _bezier(start, cp1, cp2, end, eased_t)
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
await raw.move(round(wx), round(wy))
burst_counter += 1
if burst_counter >= burst_size and i < steps:
await async_sleep_ms(rand_range(cfg.mouse_burst_pause))
burst_counter = 0
if random.random() < cfg.mouse_overshoot_chance:
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
angle = math.atan2(end_y - start_y, end_x - start_x)
await raw.move(round(end_x + math.cos(angle) * overshoot_dist),
round(end_y + math.sin(angle) * overshoot_dist))
await async_sleep_ms(rand(30, 70))
await raw.move(round(end_x + (random.random() - 0.5) * 4),
round(end_y + (random.random() - 0.5) * 4))
async def async_human_click(raw: AsyncRawMouse, is_input: bool, cfg: HumanConfig) -> None:
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
await async_sleep_ms(aim_delay)
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
await raw.down()
await async_sleep_ms(hold_time)
await raw.up()
async def async_human_idle(raw: AsyncRawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
import time as _time
end_time = _time.monotonic() + seconds
x, y = cx, cy
while _time.monotonic() < end_time:
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
x += dx
y += dy
await raw.move(round(x), round(y))
await async_sleep_ms(rand_range(cfg.idle_pause_range))
+132
View File
@@ -0,0 +1,132 @@
"""cloakbrowser-human — Human-like scrolling via mouse wheel events."""
from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
from .mouse import RawMouse, human_move
def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool:
top_edge = bounds["y"]
bottom_edge = bounds["y"] + bounds["height"]
zone_top = viewport_height * cfg.scroll_target_zone[0]
zone_bottom = viewport_height * cfg.scroll_target_zone[1]
return top_edge >= zone_top and bottom_edge <= zone_bottom
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
try:
el = page.locator(selector).first
return el.bounding_box(timeout=2000)
except Exception:
return None
def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
abs_d = abs(delta)
sign = 1 if delta > 0 else -1
sent = 0
while sent < abs_d:
step_size = rand(20, 40)
chunk = min(step_size, abs_d - sent)
raw.wheel(0, round(chunk) * sign)
sent += chunk
sleep_ms(rand(8, 20))
def scroll_to_element(
page: Any,
raw: RawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = _get_element_box(page, selector)
if box is None:
sleep_ms(200)
box = _get_element_box(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
cursor_x = scroll_area_x
cursor_y = scroll_area_y
sleep_ms(rand_range(cfg.scroll_pre_move_delay))
# Calculate scroll distance
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
element_center = box["y"] + box["height"] / 2
distance_to_scroll = element_center - target_y
direction = 1 if distance_to_scroll > 0 else -1
abs_distance = abs(distance_to_scroll)
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
accel_steps = rand_int_range(cfg.scroll_accel_steps)
decel_steps = rand_int_range(cfg.scroll_decel_steps)
# Scroll loop: accelerate → cruise → decelerate
scrolled = 0
for i in range(total_clicks):
if i < accel_steps:
delta = rand(80, 100)
pause = rand_range(cfg.scroll_pause_slow)
elif i >= total_clicks - decel_steps:
delta = rand(60, 90)
pause = rand_range(cfg.scroll_pause_slow)
else:
delta = rand_range(cfg.scroll_delta_base)
pause = rand_range(cfg.scroll_pause_fast)
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
delta = round(delta) * direction
_smooth_wheel(raw, delta, cfg)
scrolled += abs(delta)
sleep_ms(pause)
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = _get_element_box(page, selector)
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
break
# Optional overshoot + correction
if random.random() < cfg.scroll_overshoot_chance:
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
_smooth_wheel(raw, overshoot_px, cfg)
sleep_ms(rand_range(cfg.scroll_settle_delay))
corrections = rand_int_range((1, 2))
for _ in range(corrections):
corr_delta = round(rand(40, 80)) * -direction
_smooth_wheel(raw, corr_delta, cfg)
sleep_ms(rand(100, 250))
# Settle
sleep_ms(rand_range(cfg.scroll_settle_delay))
box = _get_element_box(page, selector)
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
return box, cursor_x, cursor_y
+129
View File
@@ -0,0 +1,129 @@
"""cloakbrowser-human — Async human-like scrolling via mouse wheel events.
Mirrors scroll.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
"""
from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse_async import AsyncRawMouse, async_human_move
from .scroll import _is_in_viewport
async def _get_element_box_async(page: Any, selector: str) -> Optional[dict]:
try:
el = page.locator(selector).first
return await el.bounding_box(timeout=2000)
except Exception:
return None
async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig) -> None:
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
abs_d = abs(delta)
sign = 1 if delta > 0 else -1
sent = 0
while sent < abs_d:
step_size = rand(20, 40)
chunk = min(step_size, abs_d - sent)
await raw.wheel(0, round(chunk) * sign)
sent += chunk
await async_sleep_ms(rand(8, 20))
async def async_scroll_to_element(
page: Any,
raw: AsyncRawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = await _get_element_box_async(page, selector)
if box is None:
await async_sleep_ms(200)
box = await _get_element_box_async(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
await async_human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
cursor_x = scroll_area_x
cursor_y = scroll_area_y
await async_sleep_ms(rand_range(cfg.scroll_pre_move_delay))
# Calculate scroll distance
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
element_center = box["y"] + box["height"] / 2
distance_to_scroll = element_center - target_y
direction = 1 if distance_to_scroll > 0 else -1
abs_distance = abs(distance_to_scroll)
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
accel_steps = rand_int_range(cfg.scroll_accel_steps)
decel_steps = rand_int_range(cfg.scroll_decel_steps)
# Scroll loop: accelerate → cruise → decelerate
scrolled = 0
for i in range(total_clicks):
if i < accel_steps:
delta = rand(80, 100)
pause = rand_range(cfg.scroll_pause_slow)
elif i >= total_clicks - decel_steps:
delta = rand(60, 90)
pause = rand_range(cfg.scroll_pause_slow)
else:
delta = rand_range(cfg.scroll_delta_base)
pause = rand_range(cfg.scroll_pause_fast)
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
delta = round(delta) * direction
await _async_smooth_wheel(raw, delta, cfg)
scrolled += abs(delta)
await async_sleep_ms(pause)
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = await _get_element_box_async(page, selector)
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
break
# Optional overshoot + correction
if random.random() < cfg.scroll_overshoot_chance:
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
await _async_smooth_wheel(raw, overshoot_px, cfg)
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
corrections = rand_int_range((1, 2))
for _ in range(corrections):
corr_delta = round(rand(40, 80)) * -direction
await _async_smooth_wheel(raw, corr_delta, cfg)
await async_sleep_ms(rand(100, 250))
# Settle
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
box = await _get_element_box_async(page, selector)
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
return box, cursor_x, cursor_y
+1
View File
@@ -2,6 +2,7 @@
from cloakbrowser import launch
print("Launching stealth browser...", flush=True)
browser = launch(headless=False)
page = browser.new_page()
+1
View File
@@ -185,6 +185,7 @@ def main():
print(f"Proxy: {PROXY or 'none'}")
print()
print("Launching stealth browser...", flush=True)
context = launch_context(
headless=HEADLESS,
proxy=PROXY,
+31
View File
@@ -0,0 +1,31 @@
"""Persistent context example: cookies and localStorage survive across sessions."""
from cloakbrowser import launch_persistent_context
PROFILE_DIR = "./my-profile"
# Session 1 — set some state
print("=== Session 1: Setting state ===")
print("Launching stealth browser...", flush=True)
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
page = ctx.new_page()
page.goto("https://example.com")
page.evaluate("document.cookie = 'session=abc123; path=/; max-age=3600'")
page.evaluate("localStorage.setItem('user', 'returning')")
print(f"Cookie: {page.evaluate('document.cookie')}")
ls_val = page.evaluate("localStorage.getItem('user')")
print(f"localStorage: {ls_val}")
ctx.close()
# Session 2 — state is restored
print("\n=== Session 2: Verifying persistence ===")
print("Launching stealth browser...", flush=True)
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
page = ctx.new_page()
page.goto("https://example.com")
print(f"Cookie: {page.evaluate('document.cookie')}")
ls_val = page.evaluate("localStorage.getItem('user')")
print(f"localStorage: {ls_val}")
ctx.close()
print("\nDone!")
+1
View File
@@ -9,6 +9,7 @@ import time
from cloakbrowser import launch
print("Launching stealth browser...", flush=True)
browser = launch(headless=True)
page = browser.new_page()
+80 -32
View File
@@ -53,21 +53,27 @@ def test_bot_sannysoft(page):
def test_bot_incolumitas(page):
"""bot.incolumitas.com — comprehensive 30+ check bot detection."""
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
time.sleep(12) # needs time to run all detection tests
# Site outputs JSON blocks in page text, not HTML tables
results = page.evaluate("""() => {
const text = document.body.innerText;
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
return {
passed: okMatches.length,
failed: failMatches.length,
failedTests,
total: okMatches.length + failMatches.length
};
}""")
# Poll until test count stabilizes (site runs tests progressively)
last_total = 0
for _ in range(15):
time.sleep(2)
results = page.evaluate("""() => {
const text = document.body.innerText;
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
return {
passed: okMatches.length,
failed: failMatches.length,
failedTests,
total: okMatches.length + failMatches.length
};
}""")
if results["total"] >= 30 and results["total"] == last_total:
break
last_total = results["total"]
return results
@@ -146,23 +152,18 @@ def test_recaptcha(page):
wait_until="domcontentloaded",
timeout=30000,
)
# Wait for backend response (step3 element appears when score arrives)
try:
page.wait_for_selector("li.step3", timeout=20000)
time.sleep(1)
except Exception:
time.sleep(10) # fallback
# Wait for score to appear (polls up to 30s)
for _ in range(15):
time.sleep(2)
score = page.evaluate("""() => {
const text = document.body.innerText;
const match = text.match(/"score":\\s*(\\d+\\.\\d+)/);
return match ? parseFloat(match[1]) : null;
}""")
if score is not None:
break
results = page.evaluate("""() => {
const text = document.body.innerText;
// Score appears in JSON response block: "score": 0.9
const scoreMatch = text.match(/"score":\\s*(\\d+\\.\\d+)/);
return {
score: scoreMatch ? parseFloat(scoreMatch[1]) : null,
pageText: text.substring(0, 500)
};
}""")
return results
return {"score": score}
TESTS = [
@@ -179,8 +180,11 @@ TESTS = [
"url": "https://bot.incolumitas.com",
"runner": test_bot_incolumitas,
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
+ (f" (FAILED: {', '.join(r.get('failedTests', []))})" if r.get("failed", 0) > 0 else " — ALL GREEN"),
"pass": lambda r: r.get("failed", 0) <= 1, # fpscanner.WEBDRIVER false positive expected (all builds)
+ (" — ALL GREEN" if r.get("failed", 0) == 0
else f" (FAILED: {', '.join(r.get('failedTests', []))} — known false positives)"
if set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}
else f" (FAILED: {', '.join(r.get('failedTests', []))})"),
"pass": lambda r: set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}, # known false positives
},
{
"name": "BrowserScan",
@@ -222,10 +226,54 @@ def main():
print(f"Screenshots: {'on' if SCREENSHOTS else 'off'}")
print(f"Proxy: {PROXY or 'none'}")
print()
print("Launching stealth browser...", flush=True)
browser = launch(headless=not HEADED, proxy=PROXY)
page = browser.new_page()
# Show browser fingerprint details
try:
import re
info = page.evaluate("""async () => {
const ua = navigator.userAgent;
let fullVersion = null;
try {
const data = await navigator.userAgentData.getHighEntropyValues(['fullVersionList', 'platform', 'platformVersion']);
const chrome = data.fullVersionList.find(b => b.brand === 'Chromium' || b.brand === 'Google Chrome');
fullVersion = chrome ? chrome.version : null;
} catch {}
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl ? gl.getExtension('WEBGL_debug_renderer_info') : null;
return {
ua,
fullVersion,
platform: navigator.platform,
cores: navigator.hardwareConcurrency,
gpu: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'N/A',
gpuVendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : 'N/A',
screen: screen.width + 'x' + screen.height,
languages: navigator.languages.join(', '),
};
}""")
# Condensed UA
ua_short = re.sub(r'^Mozilla/5\.0 \(', '', info["ua"])
ua_short = re.sub(r'\) AppleWebKit/[\d.]+ \(KHTML, like Gecko\) ', ' | ', ua_short)
print(f"UA: {ua_short}", flush=True)
print(f"Platform: {info['platform']} | Cores: {info['cores']} | Screen: {info['screen']}", flush=True)
print(f"GPU: {info['gpuVendor']}{info['gpu']}", flush=True)
except Exception:
print("Chrome: could not detect", flush=True)
# Show IP address
try:
page.goto("https://httpbin.org/ip", timeout=10000)
ip = page.evaluate("JSON.parse(document.body.innerText).origin")
print(f"IP: {ip}", flush=True)
except Exception:
print("IP: could not detect", flush=True)
print(f"Running {len(TESTS)} tests (this takes ~2 minutes)...\n", flush=True)
results_summary = []
for test in TESTS:
+67 -14
View File
@@ -9,13 +9,14 @@
**Stealth Chromium that passes every bot detection test.**
Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests.
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- 🔒 **25 source-level C++ patches**not JS injection, not config flags
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement**works with both Playwright and Puppeteer
- 📦 **`npm install cloakbrowser`** — binary auto-downloads, zero config
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
- **Free and open source** — no subscriptions, no usage limits
- **Works with any framework** — also tested with Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser
## Install
@@ -60,13 +61,18 @@ await browser.close();
### Options
```javascript
import { launch, launchContext } from 'cloakbrowser';
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// With proxy
const browser = await launch({
proxy: 'http://user:pass@proxy:8080',
});
// With proxy object (bypass, separate auth fields)
const browser = await launch({
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
});
// Headed mode (visible browser window)
const browser = await launch({ headless: false });
@@ -92,8 +98,18 @@ const context = await launchContext({
userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York',
timezone: 'America/New_York',
});
// Persistent profile — stay logged in, bypass incognito detection, load extensions
const ctx = await launchPersistentContext({
userDataDir: './chrome-profile',
headless: false,
proxy: 'http://user:pass@proxy:8080',
});
const page = ctx.pages()[0] || await ctx.newPage();
await page.goto('https://example.com');
await ctx.close(); // profile saved — reuse same path to restore state
```
### Auto Timezone/Locale from Proxy IP
@@ -146,6 +162,9 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
| **bot.incolumitas.com** | 13 fails | **1 fail** |
| `navigator.webdriver` | `true` | **`false`** |
| CDP detection | Detected | **Not detected** |
| TLS fingerprint | Mismatch | **Identical to Chrome** |
| | | **Tested against 30+ detection sites** |
## Configuration
@@ -171,12 +190,12 @@ const page = await browser.newPage();
## Platforms
| Platform | Status |
|---|---|
| Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | ✅ Available |
| macOS x86_64 (Intel) | ✅ Available |
| Windows x86_64 | ✅ Available |
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 26 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest |
## Requirements
@@ -185,6 +204,21 @@ const page = await browser.newPage();
## Troubleshooting
**Site detects incognito / private browsing mode**
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
```javascript
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
@@ -203,8 +237,25 @@ Other tips for maximizing reCAPTCHA scores:
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
- **Use a fixed fingerprint seed** (`--fingerprint=12345`) for consistent device identity across sessions
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
```javascript
await page.type('#email', 'user@example.com', { delay: 50 });
```
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
**New update broke something? Roll back to the previous version**
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
```
## Links
- 🌐 [Website](https://cloakbrowser.dev)
@@ -217,3 +268,5 @@ Other tips for maximizing reCAPTCHA scores:
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
Use against financial, banking, healthcare, or government authentication systems without authorization is expressly prohibited.
+40
View File
@@ -0,0 +1,40 @@
/**
* Persistent context example: cookies and localStorage survive across sessions.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/persistent-context.ts
*/
import { launchPersistentContext } from "../src/index.js";
const PROFILE_DIR = "./my-profile";
// Session 1 — set some state
console.log("=== Session 1: Setting state ===");
let ctx = await launchPersistentContext({
userDataDir: PROFILE_DIR,
headless: false,
});
let page = ctx.pages()[0] || (await ctx.newPage());
await page.goto("https://example.com");
await page.evaluate(() => {
document.cookie = "session=abc123; path=/; max-age=3600";
localStorage.setItem("user", "returning");
});
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
await ctx.close();
// Session 2 — state is restored
console.log("\n=== Session 2: Verifying persistence ===");
ctx = await launchPersistentContext({
userDataDir: PROFILE_DIR,
headless: false,
});
page = ctx.pages()[0] || (await ctx.newPage());
await page.goto("https://example.com");
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
await ctx.close();
console.log("\nDone!");
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cloakbrowser",
"version": "0.2.0",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloakbrowser",
"version": "0.2.0",
"version": "0.3.9",
"license": "MIT",
"dependencies": {
"tar": "^7.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.3",
"version": "0.3.11",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
+49
View File
@@ -0,0 +1,49 @@
/**
* Shared argument builder for Playwright and Puppeteer wrappers.
*/
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
const DEBUG = /\bcloakbrowser\b/.test(process.env.DEBUG ?? "");
/**
* Build deduplicated Chromium CLI args from stealth defaults + user overrides.
*
* Priority: stealth defaults < user args < dedicated params (timezone/locale).
*/
export function buildArgs(options: LaunchOptions): string[] {
const seen = new Map<string, string>();
if (options.stealthArgs !== false) {
for (const arg of getDefaultStealthArgs()) {
seen.set(arg.split("=")[0], arg);
}
}
if (options.args) {
for (const arg of options.args) {
const key = arg.split("=")[0];
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${arg}`);
}
seen.set(key, arg);
}
}
if (options.timezone) {
const key = "--fingerprint-timezone";
const flag = `${key}=${options.timezone}`;
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
}
seen.set(key, flag);
}
if (options.locale) {
const key = "--lang";
const flag = `${key}=${options.locale}`;
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
}
seen.set(key, flag);
}
return [...seen.values()];
}
+15 -9
View File
@@ -27,13 +27,13 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version.
// ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.109";
export const CHROMIUM_VERSION = "145.0.7632.159.3";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "145.0.7632.109",
"darwin-x64": "145.0.7632.109",
"windows-x64": "145.0.7632.109",
"linux-x64": "145.0.7632.159.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
};
// ---------------------------------------------------------------------------
@@ -126,16 +126,22 @@ export const GITHUB_API_URL =
export const GITHUB_DOWNLOAD_BASE_URL =
"https://github.com/CloakHQ/cloakbrowser/releases/download";
export function getArchiveExt(): string {
return process.platform === "win32" ? ".zip" : ".tar.gz";
}
export function getArchiveName(tag?: string): string {
return `cloakbrowser-${tag || getPlatformTag()}${getArchiveExt()}`;
}
export function getDownloadUrl(version?: string): string {
const v = version || getChromiumVersion();
const tag = getPlatformTag();
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
}
export function getFallbackDownloadUrl(version?: string): string {
const v = version || getChromiumVersion();
const tag = getPlatformTag();
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
}
export function getEffectiveVersion(): string {
+91 -37
View File
@@ -19,6 +19,8 @@ import {
GITHUB_DOWNLOAD_BASE_URL,
WRAPPER_VERSION,
checkPlatformAvailable,
getArchiveExt,
getArchiveName,
getBinaryDir,
getBinaryPath,
getCacheDir,
@@ -63,6 +65,7 @@ export async function ensureBinary(): Promise<string> {
const binaryPath = getBinaryPath(effective);
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
showWelcome();
maybeTriggerUpdateCheck();
return binaryPath;
}
@@ -87,8 +90,8 @@ export async function ensureBinary(): Promise<string> {
if (!fs.existsSync(downloadedPath)) {
throw new Error(
`Download completed but binary not found at expected path: ${downloadedPath}. ` +
`This may indicate a packaging issue. Please report at ` +
`https://github.com/CloakHQ/cloakbrowser/issues`
`This may indicate a packaging issue. Please report at ` +
`https://github.com/CloakHQ/cloakbrowser/issues`
);
}
@@ -136,6 +139,28 @@ export async function checkForUpdate(): Promise<string | null> {
return latest;
}
// ---------------------------------------------------------------------------
// Welcome message (shown once per install)
// ---------------------------------------------------------------------------
function showWelcome(): void {
const marker = path.join(getCacheDir(), ".welcome_shown");
if (fs.existsSync(marker)) return;
console.log();
console.log(" CloakBrowser — stealth Chromium for automation");
console.log(" https://github.com/CloakHQ/CloakBrowser");
console.log();
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.log(" Star us if CloakBrowser helps your project!");
console.log();
try {
fs.mkdirSync(getCacheDir(), { recursive: true });
fs.writeFileSync(marker, "");
} catch {
// Non-fatal
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -152,7 +177,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
// Download to temp file (atomic — no partial downloads in cache)
const tmpPath = path.join(
path.dirname(binaryDir),
`_download_${Date.now()}.tar.gz`
`_download_${Date.now()}${getArchiveExt()}`
);
try {
@@ -175,15 +200,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
}
await extractArchive(tmpPath, binaryDir, binaryPath);
console.log(
`[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.`
);
console.log(
`[cloakbrowser] Issues? https://github.com/CloakHQ/CloakBrowser/issues`
);
console.log(
`[cloakbrowser] Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser`
);
showWelcome();
} finally {
// Clean up temp file
if (fs.existsSync(tmpPath)) {
@@ -194,7 +211,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
const tarballName = getArchiveName();
if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
@@ -274,6 +291,9 @@ async function downloadFile(url: string, dest: string): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
// Create file stream early so we can ensure cleanup on error
const fileStream = createWriteStream(dest);
try {
const response = await fetch(url, {
signal: controller.signal,
@@ -292,7 +312,6 @@ async function downloadFile(url: string, dest: string): Promise<void> {
let downloaded = 0;
let lastLoggedPct = -1;
const fileStream = createWriteStream(dest);
const reader = response.body.getReader();
// Stream chunks to file with progress logging
@@ -316,19 +335,32 @@ async function downloadFile(url: string, dest: string): Promise<void> {
}
}
// Wait for file stream to finish
// Wait for file stream to fully close (not just finish)
await new Promise<void>((resolve, reject) => {
fileStream.end(() => resolve());
fileStream.end();
fileStream.on("close", () => resolve());
fileStream.on("error", reject);
});
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`);
} catch (err) {
// Ensure file stream is destroyed on error to release the handle
if (!fileStream.destroyed) {
await new Promise<void>((resolve) => {
fileStream.destroy();
fileStream.on("close", () => resolve());
// Safety timeout in case close never fires
setTimeout(resolve, 2000);
});
}
throw err;
} finally {
clearTimeout(timeout);
}
}
async function extractArchive(
archivePath: string,
destDir: string,
@@ -342,23 +374,11 @@ async function extractArchive(
}
fs.mkdirSync(destDir, { recursive: true });
// Extract with tar — the 'tar' package handles symlink/traversal safety
await tarExtract({
file: archivePath,
cwd: destDir,
// Security: strip leading path components and reject absolute paths
strip: 0,
filter: (entryPath: string) => {
// Reject absolute paths and path traversal
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
console.warn(
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
);
return false;
}
return true;
},
});
if (archivePath.endsWith(".zip")) {
await extractZip(archivePath, destDir);
} else {
await extractTar(archivePath, destDir);
}
// Flatten single subdirectory if needed
flattenSingleSubdir(destDir);
@@ -379,6 +399,40 @@ async function extractArchive(
}
}
async function extractTar(archivePath: string, destDir: string): Promise<void> {
await tarExtract({
file: archivePath,
cwd: destDir,
strip: 0,
filter: (entryPath: string) => {
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
console.warn(
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
);
return false;
}
return true;
},
});
}
async function extractZip(archivePath: string, destDir: string): Promise<void> {
// Brief delay to ensure OS fully releases file handles (Windows)
await new Promise(resolve => setTimeout(resolve, 500));
if (process.platform === "win32") {
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
// with recently-closed Node.js file handles. Use ZipFile API directly.
execFileSync("powershell", [
"-NoProfile", "-Command",
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
], { timeout: 120_000 });
} else {
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
}
}
/**
* If extraction created a single subdirectory, move its contents up.
* Many tarballs wrap files in a top-level directory.
@@ -452,7 +506,7 @@ export async function getLatestChromiumVersion(): Promise<string | null> {
draft: boolean;
assets: Array<{ name: string }>;
}>;
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
const platformTarball = getArchiveName();
for (const release of releases) {
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
const assetNames = new Set(
@@ -500,7 +554,7 @@ export async function checkWrapperUpdate(): Promise<void> {
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
console.warn(
`[cloakbrowser] Update available: ${WRAPPER_VERSION}${data.version}. ` +
`Run: npm install cloakbrowser@latest`
`Run: npm install cloakbrowser@latest`
);
}
} catch {
@@ -547,10 +601,10 @@ async function checkAndDownloadUpdate(): Promise<void> {
function maybeTriggerUpdateCheck(): void {
// Wrapper update: once per process, not rate-limited
if (!wrapperUpdateChecked) {
checkWrapperUpdate().catch(() => {});
checkWrapperUpdate().catch(() => { });
}
// Binary update: rate-limited to once per hour
if (!shouldCheckForUpdate()) return;
checkAndDownloadUpdate().catch(() => {});
checkAndDownloadUpdate().catch(() => { });
}
+232
View File
@@ -0,0 +1,232 @@
/**
* cloakbrowser-human — Configuration and presets.
*
* All numeric parameters for human-like behavior are centralized here.
* Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface HumanConfig {
// Keyboard
typing_delay: number;
typing_delay_spread: number;
typing_pause_chance: number;
typing_pause_range: [number, number];
shift_down_delay: [number, number];
shift_up_delay: [number, number];
key_hold: [number, number];
field_switch_delay: [number, number];
mistype_chance: number;
mistype_delay_notice: [number, number];
mistype_delay_correct: [number, number];
// Mouse — movement
mouse_steps_divisor: number;
mouse_min_steps: number;
mouse_max_steps: number;
mouse_wobble_max: number;
mouse_overshoot_chance: number;
mouse_overshoot_px: [number, number];
mouse_burst_size: [number, number];
mouse_burst_pause: [number, number];
// Mouse — clicks
click_aim_delay_input: [number, number];
click_aim_delay_button: [number, number];
click_hold_input: [number, number];
click_hold_button: [number, number];
click_input_x_range: [number, number];
// Mouse — idle
idle_drift_px: number;
idle_pause_range: [number, number];
// Scroll
scroll_delta_base: [number, number];
scroll_delta_variance: number;
scroll_pause_fast: [number, number];
scroll_pause_slow: [number, number];
scroll_accel_steps: [number, number];
scroll_decel_steps: [number, number];
scroll_overshoot_chance: number;
scroll_overshoot_px: [number, number];
scroll_settle_delay: [number, number];
scroll_target_zone: [number, number];
scroll_pre_move_delay: [number, number];
// Initial cursor position
initial_cursor_x: [number, number];
initial_cursor_y: [number, number];
// Idle micro-movements between actions (opt-in, adds latency)
idle_between_actions: boolean;
idle_between_duration: [number, number];
}
export type HumanPreset = 'default' | 'careful';
// ---------------------------------------------------------------------------
// Default preset
// ---------------------------------------------------------------------------
const DEFAULT_CONFIG: HumanConfig = {
// Keyboard
typing_delay: 70,
typing_delay_spread: 40,
typing_pause_chance: 0.1,
typing_pause_range: [400, 1000],
shift_down_delay: [30, 70],
shift_up_delay: [20, 50],
key_hold: [15, 35],
field_switch_delay: [800, 1500],
// Mistype (typo simulation)
mistype_chance: 0.02,
mistype_delay_notice: [100, 300],
mistype_delay_correct: [50, 150],
// Mouse — movement
mouse_steps_divisor: 8,
mouse_min_steps: 25,
mouse_max_steps: 80,
mouse_wobble_max: 1.5,
mouse_overshoot_chance: 0.15,
mouse_overshoot_px: [3, 6],
mouse_burst_size: [3, 5],
mouse_burst_pause: [8, 18],
// Mouse — clicks
click_aim_delay_input: [60, 140],
click_aim_delay_button: [80, 200],
click_hold_input: [40, 100],
click_hold_button: [60, 150],
click_input_x_range: [0.05, 0.30],
// Mouse — idle
idle_drift_px: 3,
idle_pause_range: [300, 1000],
// Scroll
scroll_delta_base: [80, 130],
scroll_delta_variance: 0.2,
scroll_pause_fast: [30, 80],
scroll_pause_slow: [80, 200],
scroll_accel_steps: [2, 3],
scroll_decel_steps: [2, 3],
scroll_overshoot_chance: 0.1,
scroll_overshoot_px: [50, 150],
scroll_settle_delay: [300, 600],
scroll_target_zone: [0.20, 0.80],
scroll_pre_move_delay: [100, 300],
// Initial cursor position (as if coming from the address bar area)
initial_cursor_x: [400, 700],
initial_cursor_y: [45, 60],
// Idle micro-movements between actions (off by default)
idle_between_actions: false,
idle_between_duration: [0.3, 0.8],
};
// ---------------------------------------------------------------------------
// Careful preset — everything slower and more deliberate
// ---------------------------------------------------------------------------
const CAREFUL_CONFIG: HumanConfig = {
...DEFAULT_CONFIG,
// Keyboard — slower typing
typing_delay: 100,
typing_delay_spread: 50,
typing_pause_chance: 0.15,
typing_pause_range: [500, 1200],
shift_down_delay: [40, 90],
shift_up_delay: [30, 70],
key_hold: [20, 45],
field_switch_delay: [1000, 2000],
mistype_chance: 0.03,
mistype_delay_notice: [150, 400],
mistype_delay_correct: [80, 200],
// Mouse — slower, more precise
mouse_overshoot_chance: 0.10,
mouse_burst_pause: [12, 25],
// Mouse — clicks (longer aiming and holding)
click_aim_delay_input: [80, 180],
click_aim_delay_button: [120, 280],
click_hold_input: [60, 140],
click_hold_button: [80, 200],
// Scroll — slower
scroll_pause_fast: [100, 200],
scroll_pause_slow: [250, 600],
scroll_settle_delay: [400, 800],
scroll_pre_move_delay: [150, 400],
// Idle between actions enabled for careful preset
idle_between_actions: true,
idle_between_duration: [0.4, 1.0],
};
// ---------------------------------------------------------------------------
// Preset map
// ---------------------------------------------------------------------------
const PRESETS: Record<HumanPreset, HumanConfig> = {
default: DEFAULT_CONFIG,
careful: CAREFUL_CONFIG,
};
/**
* Resolve a preset name or partial config into a full HumanConfig.
* If `preset` is a string, returns the corresponding built-in config.
* Any keys in `overrides` replace the preset values.
*/
export function resolveConfig(
preset: HumanPreset = 'default',
overrides?: Partial<HumanConfig>,
): HumanConfig {
const base = PRESETS[preset];
if (!base) {
throw new Error(
`Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}`
);
}
if (!overrides) return { ...base };
return { ...base, ...overrides };
}
// ---------------------------------------------------------------------------
// Utility: random number in range
// ---------------------------------------------------------------------------
/** Random float in [min, max]. */
export function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
/** Random integer in [min, max] (inclusive). */
export function randInt(min: number, max: number): number {
return Math.floor(rand(min, max + 1));
}
/** Random value from a [min, max] tuple. */
export function randRange(range: [number, number]): number {
return rand(range[0], range[1]);
}
/** Random integer from a [min, max] tuple. */
export function randIntRange(range: [number, number]): number {
return randInt(range[0], range[1]);
}
/** Sleep for `ms` milliseconds. */
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+478
View File
@@ -0,0 +1,478 @@
/**
* Human-like behavioral layer for cloakbrowser (JS/TS).
*
* Activated via humanize: true in launch() / launchContext().
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
*
* Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*/
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js';
export { HumanConfig, resolveConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
class CursorState {
x = 0;
y = 0;
initialized = false;
}
async function isInputElement(page: Page, selector: string): Promise<boolean> {
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}, selector).catch(() => false);
}
async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
/**
* Replace page methods with human-like implementations.
*/
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const originals = {
click: page.click.bind(page),
dblclick: page.dblclick.bind(page),
hover: page.hover.bind(page),
type: page.type.bind(page),
fill: page.fill.bind(page),
check: page.check.bind(page),
uncheck: page.uncheck.bind(page),
selectOption: page.selectOption.bind(page),
press: page.press.bind(page),
goto: page.goto.bind(page),
isChecked: page.isChecked.bind(page),
mouseMove: page.mouse.move.bind(page.mouse),
mouseClick: page.mouse.click.bind(page.mouse),
mouseDblclick: page.mouse.dblclick.bind(page.mouse),
mouseWheel: page.mouse.wheel.bind(page.mouse),
mouseDown: page.mouse.down.bind(page.mouse),
mouseUp: page.mouse.up.bind(page.mouse),
keyboardType: page.keyboard.type.bind(page.keyboard),
keyboardDown: page.keyboard.down.bind(page.keyboard),
keyboardUp: page.keyboard.up.bind(page.keyboard),
keyboardPress: page.keyboard.press.bind(page.keyboard),
keyboardInsertText: page.keyboard.insertText.bind(page.keyboard),
};
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
up: originals.mouseUp,
wheel: originals.mouseWheel,
};
const rawKb: RawKeyboard = {
down: originals.keyboardDown,
up: originals.keyboardUp,
type: originals.keyboardType,
insertText: originals.keyboardInsertText,
};
async function ensureCursorInit(): Promise<void> {
if (!cursor.initialized) {
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
await originals.mouseMove(cursor.x, cursor.y);
cursor.initialized = true;
}
}
// --- goto ---
const humanGoto = async (url: string, options?: any) => {
const response = await originals.goto(url, options);
// Patch any new frames after navigation
patchFrames(page, cfg, cursor, raw, rawKb, originals);
return response;
};
// --- click ---
const humanClickFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
await humanClick(raw, isInput, cfg);
};
// --- dblclick ---
const humanDblclickFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
// --- hover ---
const humanHoverFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
};
// --- type ---
const humanTypeFn = async (selector: string, text: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
await sleep(rand(100, 250));
await humanType(page, rawKb, text, cfg);
};
// --- fill (clears existing content first) ---
const humanFillFn = async (selector: string, value: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
await humanType(page, rawKb, value, cfg);
};
// --- clear ---
const humanClearFn = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
};
// --- check ---
const humanCheckFn = async (selector: string, options?: any) => {
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const checked = await originals.isChecked(selector).catch(() => false);
if (!checked) {
await humanClickFn(selector);
}
};
// --- uncheck ---
const humanUncheckFn = async (selector: string, options?: any) => {
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const checked = await originals.isChecked(selector).catch(() => true);
if (checked) {
await humanClickFn(selector);
}
};
// --- selectOption ---
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
await humanHoverFn(selector);
await sleep(rand(100, 300));
return originals.selectOption(selector, values, options);
};
// --- press (checks focus first — avoids redundant mouse moves) ---
const humanPressFn = async (selector: string, key: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
// --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(100, 250));
await humanType(page, rawKb, text, cfg);
};
// --- tap ---
const humanTapFn = async (selector: string, options?: any) => {
await humanClickFn(selector, options);
};
// Assign page-level patches
(page as any).goto = humanGoto;
(page as any).click = humanClickFn;
(page as any).dblclick = humanDblclickFn;
(page as any).hover = humanHoverFn;
(page as any).type = humanTypeFn;
(page as any).fill = humanFillFn;
(page as any).check = humanCheckFn;
(page as any).uncheck = humanUncheckFn;
(page as any).selectOption = humanSelectOptionFn;
(page as any).press = humanPressFn;
// --- mouse patches ---
page.mouse.move = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
await humanClick(raw, false, cfg);
};
// --- keyboard patches ---
page.keyboard.type = async (text: string, options?: any) => {
await humanType(page, rawKb, text, cfg);
};
// Store helpers for frame patching
(page as any)._humanCursor = cursor;
(page as any)._humanRaw = raw;
(page as any)._humanRawKb = rawKb;
(page as any)._humanOriginals = originals;
(page as any)._humanClickFn = humanClickFn;
(page as any)._humanHoverFn = humanHoverFn;
(page as any)._humanClearFn = humanClearFn;
(page as any)._humanPressFn = humanPressFn;
(page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn;
(page as any)._humanTapFn = humanTapFn;
(page as any)._ensureCursorInit = ensureCursorInit;
// Initialize cursor immediately so it doesn't visibly jump from (0,0)
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
originals.mouseMove(cursor.x, cursor.y).then(() => {
cursor.initialized = true;
}).catch(() => {});
// --- Patch Frame-level methods (for sub-frames) ---
patchFrames(page, cfg, cursor, raw, rawKb, originals);
}
// ============================================================================
// Frame-level patching
// ============================================================================
/**
* Patch Frame methods so Locator-based calls go through humanization.
* All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, clear, dragAndDrop.
*/
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals);
}
}
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
// Save originals for methods that need fallback
const origFrameSelectOption = frame.selectOption.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
(frame as any).dblclick = async (selector: string, options?: any) => {
await (page as any).dblclick(selector, options);
};
(frame as any).hover = async (selector: string, options?: any) => {
await (page as any).hover(selector, options);
};
(frame as any).type = async (selector: string, text: string, options?: any) => {
await (page as any).type(selector, text, options);
};
(frame as any).fill = async (selector: string, value: string, options?: any) => {
await (page as any).fill(selector, value, options);
};
(frame as any).check = async (selector: string, options?: any) => {
await (page as any).check(selector, options);
};
(frame as any).uncheck = async (selector: string, options?: any) => {
await (page as any).uncheck(selector, options);
};
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
await (page as any).hover(selector);
await sleep(rand(100, 300));
return origFrameSelectOption(selector, values, options);
};
(frame as any).press = async (selector: string, key: string, options?: any) => {
await (page as any).press(selector, key, options);
};
(frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await (page as any).click(selector);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
};
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await page.mouse.move(sx, sy);
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await page.mouse.move(tx, ty);
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origFrameDragAndDrop(source, target, options);
}
};
}
function* iterFrames(page: Page): Generator<Frame> {
try {
const mainFrame = page.mainFrame();
yield mainFrame;
for (const child of mainFrame.childFrames()) {
yield child;
}
} catch {}
}
// ============================================================================
// Context-level patching
// ============================================================================
function patchContext(context: BrowserContext, cfg: HumanConfig): void {
const cursor = new CursorState();
for (const page of context.pages()) {
patchPage(page, cfg, cursor);
}
context.on('page', (page: Page) => {
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
});
const origNewPage = context.newPage.bind(context);
(context as any).newPage = async () => {
const page = await origNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
}
// ============================================================================
// Browser-level patching
// ============================================================================
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
for (const context of browser.contexts()) {
patchContext(context, cfg);
}
const origNewContext = browser.newContext.bind(browser);
(browser as any).newContext = async (options?: any) => {
const context = await origNewContext(options);
patchContext(context, cfg);
return context;
};
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async (options?: any) => {
const page = await origNewPage(options);
if (!(page as any)._original) {
const ctx = page.context();
if (!(ctx as any)._humanPatched) {
patchContext(ctx, cfg);
(ctx as any)._humanPatched = true;
}
patchPage(page, cfg, new CursorState());
}
return page;
};
}
export { patchContext, patchPage };
+111
View File
@@ -0,0 +1,111 @@
/**
* cloakbrowser-human — Human-like keyboard input.
*/
import type { Page } from 'playwright-core';
import { RawKeyboard } from './mouse.js';
import { HumanConfig, rand, randRange, sleep } from './config.js';
const SHIFT_SYMBOLS = new Set([
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
]);
const NEARBY_KEYS: Record<string, string> = {
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
z: 'asx',
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
};
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
const neighbors = NEARBY_KEYS[lower];
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
}
return ch;
}
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
): Promise<void> {
for (let i = 0; i < text.length; i++) {
const ch = text[i];
// Mistype chance — press wrong key, notice, backspace, then correct
if (Math.random() < cfg.mistype_chance && /[a-zA-Z0-9]/.test(ch)) {
const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice));
await raw.down('Backspace');
await sleep(randRange(cfg.key_hold));
await raw.up('Backspace');
await sleep(randRange(cfg.mistype_delay_correct));
}
if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < text.length - 1) {
await interCharDelay(cfg);
}
}
}
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
}
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
if (Math.random() < cfg.typing_pause_chance) {
await sleep(randRange(cfg.typing_pause_range));
} else {
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
await sleep(Math.max(10, delay));
}
}
+193
View File
@@ -0,0 +1,193 @@
/**
* cloakbrowser-human — Human-like mouse movement and clicking.
*/
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
// ---------------------------------------------------------------------------
// Raw interface — original Playwright methods, bypassing the wrapper
// ---------------------------------------------------------------------------
export interface RawMouse {
move: (x: number, y: number) => Promise<void>;
down: (options?: any) => Promise<void>;
up: (options?: any) => Promise<void>;
wheel: (deltaX: number, deltaY: number) => Promise<void>;
}
export interface RawKeyboard {
down: (key: string) => Promise<void>;
up: (key: string) => Promise<void>;
type: (text: string) => Promise<void>;
insertText: (text: string) => Promise<void>;
}
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
function easeInOut(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ---------------------------------------------------------------------------
// Bezier
// ---------------------------------------------------------------------------
interface Point {
x: number;
y: number;
}
function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
const u = 1 - t;
const uu = u * u;
const uuu = uu * u;
const tt = t * t;
const ttt = tt * t;
return {
x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
};
}
function randomControlPoints(start: Point, end: Point): [Point, Point] {
const dx = end.x - start.x;
const dy = end.y - start.y;
const dist = Math.hypot(dx, dy);
const px = -dy / (dist || 1);
const py = dx / (dist || 1);
const bias1 = rand(-0.3, 0.3) * dist;
const bias2 = rand(-0.3, 0.3) * dist;
return [
{ x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 },
{ x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 },
];
}
// ---------------------------------------------------------------------------
// Human mouse movement
// ---------------------------------------------------------------------------
export async function humanMove(
raw: RawMouse,
startX: number,
startY: number,
endX: number,
endY: number,
cfg: HumanConfig,
): Promise<void> {
const dist = Math.hypot(endX - startX, endY - startY);
if (dist < 1) return;
const steps = Math.max(
cfg.mouse_min_steps,
Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)),
);
const start: Point = { x: startX, y: startY };
const end: Point = { x: endX, y: endY };
const [cp1, cp2] = randomControlPoints(start, end);
let burstCounter = 0;
const burstSize = randIntRange(cfg.mouse_burst_size);
for (let i = 0; i <= steps; i++) {
const progress = i / steps;
const easedT = easeInOut(progress);
const pt = bezier(start, cp1, cp2, end, easedT);
const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max;
const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp;
const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp;
await raw.move(Math.round(wx), Math.round(wy));
burstCounter++;
if (burstCounter >= burstSize && i < steps) {
await sleep(randRange(cfg.mouse_burst_pause));
burstCounter = 0;
}
}
if (Math.random() < cfg.mouse_overshoot_chance) {
const overshootDist = randRange(cfg.mouse_overshoot_px);
const angle = Math.atan2(endY - startY, endX - startX);
const ovX = Math.round(endX + Math.cos(angle) * overshootDist);
const ovY = Math.round(endY + Math.sin(angle) * overshootDist);
await raw.move(ovX, ovY);
await sleep(rand(30, 70));
const corrX = Math.round(endX + (Math.random() - 0.5) * 4);
const corrY = Math.round(endY + (Math.random() - 0.5) * 4);
await raw.move(corrX, corrY);
}
}
// ---------------------------------------------------------------------------
// Human click
// ---------------------------------------------------------------------------
export function clickTarget(
box: { x: number; y: number; width: number; height: number },
isInput: boolean,
cfg: HumanConfig,
): Point {
if (isInput) {
const xFrac = randRange(cfg.click_input_x_range);
const yFrac = rand(0.30, 0.70);
return {
x: Math.round(box.x + box.width * xFrac),
y: Math.round(box.y + box.height * yFrac),
};
}
const xFrac = rand(0.35, 0.65);
const yFrac = rand(0.35, 0.65);
return {
x: Math.round(box.x + box.width * xFrac),
y: Math.round(box.y + box.height * yFrac),
};
}
export async function humanClick(
raw: RawMouse,
isInput: boolean,
cfg: HumanConfig,
): Promise<void> {
const aimDelay = isInput
? randRange(cfg.click_aim_delay_input)
: randRange(cfg.click_aim_delay_button);
await sleep(aimDelay);
const holdTime = isInput
? randRange(cfg.click_hold_input)
: randRange(cfg.click_hold_button);
await raw.down();
await sleep(holdTime);
await raw.up();
}
// ---------------------------------------------------------------------------
// Human idle / drift
// ---------------------------------------------------------------------------
export async function humanIdle(
raw: RawMouse,
seconds: number,
cx: number,
cy: number,
cfg: HumanConfig,
): Promise<void> {
const endTime = Date.now() + seconds * 1000;
let x = cx;
let y = cy;
while (Date.now() < endTime) {
const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
x += dx;
y += dy;
await raw.move(Math.round(x), Math.round(y));
await sleep(randRange(cfg.idle_pause_range));
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
*/
import type { Page } from 'playwright-core';
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
import { RawMouse, humanMove } from './mouse.js';
interface ElementBounds {
x: number;
y: number;
width: number;
height: number;
}
function isInViewport(
bounds: ElementBounds,
viewportHeight: number,
cfg: HumanConfig,
): boolean {
const topEdge = bounds.y;
const bottomEdge = bounds.y + bounds.height;
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
let sent = 0;
while (sent < absD) {
const stepSize = rand(20, 40);
const chunk = Math.min(stepSize, absD - sent);
await raw.wheel(0, Math.round(chunk) * sign);
sent += chunk;
await sleep(rand(8, 20));
}
}
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
const viewport = page.viewportSize();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
}
// Move cursor into scroll area
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
cursorX = scrollAreaX;
cursorY = scrollAreaY;
await sleep(randRange(cfg.scroll_pre_move_delay));
// Calculate scroll distance
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
const elementCenter = box.y + box.height / 2;
const distanceToScroll = elementCenter - targetY;
const direction = distanceToScroll > 0 ? 1 : -1;
const absDistance = Math.abs(distanceToScroll);
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
const accelSteps = randIntRange(cfg.scroll_accel_steps);
const decelSteps = randIntRange(cfg.scroll_decel_steps);
let scrolled = 0;
// Scroll loop: accelerate → cruise → decelerate
for (let i = 0; i < totalClicks; i++) {
let delta: number;
let pause: number;
if (i < accelSteps) {
delta = rand(80, 100);
pause = randRange(cfg.scroll_pause_slow);
} else if (i >= totalClicks - decelSteps) {
delta = rand(60, 90);
pause = randRange(cfg.scroll_pause_slow);
} else {
delta = randRange(cfg.scroll_delta_base);
pause = randRange(cfg.scroll_pause_fast);
}
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
delta = Math.round(delta) * direction;
await smoothWheel(raw, delta, cfg);
scrolled += Math.abs(delta);
await sleep(pause);
// Check visibility every 3 steps
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
}
if (scrolled >= absDistance * 1.1) break;
}
// Optional overshoot + correction
if (Math.random() < cfg.scroll_overshoot_chance) {
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
await smoothWheel(raw, overshootPx, cfg);
await sleep(randRange(cfg.scroll_settle_delay));
const corrections = randIntRange([1, 2]);
for (let c = 0; c < corrections; c++) {
const corrDelta = Math.round(rand(40, 80)) * -direction;
await smoothWheel(raw, corrDelta, cfg);
await sleep(rand(100, 250));
}
}
// Settle
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
return { box, cursorX, cursorY };
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout: 2000 });
return box;
} catch {
return null;
}
}
+2 -2
View File
@@ -16,7 +16,7 @@
*/
// Launch functions (Playwright API)
export { launch, launchContext } from "./playwright.js";
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
@@ -25,4 +25,4 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
// Types
export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js";
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
+109 -26
View File
@@ -4,11 +4,23 @@
*/
import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
if (options.timezoneId != null) {
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
delete (merged as any).timezoneId;
return merged;
}
return options;
}
/**
* Launch stealth Chromium browser via Playwright.
*
@@ -34,10 +46,23 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchBrowser } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchBrowser(browser, cfg);
}
return browser;
}
@@ -60,9 +85,13 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext(
options: LaunchContextOptions = {}
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
const browser = await launch({ ...options, ...resolved, geoip: false });
// Skip --fingerprint-timezone binary flag: it only applies to the default
// context and interferes with Playwright's timezoneId on new contexts.
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
let context: BrowserContext;
try {
@@ -85,6 +114,78 @@ export async function launchContext(
await browser.close();
};
// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchContext(context, cfg);
}
return context;
}
/**
* Launch stealth browser with a persistent user profile (non-incognito).
* Uses Playwright's chromium.launchPersistentContext() under the hood.
*
* This avoids incognito detection by services like BrowserScan (-10% penalty)
* and enables session persistence (cookies, localStorage) across launches.
*
* @example
* ```ts
* import { launchPersistentContext } from 'cloakbrowser';
* const context = await launchPersistentContext({
* userDataDir: './chrome-profile',
* headless: false,
* proxy: 'http://user:pass@host:port',
* geoip: true,
* });
* const page = context.pages()[0] || await context.newPage();
* await page.goto('https://example.com');
* await context.close();
* ```
*/
export async function launchPersistentContext(
options: LaunchPersistentContextOptions
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const context = await chromium.launchPersistentContext(options.userDataDir, {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchContext(context, cfg);
}
return context;
}
@@ -99,7 +200,9 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
@@ -107,24 +210,4 @@ async function maybeResolveGeoip(
}
/** @internal Exposed for unit tests only. */
export function _buildArgsForTest(options: LaunchOptions): string[] {
return buildArgs(options);
}
function buildArgs(options: LaunchOptions): string[] {
const args: string[] = [];
if (options.stealthArgs !== false) {
args.push(...getDefaultStealthArgs());
}
if (options.args) {
args.push(...options.args);
}
// Timezone/locale flags — always inject when set
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
export { buildArgs as _buildArgsForTest } from "./args.js";
+24 -22
View File
@@ -5,7 +5,7 @@
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
@@ -34,10 +34,26 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// so we strip them and use page.authenticate() instead.
let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password || "" };
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password ?? "" };
}
} else {
// Strip any inline credentials from the server URL — Chromium's
// --proxy-server doesn't support them; use page.authenticate() instead.
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
// Explicit username/password fields take precedence over inline creds
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
if (username) {
proxyAuth = { username, password: password ?? "" };
}
}
}
@@ -74,26 +90,12 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
function buildArgs(options: LaunchOptions): string[] {
const args: string[] = [];
if (options.stealthArgs !== false) {
args.push(...getDefaultStealthArgs());
}
if (options.args) {
args.push(...options.args);
}
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args;
}
+19 -3
View File
@@ -5,8 +5,13 @@
export interface LaunchOptions {
/** Run in headless mode (default: true). */
headless?: boolean;
/** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */
proxy?: string;
/**
* Proxy server — URL string or Playwright proxy object.
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
* — passed directly to Playwright.
*/
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
/** Additional Chromium CLI arguments. */
args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
@@ -19,6 +24,12 @@ export interface LaunchOptions {
geoip?: boolean;
/** Raw options passed directly to playwright/puppeteer launch(). */
launchOptions?: Record<string, unknown>;
/** Enable human-like mouse, keyboard, and scroll behavior. */
humanize?: boolean;
/** Human behavior preset: 'default' or 'careful'. */
humanPreset?: 'default' | 'careful';
/** Override individual human behavior parameters. */
humanConfig?: Record<string, unknown>;
}
export interface LaunchContextOptions extends LaunchOptions {
@@ -28,12 +39,17 @@ export interface LaunchContextOptions extends LaunchOptions {
viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */
locale?: string;
/** Timezone, e.g. "America/New_York". */
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference";
}
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
/** Path to user data directory for persistent profile. */
userDataDir: string;
}
export interface BinaryInfo {
version: string;
platform: string;
+105 -2
View File
@@ -1,13 +1,15 @@
import { describe, it, expect } from "vitest";
import {
CHROMIUM_VERSION,
getArchiveExt,
getChromiumVersion,
getDefaultStealthArgs,
getCacheDir,
getBinaryDir,
getDownloadUrl,
getFallbackDownloadUrl,
} from "../src/config.js";
import { _buildArgsForTest } from "../src/playwright.js";
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => {
@@ -27,7 +29,6 @@ describe("config", () => {
expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false);
} else {
expect(args).toContain("--fingerprint-platform=windows");
expect(args).toContain("--fingerprint-hardware-concurrency=8");
}
// Should have a random fingerprint seed
@@ -68,6 +69,28 @@ describe("config", () => {
});
});
describe("archive helpers", () => {
it("getArchiveExt returns correct extension for platform", () => {
const ext = getArchiveExt();
if (process.platform === "win32") {
expect(ext).toBe(".zip");
} else {
expect(ext).toBe(".tar.gz");
}
});
it("getFallbackDownloadUrl uses GitHub Releases", () => {
const url = getFallbackDownloadUrl("145.0.0.0");
expect(url).toContain("github.com/CloakHQ/cloakbrowser/releases/download");
expect(url).toContain("chromium-v145.0.0.0");
});
it("getFallbackDownloadUrl uses default version", () => {
const url = getFallbackDownloadUrl();
expect(url).toContain(`chromium-v${getChromiumVersion()}`);
});
});
describe("buildArgs timezone/locale", () => {
it("injects --fingerprint-timezone when timezone is set", () => {
const args = _buildArgsForTest({ timezone: "America/New_York" });
@@ -98,3 +121,83 @@ describe("buildArgs timezone/locale", () => {
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
});
});
describe("buildArgs deduplication", () => {
it("user --fingerprint overrides default seed", () => {
const args = _buildArgsForTest({ args: ["--fingerprint=99887"] });
const fpArgs = args.filter(a => a.startsWith("--fingerprint="));
expect(fpArgs).toHaveLength(1);
expect(fpArgs[0]).toBe("--fingerprint=99887");
});
it("user --fingerprint-platform overrides default", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-platform=linux"] });
const platArgs = args.filter(a => a.startsWith("--fingerprint-platform="));
expect(platArgs).toHaveLength(1);
expect(platArgs[0]).toBe("--fingerprint-platform=linux");
});
it("timezone param overrides user --fingerprint-timezone arg", () => {
const args = _buildArgsForTest({
args: ["--fingerprint-timezone=Europe/London"],
timezone: "America/New_York",
});
const tzArgs = args.filter(a => a.startsWith("--fingerprint-timezone="));
expect(tzArgs).toHaveLength(1);
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
});
it("locale param overrides user --lang arg", () => {
const args = _buildArgsForTest({
args: ["--lang=de-DE"],
locale: "en-US",
});
const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US");
});
it("no duplicate flag keys in output", () => {
const args = _buildArgsForTest({
args: ["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone: "Europe/Berlin",
locale: "de-DE",
});
const keys = args.map(a => a.split("=")[0]);
expect(new Set(keys).size).toBe(keys.length);
});
it("non-value flags preserved without dedup issues", () => {
const args = _buildArgsForTest({ args: ["--disable-gpu", "--no-zygote"] });
expect(args).toContain("--disable-gpu");
expect(args).toContain("--no-zygote");
expect(args).toContain("--no-sandbox");
});
});
describe("migrateTimezoneId deprecation", () => {
it("migrates timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("Europe/Paris");
expect(result).not.toHaveProperty("timezoneId");
});
it("preserves explicit timezone over timezoneId", () => {
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("UTC");
expect(result).not.toHaveProperty("timezoneId");
});
it("returns options unchanged when no timezoneId", () => {
const opts = { timezone: "UTC" };
const result = migrateTimezoneId(opts);
expect(result).toBe(opts); // same reference, no copy
expect(result.timezone).toBe("UTC");
});
it("returns options unchanged when neither is set", () => {
const opts = {};
const result = migrateTimezoneId(opts);
expect(result).toBe(opts);
});
});
+552
View File
@@ -0,0 +1,552 @@
import { describe, it, expect, vi } from "vitest";
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
// =========================================================================
// Config resolution
// =========================================================================
describe("resolveConfig", () => {
it("returns valid default config", () => {
const cfg = resolveConfig("default");
expect(cfg).toBeDefined();
expect(cfg.mouse_min_steps).toBeGreaterThan(0);
expect(cfg.mouse_max_steps).toBeGreaterThan(cfg.mouse_min_steps);
expect(cfg.typing_delay).toBeGreaterThan(0);
expect(cfg.initial_cursor_x).toHaveLength(2);
expect(cfg.initial_cursor_y).toHaveLength(2);
});
it("returns valid careful config with slower typing", () => {
const cfg = resolveConfig("careful");
const def = resolveConfig("default");
expect(cfg).toBeDefined();
expect(cfg.typing_delay).toBeGreaterThanOrEqual(def.typing_delay);
});
it("applies custom overrides", () => {
const cfg = resolveConfig("default", { mouse_min_steps: 100, mouse_max_steps: 200 });
expect(cfg.mouse_min_steps).toBe(100);
expect(cfg.mouse_max_steps).toBe(200);
});
it("preserves idle_between_actions override", () => {
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [50, 100],
});
expect(cfg.idle_between_actions).toBe(true);
expect(cfg.idle_between_duration[0]).toBe(50);
expect(cfg.idle_between_duration[1]).toBe(100);
});
it("throws on unknown preset name", () => {
expect(() => resolveConfig("nonexistent" as any)).toThrow(/Unknown humanize preset/);
});
it("returns all required fields including mistype", () => {
const cfg = resolveConfig("default");
const required = [
"mouse_min_steps", "mouse_max_steps", "typing_delay",
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
"idle_between_duration", "field_switch_delay",
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct",
];
for (const f of required) {
expect(cfg).toHaveProperty(f);
}
});
it("mistype_delay fields are [min, max] tuples", () => {
const cfg = resolveConfig("default");
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
expect(cfg.mistype_delay_notice).toHaveLength(2);
expect(cfg.mistype_delay_notice[0]).toBeLessThanOrEqual(cfg.mistype_delay_notice[1]);
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
expect(cfg.mistype_delay_correct).toHaveLength(2);
expect(cfg.mistype_delay_correct[0]).toBeLessThanOrEqual(cfg.mistype_delay_correct[1]);
});
});
// =========================================================================
// rand / randRange / sleep
// =========================================================================
describe("rand helpers", () => {
it("rand stays within bounds over many iterations", () => {
for (let i = 0; i < 500; i++) {
const v = rand(10, 20);
expect(v).toBeGreaterThanOrEqual(10);
expect(v).toBeLessThanOrEqual(20);
}
});
it("randRange stays within bounds", () => {
for (let i = 0; i < 500; i++) {
const v = randRange([5, 15]);
expect(v).toBeGreaterThanOrEqual(5);
expect(v).toBeLessThanOrEqual(15);
}
});
it("sleep pauses for approximately correct duration", async () => {
const t0 = Date.now();
await sleep(50);
const elapsed = Date.now() - t0;
expect(elapsed).toBeGreaterThanOrEqual(40);
expect(elapsed).toBeLessThan(200);
});
});
// =========================================================================
// Bézier mouse movement (behavioral with vi.fn mocks)
// =========================================================================
describe("humanMove", () => {
function makeFakeRaw() {
const moves: Array<{ x: number; y: number }> = [];
return {
raw: {
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
},
moves,
};
}
it("generates multiple intermediate points", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 500, 300, cfg);
expect(moves.length).toBeGreaterThanOrEqual(10);
const last = moves[moves.length - 1];
expect(Math.abs(last.x - 500)).toBeLessThan(10);
expect(Math.abs(last.y - 300)).toBeLessThan(10);
});
it("raw.move called exactly once per step", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 400, 400, cfg);
expect(raw.move).toHaveBeenCalledTimes(moves.length);
});
it("no single jump exceeds 50% of total distance", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 400, 400, cfg);
const totalDist = Math.sqrt(400 ** 2 + 400 ** 2);
const maxJump = totalDist * 0.5;
for (let i = 1; i < moves.length; i++) {
const dx = moves[i].x - moves[i - 1].x;
const dy = moves[i].y - moves[i - 1].y;
expect(Math.sqrt(dx * dx + dy * dy)).toBeLessThan(maxJump);
}
});
it("produces curved path (not a straight line)", async () => {
const cfg = resolveConfig("default");
let maxDev = 0;
for (let trial = 0; trial < 10; trial++) {
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 0, 0, 500, 0, cfg);
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
if (dev > maxDev) maxDev = dev;
}
expect(maxDev).toBeGreaterThan(0.5);
});
it("handles very short distances", async () => {
const cfg = resolveConfig("default");
const { raw, moves } = makeFakeRaw();
await humanMove(raw, 100, 100, 103, 102, cfg);
expect(moves.length).toBeGreaterThanOrEqual(1);
});
it("handles zero distance without crashing", async () => {
const cfg = resolveConfig("default");
const { raw } = makeFakeRaw();
await humanMove(raw, 200, 200, 200, 200, cfg);
// Completes without error; may or may not call move (both valid)
expect(true).toBe(true);
});
});
// =========================================================================
// humanClick behavioral
// =========================================================================
describe("humanClick", () => {
it("calls down then up in correct order", async () => {
const cfg = resolveConfig("default");
const callOrder: string[] = [];
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => { callOrder.push("down"); }),
up: vi.fn(async () => { callOrder.push("up"); }),
wheel: vi.fn(async () => {}),
};
await humanClick(raw, false, cfg);
expect(raw.down).toHaveBeenCalledTimes(1);
expect(raw.up).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(["down", "up"]);
});
});
// =========================================================================
// humanIdle behavioral
// =========================================================================
describe("humanIdle", () => {
it("calls raw.move at least once during idle", async () => {
const cfg = resolveConfig("default");
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
await humanIdle(raw, 10, 100, 100, cfg);
expect(raw.move).toHaveBeenCalled();
}, 15000);
});
// =========================================================================
// clickTarget
// =========================================================================
describe("clickTarget", () => {
it("returns point within bounding box", () => {
const cfg = resolveConfig("default");
const box = { x: 100, y: 200, width: 150, height: 40 };
for (let i = 0; i < 100; i++) {
const t = clickTarget(box, false, cfg);
expect(t.x).toBeGreaterThanOrEqual(100);
expect(t.x).toBeLessThanOrEqual(250);
expect(t.y).toBeGreaterThanOrEqual(200);
expect(t.y).toBeLessThanOrEqual(240);
}
});
it("isInput=true biases click toward left side of box", () => {
const cfg = resolveConfig("default");
const box = { x: 50, y: 50, width: 200, height: 30 };
let sumX = 0;
const N = 300;
for (let i = 0; i < N; i++) {
const t = clickTarget(box, true, cfg);
expect(t.x).toBeGreaterThanOrEqual(50);
expect(t.x).toBeLessThanOrEqual(250);
sumX += t.x;
}
const avgX = sumX / N;
expect(avgX).toBeLessThan(175);
});
it("does not crash with 1x1 box", () => {
const cfg = resolveConfig("default");
const t = clickTarget({ x: 0, y: 0, width: 1, height: 1 }, false, cfg);
expect(t.x).toBeGreaterThanOrEqual(0);
expect(t.x).toBeLessThanOrEqual(1);
});
});
// =========================================================================
// patchPage behavioral: fill uses platform SELECT_ALL
// =========================================================================
describe("patchPage fill", () => {
it("fill calls keyboard.press with platform-correct select-all", async () => {
const { patchPage } = await import("../src/human/index.js");
const pressedKeys: string[] = [];
const page = buildMockPage({
keyboardPress: async (key: string) => { pressedKeys.push(key); },
evaluate: async () => false,
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).fill("input#name", "hello"); } catch (_) {}
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
if (pressedKeys.length > 0) {
expect(pressedKeys).toContain(expected);
expect(pressedKeys).not.toContain(wrong);
}
}, 30000);
});
// =========================================================================
// patchPage behavioral: check/uncheck with idle_between_actions
// =========================================================================
describe("patchPage check/uncheck idle", () => {
it("check with idle=true calls humanClickFn and does not crash on idle", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCalled = false;
const page = buildMockPage({
isChecked: async () => false,
evaluate: async () => false,
});
page.mouse.down = vi.fn(async () => { downCalled = true; });
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [1, 2],
});
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).check("input#cb"); } catch (_) {}
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
expect(downCalled).toBe(true);
}, 30000);
it("uncheck with idle=true calls humanClickFn and does not crash on idle", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCalled = false;
const page = buildMockPage({
isChecked: async () => true,
evaluate: async () => false,
});
page.mouse.down = vi.fn(async () => { downCalled = true; });
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [1, 2],
});
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).uncheck("input#cb"); } catch (_) {}
expect(downCalled).toBe(true);
}, 30000);
it("config with idle=true is accepted by resolveConfig", () => {
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [5, 10],
});
expect(cfg.idle_between_actions).toBe(true);
expect(cfg.idle_between_duration).toEqual([5, 10]);
});
});
// =========================================================================
// patchPage behavioral: press focus check
// =========================================================================
describe("patchPage press focus", () => {
it("press clicks element when NOT focused (mouse.down called)", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCount = 0;
const page = buildMockPage({
evaluate: async () => false,
});
// Intercept mouse.down before patching so raw captures it
page.mouse.down = vi.fn(async () => { downCount++; });
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
expect(downCount).toBeGreaterThan(0);
});
it("press skips click when element IS focused (no mouse.down)", async () => {
const { patchPage } = await import("../src/human/index.js");
let downCount = 0;
const page = buildMockPage({
evaluate: async () => true,
});
page.mouse.down = vi.fn(async () => { downCount++; });
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
expect(downCount).toBe(0);
});
});
// =========================================================================
// patchPage behavioral: frame patching
// =========================================================================
describe("patchPage frame patching", () => {
it("patches child frames with _humanPatched flag", async () => {
const { patchPage } = await import("../src/human/index.js");
const childFrame = buildMockFrame();
const mainFrame = {
...buildMockFrame(),
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect((childFrame as any)._humanPatched).toBe(true);
});
});
// =========================================================================
// Mistype config
// =========================================================================
describe("mistype config", () => {
it("default config has valid mistype fields", () => {
const cfg = resolveConfig("default");
expect(typeof cfg.mistype_chance).toBe("number");
expect(cfg.mistype_chance).toBeGreaterThanOrEqual(0);
expect(cfg.mistype_chance).toBeLessThanOrEqual(1);
// mistype_delay_notice and mistype_delay_correct are [min, max] tuples
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
expect(cfg.mistype_delay_notice).toHaveLength(2);
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
expect(cfg.mistype_delay_correct).toHaveLength(2);
});
it("mistype_chance can be overridden to 0 (disabled)", () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
expect(cfg.mistype_chance).toBe(0);
});
it("mistype_chance can be overridden to higher value", () => {
const cfg = resolveConfig("default", { mistype_chance: 0.15 });
expect(cfg.mistype_chance).toBe(0.15);
});
});
// =========================================================================
// Module exports
// =========================================================================
describe("module exports", () => {
it("patchBrowser, patchContext, patchPage are all exported functions", async () => {
const mod = await import("../src/human/index.js");
expect(typeof mod.patchBrowser).toBe("function");
expect(typeof mod.patchContext).toBe("function");
expect(typeof mod.patchPage).toBe("function");
});
it("humanMove, humanClick, clickTarget, humanIdle are exported", async () => {
const mod = await import("../src/human/index.js");
expect(typeof mod.humanMove).toBe("function");
expect(typeof mod.humanClick).toBe("function");
expect(typeof mod.clickTarget).toBe("function");
expect(typeof mod.humanIdle).toBe("function");
});
it("resolveConfig is re-exported from index", async () => {
const mod = await import("../src/human/index.js");
expect(typeof mod.resolveConfig).toBe("function");
});
});
// =========================================================================
// Test helpers
// =========================================================================
function buildMockPage(overrides: Record<string, any> = {}): any {
const mainFrameObj = overrides.mainFrameReturn ?? {
childFrames: vi.fn(() => []),
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
first: vi.fn(function(this: any) { return this; }),
})),
};
const makeLocator = () => {
const loc: any = {
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
scrollIntoViewIfNeeded: vi.fn(async () => {}),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
};
loc.first = vi.fn(() => loc);
return loc;
};
const page: any = {
evaluate: overrides.evaluate ?? vi.fn(async () => false),
addInitScript: vi.fn(async () => {}),
mouse: {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
},
keyboard: {
press: overrides.keyboardPress
? vi.fn(overrides.keyboardPress)
: vi.fn(async () => {}),
type: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
},
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
goto: vi.fn(async () => ({})),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
locator: vi.fn(() => makeLocator()),
viewportSize: vi.fn(() => ({ width: 1280, height: 720 })),
mainFrame: vi.fn(() => mainFrameObj),
frames: vi.fn(() => []),
context: vi.fn(() => ({
pages: vi.fn(() => []),
addInitScript: vi.fn(async () => {}),
})),
url: vi.fn(() => "about:blank"),
waitForTimeout: vi.fn(async () => {}),
};
return page;
}
function buildMockFrame(): any {
return {
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
})),
childFrames: vi.fn(() => []),
};
}
+172 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { binaryInfo } from "../src/download.js";
import { getChromiumVersion } from "../src/config.js";
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
@@ -37,3 +37,173 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
}, 30_000);
}
);
// ---------------------------------------------------------------------------
// launchContext / launchPersistentContext unit tests (mock playwright-core)
// ---------------------------------------------------------------------------
describe("launchContext (unit)", () => {
let mockContext: any;
let mockBrowser: any;
let mockChromium: any;
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
const origClose = vi.fn();
mockContext = { close: origClose, _origClose: origClose };
mockBrowser = {
newContext: vi.fn().mockResolvedValue(mockContext),
close: vi.fn(),
};
mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (origEnv) {
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
} else {
delete process.env.CLOAKBROWSER_BINARY_PATH;
}
});
it("applies DEFAULT_VIEWPORT when no viewport given", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext();
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT);
});
it("uses custom viewport when provided", async () => {
const { launchContext } = await import("../src/playwright.js");
const custom = { width: 1280, height: 720 };
await launchContext({ viewport: custom });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.viewport).toEqual(custom);
});
it("forwards userAgent to newContext", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ userAgent: "Custom/1.0" });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.userAgent).toBe("Custom/1.0");
});
it("passes timezone to context timezoneId, not to launch", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ timezone: "America/New_York" });
// launch() called with timezone: undefined (skipped for binary flag)
const launchArgs = mockChromium.launch.mock.calls[0][0];
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
a.startsWith("--fingerprint-timezone=")
);
expect(hasTimezoneFlag).toBe(false);
// newContext() gets timezoneId
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBe("America/New_York");
});
it("forwards colorScheme to newContext", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ colorScheme: "dark" });
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.colorScheme).toBe("dark");
});
it("close() also closes browser", async () => {
const { launchContext } = await import("../src/playwright.js");
const ctx = await launchContext();
await ctx.close();
// Original context close called
expect(mockContext._origClose).toHaveBeenCalledOnce();
// Browser also closed
expect(mockBrowser.close).toHaveBeenCalledOnce();
});
});
describe("launchPersistentContext (unit)", () => {
let mockContext: any;
let mockChromium: any;
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) };
mockChromium = {
launchPersistentContext: vi.fn().mockResolvedValue(mockContext),
};
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (origEnv) {
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
} else {
delete process.env.CLOAKBROWSER_BINARY_PATH;
}
});
it("applies DEFAULT_VIEWPORT", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
});
it("passes timezone and locale to context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
timezone: "Asia/Tokyo",
locale: "ja-JP",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.timezoneId).toBe("Asia/Tokyo");
expect(args.locale).toBe("ja-JP");
// Also in binary args
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(args.args).toContain("--lang=ja-JP");
});
it("forwards proxy string", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
});
it("forwards userAgent and colorScheme", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
userAgent: "Custom/1.0",
colorScheme: "dark",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.userAgent).toBe("Custom/1.0");
expect(args.colorScheme).toBe("dark");
});
});
+35
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
it("passes through URL without credentials", () => {
@@ -47,3 +48,37 @@ describe("parseProxyUrl", () => {
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
});
});
describe("proxy dict type", () => {
it("accepts string proxy in LaunchOptions", () => {
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
expect(typeof opts.proxy).toBe("string");
});
it("accepts dict proxy with bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
};
expect(typeof opts.proxy).toBe("object");
if (typeof opts.proxy === "object") {
expect(opts.proxy.server).toBe("http://proxy:8080");
expect(opts.proxy.bypass).toBe(".google.com,localhost");
}
});
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: {
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".example.com",
},
};
if (typeof opts.proxy === "object") {
expect(opts.proxy.username).toBe("user");
expect(opts.proxy.password).toBe("pass");
expect(opts.proxy.bypass).toBe(".example.com");
}
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
// Mock puppeteer-core and download before importing the module under test
vi.mock("puppeteer-core", () => ({
default: {
launch: vi.fn(),
},
}));
vi.mock("../src/download.js", () => ({
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
}));
vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
}));
describe("puppeteer launch", () => {
let puppeteerMock: any;
let mockBrowser: any;
beforeEach(async () => {
puppeteerMock = await import("puppeteer-core");
mockBrowser = {
newPage: vi.fn().mockResolvedValue({
authenticate: vi.fn(),
}),
close: vi.fn(),
};
vi.mocked(puppeteerMock.default.launch).mockResolvedValue(mockBrowser);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls ensureBinary and launches with binary path", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch();
expect(puppeteerMock.default.launch).toHaveBeenCalledWith(
expect.objectContaining({
executablePath: "/fake/chrome",
})
);
});
it("includes stealth args by default", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch();
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
expect(callArgs.args).toContain("--no-sandbox");
});
it("excludes stealth args when stealthArgs=false", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ stealthArgs: false });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
});
it("adds --proxy-server for string proxy", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ proxy: "http://proxy:8080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
});
it("adds --proxy-bypass-list for dict proxy with bypass", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
});
it("monkey-patches newPage for proxy auth", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
// newPage should auto-authenticate
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
});
it("injects timezone and locale as binary flags", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ timezone: "Asia/Tokyo", locale: "ja-JP" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(callArgs.args).toContain("--lang=ja-JP");
});
it("merges extra args", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ args: ["--disable-gpu", "--no-first-run"] });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--disable-gpu");
expect(callArgs.args).toContain("--no-first-run");
});
});
+54
View File
@@ -9,7 +9,11 @@ import {
versionNewer,
} from "../src/config.js";
import {
binaryInfo,
checkForUpdate,
checkWrapperUpdate,
clearCache,
ensureBinary,
getLatestChromiumVersion,
parseChecksums,
resetWrapperUpdateChecked,
@@ -271,3 +275,53 @@ describe("effective version", () => {
expect(getEffectiveVersion()).toBe(getChromiumVersion());
});
});
describe("ensureBinary", () => {
afterEach(() => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
});
it("returns local override when set", async () => {
// Use this test file as a "binary" that exists
process.env.CLOAKBROWSER_BINARY_PATH = __filename;
const result = await ensureBinary();
expect(result).toBe(__filename);
});
it("throws when local override path missing", async () => {
process.env.CLOAKBROWSER_BINARY_PATH = "/nonexistent/chrome";
await expect(ensureBinary()).rejects.toThrow("does not exist");
});
});
describe("clearCache", () => {
it("does not throw when cache dir missing", () => {
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
process.env.CLOAKBROWSER_CACHE_DIR = "/tmp/cloakbrowser-test-nonexistent";
expect(() => clearCache()).not.toThrow();
if (orig) {
process.env.CLOAKBROWSER_CACHE_DIR = orig;
} else {
delete process.env.CLOAKBROWSER_CACHE_DIR;
}
});
});
describe("checkForUpdate", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns null when no newer version", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => [],
} as Response);
expect(await checkForUpdate()).toBeNull();
});
it("returns null on network error", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
expect(await checkForUpdate()).toBeNull();
});
});
+3 -1
View File
@@ -49,12 +49,14 @@ classifiers = [
"Topic :: Software Development :: Testing",
]
dependencies = [
"patchright>=1.40",
"playwright>=1.40",
"httpx>=0.24",
]
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser"
+11
View File
@@ -0,0 +1,11 @@
"""Shared test fixtures."""
import os
import pytest
@pytest.fixture(autouse=True)
def _clean_backend_env(monkeypatch):
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
+45
View File
@@ -0,0 +1,45 @@
"""Unit tests for backend resolution (_resolve_backend)."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _resolve_backend
def test_resolve_backend_default():
"""No param, no env var → 'playwright'."""
with patch.dict(os.environ, {}, clear=True):
assert _resolve_backend(None) == "playwright"
def test_resolve_backend_explicit_playwright():
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_explicit_patchright():
assert _resolve_backend("patchright") == "patchright"
def test_resolve_backend_env_var():
"""CLOAKBROWSER_BACKEND env var used when no param."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend(None) == "patchright"
def test_resolve_backend_param_beats_env():
"""Explicit param overrides env var."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_invalid_raises():
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend("bogus")
def test_resolve_backend_invalid_env_raises():
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend(None)
+122 -2
View File
@@ -1,6 +1,8 @@
"""Unit tests for _build_args timezone/locale injection."""
"""Unit tests for _build_args timezone/locale injection and deprecation compat."""
from cloakbrowser.browser import _build_args
import warnings
from cloakbrowser.browser import _build_args, _migrate_timezone_id
def test_timezone_injected():
@@ -44,3 +46,121 @@ def test_extra_args_preserved():
assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args
# --- _migrate_timezone_id deprecation compat ---
def test_migrate_old_param_only():
"""timezone_id in kwargs should be promoted to timezone."""
kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result == "Europe/Paris"
assert "timezone_id" not in kwargs
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
def test_migrate_new_param_wins():
"""Explicit timezone takes precedence; timezone_id is still popped."""
kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC"
assert "timezone_id" not in kwargs
assert len(w) == 1
def test_migrate_no_old_param():
"""No warning when timezone_id is absent."""
kwargs = {"other": "value"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC"
assert "other" in kwargs
assert len(w) == 0
def test_migrate_both_none():
"""Neither param set — returns None, no warning."""
kwargs = {}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result is None
assert len(w) == 0
# --- Deduplication tests ---
def test_user_fingerprint_overrides_default():
"""User --fingerprint should override the random default seed."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
assert len(fingerprint_args) == 1
assert fingerprint_args[0] == "--fingerprint=99887"
def test_user_platform_overrides_default():
"""User --fingerprint-platform should override the default."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
assert len(platform_args) == 1
assert platform_args[0] == "--fingerprint-platform=linux"
def test_timezone_param_overrides_user_arg():
"""Dedicated timezone param should override user arg."""
args = _build_args(
stealth_args=True,
extra_args=["--fingerprint-timezone=Europe/London"],
timezone="America/New_York",
)
tz_args = [a for a in args if a.startswith("--fingerprint-timezone=")]
assert len(tz_args) == 1
assert tz_args[0] == "--fingerprint-timezone=America/New_York"
def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg."""
args = _build_args(
stealth_args=True,
extra_args=["--lang=de-DE"],
locale="en-US",
)
lang_args = [a for a in args if a.startswith("--lang=")]
assert len(lang_args) == 1
assert lang_args[0] == "--lang=en-US"
def test_no_duplicate_flags():
"""No flag key should appear more than once in the output."""
args = _build_args(
stealth_args=True,
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone="Europe/Berlin",
locale="de-DE",
)
keys = [a.split("=", 1)[0] for a in args]
assert len(keys) == len(set(keys)), f"Duplicate keys found: {keys}"
def test_non_value_flags_preserved():
"""Flags without = should be preserved without dedup issues."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
assert "--disable-gpu" in args
assert "--no-zygote" in args
assert "--no-sandbox" in args
def test_override_logs_debug(caplog):
"""Should log debug message when an override happens."""
import logging
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
+142
View File
@@ -0,0 +1,142 @@
"""Unit tests for config.py — platform detection, paths, stealth args."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.config import (
get_archive_ext,
get_archive_name,
get_binary_path,
get_cache_dir,
get_chromium_version,
get_default_stealth_args,
get_fallback_download_url,
get_platform_tag,
)
# ---------------------------------------------------------------------------
# Platform-specific binary paths
# ---------------------------------------------------------------------------
class TestGetBinaryPath:
def test_linux(self):
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
path = get_binary_path("145.0.0.0")
assert str(path).endswith("chromium-145.0.0.0/chrome")
def test_darwin(self):
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
path = get_binary_path("145.0.0.0")
assert str(path).endswith("chromium-145.0.0.0/Chromium.app/Contents/MacOS/Chromium")
def test_windows(self):
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
path = get_binary_path("145.0.0.0")
assert str(path).endswith("chromium-145.0.0.0/chrome.exe")
# ---------------------------------------------------------------------------
# Archive extension and name
# ---------------------------------------------------------------------------
class TestArchive:
def test_ext_windows(self):
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
assert get_archive_ext() == ".zip"
def test_ext_unix(self):
for system in ("Linux", "Darwin"):
with patch("cloakbrowser.config.platform.system", return_value=system):
assert get_archive_ext() == ".tar.gz"
def test_archive_name(self):
tag = get_platform_tag()
ext = get_archive_ext()
assert get_archive_name() == f"cloakbrowser-{tag}{ext}"
def test_archive_name_custom_tag(self):
name = get_archive_name("linux-x64")
assert "cloakbrowser-linux-x64" in name
# ---------------------------------------------------------------------------
# Download URLs
# ---------------------------------------------------------------------------
class TestFallbackUrl:
def test_github_releases_format(self):
url = get_fallback_download_url("145.0.0.0")
assert "github.com/CloakHQ/cloakbrowser/releases/download" in url
assert "chromium-v145.0.0.0" in url
def test_default_version(self):
url = get_fallback_download_url()
version = get_chromium_version()
assert f"chromium-v{version}" in url
# ---------------------------------------------------------------------------
# Cache directory
# ---------------------------------------------------------------------------
class TestCacheDir:
def test_default_path(self):
with patch.dict(os.environ, {}, clear=False):
# Remove override if set
env = os.environ.copy()
env.pop("CLOAKBROWSER_CACHE_DIR", None)
with patch.dict(os.environ, env, clear=True):
path = get_cache_dir()
assert str(path).endswith(".cloakbrowser")
def test_env_override(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
assert get_cache_dir() == tmp_path
# ---------------------------------------------------------------------------
# Platform tag
# ---------------------------------------------------------------------------
class TestPlatformTag:
def test_unsupported_raises(self):
with patch("cloakbrowser.config.platform.system", return_value="FreeBSD"):
with patch("cloakbrowser.config.platform.machine", return_value="x86_64"):
with pytest.raises(RuntimeError, match="Unsupported platform"):
get_platform_tag()
# ---------------------------------------------------------------------------
# Stealth args
# ---------------------------------------------------------------------------
class TestStealthArgs:
def test_seed_uniqueness(self):
"""Two calls should produce different fingerprint seeds."""
args1 = get_default_stealth_args()
args2 = get_default_stealth_args()
seed1 = [a for a in args1 if a.startswith("--fingerprint=")][0]
seed2 = [a for a in args2 if a.startswith("--fingerprint=")][0]
# Seeds are random 10000-99999 — extremely unlikely to collide
assert seed1 != seed2
def test_macos_profile(self):
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
args = get_default_stealth_args()
assert "--fingerprint-platform=macos" in args
assert any("Apple" in a for a in args)
def test_linux_windows_profile(self):
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
args = get_default_stealth_args()
assert "--fingerprint-platform=windows" in args
assert any("NVIDIA" in a for a in args)
+192
View File
@@ -0,0 +1,192 @@
"""Unit tests for archive extraction — path traversal protection, flattening, permissions."""
import io
import os
import platform
import stat
import tarfile
import zipfile
import pytest
from cloakbrowser.download import (
_extract_tar,
_extract_zip,
_flatten_single_subdir,
_is_executable,
_make_executable,
)
# ---------------------------------------------------------------------------
# tar.gz extraction
# ---------------------------------------------------------------------------
def _create_tar_gz(tmp_path, members: dict[str, bytes]) -> "Path":
"""Create a tar.gz with given {name: content} members."""
archive = tmp_path / "test.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
for name, content in members.items():
info = tarfile.TarInfo(name=name)
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
return archive
class TestExtractTar:
def test_basic(self, tmp_path):
archive = _create_tar_gz(tmp_path, {"chrome": b"binary", "lib/libfoo.so": b"lib"})
dest = tmp_path / "out"
dest.mkdir()
_extract_tar(archive, dest)
assert (dest / "chrome").read_bytes() == b"binary"
assert (dest / "lib" / "libfoo.so").read_bytes() == b"lib"
def test_path_traversal_blocked(self, tmp_path):
archive = tmp_path / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
info = tarfile.TarInfo(name="../../../etc/passwd")
info.size = 4
tar.addfile(info, io.BytesIO(b"evil"))
dest = tmp_path / "out"
dest.mkdir()
with pytest.raises(RuntimeError, match="path traversal"):
_extract_tar(archive, dest)
def test_suspicious_symlink_skipped(self, tmp_path):
"""Symlinks with absolute targets are skipped (logged as warning)."""
archive = tmp_path / "symlink.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
# Normal file
info = tarfile.TarInfo(name="chrome")
info.size = 6
tar.addfile(info, io.BytesIO(b"binary"))
# Suspicious symlink
sym = tarfile.TarInfo(name="evil_link")
sym.type = tarfile.SYMTYPE
sym.linkname = "/etc/passwd"
tar.addfile(sym)
dest = tmp_path / "out"
dest.mkdir()
_extract_tar(archive, dest)
# Normal file extracted
assert (dest / "chrome").exists()
# Suspicious symlink was skipped
assert not (dest / "evil_link").exists()
# ---------------------------------------------------------------------------
# zip extraction
# ---------------------------------------------------------------------------
def _create_zip(tmp_path, members: dict[str, bytes]) -> "Path":
"""Create a zip with given {name: content} members."""
archive = tmp_path / "test.zip"
with zipfile.ZipFile(archive, "w") as zf:
for name, content in members.items():
zf.writestr(name, content)
return archive
class TestExtractZip:
def test_basic(self, tmp_path):
archive = _create_zip(tmp_path, {"chrome.exe": b"binary", "lib/foo.dll": b"lib"})
dest = tmp_path / "out"
dest.mkdir()
_extract_zip(archive, dest)
assert (dest / "chrome.exe").read_bytes() == b"binary"
assert (dest / "lib" / "foo.dll").read_bytes() == b"lib"
def test_path_traversal_blocked(self, tmp_path):
archive = tmp_path / "evil.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("../../../etc/passwd", "evil")
dest = tmp_path / "out"
dest.mkdir()
with pytest.raises(RuntimeError, match="path traversal"):
_extract_zip(archive, dest)
# ---------------------------------------------------------------------------
# Directory flattening
# ---------------------------------------------------------------------------
class TestFlatten:
def test_single_subdir_flattened(self, tmp_path):
"""Single subdir contents moved up."""
dest = tmp_path / "out"
dest.mkdir()
subdir = dest / "fingerprint-chromium-custom-v14"
subdir.mkdir()
(subdir / "chrome").write_bytes(b"binary")
(subdir / "lib").mkdir()
_flatten_single_subdir(dest)
assert (dest / "chrome").read_bytes() == b"binary"
assert (dest / "lib").is_dir()
assert not subdir.exists()
def test_app_bundle_preserved(self, tmp_path):
""".app directory NOT flattened (macOS bundle)."""
dest = tmp_path / "out"
dest.mkdir()
app = dest / "Chromium.app"
app.mkdir()
(app / "Contents").mkdir()
(app / "Contents" / "MacOS").mkdir()
(app / "Contents" / "MacOS" / "Chromium").write_bytes(b"binary")
_flatten_single_subdir(dest)
# .app bundle kept intact
assert app.is_dir()
assert (app / "Contents" / "MacOS" / "Chromium").exists()
def test_noop_multiple_entries(self, tmp_path):
"""Multiple entries at top level — no flattening."""
dest = tmp_path / "out"
dest.mkdir()
(dest / "chrome").write_bytes(b"binary")
(dest / "lib").mkdir()
_flatten_single_subdir(dest)
# Nothing moved
assert (dest / "chrome").exists()
assert (dest / "lib").is_dir()
# ---------------------------------------------------------------------------
# Permissions
# ---------------------------------------------------------------------------
class TestPermissions:
@pytest.mark.skipif(platform.system() == "Windows", reason="chmod not applicable on Windows")
def test_make_executable(self, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
binary.chmod(0o644)
assert not _is_executable(binary)
_make_executable(binary)
assert _is_executable(binary)
def test_is_executable_true(self, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
binary.chmod(0o755)
assert _is_executable(binary)
def test_is_executable_false(self, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
binary.chmod(0o644)
assert not _is_executable(binary)
+21
View File
@@ -7,6 +7,7 @@ import pytest
from cloakbrowser.browser import _maybe_resolve_geoip
from cloakbrowser.geoip import (
COUNTRY_LOCALE_MAP,
_is_private_ip,
_resolve_proxy_ip,
)
@@ -136,3 +137,23 @@ def test_maybe_resolve_fills_both():
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin"
assert loc == "de-DE"
# ---------------------------------------------------------------------------
# _is_private_ip
# ---------------------------------------------------------------------------
def test_private_ip_loopback():
assert _is_private_ip("127.0.0.1") is True
def test_private_ip_rfc1918():
assert _is_private_ip("192.168.1.1") is True
assert _is_private_ip("10.0.0.1") is True
assert _is_private_ip("172.16.0.1") is True
def test_private_ip_public():
assert _is_private_ip("8.8.8.8") is False
assert _is_private_ip("64.176.168.43") is False
+256
View File
@@ -0,0 +1,256 @@
// test_human_visual.mjs
/**
* Visual + functional test for humanize (JS).
* Red dot = cursor, yellow = mouse held.
* Trail dots show the path taken.
*/
import { launch } from '../js/dist/index.js';
const CURSOR_JS = `
(() => {
if (document.getElementById('__hc')) return;
const el = document.createElement('div');
el.id = '__hc';
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
document.body.appendChild(el);
const trail = document.createElement('div');
trail.id = '__hcTrail';
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
document.body.appendChild(trail);
let dotCount = 0;
const maxDots = 500;
function updatePos(x, y) {
el.style.display = 'block';
el.style.left = (x - 9) + 'px';
el.style.top = (y - 9) + 'px';
if (dotCount < maxDots) {
const dot = document.createElement('div');
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
trail.appendChild(dot);
dotCount++;
}
}
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
document.addEventListener('dragend', () => { el.style.background = 'red'; });
})();
`;
const results = [];
const delay = ms => new Promise(r => setTimeout(r, ms));
async function inject(page) {
try { await page.evaluate(CURSOR_JS); } catch {}
await delay(300);
}
function step(name) {
console.log(`\n${'='.repeat(60)}`);
console.log(` STEP: ${name}`);
console.log('='.repeat(60));
}
function check(name, passed, detail = '') {
const status = passed ? 'PASS' : 'FAIL';
let msg = ` [${status}] ${name}`;
if (detail) msg += `${detail}`;
console.log(msg);
results.push({ name, status });
}
async function main() {
console.log('='.repeat(70));
console.log(' HUMAN-LIKE BEHAVIOR VISUAL TEST (JS)');
console.log(' Watch the red dot — it should move smoothly like a real cursor');
console.log('='.repeat(70));
const browser = await launch({
headless: false,
humanize: true,
});
const page = await browser.newPage();
// ============================================================
// SCENARIO 1: Wikipedia search
// ============================================================
step('Wikipedia — navigate and search');
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
console.log(' Watch: cursor moves to search box (Bezier curve)');
let t0 = Date.now();
await page.locator('#searchInput').click();
let ms = Date.now() - t0;
check('click on search input', ms > 200, `${ms} ms`);
await delay(500);
console.log(' Watch: characters appear one by one');
t0 = Date.now();
await page.locator('#searchInput').fill('Python programming language');
ms = Date.now() - t0;
let val = await page.locator('#searchInput').inputValue();
check('fill search box', val === 'Python programming language' && ms > 2000, `${ms} ms, value='${val}'`);
await delay(500);
console.log(' Watch: double click selects word');
t0 = Date.now();
await page.locator('#searchInput').dblclick();
ms = Date.now() - t0;
let sel = await page.evaluate(() => window.getSelection().toString().trim());
check('dblclick selects word', sel.length > 0 && ms > 200, `${ms} ms, selected='${sel}'`);
await delay(500);
console.log(' Watch: old text replaced');
t0 = Date.now();
await page.locator('#searchInput').fill('Artificial intelligence');
ms = Date.now() - t0;
val = await page.locator('#searchInput').inputValue();
check('fill replaces text', val === 'Artificial intelligence' && ms > 1500, `${ms} ms, value='${val}'`);
await delay(500);
console.log(' Watch: cursor hovers button without clicking');
t0 = Date.now();
await page.locator('button[type="submit"]').hover();
ms = Date.now() - t0;
check('hover search button', ms > 100, `${ms} ms`);
await delay(1000);
// ============================================================
// SCENARIO 2: Checkboxes
// ============================================================
step('Checkboxes — check and uncheck');
await page.goto('https://the-internet.herokuapp.com/checkboxes', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
const cb1 = page.locator('input[type="checkbox"]').nth(0);
const cb2 = page.locator('input[type="checkbox"]').nth(1);
if (await cb1.isChecked()) { await cb1.uncheck(); await delay(500); }
console.log(' Watch: cursor moves to checkbox, clicks');
t0 = Date.now();
await cb1.check();
ms = Date.now() - t0;
check('check checkbox 1', await cb1.isChecked() && ms > 200, `${ms} ms`);
await delay(500);
if (!(await cb2.isChecked())) { await cb2.check(); await delay(500); }
t0 = Date.now();
await cb2.uncheck();
ms = Date.now() - t0;
check('uncheck checkbox 2', !(await cb2.isChecked()) && ms > 200, `${ms} ms`);
await delay(1000);
// ============================================================
// SCENARIO 3: Dropdown
// ============================================================
step('Dropdown — select option');
await page.goto('https://the-internet.herokuapp.com/dropdown', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
console.log(' Watch: cursor hovers dropdown, option selected');
t0 = Date.now();
await page.locator('#dropdown').selectOption('2');
ms = Date.now() - t0;
val = await page.locator('#dropdown').inputValue();
check('select option', val === '2' && ms > 100, `${ms} ms, value='${val}'`);
await delay(1000);
// ============================================================
// SCENARIO 4: Drag and Drop
// ============================================================
step('Drag and Drop');
await page.goto('https://the-internet.herokuapp.com/drag_and_drop', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
const beforeA = (await page.locator('#column-a header').textContent()).trim();
console.log(` Before: A='${beforeA}'`);
console.log(' Watch: cursor to A, yellow (held), moves to B, releases');
t0 = Date.now();
await page.locator('#column-a').dragTo(page.locator('#column-b'));
ms = Date.now() - t0;
await delay(1000);
const afterA = (await page.locator('#column-a header').textContent()).trim();
const swapped = beforeA !== afterA;
check('drag A to B', swapped && ms > 300, `${ms} ms, swapped=${swapped}`);
await delay(1000);
// ============================================================
// SCENARIO 5: Text editing
// ============================================================
step('Text editing — type, press, clear');
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(2000);
await inject(page);
await delay(1000);
console.log(' Watch: types character by character');
t0 = Date.now();
await page.locator('#searchInput').type('Hello World');
ms = Date.now() - t0;
val = await page.locator('#searchInput').inputValue();
check("type 'Hello World'", val === 'Hello World' && ms > 1000, `${ms} ms`);
await delay(500);
console.log(' Watch: field cleared');
t0 = Date.now();
await page.locator('#searchInput').clear();
ms = Date.now() - t0;
val = await page.locator('#searchInput').inputValue();
check('clear field', val === '' && ms > 100, `${ms} ms`);
await delay(500);
console.log(' Watch: mouse moves in Bezier curve');
t0 = Date.now();
await page.mouse.move(600, 400);
ms = Date.now() - t0;
check('mouse.move', ms > 100, `${ms} ms`);
await delay(500);
t0 = Date.now();
await page.mouse.click(300, 300);
ms = Date.now() - t0;
check('mouse.click', ms > 100, `${ms} ms`);
await delay(1000);
// ============================================================
// SUMMARY
// ============================================================
console.log('\n' + '='.repeat(70));
console.log(' SUMMARY');
console.log('='.repeat(70));
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status === 'FAIL').length;
for (const r of results) {
const icon = r.status === 'PASS' ? 'OK' : 'XX';
console.log(` [${icon}] ${r.name}`);
}
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
if (failed === 0) console.log(' *** ALL TESTS PASSED ***');
console.log('='.repeat(70));
await browser.close();
}
main().catch(console.error);
+302
View File
@@ -0,0 +1,302 @@
"""
Visual + functional test for humanize.
Red dot = cursor, yellow = mouse held.
"""
import pytest
pytestmark = pytest.mark.slow
if __name__ == "__main__":
from cloakbrowser import launch
import time
CURSOR_JS = """
() => {
if (document.getElementById('__hc')) return;
const el = document.createElement('div');
el.id = '__hc';
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
document.body.appendChild(el);
const trail = document.createElement('div');
trail.id = '__hcTrail';
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
document.body.appendChild(trail);
let dotCount = 0;
const maxDots = 500;
function updatePos(x, y) {
el.style.display = 'block';
el.style.left = (x - 9) + 'px';
el.style.top = (y - 9) + 'px';
if (dotCount < maxDots) {
const dot = document.createElement('div');
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
trail.appendChild(dot);
dotCount++;
}
}
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
document.addEventListener('dragend', () => { el.style.background = 'red'; });
}
"""
def inject(page):
try:
page.evaluate(CURSOR_JS)
except:
pass
time.sleep(0.3)
results = []
def step(name):
print(f"\n{'='*60}")
print(f" STEP: {name}")
print(f"{'='*60}")
def check(name, passed, detail=""):
status = "PASS" if passed else "FAIL"
msg = f" [{status}] {name}"
if detail:
msg += f"{detail}"
print(msg)
results.append((name, status))
print("=" * 70)
print(" HUMAN-LIKE BEHAVIOR VISUAL TEST")
print(" Watch the red dot — it should move smoothly like a real cursor")
print(" Yellow = mouse button held")
print(" Red trail dots = path taken")
print("=" * 70)
browser = launch(headless=False, humanize=True)
page = browser.new_page()
# ============================================================
# SCENARIO 1: Wikipedia search
# ============================================================
step("Wikipedia — navigate and search")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: cursor moves to search box (Bezier curve)")
t0 = time.time()
page.locator('#searchInput').click()
click_ms = int((time.time() - t0) * 1000)
check("click on search input", click_ms > 200, f"{click_ms} ms")
time.sleep(0.5)
print(" Watch: characters appear one by one with varying speed")
t0 = time.time()
page.locator('#searchInput').fill('Python programming language')
fill_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("fill search box", val == 'Python programming language' and fill_ms > 2000, f"{fill_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: cursor moves to search box, double yellow flash, word selected")
t0 = time.time()
page.locator('#searchInput').dblclick()
dbl_ms = int((time.time() - t0) * 1000)
sel = page.evaluate('() => window.getSelection().toString().trim()')
check("dblclick selects word", len(sel) > 0 and dbl_ms > 200, f"{dbl_ms} ms, selected='{sel}'")
time.sleep(0.5)
print(" Watch: old text cleared, new text typed")
t0 = time.time()
page.locator('#searchInput').fill('Artificial intelligence')
fill2_ms = int((time.time() - t0) * 1000)
val2 = page.locator('#searchInput').input_value()
check("fill replaces text", val2 == 'Artificial intelligence' and fill2_ms > 1500, f"{fill2_ms} ms, value='{val2}'")
time.sleep(0.5)
print(" Watch: cursor moves to button without clicking")
t0 = time.time()
page.locator('button[type="submit"]').hover()
hover_ms = int((time.time() - t0) * 1000)
check("hover search button", hover_ms > 100, f"{hover_ms} ms")
time.sleep(1)
# ============================================================
# SCENARIO 2: Form interaction — checkboxes
# ============================================================
step("Checkboxes — check and uncheck")
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
cb1 = page.locator('input[type="checkbox"]').nth(0)
cb2 = page.locator('input[type="checkbox"]').nth(1)
print(" Watch: cursor moves to first checkbox, clicks")
if cb1.is_checked():
cb1.uncheck()
time.sleep(0.5)
t0 = time.time()
cb1.check()
check_ms = int((time.time() - t0) * 1000)
check("check checkbox 1", cb1.is_checked() and check_ms > 200, f"{check_ms} ms, checked={cb1.is_checked()}")
time.sleep(0.5)
print(" Watch: cursor moves to second checkbox, clicks to uncheck")
if not cb2.is_checked():
cb2.check()
time.sleep(0.5)
t0 = time.time()
cb2.uncheck()
uncheck_ms = int((time.time() - t0) * 1000)
check("uncheck checkbox 2", not cb2.is_checked() and uncheck_ms > 200, f"{uncheck_ms} ms, checked={cb2.is_checked()}")
time.sleep(1)
# ============================================================
# SCENARIO 3: Dropdown
# ============================================================
step("Dropdown — select option")
page.goto('https://the-internet.herokuapp.com/dropdown', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: cursor moves to dropdown, hovers, option selected")
t0 = time.time()
page.locator('#dropdown').select_option('1')
sel_ms = int((time.time() - t0) * 1000)
val = page.locator('#dropdown').input_value()
check("select option 1", val == '1' and sel_ms > 100, f"{sel_ms} ms, value='{val}'")
time.sleep(0.5)
t0 = time.time()
page.locator('#dropdown').select_option('2')
sel2_ms = int((time.time() - t0) * 1000)
val2 = page.locator('#dropdown').input_value()
check("select option 2", val2 == '2' and sel2_ms > 100, f"{sel2_ms} ms, value='{val2}'")
time.sleep(1)
# ============================================================
# SCENARIO 4: Drag and drop
# ============================================================
step("Drag and Drop — move column A to B")
page.goto('https://the-internet.herokuapp.com/drag_and_drop', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
before_a = page.locator('#column-a header').text_content().strip()
before_b = page.locator('#column-b header').text_content().strip()
print(f" Before: A='{before_a}', B='{before_b}'")
print(" Watch: cursor moves to A, turns yellow (held), moves to B, releases")
t0 = time.time()
page.locator('#column-a').drag_to(page.locator('#column-b'))
drag_ms = int((time.time() - t0) * 1000)
time.sleep(1)
after_a = page.locator('#column-a header').text_content().strip()
after_b = page.locator('#column-b header').text_content().strip()
swapped = before_a != after_a
print(f" After: A='{after_a}', B='{after_b}'")
check("drag A to B", swapped and drag_ms > 300, f"{drag_ms} ms, swapped={swapped}")
time.sleep(1)
# ============================================================
# SCENARIO 5: Text editing
# ============================================================
step("Text editing — type, press keys, clear")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: cursor clicks input, types character by character")
t0 = time.time()
page.locator('#searchInput').type('Hello World')
type_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("type 'Hello World'", val == 'Hello World' and type_ms > 1000, f"{type_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: cursor clicks, presses single key")
t0 = time.time()
page.locator('#searchInput').press('End')
page.locator('#searchInput').press('!')
press_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("press '!' at end", '!' in val and press_ms > 100, f"{press_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: field gets cleared (Ctrl+A, Backspace)")
t0 = time.time()
page.locator('#searchInput').clear()
clear_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("clear field", val == '' and clear_ms > 100, f"{clear_ms} ms, value='{repr(val)}'")
time.sleep(0.5)
print(" Watch: press_sequentially types each key individually")
t0 = time.time()
page.locator('#searchInput').press_sequentially('Sequential')
pseq_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("press_sequentially", val == 'Sequential' and pseq_ms > 500, f"{pseq_ms} ms, value='{val}'")
time.sleep(1)
# ============================================================
# SCENARIO 6: Mouse precision
# ============================================================
step("Mouse precision — move to coordinates")
print(" Watch: cursor moves in a Bezier curve to (600, 400)")
t0 = time.time()
page.mouse.move(600, 400)
move_ms = int((time.time() - t0) * 1000)
check("mouse.move to (600,400)", move_ms > 100, f"{move_ms} ms")
time.sleep(0.5)
print(" Watch: cursor moves to (200, 200), clicks")
t0 = time.time()
page.mouse.click(200, 200)
mclick_ms = int((time.time() - t0) * 1000)
check("mouse.click at (200,200)", mclick_ms > 100, f"{mclick_ms} ms")
time.sleep(0.5)
print(" Watch: keyboard types directly (no click needed)")
page.locator('#searchInput').click()
time.sleep(0.3)
t0 = time.time()
page.keyboard.type('Direct keyboard')
kb_ms = int((time.time() - t0) * 1000)
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
time.sleep(1)
# ============================================================
# SUMMARY
# ============================================================
print("\n" + "=" * 70)
print(" SUMMARY")
print("=" * 70)
passed = sum(1 for _, s in results if s == "PASS")
failed = sum(1 for _, s in results if s == "FAIL")
total = len(results)
for name, status in results:
icon = "OK" if status == "PASS" else "XX"
print(f" [{icon}] {name}")
print(f"\n {passed}/{total} passed, {failed} failed")
if failed == 0:
print(" *** ALL TESTS PASSED ***")
print("=" * 70)
input("\nPress Enter to close browser...")
browser.close()
+470
View File
@@ -0,0 +1,470 @@
/**
* Unit + integration tests for the humanize layer (JS).
* Covers: config resolution, Bézier math, fill clearing,
* bot-detection form, and patching integrity.
*
* Run: node tests/test_humanize_unit.mjs
*/
import { launch } from '../js/dist/index.js';
import { resolveConfig, rand, randRange, sleep } from '../js/dist/human/config.js';
import { humanMove, clickTarget } from '../js/dist/human/mouse.js';
const PROXY = {
};
const delay = ms => new Promise(r => setTimeout(r, ms));
const results = [];
async function test(name, fn) {
try {
await fn();
console.log(` [PASS] ${name}`);
results.push({ name, status: 'PASS' });
} catch (e) {
console.log(` [FAIL] ${name}${e.message || e}`);
results.push({ name, status: 'FAIL' });
}
}
// =========================================================================
// 1. Config resolution
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' CONFIG RESOLUTION');
console.log('='.repeat(60));
await test('default config resolves', async () => {
const cfg = resolveConfig('default');
if (!cfg) throw new Error('resolveConfig returned null');
if (cfg.mouse_min_steps <= 0) throw new Error('mouse_min_steps should be > 0');
if (cfg.mouse_max_steps <= cfg.mouse_min_steps) throw new Error('mouse_max_steps should be > min');
if (cfg.typing_delay <= 0) throw new Error('typing_delay should be > 0');
if (!Array.isArray(cfg.initial_cursor_x) || cfg.initial_cursor_x.length !== 2) throw new Error('initial_cursor_x invalid');
if (!Array.isArray(cfg.initial_cursor_y) || cfg.initial_cursor_y.length !== 2) throw new Error('initial_cursor_y invalid');
});
await test('careful config resolves', async () => {
const cfg = resolveConfig('careful');
const def = resolveConfig('default');
if (!cfg) throw new Error('resolveConfig returned null');
if (cfg.typing_delay < def.typing_delay) throw new Error('careful should have >= typing_delay');
});
await test('custom config override', async () => {
const cfg = resolveConfig('default', { mouse_min_steps: 100, mouse_max_steps: 200 });
if (cfg.mouse_min_steps !== 100) throw new Error(`Override failed: ${cfg.mouse_min_steps}`);
if (cfg.mouse_max_steps !== 200) throw new Error(`Override failed: ${cfg.mouse_max_steps}`);
});
await test('rand within bounds', async () => {
for (let i = 0; i < 100; i++) {
const v = rand(10, 20);
if (v < 10 || v > 20) throw new Error(`rand out of range: ${v}`);
}
});
await test('randRange within bounds', async () => {
for (let i = 0; i < 100; i++) {
const v = randRange([5, 15]);
if (v < 5 || v > 15) throw new Error(`randRange out of range: ${v}`);
}
});
await test('sleep timing', async () => {
const t0 = Date.now();
await sleep(50);
const elapsed = Date.now() - t0;
if (elapsed < 40) throw new Error(`sleep too short: ${elapsed} ms`);
if (elapsed > 200) throw new Error(`sleep too long: ${elapsed} ms`);
});
// =========================================================================
// 2. Bézier math (via humanMove recording)
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' BÉZIER MATH (via mouse movement recording)');
console.log('='.repeat(60));
await test('humanMove generates multiple points', async () => {
const cfg = resolveConfig('default');
const moves = [];
const fakeRaw = {
move: async (x, y) => moves.push({ x, y }),
down: async () => {},
up: async () => {},
wheel: async () => {},
};
await humanMove(fakeRaw, 0, 0, 500, 300, cfg);
if (moves.length < 10) throw new Error(`Expected >= 10 moves, got ${moves.length}`);
const last = moves[moves.length - 1];
if (Math.abs(last.x - 500) > 10) throw new Error(`Last x too far: ${last.x}`);
if (Math.abs(last.y - 300) > 10) throw new Error(`Last y too far: ${last.y}`);
});
await test('humanMove smoothness (no large jumps)', async () => {
const cfg = resolveConfig('default');
const moves = [];
const fakeRaw = {
move: async (x, y) => moves.push({ x, y }),
down: async () => {},
up: async () => {},
wheel: async () => {},
};
await humanMove(fakeRaw, 0, 0, 400, 400, cfg);
const totalDist = Math.sqrt(400 * 400 + 400 * 400);
const maxJump = totalDist * 0.5;
for (let i = 1; i < moves.length; i++) {
const dx = moves[i].x - moves[i - 1].x;
const dy = moves[i].y - moves[i - 1].y;
const jump = Math.sqrt(dx * dx + dy * dy);
if (jump > maxJump) throw new Error(`Jump too large at step ${i}: ${jump.toFixed(1)}`);
}
});
await test('humanMove not a straight line', async () => {
const cfg = resolveConfig('default');
let maxDev = 0;
for (let trial = 0; trial < 5; trial++) {
const moves = [];
const fakeRaw = {
move: async (x, y) => moves.push({ x, y }),
down: async () => {},
up: async () => {},
wheel: async () => {},
};
await humanMove(fakeRaw, 0, 0, 500, 0, cfg);
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
if (dev > maxDev) maxDev = dev;
}
if (maxDev < 0.5) throw new Error(`Curve too straight, max y deviation: ${maxDev.toFixed(2)}`);
});
await test('clickTarget within bounding box', async () => {
const cfg = resolveConfig('default');
const box = { x: 100, y: 200, width: 150, height: 40 };
for (let i = 0; i < 50; i++) {
const t = clickTarget(box, false, cfg);
if (t.x < 100 || t.x > 250) throw new Error(`x out of box: ${t.x}`);
if (t.y < 200 || t.y > 240) throw new Error(`y out of box: ${t.y}`);
}
});
// =========================================================================
// 3. Fill clearing (with real browser)
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' FILL CLEARING (browser)');
console.log('='.repeat(60));
await test('fill() clears existing text', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
await page.locator('#searchInput').type('initial text');
await delay(500);
const before = await page.locator('#searchInput').inputValue();
if (before !== 'initial text') throw new Error(`Initial type failed: '${before}'`);
await page.locator('#searchInput').fill('replaced text');
await delay(500);
const after = await page.locator('#searchInput').inputValue();
if (after !== 'replaced text') throw new Error(`Fill did not replace: '${after}'`);
if (after.includes('initial')) throw new Error('Old text still present');
await browser.close();
});
await test('fill() timing is humanized (>1s)', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
const t0 = Date.now();
await page.locator('#searchInput').fill('Human speed test');
const elapsed = Date.now() - t0;
if (elapsed < 1000) throw new Error(`fill() too fast: ${elapsed} ms`);
await browser.close();
});
await test('clear() empties field', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
await page.locator('#searchInput').fill('some text');
await delay(500);
await page.locator('#searchInput').clear();
await delay(500);
const val = await page.locator('#searchInput').inputValue();
if (val !== '') throw new Error(`clear() did not empty: '${val}'`);
await browser.close();
});
// =========================================================================
// 4. Bot detection form — deviceandbrowserinfo.com
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' BOT DETECTION FORM (deviceandbrowserinfo.com)');
console.log('='.repeat(60));
await test('bot detection form — behavioral checks pass', async () => {
const browser = await launch({ headless: false, humanize: true, proxy: PROXY });
const page = await browser.newPage();
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
await delay(3000);
await page.locator('#email').click();
await delay(300);
await page.locator('#email').fill('test@example.com');
await delay(500);
await page.locator('#password').click();
await delay(300);
await page.locator('#password').fill('SecurePass!123');
await delay(500);
await page.locator('button[type="submit"]').click();
await delay(5000);
const body = await page.locator('body').textContent();
const superHuman = body.includes('"superHumanSpeed": true');
const suspicious = body.includes('"suspiciousClientSideBehavior": true');
const cdpMouse = body.includes('"hasCDPMouseLeak": true');
console.log(` superHumanSpeed: ${superHuman}`);
console.log(` suspiciousClientSideBehavior: ${suspicious}`);
console.log(` hasCDPMouseLeak: ${cdpMouse}`);
if (superHuman) throw new Error('superHumanSpeed detected');
if (suspicious) throw new Error('suspiciousClientSideBehavior detected');
if (body.includes('"isAutomatedWithCDP": true')) {
console.log(' [INFO] isAutomatedWithCDP=true — stealth issue, not humanize');
}
await browser.close();
});
await test('bot detection form timing (>3s)', async () => {
const browser = await launch({ headless: true, humanize: true, proxy: PROXY });
const page = await browser.newPage();
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
await delay(2000);
const t0 = Date.now();
await page.locator('#email').fill('test@example.com');
await page.locator('#password').fill('MyPassword!99');
await page.locator('button[type="submit"]').click();
const elapsed = Date.now() - t0;
await delay(3000);
console.log(` Form fill + submit took: ${elapsed} ms`);
if (elapsed < 3000) throw new Error(`Form filled too fast: ${elapsed} ms`);
await browser.close();
});
// =========================================================================
// 5. Patching integrity
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' PATCHING INTEGRITY');
console.log('='.repeat(60));
await test('page has _original after launch', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
if (!page._original) throw new Error('page._original missing');
if (!page._humanCfg) throw new Error('page._humanCfg missing');
if (!page._humanCursor) throw new Error('page._humanCursor missing');
await browser.close();
});
await test('page.click is humanized', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
const clickStr = page.click.toString();
if (!clickStr.includes('ensureCursorInit') && !clickStr.includes('humanClickFn') && !clickStr.includes('scrollToElement')) {
throw new Error('page.click does not appear humanized');
}
await browser.close();
});
await test('non-humanized page works normally', async () => {
const browser = await launch({ headless: true, humanize: false });
const page = await browser.newPage();
if (page._original) throw new Error('Non-humanized page should not have _original');
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
const t0 = Date.now();
await page.locator('#searchInput').fill('test');
const elapsed = Date.now() - t0;
if (elapsed > 500) throw new Error(`Non-humanized fill too slow: ${elapsed} ms`);
await browser.close();
});
// =========================================================================
// 6. Focus check — press skips click when focused
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' FOCUS CHECK (press / pressSequentially)');
console.log('='.repeat(60));
await test('press skips click when element already focused', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
// Click input first to focus it
await page.locator('#searchInput').click();
await delay(300);
// Record mouse moves before pressing Enter
const movesBefore = [];
const origMove = page._humanOriginals.mouseMove;
let moveCount = 0;
page._humanOriginals.mouseMove = async (x, y, opts) => {
moveCount++;
return origMove(x, y, opts);
};
// Press Enter — element is already focused, should NOT trigger mouse move
const movesAtStart = moveCount;
await page.locator('#searchInput').press('a');
const movesUsed = moveCount - movesAtStart;
// Restore
page._humanOriginals.mouseMove = origMove;
// If focus check works, should be 0 moves (just keyboard press)
if (movesUsed > 0) {
console.log(` [INFO] press() triggered ${movesUsed} mouse moves on focused element`);
}
// Lenient: allow some moves but not a full Bézier path (>10 would indicate a click)
if (movesUsed > 10) {
throw new Error(`press() moved mouse ${movesUsed} times on already-focused element — focus check broken`);
}
await browser.close();
});
// =========================================================================
// 7. check/uncheck idle
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' CHECK/UNCHECK IDLE');
console.log('='.repeat(60));
await test('check() respects idle_between_actions config', async () => {
const cfg = resolveConfig('default', { idle_between_actions: true, idle_between_duration: [50, 100] });
if (!cfg.idle_between_actions) throw new Error('idle_between_actions should be true');
if (!cfg.idle_between_duration || cfg.idle_between_duration[0] !== 50) {
throw new Error('idle_between_duration not set');
}
// Verify config is carried through to page
const browser = await launch({ headless: true, humanize: true, humanize_config: { idle_between_actions: true } });
const page = await browser.newPage();
if (!page._humanCfg) throw new Error('page._humanCfg missing');
await browser.close();
});
// =========================================================================
// 8. Frame patching completeness
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' FRAME PATCHING COMPLETENESS');
console.log('='.repeat(60));
await test('frame has all methods patched', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await delay(1000);
const mainFrame = page.mainFrame();
const expected = ['click', 'dblclick', 'hover', 'type', 'fill',
'check', 'uncheck', 'selectOption', 'press',
'clear', 'dragAndDrop'];
const missing = [];
for (const method of expected) {
if (typeof mainFrame[method] !== 'function') {
missing.push(method);
}
}
if (missing.length > 0) {
throw new Error(`Frame missing patched methods: ${missing.join(', ')}`);
}
// Verify they are patched (not original Playwright bindings)
if (!mainFrame._humanPatched) {
throw new Error('mainFrame._humanPatched flag not set');
}
await browser.close();
});
// =========================================================================
// 9. drag_to safety — page._original check
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' DRAG_TO SAFETY');
console.log('='.repeat(60));
await test('page._humanCfg is accessible', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
if (!page._humanCfg) throw new Error('page._humanCfg not set');
if (!page._original) throw new Error('page._original not set');
if (typeof page._original.mouseDown !== 'function') throw new Error('mouseDown not preserved');
if (typeof page._original.mouseUp !== 'function') throw new Error('mouseUp not preserved');
await browser.close();
});
// =========================================================================
// 10. patchBrowser.newPage uses original context
// =========================================================================
console.log('\n' + '='.repeat(60));
console.log(' PATCH BROWSER — newPage context');
console.log('='.repeat(60));
await test('browser.newPage returns patched page', async () => {
const browser = await launch({ headless: true, humanize: true });
const page = await browser.newPage();
if (!page._original) throw new Error('page from browser.newPage() not patched');
if (!page._humanCfg) throw new Error('page._humanCfg missing from browser.newPage()');
await browser.close();
});
// =========================================================================
// SUMMARY
// =========================================================================
console.log('\n' + '='.repeat(70));
console.log(' TEST SUMMARY');
console.log('='.repeat(70));
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status === 'FAIL').length;
for (const r of results) {
const icon = r.status === 'PASS' ? 'OK' : 'XX';
console.log(` [${icon}] ${r.name}`);
}
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
if (failed === 0) console.log(' *** ALL JS TESTS PASSED ***');
else console.log(` *** ${failed} TESTS FAILED ***`);
console.log('='.repeat(70));
process.exit(failed === 0 ? 0 : 1);
+575
View File
@@ -0,0 +1,575 @@
"""
Unit + integration tests for the humanize layer.
Fast unit tests (config, Bézier math, mocks) are proper test_ functions
that pytest discovers automatically.
Browser-dependent tests are marked @pytest.mark.slow and skipped in CI
unless explicitly requested (pytest -m slow).
Can also run directly: python tests/test_humanize_unit.py
"""
import math
import time
import sys
import pytest
# =========================================================================
# Helper: ensure Locator class is patched before mock tests
# =========================================================================
def _ensure_locator_patched():
import cloakbrowser.human as h
h._locator_sync_patched = False
h._patch_locator_class_sync()
# =========================================================================
# Helper: fake RawMouse for Bézier tests
# =========================================================================
class _FakeRawMouse:
def __init__(self):
self.moves = []
def move(self, x, y, **kw):
self.moves.append((x, y))
def down(self, **kw):
pass
def up(self, **kw):
pass
def wheel(self, dx, dy):
pass
# =========================================================================
# 1. Config resolution
# =========================================================================
class TestConfigResolution:
def test_default_config_resolves(self):
from cloakbrowser.human.config import resolve_config, HumanConfig
cfg = resolve_config("default", None)
assert isinstance(cfg, HumanConfig)
assert cfg.mouse_min_steps > 0
assert cfg.mouse_max_steps > cfg.mouse_min_steps
assert len(cfg.initial_cursor_x) == 2
assert len(cfg.initial_cursor_y) == 2
assert cfg.typing_delay > 0
def test_careful_config_resolves(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("careful", None)
default_cfg = resolve_config("default", None)
assert cfg.mouse_min_steps > 0
assert cfg.typing_delay >= default_cfg.typing_delay
def test_custom_override(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", {"mouse_min_steps": 100, "mouse_max_steps": 200})
assert cfg.mouse_min_steps == 100
assert cfg.mouse_max_steps == 200
def test_invalid_preset_raises(self):
from cloakbrowser.human.config import resolve_config
with pytest.raises(ValueError, match="Unknown humanize preset"):
resolve_config("nonexistent", None)
def test_rand_within_bounds(self):
from cloakbrowser.human.config import rand, rand_range
for _ in range(200):
v = rand(10, 20)
assert 10 <= v <= 20
for _ in range(200):
v = rand_range([5, 15])
assert 5 <= v <= 15
def test_sleep_ms_timing(self):
from cloakbrowser.human.config import sleep_ms
t0 = time.time()
sleep_ms(50)
elapsed = (time.time() - t0) * 1000
assert elapsed >= 40
assert elapsed < 200
# =========================================================================
# 2. Bézier math
# =========================================================================
class TestBezierMath:
def test_generates_multiple_points(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
raw = _FakeRawMouse()
human_move(raw, 0, 0, 500, 300, cfg)
assert len(raw.moves) >= 10
last_x, last_y = raw.moves[-1]
assert abs(last_x - 500) < 10
assert abs(last_y - 300) < 10
def test_smoothness_no_large_jumps(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
raw = _FakeRawMouse()
human_move(raw, 0, 0, 400, 400, cfg)
total_dist = math.sqrt(400**2 + 400**2)
max_jump = total_dist * 0.5
for i in range(1, len(raw.moves)):
dx = raw.moves[i][0] - raw.moves[i-1][0]
dy = raw.moves[i][1] - raw.moves[i-1][1]
assert math.sqrt(dx*dx + dy*dy) < max_jump
def test_short_distance(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
raw = _FakeRawMouse()
human_move(raw, 100, 100, 103, 102, cfg)
assert len(raw.moves) >= 1
def test_not_straight_line(self):
from cloakbrowser.human.mouse import human_move
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
max_dev = 0
for _ in range(5):
raw = _FakeRawMouse()
human_move(raw, 0, 0, 500, 0, cfg)
dev = max(abs(y) for _, y in raw.moves)
if dev > max_dev:
max_dev = dev
assert max_dev > 0.5
def test_click_target_within_box(self):
from cloakbrowser.human.mouse import click_target
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
box = {"x": 100, "y": 200, "width": 150, "height": 40}
for _ in range(50):
t = click_target(box, False, cfg)
assert 100 <= t.x <= 250
assert 200 <= t.y <= 240
def test_click_target_input_mode(self):
from cloakbrowser.human.mouse import click_target
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", None)
box = {"x": 50, "y": 50, "width": 200, "height": 30}
for _ in range(20):
t = click_target(box, True, cfg)
assert 50 <= t.x <= 250
assert 50 <= t.y <= 80
# =========================================================================
# 3. Async compatibility
# =========================================================================
class TestAsyncCompat:
def test_async_modules_import(self):
from cloakbrowser.human.mouse_async import AsyncRawMouse, async_human_move
from cloakbrowser.human.keyboard_async import AsyncRawKeyboard, async_human_type
from cloakbrowser.human.scroll_async import async_scroll_to_element
from cloakbrowser.human import patch_page_async, patch_browser_async, patch_context_async
assert callable(async_human_move)
assert callable(async_human_type)
assert callable(async_scroll_to_element)
def test_async_locator_patch(self):
import cloakbrowser.human as h
h._locator_async_patched = False
h._patch_locator_class_async()
assert h._locator_async_patched
from playwright.async_api._generated import Locator as AsyncLocator
assert 'humanized' in AsyncLocator.fill.__name__
def test_async_sleep_is_coroutine(self):
from cloakbrowser.human.config import async_sleep_ms
import asyncio
assert asyncio.iscoroutinefunction(async_sleep_ms)
# =========================================================================
# 4. Focus check — press / clear / pressSequentially
# =========================================================================
class TestFocusCheck:
def test_press_skips_click_when_focused(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
page = MagicMock()
page._original = MagicMock()
page._human_cfg = MagicMock()
page._human_cfg.idle_between_actions = False
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=True):
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#test"
Locator.press(loc, "Enter")
page.click.assert_not_called()
def test_press_clicks_when_not_focused(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
page = MagicMock()
page._original = MagicMock()
page._human_cfg = MagicMock()
page._human_cfg.idle_between_actions = False
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=False):
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#test"
Locator.press(loc, "Enter")
page.click.assert_called_with("#test")
# =========================================================================
# 5. check/uncheck idle
# =========================================================================
class TestCheckUncheckIdle:
def test_check_calls_idle_when_enabled(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
page = MagicMock()
page._original = MagicMock()
page._original.mouse_move = MagicMock()
page._human_cfg = cfg
idle_called = {"n": 0}
def fake_idle(*a, **kw):
idle_called["n"] += 1
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#checkbox"
loc.is_checked = MagicMock(return_value=False)
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
Locator.check(loc)
assert idle_called["n"] >= 1
def test_uncheck_calls_idle_when_enabled(self):
_ensure_locator_patched()
from unittest.mock import MagicMock, patch as mock_patch
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
page = MagicMock()
page._original = MagicMock()
page._original.mouse_move = MagicMock()
page._human_cfg = cfg
idle_called = {"n": 0}
def fake_idle(*a, **kw):
idle_called["n"] += 1
from playwright.sync_api._generated import Locator
loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#checkbox"
loc.is_checked = MagicMock(return_value=True)
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
Locator.uncheck(loc)
assert idle_called["n"] >= 1
# =========================================================================
# 6. Frame patching completeness
# =========================================================================
class TestFramePatching:
def test_all_11_methods_patched(self):
from cloakbrowser.human import _patch_single_frame_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
cursor = _CursorState()
page = MagicMock()
page._original = MagicMock()
frame = MagicMock()
frame._human_patched = False
_patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), page._original)
expected = ['click', 'dblclick', 'hover', 'type', 'fill',
'check', 'uncheck', 'select_option', 'press',
'clear', 'drag_and_drop']
for method in expected:
fn = getattr(frame, method)
assert not isinstance(fn, MagicMock), f"frame.{method} was not patched"
# =========================================================================
# 7. drag_to safety
# =========================================================================
class TestDragToSafety:
def test_handles_missing_original(self):
_ensure_locator_patched()
from playwright.sync_api._generated import Locator
from unittest.mock import MagicMock
page = MagicMock()
page._original = None
source_loc = MagicMock()
source_loc.page = page
source_loc._impl_obj = MagicMock()
source_loc._impl_obj._selector = "#src"
source_loc.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 50})
target_loc = MagicMock()
target_loc.page = page
target_loc._impl_obj = MagicMock()
target_loc._impl_obj._selector = "#tgt"
target_loc.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 50, "height": 50})
try:
Locator.drag_to(source_loc, target_loc)
except AttributeError:
pytest.fail("drag_to crashed without page._original")
# =========================================================================
# 8. Page config persistence
# =========================================================================
class TestPageConfigPersistence:
def test_resolve_config_has_all_fields(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default")
required = ["mouse_min_steps", "mouse_max_steps", "typing_delay",
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
"idle_between_duration", "field_switch_delay",
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct"]
for field in required:
assert hasattr(cfg, field), f"Config missing field: {field}"
# =========================================================================
# 9. Mistype config
# =========================================================================
class TestMistypeConfig:
def test_default_mistype_chance(self):
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default")
assert 0 < cfg.mistype_chance < 1
assert len(cfg.mistype_delay_notice) == 2
assert len(cfg.mistype_delay_correct) == 2
def test_careful_mistype_higher(self):
from cloakbrowser.human.config import resolve_config
default = resolve_config("default")
careful = resolve_config("careful")
assert careful.mistype_chance >= default.mistype_chance
# =========================================================================
# 10. Select-all platform detection
# =========================================================================
class TestSelectAllPlatform:
def test_select_all_constant_exists(self):
from cloakbrowser.human import _SELECT_ALL
assert _SELECT_ALL in ("Meta+a", "Control+a")
def test_select_all_matches_platform(self):
import sys
from cloakbrowser.human import _SELECT_ALL
if sys.platform == "darwin":
assert _SELECT_ALL == "Meta+a"
else:
assert _SELECT_ALL == "Control+a"
# =========================================================================
# SLOW TESTS — require browser (skipped in CI unless pytest -m slow)
# =========================================================================
@pytest.mark.slow
class TestBrowserFill:
def test_fill_clears_existing(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
page.locator('#searchInput').type('initial text')
time.sleep(0.5)
page.locator('#searchInput').fill('replaced text')
time.sleep(0.5)
val = page.locator('#searchInput').input_value()
assert val == 'replaced text'
assert 'initial' not in val
browser.close()
def test_fill_timing_humanized(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
t0 = time.time()
page.locator('#searchInput').fill('Human speed test')
elapsed_ms = int((time.time() - t0) * 1000)
assert elapsed_ms > 1000
browser.close()
def test_clear_empties_field(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1)
page.locator('#searchInput').fill('some text')
time.sleep(0.5)
page.locator('#searchInput').clear()
time.sleep(0.5)
val = page.locator('#searchInput').input_value()
assert val == ''
browser.close()
@pytest.mark.slow
class TestBrowserPatching:
def test_page_has_original(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
assert hasattr(page, '_original')
assert hasattr(page, '_human_cfg')
browser.close()
def test_locator_methods_patched(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
from playwright.sync_api._generated import Locator
methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
'set_checked', 'select_option', 'press', 'press_sequentially',
'tap', 'drag_to', 'clear']
for method in methods:
fn = getattr(Locator, method)
assert 'humanized' in fn.__name__, f"{method} not patched"
browser.close()
def test_non_humanized_page_normal(self):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
assert not hasattr(page, '_original')
browser.close()
def test_page_human_cfg_persists(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True)
page = browser.new_page()
assert page._human_cfg is not None
assert hasattr(page._human_cfg, 'idle_between_actions')
assert hasattr(page._human_cfg, 'mistype_chance')
browser.close()
@pytest.mark.slow
class TestBrowserBotDetection:
PROXY = ''
def test_behavioral_checks_pass(self):
from cloakbrowser import launch
browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
page = browser.new_page()
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
wait_until='domcontentloaded')
time.sleep(3)
page.locator('#email').click()
time.sleep(0.3)
page.locator('#email').fill('test@example.com')
time.sleep(0.5)
page.locator('#password').click()
time.sleep(0.3)
page.locator('#password').fill('SecurePass!123')
time.sleep(0.5)
page.locator('button[type="submit"]').click()
time.sleep(5)
body = page.locator('body').text_content()
assert '"superHumanSpeed": true' not in body
assert '"suspiciousClientSideBehavior": true' not in body
browser.close()
def test_form_timing(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True)
page = browser.new_page()
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
wait_until='domcontentloaded')
time.sleep(2)
t0 = time.time()
page.locator('#email').fill('test@example.com')
page.locator('#password').fill('MyPassword!99')
page.locator('button[type="submit"]').click()
elapsed_ms = int((time.time() - t0) * 1000)
time.sleep(3)
assert elapsed_ms > 3000
browser.close()
@pytest.mark.slow
class TestAsyncEndToEnd:
def test_async_launch_click_fill(self):
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
import asyncio
from cloakbrowser import launch_async
async def _run():
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
assert hasattr(page, '_original'), "async page not patched"
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
t0 = time.time()
await page.locator('#searchInput').fill('async test')
elapsed_ms = int((time.time() - t0) * 1000)
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
val = await page.locator('#searchInput').input_value()
assert val == 'async test', f"async fill wrong value: {val}"
await browser.close()
asyncio.run(_run())
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))
+213
View File
@@ -0,0 +1,213 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
import warnings
from unittest.mock import MagicMock, call, patch
import pytest
from cloakbrowser.config import DEFAULT_VIEWPORT
# All tests mock launch() to avoid needing a binary.
# launch_context() calls launch() internally, then browser.new_context().
def _make_mock_browser():
"""Create a mock browser with new_context() returning a mock context."""
browser = MagicMock()
context = MagicMock()
browser.new_context.return_value = context
return browser, context
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_default_viewport(mock_launch, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context()
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_custom_viewport(mock_launch, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
custom = {"width": 1280, "height": 720}
launch_context(viewport=custom)
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["viewport"] == custom
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_user_agent(mock_launch, _mock_bin):
"""user_agent forwarded to new_context()."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(user_agent="Mozilla/5.0 Custom")
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["user_agent"] == "Mozilla/5.0 Custom"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_locale_forwarded(mock_launch, _mock_bin):
"""locale flows to both launch() binary args AND new_context()."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(locale="de-DE")
# Locale in launch() call (for --lang binary flag)
assert mock_launch.call_args[1]["locale"] == "de-DE"
# Locale in new_context() call
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["locale"] == "de-DE"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
"""timezone passed to new_context(timezone_id=...) but NOT to launch(timezone=...).
This is intentional: the --fingerprint-timezone binary flag only applies to the
default context and would conflict with Playwright's timezone_id on new contexts.
"""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(timezone="America/New_York")
# timezone=None in launch() — binary flag skipped
assert mock_launch.call_args[1]["timezone"] is None
# timezone_id in new_context()
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "America/New_York"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_color_scheme(mock_launch, _mock_bin):
"""color_scheme forwarded to new_context()."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(color_scheme="dark")
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
"""geoip fills timezone+locale, both flow to correct places."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(proxy="http://proxy:8080", geoip=True)
# Locale goes to launch() for binary flag
assert mock_launch.call_args[1]["locale"] == "de-DE"
# Timezone goes to context, not binary
assert mock_launch.call_args[1]["timezone"] is None
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin"
assert ctx_kwargs[1]["locale"] == "de-DE"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_timezone_id_deprecation(mock_launch, _mock_bin):
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_context(timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
assert "timezone_id" in str(w[0].message)
# Migrated value flows to context
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Paris"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_close_closes_browser(mock_launch, _mock_bin):
"""context.close() also calls browser.close()."""
browser, context = _make_mock_browser()
# Save reference before launch_context() monkey-patches context.close
original_ctx_close = context.close
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
ctx = launch_context()
# The returned context has a patched close()
ctx.close()
# Original context close was called
original_ctx_close.assert_called_once()
# Browser close was also called
browser.close.assert_called_once()
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_error_closes_browser(mock_launch, _mock_bin):
"""If new_context() raises, browser is still closed."""
browser = MagicMock()
browser.new_context.side_effect = RuntimeError("context creation failed")
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
with pytest.raises(RuntimeError, match="context creation failed"):
launch_context()
browser.close.assert_called_once()
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_kwargs_passthrough(mock_launch, _mock_bin):
"""Extra kwargs forwarded to new_context(), NOT to launch().
Important contract: kwargs like record_video_dir go to context creation,
not browser launch.
"""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(record_video_dir="/tmp/videos")
# Verify kwarg reached new_context()
ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["record_video_dir"] == "/tmp/videos"
# Verify kwarg did NOT leak to launch()
launch_kwargs = mock_launch.call_args[1]
assert "record_video_dir" not in launch_kwargs
+262
View File
@@ -0,0 +1,262 @@
"""Unit tests for launch_persistent_context() and launch_persistent_context_async().
All tests mock playwright to avoid needing a binary.
"""
import warnings
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cloakbrowser.config import DEFAULT_VIEWPORT
def _make_mock_pw_and_context():
"""Create mock sync_playwright chain returning a mock context."""
context = MagicMock()
pw = MagicMock()
pw.chromium.launch_persistent_context.return_value = context
pw_cm = MagicMock()
pw_cm.start.return_value = pw
return pw_cm, pw, context
# ---------------------------------------------------------------------------
# Sync: launch_persistent_context()
# ---------------------------------------------------------------------------
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_args_built(_mock_geoip, _mock_bin):
"""Stealth args + extra args combined correctly."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", args=["--disable-gpu"])
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert "--disable-gpu" in call_kwargs["args"]
# Stealth args present by default
assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"])
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT."""
pw_cm, pw, context = _make_mock_pw_and_context()
custom = {"width": 1280, "height": 720}
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", viewport=custom)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["viewport"] == custom
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
"""user_agent forwarded to launch_persistent_context()."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", user_agent="Custom/1.0")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["user_agent"] == "Custom/1.0"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_locale_and_timezone(_mock_bin):
"""Both timezone and locale flow to context kwargs and binary args."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
# Context kwargs
assert call_kwargs["timezone_id"] == "Asia/Tokyo"
assert call_kwargs["locale"] == "ja-JP"
# Binary args
assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"]
assert "--lang=ja-JP" in call_kwargs["args"]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", color_scheme="dark")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Berlin"
assert call_kwargs["locale"] == "de-DE"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_timezone_id_deprecation(_mock_bin):
"""Old timezone_id kwarg migrated with warning."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
"""context.close() also calls pw.stop()."""
pw_cm, pw, context = _make_mock_pw_and_context()
original_close = context.close
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
ctx = launch_persistent_context("/tmp/profile")
ctx.close()
original_close.assert_called_once()
pw.stop.assert_called_once()
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["proxy"]["server"] == "http://proxy:8080"
assert call_kwargs["proxy"]["username"] == "user"
assert call_kwargs["proxy"]["password"] == "pass"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
"""Proxy dict passed through."""
pw_cm, pw, context = _make_mock_pw_and_context()
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy=proxy_dict)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["proxy"] == proxy_dict
# ---------------------------------------------------------------------------
# Async: launch_persistent_context_async()
# ---------------------------------------------------------------------------
def _make_mock_async_pw_and_context():
"""Create mock async_playwright chain returning a mock context."""
context = AsyncMock()
pw = AsyncMock()
pw.chromium.launch_persistent_context.return_value = context
pw_cm = AsyncMock()
pw_cm.start.return_value = pw
return pw_cm, pw, context
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
"""Async launch builds args correctly."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"])
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert "--disable-gpu" in call_kwargs["args"]
assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"])
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
"""await context.close() calls await pw.stop()."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
original_close = context.close
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
ctx = await launch_persistent_context_async("/tmp/profile")
await ctx.close()
original_close.assert_called_once()
pw.stop.assert_called_once()
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
async def test_persistent_context_async_timezone_id_deprecation(_mock_bin):
"""Deprecated timezone_id kwarg migrated with warning in async path."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris"
+51 -1
View File
@@ -1,6 +1,8 @@
"""Tests for proxy URL parsing and credential extraction."""
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
from unittest.mock import patch
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
class TestParseProxyUrl:
@@ -48,3 +50,51 @@ class TestBuildProxyKwargs:
assert result == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
}
def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
result = _build_proxy_kwargs(proxy_dict)
assert result == {"proxy": proxy_dict}
def test_proxy_dict_with_auth(self):
proxy_dict = {
"server": "http://proxy:8080",
"username": "user",
"password": "pass",
"bypass": ".example.com",
}
result = _build_proxy_kwargs(proxy_dict)
assert result == {"proxy": proxy_dict}
class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_with_string_proxy(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "Europe/London"
assert locale == "en-GB"
def test_geoip_disabled_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert locale is None
def test_geoip_no_proxy_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert locale is None
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin"
assert locale == "ja-JP"
+67
View File
@@ -217,3 +217,70 @@ class TestBotDetectionSites:
score = results["score"]
assert score is not None, "Could not extract reCAPTCHA score"
assert score >= 0.7, f"reCAPTCHA score too low: {score}"
class TestIssueRegressions:
"""Regression tests for specific GitHub issues.
Uses the shared browser fixture to avoid "Sync API inside asyncio loop"
errors when pytest-asyncio is active.
"""
@pytest.mark.slow
def test_immediate_goto_works(self, browser):
"""Issue #9: page.goto() immediately after launch must not fail.
User reported reCAPTCHA fails if goto is called too quickly after
launch. This test verifies that immediate navigation works without
needing an artificial delay.
"""
page = browser.new_page()
# No delay — goto immediately
page.goto("https://example.com", timeout=30000)
title = page.title()
page.close()
assert "Example Domain" in title, f"Immediate goto failed, title={title}"
@pytest.mark.slow
def test_add_init_script_without_proxy(self, browser):
"""Issue #27: add_init_script must work (baseline without proxy).
The bug is proxy + add_init_script, but we first verify init_script
alone works so we have a baseline.
"""
page = browser.new_page()
page.add_init_script("window.__cloaktest = 42;")
page.goto("https://example.com", timeout=30000)
val = page.evaluate("window.__cloaktest")
page.close()
assert val == 42, f"add_init_script failed, got {val}"
@pytest.mark.slow
def test_add_init_script_with_proxy(self, browser):
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
Patchright bug: add_init_script breaks proxy auth. This test guards
against regression if/when the upstream fix lands. Uses context-level
proxy to avoid launching a separate browser (event loop conflict).
"""
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
if not proxy:
pytest.skip("CLOAKBROWSER_TEST_PROXY not set")
ctx = browser.new_context(proxy={"server": proxy})
page = ctx.new_page()
page.add_init_script("window.__cloaktest = 99;")
try:
page.goto("https://httpbin.org/ip", timeout=30000)
body = page.evaluate("document.body.innerText")
val = page.evaluate("window.__cloaktest")
assert val == 99, f"init_script value wrong: {val}"
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
except Exception as e:
err = str(e)
if "ERR_TUNNEL_CONNECTION_FAILED" in err:
pytest.xfail("Known patchright bug: add_init_script + proxy auth (issue #27)")
raise
finally:
page.close()
ctx.close()
+118
View File
@@ -24,6 +24,10 @@ from cloakbrowser.download import (
_parse_checksums,
_should_check_for_update,
_verify_checksum,
_write_version_marker,
check_for_update,
clear_cache,
ensure_binary,
)
@@ -356,3 +360,117 @@ class TestVerifyChecksum:
file.write_bytes(b"real content")
with pytest.raises(RuntimeError, match="Checksum verification failed"):
_verify_checksum(file, "0" * 64)
class TestClearCache:
def test_removes_dir(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
# Create some content
(tmp_path / "chromium-145").mkdir()
(tmp_path / "chromium-145" / "chrome").write_bytes(b"binary")
clear_cache()
assert not tmp_path.exists()
def test_noop_if_missing(self, tmp_path):
nonexistent = tmp_path / "nonexistent"
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(nonexistent)}):
clear_cache() # Should not raise
class TestCheckForUpdate:
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_returns_none_when_current(self, _mock_update):
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
assert check_for_update() is None
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_returns_none_on_network_error(self, _mock_update):
with patch("cloakbrowser.download._get_latest_chromium_version", side_effect=Exception("timeout")):
# _get_latest_chromium_version catches exceptions internally, but
# check_for_update itself can also fail — test graceful None return
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
assert check_for_update() is None
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_returns_version_when_newer(self, _mock_update, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
with patch("cloakbrowser.download._download_and_extract"):
result = check_for_update()
assert result == "999.0.0.0"
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_skips_download_if_already_cached(self, _mock_update, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
# Create the binary dir so it looks already downloaded
binary_dir = tmp_path / "chromium-999.0.0.0"
binary_dir.mkdir()
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
result = check_for_update()
assert result == "999.0.0.0"
mock_dl.assert_not_called()
class TestEnsureBinary:
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_local_override(self, _mock_update, tmp_path):
binary = tmp_path / "chrome"
binary.write_bytes(b"binary")
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": str(binary)}):
result = ensure_binary()
assert result == str(binary)
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_local_override_missing_file(self, _mock_update):
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/nonexistent/chrome"}):
with pytest.raises(FileNotFoundError, match="does not exist"):
ensure_binary()
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_cached_binary_found(self, _mock_update, tmp_path):
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
}):
# Create a fake cached binary
version = get_chromium_version()
with patch("cloakbrowser.download.get_binary_path") as mock_path:
fake_binary = tmp_path / "chrome"
fake_binary.write_bytes(b"binary")
fake_binary.chmod(0o755)
mock_path.return_value = fake_binary
with patch("cloakbrowser.download.check_platform_available"):
result = ensure_binary()
assert result == str(fake_binary)
@patch("cloakbrowser.download._maybe_trigger_update_check")
def test_downloads_when_missing(self, _mock_update, tmp_path):
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_BINARY_PATH": "",
}):
fake_binary = tmp_path / "chrome"
with patch("cloakbrowser.download.check_platform_available"):
with patch("cloakbrowser.download.get_binary_path") as mock_path:
# effective == platform_version (no marker), so fallback block skipped.
# Call 1: get_binary_path(effective) → nonexistent (triggers download)
# Call 2: get_binary_path() → fake_binary (post-download verify)
mock_path.side_effect = [
tmp_path / "nonexistent", # pre-download: not cached
fake_binary, # post-download: binary ready
]
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
fake_binary.write_bytes(b"binary")
result = ensure_binary()
mock_dl.assert_called_once()
assert result == str(fake_binary)
class TestWriteVersionMarker:
def test_creates_file(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
_write_version_marker("999.0.0.0")
marker = tmp_path / f"latest_version_{get_platform_tag()}"
assert marker.exists()
assert marker.read_text() == "999.0.0.0"