mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3880d30d0f | ||
|
|
9c533e4120 | ||
|
|
98c216f07e | ||
|
|
ee953709b0 | ||
|
|
a45fdc4d7e | ||
|
|
e411f24cf3 | ||
|
|
0a99a1458a | ||
|
|
f76dbdb044 | ||
|
|
976f5ae534 | ||
|
|
0719f750ef | ||
|
|
05fa1a052a | ||
|
|
25acff23b7 | ||
|
|
bd22e51bc2 |
@@ -61,3 +61,4 @@ publish.sh
|
|||||||
deploy.sh
|
deploy.sh
|
||||||
.env
|
.env
|
||||||
debug
|
debug
|
||||||
|
publish-docker.sh
|
||||||
|
|||||||
+22
-3
@@ -26,13 +26,21 @@ You may NOT:
|
|||||||
4. **Modify** the Binary or create derivative works based on it
|
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
|
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
|
## 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
|
## 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.
|
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
|
## 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.
|
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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [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 `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)
|
||||||
|
|||||||
+29
-5
@@ -1,6 +1,6 @@
|
|||||||
FROM python:3.12-slim
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
|
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
|
||||||
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
|
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
|
||||||
@@ -9,16 +9,40 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libxcb1 libxext6 libxshmfence1 \
|
libxcb1 libxext6 libxshmfence1 \
|
||||||
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
|
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
|
||||||
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml README.md LICENSE ./
|
# Python wrapper
|
||||||
|
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
|
||||||
COPY cloakbrowser/ cloakbrowser/
|
COPY cloakbrowser/ cloakbrowser/
|
||||||
|
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
# Pre-download stealth Chromium binary during build (not at runtime)
|
# JS wrapper
|
||||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()"
|
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
|
||||||
|
RUN chmod +x /usr/local/bin/cloaktest
|
||||||
|
|
||||||
|
# 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"]
|
CMD ["python"]
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<p align="center">
|
<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://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="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="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>
|
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/cloakhq/cloakbrowser" alt="Last Commit"></a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a>
|
<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://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://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm 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>
|
</p>
|
||||||
@@ -35,13 +35,17 @@ 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>
|
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
- 🔒 **26 source-level C++ patches** — not JS injection, not config flags
|
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||||
- 🛡️ **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
|
||||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
- **Auto-updating binary** — background update checks, always on the latest stealth build
|
||||||
- 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS)
|
- **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||||
- 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
- **Free and open source** — no subscriptions, no usage limits
|
||||||
- 💸 **Enterprise results, zero cost** — anti-detect browsers charge $49–299/month for the same results. CloakBrowser is free
|
|
||||||
|
**Try it now** — no install needed:
|
||||||
|
```bash
|
||||||
|
docker run --rm cloakhq/cloakbrowser cloaktest
|
||||||
|
```
|
||||||
|
|
||||||
**Python:**
|
**Python:**
|
||||||
```python
|
```python
|
||||||
@@ -104,16 +108,16 @@ page.goto("https://example.com")
|
|||||||
|
|
||||||
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
|
> ⭐ **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.4
|
## Latest: v0.3.8 (Chromium 145.0.7632.159)
|
||||||
|
|
||||||
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
|
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
|
||||||
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
|
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
|
||||||
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
||||||
- **Deterministic seeds** — `--fingerprint=seed` produces the same identity across launches for session persistence
|
|
||||||
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
|
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
|
||||||
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
|
|
||||||
- **SHA-256 checksum verification** — binary downloads are verified for integrity
|
|
||||||
- **CDP hardening** — audited and patched known automation detection vectors
|
- **CDP hardening** — audited and patched known automation detection vectors
|
||||||
|
- **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.
|
See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||||
|
|
||||||
@@ -121,12 +125,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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
||||||
- **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services.
|
|
||||||
|
|
||||||
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
|
## Test Results
|
||||||
|
|
||||||
@@ -148,7 +151,7 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
|
|||||||
| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
|
| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
|
||||||
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
|
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
|
||||||
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
|
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
|
||||||
| | | **30/30 passed** | |
|
| | | **Tested against 30+ detection sites** | |
|
||||||
|
|
||||||
### Proof
|
### Proof
|
||||||
|
|
||||||
@@ -172,6 +175,18 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
|
|||||||
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
|
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
|
||||||
</p>
|
</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
|
## How It Works
|
||||||
|
|
||||||
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
|
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
|
||||||
@@ -203,6 +218,9 @@ browser = launch(headless=False)
|
|||||||
# With proxy
|
# With proxy
|
||||||
browser = launch(proxy="http://user:pass@proxy:8080")
|
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
|
# With extra Chrome args
|
||||||
browser = launch(args=["--disable-gpu"])
|
browser = launch(args=["--disable-gpu"])
|
||||||
|
|
||||||
@@ -248,7 +266,7 @@ context = launch_context(
|
|||||||
user_agent="Custom UA",
|
user_agent="Custom UA",
|
||||||
viewport={"width": 1920, "height": 1080},
|
viewport={"width": 1920, "height": 1080},
|
||||||
locale="en-US",
|
locale="en-US",
|
||||||
timezone_id="America/New_York",
|
timezone="America/New_York",
|
||||||
)
|
)
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
page.goto("https://protected-site.com")
|
page.goto("https://protected-site.com")
|
||||||
@@ -259,6 +277,12 @@ context.close()
|
|||||||
|
|
||||||
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.
|
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
|
```python
|
||||||
from cloakbrowser import launch_persistent_context
|
from cloakbrowser import launch_persistent_context
|
||||||
|
|
||||||
@@ -272,7 +296,7 @@ ctx.close() # profile saved
|
|||||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||||
```
|
```
|
||||||
|
|
||||||
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone_id`, `color_scheme`, `geoip`.
|
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`.
|
||||||
|
|
||||||
Async version: `launch_persistent_context_async()`.
|
Async version: `launch_persistent_context_async()`.
|
||||||
|
|
||||||
@@ -283,7 +307,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
|
|||||||
|
|
||||||
# Check binary installation status
|
# Check binary installation status
|
||||||
print(binary_info())
|
print(binary_info())
|
||||||
# {'version': '145.0.7632.109', 'platform': 'linux-x64', 'installed': True, ...}
|
# {'version': '145.0.7632.159', 'platform': 'linux-x64', 'installed': True, ...}
|
||||||
|
|
||||||
# Force re-download
|
# Force re-download
|
||||||
clear_cache()
|
clear_cache()
|
||||||
@@ -318,7 +342,7 @@ const context = await launchContext({
|
|||||||
userAgent: 'Custom UA',
|
userAgent: 'Custom UA',
|
||||||
viewport: { width: 1920, height: 1080 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
locale: 'en-US',
|
locale: 'en-US',
|
||||||
timezoneId: 'America/New_York',
|
timezone: 'America/New_York',
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
@@ -453,17 +477,19 @@ browser = launch(args=[
|
|||||||
])
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comparison
|
## Examples
|
||||||
|
|
||||||
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|
**Python** — see [`examples/`](examples/):
|
||||||
|---|---|---|---|---|---|
|
- [`basic.py`](examples/basic.py) — Launch and load a page
|
||||||
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** |
|
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
|
||||||
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
|
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
|
||||||
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
|
- [`stealth_test.py`](examples/stealth_test.py) — Run against 6 detection sites
|
||||||
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
|
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
|
||||||
| Maintained | Yes | Stale | Stale | Unstable (2026 beta) | **Active** |
|
|
||||||
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
|
**JavaScript** — see [`js/examples/`](js/examples/):
|
||||||
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
|
- [`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) — Run against 6 detection sites
|
||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
@@ -478,58 +504,26 @@ 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.
|
**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
|
|
||||||
- [`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
|
|
||||||
- [`persistent-context.ts`](js/examples/persistent-context.ts) — Persistent profile with cookie/localStorage persistence
|
|
||||||
- [`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
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|
|
||||||
## Docker
|
## 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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t cloakbrowser .
|
# Run the stealth test suite
|
||||||
docker run --rm cloakbrowser python examples/basic.py
|
docker run --rm cloakhq/cloakbrowser cloaktest
|
||||||
```
|
|
||||||
|
|
||||||
The key steps in the Dockerfile:
|
# Run your own script
|
||||||
1. **System deps** — Chromium requires ~15 shared libraries (`libnss3`, `libgbm1`, etc.)
|
docker run --rm cloakhq/cloakbrowser python -c "
|
||||||
2. **`pip install .`** — installs CloakBrowser + Playwright
|
from cloakbrowser import launch
|
||||||
3. **`ensure_binary()`** — downloads the stealth Chromium binary at build time (~200MB), so containers start instantly
|
browser = launch()
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto('https://example.com')
|
||||||
|
print(page.title())
|
||||||
|
browser.close()
|
||||||
|
"
|
||||||
|
|
||||||
To extend with your own script, just add a `COPY` + `CMD`:
|
# With a proxy
|
||||||
|
docker run --rm cloakhq/cloakbrowser python -c "
|
||||||
```dockerfile
|
|
||||||
FROM cloakbrowser
|
|
||||||
COPY your_script.py /app/
|
|
||||||
CMD ["python", "your_script.py"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**With a proxy** (the most common production setup):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm cloakbrowser python -c "
|
|
||||||
from cloakbrowser import launch
|
from cloakbrowser import launch
|
||||||
browser = launch(proxy='http://user:pass@proxy:8080')
|
browser = launch(proxy='http://user:pass@proxy:8080')
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
@@ -539,13 +533,29 @@ browser.close()
|
|||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To extend with your own script:
|
||||||
|
|
||||||
|
```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.
|
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.
|
**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
|
```bash
|
||||||
# Install Xvfb (virtual framebuffer)
|
# Install Xvfb (virtual framebuffer)
|
||||||
@@ -566,31 +576,48 @@ page.goto("https://heavily-protected-site.com") # passes DataDome, etc.
|
|||||||
browser.close()
|
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
|
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:
|
||||||
|
|
||||||
**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:
|
|
||||||
|
|
||||||
```python
|
```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
|
```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:
|
Older versions may use outdated stealth args or download an older binary:
|
||||||
```bash
|
```bash
|
||||||
pip install -U cloakbrowser # Python
|
pip install -U cloakbrowser # Python
|
||||||
npm install cloakbrowser@latest # JavaScript
|
npm install cloakbrowser@latest # JavaScript
|
||||||
|
docker pull cloakhq/cloakbrowser:latest # Docker
|
||||||
```
|
```
|
||||||
|
|
||||||
**Binary download fails / timeout**
|
**Binary download fails / timeout**
|
||||||
@@ -599,6 +626,19 @@ Set a custom download URL or use a local binary:
|
|||||||
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
|
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/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**
|
**macOS: "App is damaged" or Gatekeeper blocks launch**
|
||||||
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
|
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
|
||||||
```bash
|
```bash
|
||||||
@@ -608,13 +648,35 @@ xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
|
|||||||
**"playwright install" vs CloakBrowser binary**
|
**"playwright install" vs CloakBrowser binary**
|
||||||
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
|
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
|
||||||
```bash
|
```bash
|
||||||
patchright install-deps chromium
|
playwright install-deps chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
**macOS: Blocked on some sites that pass on Linux**
|
**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).
|
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.1–0.3)**
|
**reCAPTCHA v3 scores are low (0.1–0.3)**
|
||||||
|
|
||||||
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||||
@@ -637,6 +699,7 @@ await new Promise(r => setTimeout(r, 3000));
|
|||||||
```
|
```
|
||||||
|
|
||||||
Other tips for maximizing reCAPTCHA scores:
|
Other tips for maximizing reCAPTCHA scores:
|
||||||
|
- **Try the Patchright backend** — suppresses CDP automation signals that reCAPTCHA Enterprise detects. 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 when you need the extra CDP stealth
|
||||||
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
|
- **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
|
- **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
|
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
|
||||||
@@ -651,7 +714,7 @@ Other tips for maximizing reCAPTCHA scores:
|
|||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Is this legal?**
|
**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?**
|
**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.
|
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.
|
||||||
@@ -662,6 +725,17 @@ A: Possibly. Bot detection is an arms race. Source-level patches are harder to d
|
|||||||
**Q: Can I use my own proxy?**
|
**Q: Can I use my own proxy?**
|
||||||
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
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
|
## Links
|
||||||
|
|
||||||
- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md)
|
- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md)
|
||||||
|
|||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run CloakBrowser stealth test suite
|
||||||
|
exec python -u /app/examples/stealth_test.py --no-screenshots "$@"
|
||||||
@@ -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 "$@"
|
||||||
@@ -11,7 +11,7 @@ Usage:
|
|||||||
browser.close()
|
browser.close()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async
|
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 .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
@@ -28,5 +28,6 @@ __all__ = [
|
|||||||
"check_for_update",
|
"check_for_update",
|
||||||
"CHROMIUM_VERSION",
|
"CHROMIUM_VERSION",
|
||||||
"get_default_stealth_args",
|
"get_default_stealth_args",
|
||||||
|
"ProxySettings",
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.5"
|
__version__ = "0.3.9"
|
||||||
|
|||||||
+156
-44
@@ -16,7 +16,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Literal
|
import warnings
|
||||||
|
from typing import Any, Literal, TypedDict
|
||||||
from urllib.parse import unquote, urlparse, urlunparse
|
from urllib.parse import unquote, urlparse, urlunparse
|
||||||
|
|
||||||
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
|
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
|
||||||
@@ -25,21 +26,48 @@ from .download import ensure_binary
|
|||||||
logger = logging.getLogger("cloakbrowser")
|
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(
|
def launch(
|
||||||
headless: bool = True,
|
headless: bool = True,
|
||||||
proxy: str | None = None,
|
proxy: str | ProxySettings | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
stealth_args: bool = True,
|
stealth_args: bool = True,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
|
backend: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
headless: Run in headless mode (default True).
|
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.
|
args: Additional Chromium CLI arguments to pass.
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
Set to False if you want to pass your own --fingerprint flags.
|
Set to False if you want to pass your own --fingerprint flags.
|
||||||
@@ -49,6 +77,10 @@ def launch(
|
|||||||
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
|
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
|
||||||
GeoLite2-City database on first use. Explicit timezone/locale
|
GeoLite2-City database on first use. Explicit timezone/locale
|
||||||
always override geoip results.
|
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.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -62,7 +94,7 @@ def launch(
|
|||||||
>>> print(page.title())
|
>>> print(page.title())
|
||||||
>>> browser.close()
|
>>> browser.close()
|
||||||
"""
|
"""
|
||||||
from patchright.sync_api import sync_playwright
|
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
@@ -94,24 +126,26 @@ def launch(
|
|||||||
|
|
||||||
async def launch_async(
|
async def launch_async(
|
||||||
headless: bool = True,
|
headless: bool = True,
|
||||||
proxy: str | None = None,
|
proxy: str | ProxySettings | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
stealth_args: bool = True,
|
stealth_args: bool = True,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
|
backend: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Async version of launch(). Returns a Playwright Browser object.
|
"""Async version of launch(). Returns a Playwright Browser object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
headless: Run in headless mode (default True).
|
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.
|
args: Additional Chromium CLI arguments to pass.
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
|
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.
|
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -130,7 +164,7 @@ async def launch_async(
|
|||||||
>>>
|
>>>
|
||||||
>>> asyncio.run(main())
|
>>> asyncio.run(main())
|
||||||
"""
|
"""
|
||||||
from patchright.async_api import async_playwright
|
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
@@ -163,15 +197,16 @@ async def launch_async(
|
|||||||
def launch_persistent_context(
|
def launch_persistent_context(
|
||||||
user_data_dir: str | os.PathLike,
|
user_data_dir: str | os.PathLike,
|
||||||
headless: bool = True,
|
headless: bool = True,
|
||||||
proxy: str | None = None,
|
proxy: str | ProxySettings | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
stealth_args: bool = True,
|
stealth_args: bool = True,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
viewport: dict | None = None,
|
viewport: dict | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
timezone_id: str | None = None,
|
timezone: str | None = None,
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
|
backend: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||||
@@ -185,17 +220,18 @@ def launch_persistent_context(
|
|||||||
Created automatically if it doesn't exist. Reuse the same path across
|
Created automatically if it doesn't exist. Reuse the same path across
|
||||||
sessions to restore cookies, localStorage, cached credentials, etc.
|
sessions to restore cookies, localStorage, cached credentials, etc.
|
||||||
headless: Run in headless mode (default True).
|
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.
|
args: Additional Chromium CLI arguments.
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
user_agent: Custom user agent string.
|
user_agent: Custom user agent string.
|
||||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||||
locale: Browser locale, e.g. "en-US".
|
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'.
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
Default: None (uses Chromium default, which is 'light').
|
Default: None (uses Chromium default, which is 'light').
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
Requires ``pip install cloakbrowser[geoip]``.
|
Requires ``pip install cloakbrowser[geoip]``.
|
||||||
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -209,11 +245,13 @@ def launch_persistent_context(
|
|||||||
>>> page.goto("https://protected-site.com")
|
>>> page.goto("https://protected-site.com")
|
||||||
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
|
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
|
||||||
"""
|
"""
|
||||||
from patchright.sync_api import sync_playwright
|
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||||
|
|
||||||
|
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
chrome_args = _build_args(stealth_args, args, timezone=timezone_id, locale=locale)
|
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||||
@@ -227,8 +265,8 @@ def launch_persistent_context(
|
|||||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||||
if locale:
|
if locale:
|
||||||
context_kwargs["locale"] = locale
|
context_kwargs["locale"] = locale
|
||||||
if timezone_id:
|
if timezone:
|
||||||
context_kwargs["timezone_id"] = timezone_id
|
context_kwargs["timezone_id"] = timezone
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
@@ -259,15 +297,16 @@ def launch_persistent_context(
|
|||||||
async def launch_persistent_context_async(
|
async def launch_persistent_context_async(
|
||||||
user_data_dir: str | os.PathLike,
|
user_data_dir: str | os.PathLike,
|
||||||
headless: bool = True,
|
headless: bool = True,
|
||||||
proxy: str | None = None,
|
proxy: str | ProxySettings | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
stealth_args: bool = True,
|
stealth_args: bool = True,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
viewport: dict | None = None,
|
viewport: dict | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
timezone_id: str | None = None,
|
timezone: str | None = None,
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
|
backend: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Async version of launch_persistent_context().
|
"""Async version of launch_persistent_context().
|
||||||
@@ -280,15 +319,16 @@ async def launch_persistent_context_async(
|
|||||||
user_data_dir: Path to the directory where browser profile data is stored.
|
user_data_dir: Path to the directory where browser profile data is stored.
|
||||||
Created automatically if it doesn't exist.
|
Created automatically if it doesn't exist.
|
||||||
headless: Run in headless mode (default True).
|
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.
|
args: Additional Chromium CLI arguments.
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
user_agent: Custom user agent string.
|
user_agent: Custom user agent string.
|
||||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||||
locale: Browser locale, e.g. "en-US".
|
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'.
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -307,11 +347,13 @@ async def launch_persistent_context_async(
|
|||||||
>>>
|
>>>
|
||||||
>>> asyncio.run(main())
|
>>> asyncio.run(main())
|
||||||
"""
|
"""
|
||||||
from patchright.async_api import async_playwright
|
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||||
|
|
||||||
|
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
chrome_args = _build_args(stealth_args, args, timezone=timezone_id, locale=locale)
|
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||||
@@ -325,8 +367,8 @@ async def launch_persistent_context_async(
|
|||||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||||
if locale:
|
if locale:
|
||||||
context_kwargs["locale"] = locale
|
context_kwargs["locale"] = locale
|
||||||
if timezone_id:
|
if timezone:
|
||||||
context_kwargs["timezone_id"] = timezone_id
|
context_kwargs["timezone_id"] = timezone
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
@@ -356,15 +398,16 @@ async def launch_persistent_context_async(
|
|||||||
|
|
||||||
def launch_context(
|
def launch_context(
|
||||||
headless: bool = True,
|
headless: bool = True,
|
||||||
proxy: str | None = None,
|
proxy: str | ProxySettings | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
stealth_args: bool = True,
|
stealth_args: bool = True,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
viewport: dict | None = None,
|
viewport: dict | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
timezone_id: str | None = None,
|
timezone: str | None = None,
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
|
backend: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||||
@@ -374,30 +417,32 @@ def launch_context(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
headless: Run in headless mode (default True).
|
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.
|
args: Additional Chromium CLI arguments.
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
user_agent: Custom user agent string.
|
user_agent: Custom user agent string.
|
||||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||||
locale: Browser locale, e.g. "en-US".
|
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'.
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
Default: None (uses Chromium default, which is 'light').
|
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).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
**kwargs: Passed to browser.new_context().
|
**kwargs: Passed to browser.new_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Playwright BrowserContext object.
|
Playwright BrowserContext object.
|
||||||
"""
|
"""
|
||||||
|
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||||
|
|
||||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||||
# resolved values flow to both binary flags AND context params
|
# 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
|
# Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||||
# context and interferes with Playwright's timezone_id on new contexts.
|
# context and interferes with Playwright's timezone_id on new contexts.
|
||||||
# Timezone is set via browser.new_context(timezone_id=...) below instead.
|
# Timezone is set via browser.new_context(timezone_id=...) below instead.
|
||||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||||
timezone=None, locale=locale)
|
timezone=None, locale=locale, backend=backend)
|
||||||
|
|
||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
@@ -405,8 +450,8 @@ def launch_context(
|
|||||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||||
if locale:
|
if locale:
|
||||||
context_kwargs["locale"] = locale
|
context_kwargs["locale"] = locale
|
||||||
if timezone_id:
|
if timezone:
|
||||||
context_kwargs["timezone_id"] = timezone_id
|
context_kwargs["timezone_id"] = timezone
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
@@ -429,6 +474,47 @@ def launch_context(
|
|||||||
return context
|
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
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -436,7 +522,7 @@ def launch_context(
|
|||||||
|
|
||||||
def _maybe_resolve_geoip(
|
def _maybe_resolve_geoip(
|
||||||
geoip: bool,
|
geoip: bool,
|
||||||
proxy: str | None,
|
proxy: str | ProxySettings | None,
|
||||||
timezone: str | None,
|
timezone: str | None,
|
||||||
locale: str | None,
|
locale: str | None,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
@@ -446,7 +532,10 @@ def _maybe_resolve_geoip(
|
|||||||
|
|
||||||
from .geoip import resolve_proxy_geo
|
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:
|
if timezone is None:
|
||||||
timezone = geo_tz
|
timezone = geo_tz
|
||||||
if locale is None:
|
if locale is None:
|
||||||
@@ -460,18 +549,39 @@ def _build_args(
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Combine stealth args with user-provided args and locale flags."""
|
"""Combine stealth args with user-provided args and locale flags.
|
||||||
result = []
|
|
||||||
|
Deduplicates by flag key (everything before '=').
|
||||||
|
Priority: stealth defaults < user args < dedicated params (timezone/locale).
|
||||||
|
"""
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
|
||||||
if stealth_args:
|
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:
|
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
|
# Timezone/locale flags are independent of stealth_args — always inject when set
|
||||||
if timezone:
|
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:
|
if locale:
|
||||||
result.append(f"--lang={locale}")
|
key = "--lang"
|
||||||
return result
|
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]:
|
def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||||
@@ -500,8 +610,10 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
|||||||
return result
|
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."""
|
"""Build proxy kwargs for Playwright launch."""
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
return {}
|
return {}
|
||||||
|
if isinstance(proxy, dict):
|
||||||
|
return {"proxy": proxy}
|
||||||
return {"proxy": _parse_proxy_url(proxy)}
|
return {"proxy": _parse_proxy_url(proxy)}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ from ._version import __version__
|
|||||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||||
# Use get_chromium_version() for the current platform's actual version.
|
# Use get_chromium_version() for the current platform's actual version.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
CHROMIUM_VERSION = "145.0.7632.109.2"
|
CHROMIUM_VERSION = "145.0.7632.159"
|
||||||
|
|
||||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||||
"linux-x64": "145.0.7632.109.2",
|
"linux-x64": "145.0.7632.159",
|
||||||
"darwin-arm64": "145.0.7632.109.2",
|
"darwin-arm64": "145.0.7632.109.2",
|
||||||
"darwin-x64": "145.0.7632.109.2",
|
"darwin-x64": "145.0.7632.109.2",
|
||||||
"windows-x64": "145.0.7632.109.2",
|
"windows-x64": "145.0.7632.109.2",
|
||||||
|
|||||||
@@ -44,12 +44,31 @@ from .config import (
|
|||||||
logger = logging.getLogger("cloakbrowser")
|
logger = logging.getLogger("cloakbrowser")
|
||||||
|
|
||||||
# Timeout for download (large binary, allow 10 min)
|
# 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)
|
# Auto-update check interval (1 hour)
|
||||||
UPDATE_CHECK_INTERVAL = 3600
|
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:
|
def ensure_binary() -> str:
|
||||||
"""Ensure the stealth Chromium binary is available. Download if needed.
|
"""Ensure the stealth Chromium binary is available. Download if needed.
|
||||||
|
|
||||||
@@ -77,6 +96,7 @@ def ensure_binary() -> str:
|
|||||||
|
|
||||||
if binary_path.exists() and _is_executable(binary_path):
|
if binary_path.exists() and _is_executable(binary_path):
|
||||||
logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
|
logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
|
||||||
|
_show_welcome()
|
||||||
_maybe_trigger_update_check()
|
_maybe_trigger_update_check()
|
||||||
return str(binary_path)
|
return str(binary_path)
|
||||||
|
|
||||||
@@ -146,9 +166,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
|||||||
_verify_download_checksum(tmp_path, version)
|
_verify_download_checksum(tmp_path, version)
|
||||||
|
|
||||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||||
logger.info("Visit https://cloakbrowser.dev for docs and release notifications.")
|
_show_welcome()
|
||||||
logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues")
|
|
||||||
logger.info("Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser")
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from cloakbrowser import launch
|
from cloakbrowser import launch
|
||||||
|
|
||||||
|
print("Launching stealth browser...", flush=True)
|
||||||
browser = launch(headless=False)
|
browser = launch(headless=False)
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ def main():
|
|||||||
print(f"Proxy: {PROXY or 'none'}")
|
print(f"Proxy: {PROXY or 'none'}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
print("Launching stealth browser...", flush=True)
|
||||||
context = launch_context(
|
context = launch_context(
|
||||||
headless=HEADLESS,
|
headless=HEADLESS,
|
||||||
proxy=PROXY,
|
proxy=PROXY,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ PROFILE_DIR = "./my-profile"
|
|||||||
|
|
||||||
# Session 1 — set some state
|
# Session 1 — set some state
|
||||||
print("=== Session 1: Setting state ===")
|
print("=== Session 1: Setting state ===")
|
||||||
|
print("Launching stealth browser...", flush=True)
|
||||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||||
page = ctx.new_page()
|
page = ctx.new_page()
|
||||||
page.goto("https://example.com")
|
page.goto("https://example.com")
|
||||||
@@ -18,6 +19,7 @@ ctx.close()
|
|||||||
|
|
||||||
# Session 2 — state is restored
|
# Session 2 — state is restored
|
||||||
print("\n=== Session 2: Verifying persistence ===")
|
print("\n=== Session 2: Verifying persistence ===")
|
||||||
|
print("Launching stealth browser...", flush=True)
|
||||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||||
page = ctx.new_page()
|
page = ctx.new_page()
|
||||||
page.goto("https://example.com")
|
page.goto("https://example.com")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import time
|
|||||||
|
|
||||||
from cloakbrowser import launch
|
from cloakbrowser import launch
|
||||||
|
|
||||||
|
print("Launching stealth browser...", flush=True)
|
||||||
browser = launch(headless=True)
|
browser = launch(headless=True)
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
|
|
||||||
|
|||||||
+80
-32
@@ -53,21 +53,27 @@ def test_bot_sannysoft(page):
|
|||||||
def test_bot_incolumitas(page):
|
def test_bot_incolumitas(page):
|
||||||
"""bot.incolumitas.com — comprehensive 30+ check bot detection."""
|
"""bot.incolumitas.com — comprehensive 30+ check bot detection."""
|
||||||
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
|
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
|
# Poll until test count stabilizes (site runs tests progressively)
|
||||||
results = page.evaluate("""() => {
|
last_total = 0
|
||||||
const text = document.body.innerText;
|
for _ in range(15):
|
||||||
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
|
time.sleep(2)
|
||||||
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
|
results = page.evaluate("""() => {
|
||||||
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
|
const text = document.body.innerText;
|
||||||
return {
|
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
|
||||||
passed: okMatches.length,
|
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
|
||||||
failed: failMatches.length,
|
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
|
||||||
failedTests,
|
return {
|
||||||
total: okMatches.length + failMatches.length
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -146,23 +152,18 @@ def test_recaptcha(page):
|
|||||||
wait_until="domcontentloaded",
|
wait_until="domcontentloaded",
|
||||||
timeout=30000,
|
timeout=30000,
|
||||||
)
|
)
|
||||||
# Wait for backend response (step3 element appears when score arrives)
|
# Wait for score to appear (polls up to 30s)
|
||||||
try:
|
for _ in range(15):
|
||||||
page.wait_for_selector("li.step3", timeout=20000)
|
time.sleep(2)
|
||||||
time.sleep(1)
|
score = page.evaluate("""() => {
|
||||||
except Exception:
|
const text = document.body.innerText;
|
||||||
time.sleep(10) # fallback
|
const match = text.match(/"score":\\s*(\\d+\\.\\d+)/);
|
||||||
|
return match ? parseFloat(match[1]) : null;
|
||||||
|
}""")
|
||||||
|
if score is not None:
|
||||||
|
break
|
||||||
|
|
||||||
results = page.evaluate("""() => {
|
return {"score": score}
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
TESTS = [
|
TESTS = [
|
||||||
@@ -179,8 +180,11 @@ TESTS = [
|
|||||||
"url": "https://bot.incolumitas.com",
|
"url": "https://bot.incolumitas.com",
|
||||||
"runner": test_bot_incolumitas,
|
"runner": test_bot_incolumitas,
|
||||||
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
|
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
|
||||||
+ (f" (FAILED: {', '.join(r.get('failedTests', []))})" if r.get("failed", 0) > 0 else " — ALL GREEN"),
|
+ (" — ALL GREEN" if r.get("failed", 0) == 0
|
||||||
"pass": lambda r: r.get("failed", 0) <= 1, # fpscanner.WEBDRIVER false positive expected (all builds)
|
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",
|
"name": "BrowserScan",
|
||||||
@@ -222,10 +226,54 @@ def main():
|
|||||||
print(f"Screenshots: {'on' if SCREENSHOTS else 'off'}")
|
print(f"Screenshots: {'on' if SCREENSHOTS else 'off'}")
|
||||||
print(f"Proxy: {PROXY or 'none'}")
|
print(f"Proxy: {PROXY or 'none'}")
|
||||||
print()
|
print()
|
||||||
|
print("Launching stealth browser...", flush=True)
|
||||||
|
|
||||||
browser = launch(headless=not HEADED, proxy=PROXY)
|
browser = launch(headless=not HEADED, proxy=PROXY)
|
||||||
page = browser.new_page()
|
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 = []
|
results_summary = []
|
||||||
|
|
||||||
for test in TESTS:
|
for test in TESTS:
|
||||||
|
|||||||
+57
-14
@@ -9,13 +9,14 @@
|
|||||||
|
|
||||||
**Stealth Chromium that passes every bot detection test.**
|
**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.**
|
||||||
|
|
||||||
- 🔒 **26 source-level C++ patches** — not JS injection, not config flags
|
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||||
- 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer
|
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
||||||
- 📦 **`npm install cloakbrowser`** — binary auto-downloads, 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
|
## Install
|
||||||
|
|
||||||
@@ -67,6 +68,11 @@ const browser = await launch({
|
|||||||
proxy: 'http://user:pass@proxy:8080',
|
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)
|
// Headed mode (visible browser window)
|
||||||
const browser = await launch({ headless: false });
|
const browser = await launch({ headless: false });
|
||||||
|
|
||||||
@@ -92,10 +98,10 @@ const context = await launchContext({
|
|||||||
userAgent: 'Custom UA',
|
userAgent: 'Custom UA',
|
||||||
viewport: { width: 1920, height: 1080 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
locale: 'en-US',
|
locale: 'en-US',
|
||||||
timezoneId: 'America/New_York',
|
timezone: 'America/New_York',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection
|
// Persistent profile — stay logged in, bypass incognito detection, load extensions
|
||||||
const ctx = await launchPersistentContext({
|
const ctx = await launchPersistentContext({
|
||||||
userDataDir: './chrome-profile',
|
userDataDir: './chrome-profile',
|
||||||
headless: false,
|
headless: false,
|
||||||
@@ -156,6 +162,9 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
|||||||
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
|
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
|
||||||
| **bot.incolumitas.com** | 13 fails | **1 fail** |
|
| **bot.incolumitas.com** | 13 fails | **1 fail** |
|
||||||
| `navigator.webdriver` | `true` | **`false`** |
|
| `navigator.webdriver` | `true` | **`false`** |
|
||||||
|
| CDP detection | Detected | **Not detected** |
|
||||||
|
| TLS fingerprint | Mismatch | **Identical to Chrome** |
|
||||||
|
| | | **Tested against 30+ detection sites** |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -181,12 +190,12 @@ const page = await browser.newPage();
|
|||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
| Platform | Status |
|
| Platform | Chromium | Patches | Status |
|
||||||
|---|---|
|
|---|---|---|---|
|
||||||
| Linux x86_64 | ✅ Available |
|
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||||
| macOS arm64 (Apple Silicon) | ✅ Available |
|
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||||
| macOS x86_64 (Intel) | ✅ Available |
|
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||||
| Windows x86_64 | ✅ Available |
|
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -195,6 +204,21 @@ const page = await browser.newPage();
|
|||||||
|
|
||||||
## Troubleshooting
|
## 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.1–0.3)**
|
**reCAPTCHA v3 scores are low (0.1–0.3)**
|
||||||
|
|
||||||
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||||
@@ -213,8 +237,25 @@ Other tips for maximizing reCAPTCHA scores:
|
|||||||
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
|
- **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
|
- **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 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
|
- **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/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
|
## Links
|
||||||
|
|
||||||
- 🌐 [Website](https://cloakbrowser.dev)
|
- 🌐 [Website](https://cloakbrowser.dev)
|
||||||
@@ -227,3 +268,5 @@ Other tips for maximizing reCAPTCHA scores:
|
|||||||
|
|
||||||
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
- **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).
|
- **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.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.5",
|
"version": "0.3.9",
|
||||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -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()];
|
||||||
|
}
|
||||||
+2
-2
@@ -27,10 +27,10 @@ export { WRAPPER_VERSION };
|
|||||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||||
// Use getChromiumVersion() for the current platform's actual version.
|
// Use getChromiumVersion() for the current platform's actual version.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const CHROMIUM_VERSION = "145.0.7632.109.2";
|
export const CHROMIUM_VERSION = "145.0.7632.159";
|
||||||
|
|
||||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||||
"linux-x64": "145.0.7632.109.2",
|
"linux-x64": "145.0.7632.159",
|
||||||
"darwin-arm64": "145.0.7632.109.2",
|
"darwin-arm64": "145.0.7632.109.2",
|
||||||
"darwin-x64": "145.0.7632.109.2",
|
"darwin-x64": "145.0.7632.109.2",
|
||||||
"windows-x64": "145.0.7632.109.2",
|
"windows-x64": "145.0.7632.109.2",
|
||||||
|
|||||||
+24
-9
@@ -65,6 +65,7 @@ export async function ensureBinary(): Promise<string> {
|
|||||||
const binaryPath = getBinaryPath(effective);
|
const binaryPath = getBinaryPath(effective);
|
||||||
|
|
||||||
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
|
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
|
||||||
|
showWelcome();
|
||||||
maybeTriggerUpdateCheck();
|
maybeTriggerUpdateCheck();
|
||||||
return binaryPath;
|
return binaryPath;
|
||||||
}
|
}
|
||||||
@@ -138,6 +139,28 @@ export async function checkForUpdate(): Promise<string | null> {
|
|||||||
return latest;
|
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
|
// Internal helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -177,15 +200,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||||
console.log(
|
showWelcome();
|
||||||
`[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`
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up temp file
|
// Clean up temp file
|
||||||
if (fs.existsSync(tmpPath)) {
|
if (fs.existsSync(tmpPath)) {
|
||||||
|
|||||||
+29
-26
@@ -5,10 +5,22 @@
|
|||||||
|
|
||||||
import type { Browser, BrowserContext } from "playwright-core";
|
import type { Browser, BrowserContext } from "playwright-core";
|
||||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||||
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
|
import { DEFAULT_VIEWPORT } from "./config.js";
|
||||||
|
import { buildArgs } from "./args.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { parseProxyUrl } from "./proxy.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.
|
* Launch stealth Chromium browser via Playwright.
|
||||||
*
|
*
|
||||||
@@ -34,7 +46,9 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: ["--enable-automation"],
|
ignoreDefaultArgs: ["--enable-automation"],
|
||||||
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
|
...(options.proxy
|
||||||
|
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||||
|
: {}),
|
||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,9 +74,13 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
export async function launchContext(
|
export async function launchContext(
|
||||||
options: LaunchContextOptions = {}
|
options: LaunchContextOptions = {}
|
||||||
): Promise<BrowserContext> {
|
): Promise<BrowserContext> {
|
||||||
|
options = migrateTimezoneId(options);
|
||||||
// Resolve geoip BEFORE launch() to avoid double-resolution
|
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||||
const resolved = await maybeResolveGeoip(options);
|
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;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
@@ -112,6 +130,7 @@ export async function launchContext(
|
|||||||
export async function launchPersistentContext(
|
export async function launchPersistentContext(
|
||||||
options: LaunchPersistentContextOptions
|
options: LaunchPersistentContextOptions
|
||||||
): Promise<BrowserContext> {
|
): Promise<BrowserContext> {
|
||||||
|
options = migrateTimezoneId(options);
|
||||||
const { chromium } = await import("playwright-core");
|
const { chromium } = await import("playwright-core");
|
||||||
|
|
||||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
@@ -123,7 +142,9 @@ export async function launchPersistentContext(
|
|||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: ["--enable-automation"],
|
ignoreDefaultArgs: ["--enable-automation"],
|
||||||
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
|
...(options.proxy
|
||||||
|
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||||
|
: {}),
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
||||||
...(resolved.locale ? { locale: resolved.locale } : {}),
|
...(resolved.locale ? { locale: resolved.locale } : {}),
|
||||||
@@ -146,7 +167,9 @@ async function maybeResolveGeoip(
|
|||||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||||
|
|
||||||
const { resolveProxyGeo } = await import("./geoip.js");
|
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 {
|
return {
|
||||||
timezone: options.timezone ?? geoTz ?? undefined,
|
timezone: options.timezone ?? geoTz ?? undefined,
|
||||||
locale: options.locale ?? geoLocale ?? undefined,
|
locale: options.locale ?? geoLocale ?? undefined,
|
||||||
@@ -154,24 +177,4 @@ async function maybeResolveGeoip(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal Exposed for unit tests only. */
|
/** @internal Exposed for unit tests only. */
|
||||||
export function _buildArgsForTest(options: LaunchOptions): string[] {
|
export { buildArgs as _buildArgsForTest } from "./args.js";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-22
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { Browser } from "puppeteer-core";
|
import type { Browser } from "puppeteer-core";
|
||||||
import type { LaunchOptions } from "./types.js";
|
import type { LaunchOptions } from "./types.js";
|
||||||
import { getDefaultStealthArgs } from "./config.js";
|
import { buildArgs } from "./args.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { parseProxyUrl } from "./proxy.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.
|
// so we strip them and use page.authenticate() instead.
|
||||||
let proxyAuth: { username: string; password: string } | undefined;
|
let proxyAuth: { username: string; password: string } | undefined;
|
||||||
if (options.proxy) {
|
if (options.proxy) {
|
||||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
if (typeof options.proxy === "string") {
|
||||||
args.push(`--proxy-server=${server}`);
|
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||||
if (username) {
|
args.push(`--proxy-server=${server}`);
|
||||||
proxyAuth = { username, password: password || "" };
|
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 };
|
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||||
|
|
||||||
const { resolveProxyGeo } = await import("./geoip.js");
|
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 {
|
return {
|
||||||
timezone: options.timezone ?? geoTz ?? undefined,
|
timezone: options.timezone ?? geoTz ?? undefined,
|
||||||
locale: options.locale ?? geoLocale ?? 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;
|
|
||||||
}
|
|
||||||
|
|||||||
+8
-3
@@ -5,8 +5,13 @@
|
|||||||
export interface LaunchOptions {
|
export interface LaunchOptions {
|
||||||
/** Run in headless mode (default: true). */
|
/** Run in headless mode (default: true). */
|
||||||
headless?: boolean;
|
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. */
|
/** Additional Chromium CLI arguments. */
|
||||||
args?: string[];
|
args?: string[];
|
||||||
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
|
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
|
||||||
@@ -28,7 +33,7 @@ export interface LaunchContextOptions extends LaunchOptions {
|
|||||||
viewport?: { width: number; height: number };
|
viewport?: { width: number; height: number };
|
||||||
/** Browser locale, e.g. "en-US". */
|
/** Browser locale, e.g. "en-US". */
|
||||||
locale?: string;
|
locale?: string;
|
||||||
/** Timezone, e.g. "America/New_York". */
|
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
|
||||||
timezoneId?: string;
|
timezoneId?: string;
|
||||||
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
||||||
colorScheme?: "light" | "dark" | "no-preference";
|
colorScheme?: "light" | "dark" | "no-preference";
|
||||||
|
|||||||
+105
-1
@@ -1,13 +1,15 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
CHROMIUM_VERSION,
|
CHROMIUM_VERSION,
|
||||||
|
getArchiveExt,
|
||||||
getChromiumVersion,
|
getChromiumVersion,
|
||||||
getDefaultStealthArgs,
|
getDefaultStealthArgs,
|
||||||
getCacheDir,
|
getCacheDir,
|
||||||
getBinaryDir,
|
getBinaryDir,
|
||||||
getDownloadUrl,
|
getDownloadUrl,
|
||||||
|
getFallbackDownloadUrl,
|
||||||
} from "../src/config.js";
|
} from "../src/config.js";
|
||||||
import { _buildArgsForTest } from "../src/playwright.js";
|
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
|
||||||
|
|
||||||
describe("config", () => {
|
describe("config", () => {
|
||||||
it("CHROMIUM_VERSION matches expected format", () => {
|
it("CHROMIUM_VERSION matches expected format", () => {
|
||||||
@@ -68,6 +70,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", () => {
|
describe("buildArgs timezone/locale", () => {
|
||||||
it("injects --fingerprint-timezone when timezone is set", () => {
|
it("injects --fingerprint-timezone when timezone is set", () => {
|
||||||
const args = _buildArgsForTest({ timezone: "America/New_York" });
|
const args = _buildArgsForTest({ timezone: "America/New_York" });
|
||||||
@@ -98,3 +122,83 @@ describe("buildArgs timezone/locale", () => {
|
|||||||
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+172
-2
@@ -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 { binaryInfo } from "../src/download.js";
|
||||||
import { getChromiumVersion } from "../src/config.js";
|
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||||
|
|
||||||
describe("binaryInfo", () => {
|
describe("binaryInfo", () => {
|
||||||
it("returns correct structure", () => {
|
it("returns correct structure", () => {
|
||||||
@@ -37,3 +37,173 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
|
|||||||
}, 30_000);
|
}, 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseProxyUrl } from "../src/proxy.js";
|
import { parseProxyUrl } from "../src/proxy.js";
|
||||||
|
import type { LaunchOptions } from "../src/types.js";
|
||||||
|
|
||||||
describe("parseProxyUrl", () => {
|
describe("parseProxyUrl", () => {
|
||||||
it("passes through URL without credentials", () => {
|
it("passes through URL without credentials", () => {
|
||||||
@@ -47,3 +48,37 @@ describe("parseProxyUrl", () => {
|
|||||||
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
versionNewer,
|
versionNewer,
|
||||||
} from "../src/config.js";
|
} from "../src/config.js";
|
||||||
import {
|
import {
|
||||||
|
binaryInfo,
|
||||||
|
checkForUpdate,
|
||||||
checkWrapperUpdate,
|
checkWrapperUpdate,
|
||||||
|
clearCache,
|
||||||
|
ensureBinary,
|
||||||
getLatestChromiumVersion,
|
getLatestChromiumVersion,
|
||||||
parseChecksums,
|
parseChecksums,
|
||||||
resetWrapperUpdateChecked,
|
resetWrapperUpdateChecked,
|
||||||
@@ -271,3 +275,53 @@ describe("effective version", () => {
|
|||||||
expect(getEffectiveVersion()).toBe(getChromiumVersion());
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+2
-1
@@ -49,12 +49,13 @@ classifiers = [
|
|||||||
"Topic :: Software Development :: Testing",
|
"Topic :: Software Development :: Testing",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"patchright>=1.40",
|
"playwright>=1.40",
|
||||||
"httpx>=0.24",
|
"httpx>=0.24",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
geoip = ["geoip2>=4.0"]
|
geoip = ["geoip2>=4.0"]
|
||||||
|
patchright = ["patchright>=1.40"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/CloakHQ/CloakBrowser"
|
Homepage = "https://github.com/CloakHQ/CloakBrowser"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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():
|
def test_timezone_injected():
|
||||||
@@ -44,3 +46,121 @@ def test_extra_args_preserved():
|
|||||||
assert "--disable-gpu" in args
|
assert "--disable-gpu" in args
|
||||||
assert "--fingerprint-timezone=Asia/Tokyo" in args
|
assert "--fingerprint-timezone=Asia/Tokyo" in args
|
||||||
assert "--lang=ja-JP" 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
from cloakbrowser.browser import _maybe_resolve_geoip
|
from cloakbrowser.browser import _maybe_resolve_geoip
|
||||||
from cloakbrowser.geoip import (
|
from cloakbrowser.geoip import (
|
||||||
COUNTRY_LOCALE_MAP,
|
COUNTRY_LOCALE_MAP,
|
||||||
|
_is_private_ip,
|
||||||
_resolve_proxy_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)
|
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||||
assert tz == "Europe/Berlin"
|
assert tz == "Europe/Berlin"
|
||||||
assert loc == "de-DE"
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
"""Tests for proxy URL parsing and credential extraction."""
|
"""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:
|
class TestParseProxyUrl:
|
||||||
@@ -48,3 +50,51 @@ class TestBuildProxyKwargs:
|
|||||||
assert result == {
|
assert result == {
|
||||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
"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"
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ from cloakbrowser.download import (
|
|||||||
_parse_checksums,
|
_parse_checksums,
|
||||||
_should_check_for_update,
|
_should_check_for_update,
|
||||||
_verify_checksum,
|
_verify_checksum,
|
||||||
|
_write_version_marker,
|
||||||
|
check_for_update,
|
||||||
|
clear_cache,
|
||||||
|
ensure_binary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,3 +360,117 @@ class TestVerifyChecksum:
|
|||||||
file.write_bytes(b"real content")
|
file.write_bytes(b"real content")
|
||||||
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
||||||
_verify_checksum(file, "0" * 64)
|
_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"
|
||||||
|
|||||||
Reference in New Issue
Block a user