mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0446e142 | ||
|
|
0578fdc5e1 | ||
|
|
e411f24cf3 | ||
|
|
0a99a1458a | ||
|
|
f76dbdb044 | ||
|
|
976f5ae534 | ||
|
|
0719f750ef | ||
|
|
05fa1a052a | ||
|
|
25acff23b7 | ||
|
|
bd22e51bc2 | ||
|
|
ca5cce2222 | ||
|
|
de54e67f74 | ||
|
|
ef066fa091 | ||
|
|
5237065385 | ||
|
|
8e83b8c399 | ||
|
|
c44b04a953 | ||
|
|
51c3f464a5 | ||
|
|
ed0ecf0e48 | ||
|
|
46049a15d3 | ||
|
|
11b3bcb701 | ||
|
|
28de7bb147 | ||
|
|
0c64a32122 | ||
|
|
f9887943c0 | ||
|
|
55418add96 |
@@ -61,3 +61,4 @@ publish.sh
|
||||
deploy.sh
|
||||
.env
|
||||
debug
|
||||
publish-docker.sh
|
||||
|
||||
+22
-3
@@ -26,13 +26,21 @@ You may NOT:
|
||||
4. **Modify** the Binary or create derivative works based on it
|
||||
5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary
|
||||
|
||||
Listing CloakBrowser as a dependency in your project (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution — end users download the Binary directly from official CloakHQ channels.
|
||||
Normal use of the Binary with command-line flags, browser extensions, managed policies, custom profiles, or user data directories does not constitute modification or creation of derivative works.
|
||||
|
||||
Internal caching or mirroring (including via artifact repositories such as Artifactory or Nexus) of unmodified Binaries that were originally obtained from official CloakHQ distribution channels is permitted solely for internal operational purposes within your organization. This permission does not allow public redistribution or distribution to third parties.
|
||||
## Cloud, Container & Integration Use
|
||||
|
||||
**Internal use** — You may store and run the unmodified Binary within internal infrastructure, including Docker images, VM templates, CI runners, container registries, and artifact repositories (e.g., Artifactory, Nexus), solely for your organization's internal operational purposes.
|
||||
|
||||
**Dependency listing** — Listing CloakBrowser as a dependency in your project or third-party framework (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution, as end users download the Binary directly from official CloakHQ channels. No commercial license is required for this.
|
||||
|
||||
**Using CloakBrowser for your own business is free** — no license beyond this one is needed, regardless of company size or revenue.
|
||||
|
||||
**OEM/SaaS license required** — Bundling, embedding, or pre-installing the Binary into a product, hosted service, or cloud artifact distributed to third parties requires a separate OEM license. This includes running the Binary on your infrastructure to serve third-party customers (e.g., browser-as-a-service). Contact cloakhq@pm.me for OEM/SaaS licensing.
|
||||
|
||||
## Official Distribution
|
||||
|
||||
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Restrictions section are not considered unauthorized sources.
|
||||
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Cloud, Container & Integration Use section are not considered unauthorized sources.
|
||||
|
||||
## Trademark Notice
|
||||
|
||||
@@ -46,6 +54,13 @@ Attribution is appreciated but not required. If you'd like to credit CloakBrowse
|
||||
|
||||
You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use.
|
||||
|
||||
Without limiting the above, the following uses are expressly prohibited:
|
||||
|
||||
- Unauthorized access to financial, banking, healthcare, or government authentication systems
|
||||
- Credential stuffing, brute-force login attempts, or automated account creation
|
||||
- Circumventing authentication on systems you do not own or have authorization to test
|
||||
- Any activity that constitutes fraud, identity theft, or unauthorized data collection
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to indemnify and hold harmless CloakHQ and its contributors from any claims, damages, losses, liabilities, and expenses (including reasonable legal fees) arising from your unlawful use of the Binary or your violation of this license.
|
||||
@@ -54,6 +69,10 @@ You agree to indemnify and hold harmless CloakHQ and its contributors from any c
|
||||
|
||||
THE BINARY IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE BINARY OR THE USE OR OTHER DEALINGS IN THE BINARY.
|
||||
|
||||
## Limitation of Liability
|
||||
|
||||
IN NO EVENT SHALL CLOAKHQ OR ITS CONTRIBUTORS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, BUSINESS OPPORTUNITIES, OR GOODWILL, ARISING OUT OF OR IN CONNECTION WITH THE USE OF THE BINARY, REGARDLESS OF THE THEORY OF LIABILITY. CLOAKHQ'S TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED ONE HUNDRED US DOLLARS (US $100).
|
||||
|
||||
## Data Collection
|
||||
|
||||
CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary. The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ.
|
||||
|
||||
@@ -6,6 +6,57 @@ 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]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
|
||||
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
|
||||
|
||||
## [0.3.7] — 2026-03-05
|
||||
|
||||
- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`)
|
||||
- **[wrapper]** Docker Hub image (`cloakhq/cloakbrowser`) — pre-built with Python + JS wrappers, Xvfb for headed mode, and `cloaktest` CLI shortcut. One-liner: `docker run --rm cloakhq/cloakbrowser cloaktest`
|
||||
- **[wrapper]** Add "Launching stealth browser..." feedback to all examples for better UX in Docker/CI
|
||||
- **[wrapper]** Comprehensive unit tests: 169 Python + 88 JS (up from 59 + 47)
|
||||
- **[docs]** Streamline READMEs for launch — reorder for conversion, collapse fingerprint flags, update Docker section
|
||||
|
||||
## [0.3.6] — 2026-03-04
|
||||
|
||||
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
|
||||
|
||||
## [0.3.5] — 2026-03-04
|
||||
|
||||
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
|
||||
- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support
|
||||
- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23)
|
||||
|
||||
## [0.3.4] — 2026-03-04
|
||||
|
||||
Binary v14: auto-spoof restored with seed, wrapper simplified to match.
|
||||
|
||||
- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently
|
||||
- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags
|
||||
- **[binary]** 26 source-level C++ patches (up from 25)
|
||||
- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox`
|
||||
- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip
|
||||
- **[wrapper]** Clarify README platform detection behavior
|
||||
|
||||
## [0.3.3] — 2026-03-03
|
||||
|
||||
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
|
||||
|
||||
- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required
|
||||
- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags
|
||||
- **[binary]** Expanded GPU model database for realistic per-session diversity
|
||||
- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142
|
||||
- **[binary]** First Windows x64 v145 build — 25 patches
|
||||
- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection
|
||||
- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build
|
||||
- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints
|
||||
- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep
|
||||
- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`)
|
||||
|
||||
## [0.3.1] — 2026-03-03
|
||||
|
||||
- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`.
|
||||
|
||||
+29
-5
@@ -1,6 +1,6 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Chromium system deps (matches fingerprint-chromium 142+ requirements)
|
||||
# Chromium system deps + Node.js
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
|
||||
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
|
||||
@@ -9,16 +9,40 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libxcb1 libxext6 libxshmfence1 \
|
||||
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
|
||||
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
|
||||
xvfb xdotool \
|
||||
curl ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
# Python wrapper
|
||||
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
|
||||
COPY cloakbrowser/ cloakbrowser/
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Pre-download stealth Chromium binary during build (not at runtime)
|
||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()"
|
||||
# JS wrapper
|
||||
COPY js/ js/
|
||||
RUN cd js && npm install && npm run build
|
||||
|
||||
# Examples
|
||||
COPY examples/ examples/
|
||||
|
||||
# Pre-download stealth Chromium binary during build (not at runtime)
|
||||
# Remove welcome marker so users see it on first container run
|
||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
|
||||
&& rm -f ~/.cloakbrowser/.welcome_shown
|
||||
|
||||
# CLI shortcuts
|
||||
COPY bin/cloaktest /usr/local/bin/cloaktest
|
||||
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"]
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<p align="center">
|
||||
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a>
|
||||
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/CloakHQ/CloakBrowser" alt="License"></a>
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/cloakhq/cloakbrowser?v=1" alt="License"></a>
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/cloakhq/cloakbrowser" alt="Last Commit"></a>
|
||||
<br>
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a>
|
||||
<a href="https://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://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
|
||||
</p>
|
||||
@@ -35,13 +35,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>
|
||||
</p>
|
||||
|
||||
- 🔒 **25 source-level C++ patches** — not JS injection, not config flags
|
||||
- 🛡️ **CDP stealth built-in** — uses [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) to reduce Playwright's automation footprint
|
||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
||||
- 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS)
|
||||
- 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- 💸 **Enterprise results, zero cost** — anti-detect browsers charge $49–299/month for the same results. CloakBrowser is free
|
||||
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **Auto-updating binary** — background update checks, always on the latest stealth build
|
||||
- **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- **Free and open source** — no subscriptions, no usage limits
|
||||
|
||||
**Try it now** — no install needed:
|
||||
```bash
|
||||
docker run --rm cloakhq/cloakbrowser cloaktest
|
||||
```
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
@@ -104,14 +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.
|
||||
|
||||
## What's New in v0.3.0
|
||||
## Latest: v0.3.8 (Chromium 145.0.7632.159)
|
||||
|
||||
- **Chromium 145** (Linux) — latest stable, 25 fingerprint patches (up from 16). macOS v145 coming soon
|
||||
- **9 new patches** — screen dimensions, device memory, audio, WebGL, and more
|
||||
- **SHA-256 checksum verification** — binary downloads are verified for integrity
|
||||
- **CDP hardening** — audited and patched known automation detection vectors
|
||||
- **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)
|
||||
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
||||
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
|
||||
- **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.
|
||||
|
||||
@@ -121,10 +127,9 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
- **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.
|
||||
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
|
||||
- **Works with AI browser agents** — drop-in stealth binary for [browser-use](https://github.com/browser-use/browser-use), [agent-browser](https://github.com/nichochar/agent-browser), Claude computer use, and OpenAI Operator
|
||||
- **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services.
|
||||
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path.
|
||||
|
||||
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. Antibot systems score it as a normal browser because it *is* a normal browser, just with your fingerprints instead of theirs. No CAPTCHA services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
|
||||
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
|
||||
|
||||
## Test Results
|
||||
|
||||
@@ -146,7 +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 |
|
||||
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
|
||||
| | | **30/30 passed** | |
|
||||
| | | **Tested against 30+ detection sites** | |
|
||||
|
||||
### Proof
|
||||
|
||||
@@ -170,16 +175,28 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
|
||||
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
|
||||
</p>
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|
||||
|---|---|---|---|---|---|
|
||||
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** |
|
||||
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
|
||||
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
|
||||
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
|
||||
| Maintained | Yes | Stale | Stale | Unstable | **Active** |
|
||||
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
|
||||
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
|
||||
|
||||
## How It Works
|
||||
|
||||
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
|
||||
|
||||
1. **You install** → `pip install cloakbrowser` or `npm install cloakbrowser`
|
||||
2. **First launch** → binary auto-downloads for your platform (Linux x64: Chromium 145, macOS: Chromium 142)
|
||||
2. **First launch** → binary auto-downloads for your platform (Chromium 145)
|
||||
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
|
||||
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
|
||||
|
||||
The binary includes 25 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
|
||||
The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
|
||||
|
||||
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
|
||||
|
||||
@@ -201,8 +218,11 @@ browser = launch(headless=False)
|
||||
# With proxy
|
||||
browser = launch(proxy="http://user:pass@proxy:8080")
|
||||
|
||||
# With proxy dict (bypass, separate auth fields)
|
||||
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
|
||||
|
||||
# With extra Chrome args
|
||||
browser = launch(args=["--disable-gpu", "--window-size=1920,1080"])
|
||||
browser = launch(args=["--disable-gpu"])
|
||||
|
||||
# With timezone and locale (sets both binary flags and Playwright context)
|
||||
browser = launch(timezone="America/New_York", locale="en-US")
|
||||
@@ -237,7 +257,7 @@ asyncio.run(main())
|
||||
|
||||
### `launch_context()`
|
||||
|
||||
Convenience function that creates browser + context with common options:
|
||||
Convenience function that creates browser + context in one call with user agent, viewport, locale, and timezone:
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_context
|
||||
@@ -246,11 +266,40 @@ context = launch_context(
|
||||
user_agent="Custom UA",
|
||||
viewport={"width": 1920, "height": 1080},
|
||||
locale="en-US",
|
||||
timezone_id="America/New_York",
|
||||
timezone="America/New_York",
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto("https://protected-site.com")
|
||||
context.close()
|
||||
```
|
||||
|
||||
### `launch_persistent_context()`
|
||||
|
||||
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan.
|
||||
|
||||
Use this when you need to:
|
||||
- **Stay logged in** across runs (cookies/sessions survive restarts)
|
||||
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
|
||||
- **Load Chrome extensions** (extensions only work from a real user data dir)
|
||||
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
# First run — creates the profile
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://protected-site.com")
|
||||
ctx.close() # profile saved
|
||||
|
||||
# Next run — cookies, localStorage restored automatically
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
```
|
||||
|
||||
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`.
|
||||
|
||||
Async version: `launch_persistent_context_async()`.
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```python
|
||||
@@ -258,7 +307,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
|
||||
|
||||
# Check binary installation status
|
||||
print(binary_info())
|
||||
# {'version': '145.0.7632.109', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
# {'version': '145.0.7632.159', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
|
||||
# Force re-download
|
||||
clear_cache()
|
||||
@@ -274,7 +323,7 @@ CloakBrowser ships a TypeScript package with full type definitions. Choose Playw
|
||||
### Playwright (default)
|
||||
|
||||
```javascript
|
||||
import { launch, launchContext } from 'cloakbrowser';
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// Basic
|
||||
const browser = await launch();
|
||||
@@ -283,7 +332,7 @@ const browser = await launch();
|
||||
const browser = await launch({
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
args: ['--window-size=1920,1080'],
|
||||
args: ['--fingerprint=12345'],
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
});
|
||||
@@ -293,9 +342,16 @@ const context = await launchContext({
|
||||
userAgent: 'Custom UA',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './chrome-profile',
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
```
|
||||
|
||||
> **Note:** Each example above is standalone — not meant to run as one block.
|
||||
@@ -342,7 +398,17 @@ clearCache();
|
||||
|
||||
## Fingerprint Management
|
||||
|
||||
Every launch automatically generates a **unique fingerprint**. A random seed (10000–99999) drives all seed-based patches — canvas, WebGL, audio, fonts, and client rects all produce consistent, correlated values derived from that single seed.
|
||||
The binary is **stealthy by default** — no flags needed. It auto-generates a random fingerprint seed at startup and spoofs all detectable values (GPU, hardware specs, screen dimensions, canvas, WebGL, audio, fonts). Every launch produces a fresh, coherent identity.
|
||||
|
||||
**How fingerprinting works:**
|
||||
|
||||
| Scenario | What happens |
|
||||
|----------|-------------|
|
||||
| **No flags** | Random seed auto-generated at startup. GPU, screen, hardware specs, and all noise patches are spoofed automatically. Fresh identity each launch. |
|
||||
| **`--fingerprint=seed`** | Deterministic identity from the seed. Same seed = same fingerprint across launches. Use this for session persistence (returning visitor). |
|
||||
| **`--fingerprint=seed` + explicit flags** | Explicit flags override individual auto-generated values. The seed fills in everything else. |
|
||||
|
||||
The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly.
|
||||
|
||||
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
|
||||
> ```python
|
||||
@@ -354,21 +420,20 @@ Every launch automatically generates a **unique fingerprint**. A random seed (10
|
||||
|
||||
### Default Fingerprint
|
||||
|
||||
Every `launch()` call sets these automatically. Defaults are **platform-aware** — macOS runs as a native Mac browser, Linux spoofs Windows:
|
||||
Every `launch()` call sets these automatically. The **wrapper** applies platform-aware defaults — on Linux it spoofs as Windows for a more common fingerprint, on macOS it runs as a native Mac browser:
|
||||
|
||||
| Flag | Linux Default | macOS Default | Controls |
|
||||
| Flag | Linux/Windows Default | macOS Default | Controls |
|
||||
|------|--------------|---------------|----------|
|
||||
| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | Master seed for canvas, WebGL, audio, fonts, client rects |
|
||||
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
|
||||
| `--fingerprint-hardware-concurrency` | `8` | *(not set — uses real value)* | `navigator.hardwareConcurrency` |
|
||||
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | *(not set — native Apple GPU)* | WebGL `UNMASKED_VENDOR_WEBGL` |
|
||||
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | *(not set — native Metal renderer)* | WebGL `UNMASKED_RENDERER_WEBGL` |
|
||||
| `--fingerprint-device-memory` | `8` | *(not set)* | `navigator.deviceMemory` |
|
||||
| `--fingerprint-screen-width` | `1920` | *(not set)* | Screen width reporting |
|
||||
| `--fingerprint-screen-height` | `1080` | *(not set)* | Screen height reporting |
|
||||
| `--window-size` | `1920,1080` | *(not set)* | Browser window dimensions |
|
||||
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | `Google Inc. (Apple)` | WebGL `UNMASKED_VENDOR_WEBGL` |
|
||||
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | `ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)` | WebGL `UNMASKED_RENDERER_WEBGL` |
|
||||
|
||||
> **Important:** `--fingerprint-platform` should always be set. Without it, platform-specific patches (GPU, UA, screen, taskbar) won't activate. The wrapper handles this automatically.
|
||||
The binary auto-generates hardware concurrency (8), device memory (8), and screen dimensions (1920x1080 on Windows/Linux, 1440x900 on macOS) from the seed. Override with explicit flags if needed.
|
||||
|
||||
> **Using the binary directly?** It works out of the box with zero flags — the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
|
||||
|
||||
> **Production tip:** For better stealth at scale, pass your own GPU, screen, and hardware values instead of relying on defaults. Custom parameters make your sessions harder to cluster by anti-bot systems that look for uniform fingerprint profiles.
|
||||
|
||||
### Additional Flags
|
||||
|
||||
@@ -376,6 +441,10 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
||||
|
||||
| Flag | Controls |
|
||||
|------|----------|
|
||||
| `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) |
|
||||
| `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) |
|
||||
| `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) |
|
||||
| `--fingerprint-screen-height` | Screen height (auto-generated: `1080` Win/Linux, `900` macOS) |
|
||||
| `--fingerprint-brand` | Browser brand: `Chrome`, `Edge`, `Opera`, `Vivaldi` |
|
||||
| `--fingerprint-brand-version` | Brand version (UA + Client Hints) |
|
||||
| `--fingerprint-platform-version` | Client Hints platform version |
|
||||
@@ -397,7 +466,6 @@ browser = launch(args=["--fingerprint=42069"])
|
||||
browser = launch(stealth_args=False, args=[
|
||||
"--fingerprint=42069",
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
])
|
||||
@@ -409,84 +477,53 @@ browser = launch(args=[
|
||||
])
|
||||
```
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|
||||
|---|---|---|---|---|---|
|
||||
| reCAPTCHA v3 score | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** |
|
||||
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
|
||||
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
|
||||
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
|
||||
| Maintained | Yes | Stale | Stale | Unstable (2026 beta) | **Active** |
|
||||
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
|
||||
| Playwright API | Native | Native | No (Selenium) | No | **Native** |
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 25 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 142 | 16 | ✅ Available (v145 coming soon) |
|
||||
| macOS x86_64 (Intel) | 142 | 16 | ✅ Available (v145 coming soon) |
|
||||
| Windows | — | — | Planned |
|
||||
|
||||
The wrapper auto-downloads the correct binary for your platform. Linux gets Chromium 145 with all 25 patches. macOS currently runs Chromium 142 (16 patches) — the v145 macOS build is in progress.
|
||||
|
||||
**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.
|
||||
|
||||
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
|
||||
|
||||
## Examples
|
||||
|
||||
**Python** — see [`examples/`](examples/):
|
||||
- [`basic.py`](examples/basic.py) — Launch and load a page
|
||||
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
|
||||
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
|
||||
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
|
||||
- [`stealth_test.py`](examples/stealth_test.py) — Run against 6 detection sites
|
||||
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
|
||||
|
||||
**JavaScript** — see [`js/examples/`](js/examples/):
|
||||
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
|
||||
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
|
||||
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Full 6-site detection test suite
|
||||
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
|
||||
|
||||
## Roadmap
|
||||
## Platforms
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 — Chromium 145 (25 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 142 (16 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 145 | 🔨 In progress |
|
||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
| Windows support | 📋 Planned |
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||
|
||||
The wrapper auto-downloads the correct binary for your platform.
|
||||
|
||||
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
|
||||
|
||||
## Docker
|
||||
|
||||
A ready-to-use [`Dockerfile`](Dockerfile) is included. It installs system deps, the package, and pre-downloads the stealth binary during build:
|
||||
Pre-built image on Docker Hub — no install, no setup:
|
||||
|
||||
```bash
|
||||
docker build -t cloakbrowser .
|
||||
docker run --rm cloakbrowser python examples/basic.py
|
||||
```
|
||||
# Run the stealth test suite
|
||||
docker run --rm cloakhq/cloakbrowser cloaktest
|
||||
|
||||
The key steps in the Dockerfile:
|
||||
1. **System deps** — Chromium requires ~15 shared libraries (`libnss3`, `libgbm1`, etc.)
|
||||
2. **`pip install .`** — installs CloakBrowser + Playwright
|
||||
3. **`ensure_binary()`** — downloads the stealth Chromium binary at build time (~200MB), so containers start instantly
|
||||
# Run your own script
|
||||
docker run --rm cloakhq/cloakbrowser python -c "
|
||||
from cloakbrowser import launch
|
||||
browser = launch()
|
||||
page = browser.new_page()
|
||||
page.goto('https://example.com')
|
||||
print(page.title())
|
||||
browser.close()
|
||||
"
|
||||
|
||||
To extend with your own script, just add a `COPY` + `CMD`:
|
||||
|
||||
```dockerfile
|
||||
FROM cloakbrowser
|
||||
COPY your_script.py /app/
|
||||
CMD ["python", "your_script.py"]
|
||||
```
|
||||
|
||||
**With a proxy** (the most common production setup):
|
||||
|
||||
```bash
|
||||
docker run --rm cloakbrowser python -c "
|
||||
# With a proxy
|
||||
docker run --rm cloakhq/cloakbrowser python -c "
|
||||
from cloakbrowser import launch
|
||||
browser = launch(proxy='http://user:pass@proxy:8080')
|
||||
page = browser.new_page()
|
||||
@@ -496,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.
|
||||
|
||||
**Note:** If you run CloakBrowser inside a web server with uvloop (e.g., `uvicorn[standard]`), use `--loop asyncio` to avoid subprocess pipe hangs.
|
||||
|
||||
## Headed Mode (for aggressive bot detection)
|
||||
## Troubleshooting
|
||||
|
||||
Some sites using advanced bot detection (e.g., DataDome, Cloudflare Turnstile) can detect headless mode even with our C++ patches. For these sites, run in **headed mode** with a virtual display:
|
||||
**Still getting blocked on aggressive sites (DataDome, Turnstile)?**
|
||||
|
||||
Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display:
|
||||
|
||||
```bash
|
||||
# Install Xvfb (virtual framebuffer)
|
||||
@@ -523,25 +576,49 @@ page.goto("https://heavily-protected-site.com") # passes DataDome, etc.
|
||||
browser.close()
|
||||
```
|
||||
|
||||
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services.
|
||||
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
|
||||
|
||||
> **Tip:** Datacenter IPs are often flagged by IP reputation databases regardless of browser fingerprint. For sites with strict bot detection, a residential proxy makes the difference.
|
||||
**Sites challenge fresh sessions but work after first visit**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Reddit or similar sites show CAPTCHA / "Prove your humanity"**
|
||||
|
||||
Some sites (notably Reddit homepage) use HTTP/2 fingerprinting that detects Playwright's connection layer. Pass `--disable-http2` to fall back to HTTP/1.1:
|
||||
Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions:
|
||||
|
||||
```python
|
||||
browser = launch(args=["--disable-http2"])
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
# First run: warm up with --disable-http2
|
||||
ctx = launch_persistent_context("./profile", args=["--disable-http2"])
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com") # warms up cookies
|
||||
ctx.close()
|
||||
|
||||
# Future runs — no --disable-http2 needed
|
||||
ctx = launch_persistent_context("./profile")
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com") # passes with saved cookies
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({ args: ['--disable-http2'] });
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// First run: warm up with --disable-http2
|
||||
let ctx = await launchPersistentContext({ userDataDir: './profile', args: ['--disable-http2'] });
|
||||
let page = await ctx.newPage();
|
||||
await page.goto('https://example.com');
|
||||
await ctx.close();
|
||||
|
||||
// Future runs — no --disable-http2 needed
|
||||
ctx = await launchPersistentContext({ userDataDir: './profile' });
|
||||
```
|
||||
|
||||
Only use this flag for sites that require it — most sites work fine with HTTP/2.
|
||||
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
|
||||
|
||||
**Something not working? Make sure you're on the latest version**
|
||||
Older versions may use outdated stealth args or download an older binary:
|
||||
```bash
|
||||
pip install -U cloakbrowser # Python
|
||||
npm install cloakbrowser@latest # JavaScript
|
||||
docker pull cloakhq/cloakbrowser:latest # Docker
|
||||
```
|
||||
|
||||
**Binary download fails / timeout**
|
||||
Set a custom download URL or use a local binary:
|
||||
@@ -549,6 +626,19 @@ Set a custom download URL or use a local binary:
|
||||
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
|
||||
```
|
||||
|
||||
**New update broke something? Roll back to the previous version**
|
||||
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
|
||||
```bash
|
||||
# Linux
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
|
||||
|
||||
# macOS
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
|
||||
|
||||
# Windows
|
||||
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
|
||||
```
|
||||
|
||||
**macOS: "App is damaged" or Gatekeeper blocks launch**
|
||||
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
|
||||
```bash
|
||||
@@ -561,6 +651,32 @@ You do NOT need `playwright install chromium`. CloakBrowser downloads its own bi
|
||||
patchright install-deps chromium
|
||||
```
|
||||
|
||||
**macOS: Blocked on some sites that pass on Linux**
|
||||
|
||||
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
|
||||
|
||||
**Site detects incognito / private browsing mode**
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes:
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
page = ctx.new_page()
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './my-profile',
|
||||
headless: false,
|
||||
});
|
||||
```
|
||||
|
||||
This also gives you cookie and localStorage persistence across sessions.
|
||||
|
||||
**reCAPTCHA v3 scores are low (0.1–0.3)**
|
||||
|
||||
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
@@ -597,7 +713,7 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
## FAQ
|
||||
|
||||
**Q: Is this legal?**
|
||||
A: CloakBrowser is a browser. Using it is legal. What you do with it is your responsibility, just like with Chrome, Firefox, or any browser. We do not endorse violating website terms of service.
|
||||
A: CloakBrowser is a browser built on open-source Chromium. We do not condone illegal use. Automating systems without authorization, credential stuffing, and account creation abuse are expressly prohibited. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
|
||||
|
||||
**Q: How is this different from Camoufox?**
|
||||
A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready.
|
||||
@@ -608,6 +724,17 @@ A: Possibly. Bot detection is an arms race. Source-level patches are harder to d
|
||||
**Q: Can I use my own proxy?**
|
||||
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
|
||||
## Links
|
||||
|
||||
- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
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()
|
||||
"""
|
||||
|
||||
from .browser import launch, launch_async, launch_context
|
||||
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
|
||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||
from ._version import __version__
|
||||
@@ -20,11 +20,14 @@ __all__ = [
|
||||
"launch",
|
||||
"launch_async",
|
||||
"launch_context",
|
||||
"launch_persistent_context",
|
||||
"launch_persistent_context_async",
|
||||
"ensure_binary",
|
||||
"clear_cache",
|
||||
"binary_info",
|
||||
"check_for_update",
|
||||
"CHROMIUM_VERSION",
|
||||
"get_default_stealth_args",
|
||||
"ProxySettings",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.3.9"
|
||||
|
||||
+281
-24
@@ -15,7 +15,9 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
|
||||
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
|
||||
@@ -24,9 +26,32 @@ from .download import ensure_binary
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
|
||||
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone."""
|
||||
if "timezone_id" in kwargs:
|
||||
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
|
||||
if timezone is None:
|
||||
timezone = kwargs.pop("timezone_id")
|
||||
else:
|
||||
kwargs.pop("timezone_id")
|
||||
return timezone
|
||||
|
||||
|
||||
class _ProxySettingsRequired(TypedDict):
|
||||
server: str
|
||||
|
||||
|
||||
class ProxySettings(_ProxySettingsRequired, total=False):
|
||||
"""Playwright-compatible proxy configuration."""
|
||||
|
||||
bypass: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
def launch(
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
timezone: str | None = None,
|
||||
@@ -38,7 +63,10 @@ def launch(
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
||||
proxy: Proxy URL string or Playwright proxy dict.
|
||||
String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
|
||||
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
|
||||
— passed directly to Playwright.
|
||||
args: Additional Chromium CLI arguments to pass.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
Set to False if you want to pass your own --fingerprint flags.
|
||||
@@ -93,7 +121,7 @@ def launch(
|
||||
|
||||
async def launch_async(
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
timezone: str | None = None,
|
||||
@@ -105,7 +133,7 @@ async def launch_async(
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments to pass.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
|
||||
@@ -159,15 +187,213 @@ async def launch_async(
|
||||
return browser
|
||||
|
||||
|
||||
def launch_context(
|
||||
def launch_persistent_context(
|
||||
user_data_dir: str | os.PathLike,
|
||||
headless: bool = True,
|
||||
proxy: str | None = None,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone_id: str | None = None,
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
|
||||
This persists cookies, localStorage, cache, and other browser state across
|
||||
sessions by storing them in ``user_data_dir``. Also avoids incognito detection
|
||||
by services like BrowserScan (-10% penalty).
|
||||
|
||||
Args:
|
||||
user_data_dir: Path to the directory where browser profile data is stored.
|
||||
Created automatically if it doesn't exist. Reuse the same path across
|
||||
sessions to restore cookies, localStorage, cached credentials, etc.
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
Requires ``pip install cloakbrowser[geoip]``.
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object backed by a persistent profile.
|
||||
Call ``.close()`` when done — this also stops the Playwright instance.
|
||||
|
||||
Example:
|
||||
>>> from cloakbrowser import launch_persistent_context
|
||||
>>> ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
>>> page = ctx.new_page()
|
||||
>>> page.goto("https://protected-site.com")
|
||||
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
|
||||
"""
|
||||
from patchright.sync_api import sync_playwright
|
||||
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||
headless,
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
pw = sync_playwright().start()
|
||||
context = pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
# Patch close() to also stop the Playwright instance
|
||||
_original_close = context.close
|
||||
|
||||
def _close_with_cleanup() -> None:
|
||||
_original_close()
|
||||
pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
return context
|
||||
|
||||
|
||||
async def launch_persistent_context_async(
|
||||
user_data_dir: str | os.PathLike,
|
||||
headless: bool = True,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_persistent_context().
|
||||
|
||||
Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
This persists cookies, localStorage, cache, and other browser state across
|
||||
sessions by storing them in ``user_data_dir``.
|
||||
|
||||
Args:
|
||||
user_data_dir: Path to the directory where browser profile data is stored.
|
||||
Created automatically if it doesn't exist.
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object backed by a persistent profile (async API).
|
||||
Call ``await .close()`` when done.
|
||||
|
||||
Example:
|
||||
>>> import asyncio
|
||||
>>> from cloakbrowser import launch_persistent_context_async
|
||||
>>>
|
||||
>>> async def main():
|
||||
... ctx = await launch_persistent_context_async("./my-profile", headless=False)
|
||||
... page = await ctx.new_page()
|
||||
... await page.goto("https://protected-site.com")
|
||||
... await ctx.close()
|
||||
>>>
|
||||
>>> asyncio.run(main())
|
||||
"""
|
||||
from patchright.async_api import async_playwright
|
||||
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||
headless,
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=os.fspath(user_data_dir),
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
# Patch close() to also stop the Playwright instance
|
||||
_original_close = context.close
|
||||
|
||||
async def _close_with_cleanup() -> None:
|
||||
await _original_close()
|
||||
await pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def launch_context(
|
||||
headless: bool = True,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
stealth_args: bool = True,
|
||||
user_agent: str | None = None,
|
||||
viewport: dict | None = None,
|
||||
locale: str | None = None,
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
**kwargs: Any,
|
||||
@@ -179,13 +405,13 @@ def launch_context(
|
||||
|
||||
Args:
|
||||
headless: Run in headless mode (default True).
|
||||
proxy: Proxy server URL.
|
||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||
args: Additional Chromium CLI arguments.
|
||||
stealth_args: Include default stealth fingerprint args (default True).
|
||||
user_agent: Custom user agent string.
|
||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||
locale: Browser locale, e.g. "en-US".
|
||||
timezone_id: Timezone, e.g. "America/New_York".
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
Note: 'no-preference' doesn't work in Patchright (falls back to 'light').
|
||||
@@ -195,11 +421,16 @@ def launch_context(
|
||||
Returns:
|
||||
Playwright BrowserContext object.
|
||||
"""
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
|
||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||
# resolved values flow to both binary flags AND context params
|
||||
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
# Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
# context and interferes with Playwright's timezone_id on new contexts.
|
||||
# Timezone is set via browser.new_context(timezone_id=...) below instead.
|
||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||
timezone=timezone_id, locale=locale)
|
||||
timezone=None, locale=locale)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
@@ -207,8 +438,8 @@ def launch_context(
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone_id:
|
||||
context_kwargs["timezone_id"] = timezone_id
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
@@ -238,7 +469,7 @@ def launch_context(
|
||||
|
||||
def _maybe_resolve_geoip(
|
||||
geoip: bool,
|
||||
proxy: str | None,
|
||||
proxy: str | ProxySettings | None,
|
||||
timezone: str | None,
|
||||
locale: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
@@ -248,7 +479,10 @@ def _maybe_resolve_geoip(
|
||||
|
||||
from .geoip import resolve_proxy_geo
|
||||
|
||||
geo_tz, geo_locale = resolve_proxy_geo(proxy)
|
||||
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
|
||||
if not proxy_url:
|
||||
return timezone, locale
|
||||
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
|
||||
if timezone is None:
|
||||
timezone = geo_tz
|
||||
if locale is None:
|
||||
@@ -262,18 +496,39 @@ def _build_args(
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Combine stealth args with user-provided args and locale flags."""
|
||||
result = []
|
||||
"""Combine stealth args with user-provided args and locale flags.
|
||||
|
||||
Deduplicates by flag key (everything before '=').
|
||||
Priority: stealth defaults < user args < dedicated params (timezone/locale).
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
if stealth_args:
|
||||
result.extend(get_default_stealth_args())
|
||||
for arg in get_default_stealth_args():
|
||||
seen[arg.split("=", 1)[0]] = arg
|
||||
|
||||
if extra_args:
|
||||
result.extend(extra_args)
|
||||
for arg in extra_args:
|
||||
key = arg.split("=", 1)[0]
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], arg)
|
||||
seen[key] = arg
|
||||
|
||||
# Timezone/locale flags are independent of stealth_args — always inject when set
|
||||
if timezone:
|
||||
result.append(f"--fingerprint-timezone={timezone}")
|
||||
key = "--fingerprint-timezone"
|
||||
flag = f"{key}={timezone}"
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], flag)
|
||||
seen[key] = flag
|
||||
if locale:
|
||||
result.append(f"--lang={locale}")
|
||||
return result
|
||||
key = "--lang"
|
||||
flag = f"{key}={locale}"
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], flag)
|
||||
seen[key] = flag
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
@@ -302,8 +557,10 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
|
||||
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
|
||||
"""Build proxy kwargs for Playwright launch."""
|
||||
if proxy is None:
|
||||
return {}
|
||||
if isinstance(proxy, dict):
|
||||
return {"proxy": proxy}
|
||||
return {"proxy": _parse_proxy_url(proxy)}
|
||||
|
||||
+30
-19
@@ -11,17 +11,17 @@ from ._version import __version__
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chromium version shipped with this release.
|
||||
# Different platforms may ship different versions (e.g. Linux gets v145 first,
|
||||
# macOS stays on v142 until Mac builds are ready).
|
||||
# Different platforms may ship different versions during transition periods.
|
||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
# Use get_chromium_version() for the current platform's actual version.
|
||||
# ---------------------------------------------------------------------------
|
||||
CHROMIUM_VERSION = "145.0.7632.109"
|
||||
CHROMIUM_VERSION = "145.0.7632.159"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "145.0.7632.109",
|
||||
"darwin-arm64": "142.0.7444.175",
|
||||
"darwin-x64": "142.0.7444.175",
|
||||
"linux-x64": "145.0.7632.159",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -47,18 +47,17 @@ def get_default_stealth_args() -> list[str]:
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + [
|
||||
"--fingerprint-platform=macos",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
|
||||
]
|
||||
|
||||
# Linux: spoof as Windows
|
||||
# Linux/Windows: Windows fingerprint profile
|
||||
# Hardware concurrency, device memory, screen, and window size are
|
||||
# auto-generated by the binary from the seed (v14+).
|
||||
return base + [
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-device-memory=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--window-size=1920,1080",
|
||||
]
|
||||
|
||||
|
||||
@@ -77,6 +76,8 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
|
||||
("Linux", "aarch64"): "linux-arm64",
|
||||
("Darwin", "arm64"): "darwin-arm64",
|
||||
("Darwin", "x86_64"): "darwin-x64",
|
||||
("Windows", "AMD64"): "windows-x64",
|
||||
("Windows", "x86_64"): "windows-x64",
|
||||
}
|
||||
|
||||
# Platforms with pre-built binaries available for download (derived from version map).
|
||||
@@ -130,6 +131,8 @@ def get_binary_path(version: str | None = None) -> Path:
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
|
||||
elif platform.system() == "Windows":
|
||||
return binary_dir / "chrome.exe"
|
||||
else:
|
||||
# Linux: flat binary
|
||||
return binary_dir / "chrome"
|
||||
@@ -148,9 +151,8 @@ def check_platform_available() -> None:
|
||||
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
|
||||
import sys
|
||||
sys.exit(
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n"
|
||||
f"Windows builds are coming soon.\n\n"
|
||||
f"To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH."
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
|
||||
f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
|
||||
)
|
||||
|
||||
|
||||
@@ -202,18 +204,27 @@ GITHUB_DOWNLOAD_BASE_URL = (
|
||||
)
|
||||
|
||||
|
||||
def get_archive_ext() -> str:
|
||||
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
|
||||
return ".zip" if platform.system() == "Windows" else ".tar.gz"
|
||||
|
||||
|
||||
def get_archive_name(tag: str | None = None) -> str:
|
||||
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
|
||||
t = tag or get_platform_tag()
|
||||
return f"cloakbrowser-{t}{get_archive_ext()}"
|
||||
|
||||
|
||||
def get_download_url(version: str | None = None) -> str:
|
||||
"""Return the full download URL for the current platform's binary archive."""
|
||||
v = version or get_chromium_version()
|
||||
tag = get_platform_tag()
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
def get_fallback_download_url(version: str | None = None) -> str:
|
||||
"""Return the GitHub Releases fallback URL for the binary archive."""
|
||||
v = version or get_chromium_version()
|
||||
tag = get_platform_tag()
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+67
-27
@@ -28,6 +28,8 @@ from .config import (
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
_version_newer,
|
||||
check_platform_available,
|
||||
get_archive_ext,
|
||||
get_archive_name,
|
||||
get_binary_dir,
|
||||
get_binary_path,
|
||||
get_cache_dir,
|
||||
@@ -48,6 +50,25 @@ DOWNLOAD_TIMEOUT = 600.0
|
||||
UPDATE_CHECK_INTERVAL = 3600
|
||||
|
||||
|
||||
def _show_welcome() -> None:
|
||||
"""Show welcome message on first launch. Uses a marker file to show only once."""
|
||||
marker = get_cache_dir() / ".welcome_shown"
|
||||
if marker.exists():
|
||||
return
|
||||
print()
|
||||
print(" CloakBrowser — stealth Chromium for automation")
|
||||
print(" https://github.com/CloakHQ/CloakBrowser")
|
||||
print()
|
||||
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues")
|
||||
print(" Star us if CloakBrowser helps your project!")
|
||||
print()
|
||||
try:
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_binary() -> str:
|
||||
"""Ensure the stealth Chromium binary is available. Download if needed.
|
||||
|
||||
@@ -75,6 +96,7 @@ def ensure_binary() -> str:
|
||||
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
|
||||
_show_welcome()
|
||||
_maybe_trigger_update_check()
|
||||
return str(binary_path)
|
||||
|
||||
@@ -123,7 +145,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download to temp file first (atomic — no partial downloads in cache)
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
@@ -144,9 +166,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
_verify_download_checksum(tmp_path, version)
|
||||
|
||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||
logger.info("Visit https://cloakbrowser.dev for docs and release notifications.")
|
||||
logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues")
|
||||
logger.info("Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser")
|
||||
_show_welcome()
|
||||
finally:
|
||||
# Clean up temp file
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
@@ -155,7 +175,7 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
|
||||
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
|
||||
checksums = _fetch_checksums(version)
|
||||
tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
|
||||
tarball_name = get_archive_name()
|
||||
|
||||
if checksums is None:
|
||||
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
|
||||
@@ -256,7 +276,7 @@ def _download_file(url: str, dest: Path) -> None:
|
||||
def _extract_archive(
|
||||
archive_path: Path, dest_dir: Path, binary_path: Path | None = None
|
||||
) -> None:
|
||||
"""Extract tar.gz archive to destination directory."""
|
||||
"""Extract tar.gz or zip archive to destination directory."""
|
||||
logger.info("Extracting to %s", dest_dir)
|
||||
|
||||
# Clean existing dir if partial download existed
|
||||
@@ -266,26 +286,12 @@ def _extract_archive(
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
# Security: prevent path traversal
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
# Reject symlinks that escape the dest dir
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
else:
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
if str(archive_path).endswith(".zip"):
|
||||
_extract_zip(archive_path, dest_dir)
|
||||
else:
|
||||
_extract_tar(archive_path, dest_dir)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
# If tar extracted into a single subdirectory, flatten it
|
||||
# If extracted into a single subdirectory, flatten it
|
||||
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
|
||||
# But never flatten .app bundles — macOS needs the bundle structure intact
|
||||
_flatten_single_subdir(dest_dir)
|
||||
@@ -303,6 +309,38 @@ def _extract_archive(
|
||||
logger.info("Binary ready: %s", bp)
|
||||
|
||||
|
||||
def _extract_tar(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract tar.gz archive with path traversal protection."""
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
link_target = member.linkname
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
else:
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
|
||||
def _extract_zip(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract zip archive with path traversal protection."""
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
member_path = (dest_dir / info.filename).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {info.filename}")
|
||||
zf.extractall(dest_dir)
|
||||
|
||||
|
||||
def _flatten_single_subdir(dest_dir: Path) -> None:
|
||||
"""If extraction created a single subdirectory, move its contents up.
|
||||
|
||||
@@ -330,7 +368,9 @@ def _is_executable(path: Path) -> bool:
|
||||
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Make a file executable (chmod +x)."""
|
||||
"""Make a file executable (chmod +x). Skipped on Windows (no-op / AV lock risk)."""
|
||||
if platform.system() == "Windows":
|
||||
return
|
||||
current = path.stat().st_mode
|
||||
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
@@ -433,7 +473,7 @@ def _get_latest_chromium_version() -> str | None:
|
||||
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
|
||||
)
|
||||
resp.raise_for_status()
|
||||
platform_tarball = f"cloakbrowser-{get_platform_tag()}.tar.gz"
|
||||
platform_tarball = get_archive_name()
|
||||
for release in resp.json():
|
||||
tag = release.get("tag_name", "")
|
||||
if tag.startswith("chromium-v") and not release.get("draft"):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
browser = launch(headless=False)
|
||||
page = browser.new_page()
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ def main():
|
||||
print(f"Proxy: {PROXY or 'none'}")
|
||||
print()
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
context = launch_context(
|
||||
headless=HEADLESS,
|
||||
proxy=PROXY,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Persistent context example: cookies and localStorage survive across sessions."""
|
||||
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
PROFILE_DIR = "./my-profile"
|
||||
|
||||
# Session 1 — set some state
|
||||
print("=== Session 1: Setting state ===")
|
||||
print("Launching stealth browser...", flush=True)
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
page.evaluate("document.cookie = 'session=abc123; path=/; max-age=3600'")
|
||||
page.evaluate("localStorage.setItem('user', 'returning')")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
# Session 2 — state is restored
|
||||
print("\n=== Session 2: Verifying persistence ===")
|
||||
print("Launching stealth browser...", flush=True)
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
print("\nDone!")
|
||||
@@ -9,6 +9,7 @@ import time
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
browser = launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
|
||||
+81
-29
@@ -53,21 +53,27 @@ def test_bot_sannysoft(page):
|
||||
def test_bot_incolumitas(page):
|
||||
"""bot.incolumitas.com — comprehensive 30+ check bot detection."""
|
||||
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
|
||||
time.sleep(12) # needs time to run all detection tests
|
||||
|
||||
# Site outputs JSON blocks in page text, not HTML tables
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
|
||||
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
|
||||
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
|
||||
return {
|
||||
passed: okMatches.length,
|
||||
failed: failMatches.length,
|
||||
failedTests,
|
||||
total: okMatches.length + failMatches.length
|
||||
};
|
||||
}""")
|
||||
# Poll until test count stabilizes (site runs tests progressively)
|
||||
last_total = 0
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
|
||||
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
|
||||
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
|
||||
return {
|
||||
passed: okMatches.length,
|
||||
failed: failMatches.length,
|
||||
failedTests,
|
||||
total: okMatches.length + failMatches.length
|
||||
};
|
||||
}""")
|
||||
if results["total"] >= 30 and results["total"] == last_total:
|
||||
break
|
||||
last_total = results["total"]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -143,22 +149,21 @@ def test_recaptcha(page):
|
||||
"""recaptcha-demo.appspot.com — Google's official reCAPTCHA v3 score."""
|
||||
page.goto(
|
||||
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
|
||||
wait_until="networkidle",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30000,
|
||||
)
|
||||
# Page auto-submits via grecaptcha.execute() — wait for backend response
|
||||
time.sleep(8)
|
||||
# Wait for score to appear (polls up to 30s)
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
score = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const match = text.match(/"score":\\s*(\\d+\\.\\d+)/);
|
||||
return match ? parseFloat(match[1]) : null;
|
||||
}""")
|
||||
if score is not None:
|
||||
break
|
||||
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
// Score appears in JSON response block: "score": 0.9
|
||||
const scoreMatch = text.match(/"score":\\s*(\\d+\\.\\d+)/);
|
||||
return {
|
||||
score: scoreMatch ? parseFloat(scoreMatch[1]) : null,
|
||||
pageText: text.substring(0, 500)
|
||||
};
|
||||
}""")
|
||||
return results
|
||||
return {"score": score}
|
||||
|
||||
|
||||
TESTS = [
|
||||
@@ -175,8 +180,11 @@ TESTS = [
|
||||
"url": "https://bot.incolumitas.com",
|
||||
"runner": test_bot_incolumitas,
|
||||
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
|
||||
+ (f" (FAILED: {', '.join(r.get('failedTests', []))})" if r.get("failed", 0) > 0 else " — ALL GREEN"),
|
||||
"pass": lambda r: r.get("failed", 0) <= 1, # fpscanner.WEBDRIVER false positive expected (all builds)
|
||||
+ (" — ALL GREEN" if r.get("failed", 0) == 0
|
||||
else f" (FAILED: {', '.join(r.get('failedTests', []))} — known false positives)"
|
||||
if set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}
|
||||
else f" (FAILED: {', '.join(r.get('failedTests', []))})"),
|
||||
"pass": lambda r: set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}, # known false positives
|
||||
},
|
||||
{
|
||||
"name": "BrowserScan",
|
||||
@@ -218,10 +226,54 @@ def main():
|
||||
print(f"Screenshots: {'on' if SCREENSHOTS else 'off'}")
|
||||
print(f"Proxy: {PROXY or 'none'}")
|
||||
print()
|
||||
print("Launching stealth browser...", flush=True)
|
||||
|
||||
browser = launch(headless=not HEADED, proxy=PROXY)
|
||||
page = browser.new_page()
|
||||
|
||||
# Show browser fingerprint details
|
||||
try:
|
||||
import re
|
||||
info = page.evaluate("""async () => {
|
||||
const ua = navigator.userAgent;
|
||||
let fullVersion = null;
|
||||
try {
|
||||
const data = await navigator.userAgentData.getHighEntropyValues(['fullVersionList', 'platform', 'platformVersion']);
|
||||
const chrome = data.fullVersionList.find(b => b.brand === 'Chromium' || b.brand === 'Google Chrome');
|
||||
fullVersion = chrome ? chrome.version : null;
|
||||
} catch {}
|
||||
const gl = document.createElement('canvas').getContext('webgl');
|
||||
const dbg = gl ? gl.getExtension('WEBGL_debug_renderer_info') : null;
|
||||
return {
|
||||
ua,
|
||||
fullVersion,
|
||||
platform: navigator.platform,
|
||||
cores: navigator.hardwareConcurrency,
|
||||
gpu: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'N/A',
|
||||
gpuVendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : 'N/A',
|
||||
screen: screen.width + 'x' + screen.height,
|
||||
languages: navigator.languages.join(', '),
|
||||
};
|
||||
}""")
|
||||
# Condensed UA
|
||||
ua_short = re.sub(r'^Mozilla/5\.0 \(', '', info["ua"])
|
||||
ua_short = re.sub(r'\) AppleWebKit/[\d.]+ \(KHTML, like Gecko\) ', ' | ', ua_short)
|
||||
print(f"UA: {ua_short}", flush=True)
|
||||
print(f"Platform: {info['platform']} | Cores: {info['cores']} | Screen: {info['screen']}", flush=True)
|
||||
print(f"GPU: {info['gpuVendor']} — {info['gpu']}", flush=True)
|
||||
except Exception:
|
||||
print("Chrome: could not detect", flush=True)
|
||||
|
||||
# Show IP address
|
||||
try:
|
||||
page.goto("https://httpbin.org/ip", timeout=10000)
|
||||
ip = page.evaluate("JSON.parse(document.body.innerText).origin")
|
||||
print(f"IP: {ip}", flush=True)
|
||||
except Exception:
|
||||
print("IP: could not detect", flush=True)
|
||||
|
||||
print(f"Running {len(TESTS)} tests (this takes ~2 minutes)...\n", flush=True)
|
||||
|
||||
results_summary = []
|
||||
|
||||
for test in TESTS:
|
||||
|
||||
+68
-17
@@ -9,13 +9,14 @@
|
||||
|
||||
**Stealth Chromium that passes every bot detection test.**
|
||||
|
||||
Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests.
|
||||
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
||||
|
||||
- 🔒 **25 source-level C++ patches** — not JS injection, not config flags
|
||||
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
|
||||
- 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer
|
||||
- 📦 **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
||||
- **Free and open source** — no subscriptions, no usage limits
|
||||
- **Works with any framework** — also tested with Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser
|
||||
|
||||
## Install
|
||||
|
||||
@@ -60,19 +61,24 @@ await browser.close();
|
||||
### Options
|
||||
|
||||
```javascript
|
||||
import { launch, launchContext } from 'cloakbrowser';
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// With proxy
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
|
||||
// With proxy object (bypass, separate auth fields)
|
||||
const browser = await launch({
|
||||
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
|
||||
});
|
||||
|
||||
// Headed mode (visible browser window)
|
||||
const browser = await launch({ headless: false });
|
||||
|
||||
// Extra Chrome args
|
||||
const browser = await launch({
|
||||
args: ['--window-size=1920,1080'],
|
||||
args: ['--fingerprint=12345'],
|
||||
});
|
||||
|
||||
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
|
||||
@@ -92,8 +98,18 @@ const context = await launchContext({
|
||||
userAgent: 'Custom UA',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
|
||||
// Persistent profile — stay logged in, bypass incognito detection, load extensions
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './chrome-profile',
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const page = ctx.pages()[0] || await ctx.newPage();
|
||||
await page.goto('https://example.com');
|
||||
await ctx.close(); // profile saved — reuse same path to restore state
|
||||
```
|
||||
|
||||
### Auto Timezone/Locale from Proxy IP
|
||||
@@ -146,6 +162,9 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
|
||||
| **bot.incolumitas.com** | 13 fails | **1 fail** |
|
||||
| `navigator.webdriver` | `true` | **`false`** |
|
||||
| CDP detection | Detected | **Not detected** |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** |
|
||||
| | | **Tested against 30+ detection sites** |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -171,14 +190,12 @@ const page = await browser.newPage();
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Status |
|
||||
|---|---|
|
||||
| Linux x86_64 | ✅ Available |
|
||||
| macOS arm64 (Apple Silicon) | ✅ Available |
|
||||
| macOS x86_64 (Intel) | ✅ Available |
|
||||
| Windows | Planned |
|
||||
|
||||
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -187,6 +204,21 @@ const page = await browser.newPage();
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Site detects incognito / private browsing mode**
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
|
||||
|
||||
```javascript
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './my-profile',
|
||||
headless: false,
|
||||
});
|
||||
```
|
||||
|
||||
This also gives you cookie and localStorage persistence across sessions.
|
||||
|
||||
**reCAPTCHA v3 scores are low (0.1–0.3)**
|
||||
|
||||
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
@@ -205,8 +237,25 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
|
||||
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
|
||||
- **Use a fixed fingerprint seed** (`--fingerprint=12345`) for consistent device identity across sessions
|
||||
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
|
||||
```javascript
|
||||
await page.type('#email', 'user@example.com', { delay: 50 });
|
||||
```
|
||||
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
|
||||
|
||||
**New update broke something? Roll back to the previous version**
|
||||
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
|
||||
```bash
|
||||
# Linux
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
|
||||
|
||||
# macOS
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
|
||||
|
||||
# Windows
|
||||
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- 🌐 [Website](https://cloakbrowser.dev)
|
||||
@@ -219,3 +268,5 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
|
||||
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
||||
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
|
||||
|
||||
Use against financial, banking, healthcare, or government authentication systems without authorization is expressly prohibited.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Persistent context example: cookies and localStorage survive across sessions.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/persistent-context.ts
|
||||
*/
|
||||
|
||||
import { launchPersistentContext } from "../src/index.js";
|
||||
|
||||
const PROFILE_DIR = "./my-profile";
|
||||
|
||||
// Session 1 — set some state
|
||||
console.log("=== Session 1: Setting state ===");
|
||||
let ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
let page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "session=abc123; path=/; max-age=3600";
|
||||
localStorage.setItem("user", "returning");
|
||||
});
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
// Session 2 — state is restored
|
||||
console.log("\n=== Session 2: Verifying persistence ===");
|
||||
ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
console.log("\nDone!");
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.9",
|
||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
+32
-20
@@ -23,17 +23,17 @@ export { WRAPPER_VERSION };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chromium version shipped with this release.
|
||||
// Different platforms may ship different versions (e.g. Linux gets v145 first,
|
||||
// macOS stays on v142 until Mac builds are ready).
|
||||
// Different platforms may ship different versions during transition periods.
|
||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
// Use getChromiumVersion() for the current platform's actual version.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHROMIUM_VERSION = "145.0.7632.109";
|
||||
export const CHROMIUM_VERSION = "145.0.7632.159";
|
||||
|
||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||
"linux-x64": "145.0.7632.109",
|
||||
"darwin-arm64": "142.0.7444.175",
|
||||
"darwin-x64": "142.0.7444.175",
|
||||
"linux-x64": "145.0.7632.159",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,6 +44,7 @@ const SUPPORTED_PLATFORMS: Record<string, string> = {
|
||||
"linux-arm64": "linux-arm64",
|
||||
"darwin-arm64": "darwin-arm64",
|
||||
"darwin-x64": "darwin-x64",
|
||||
"win32-x64": "windows-x64",
|
||||
};
|
||||
|
||||
// Platforms with pre-built binaries available for download (derived from version map).
|
||||
@@ -64,6 +65,7 @@ export function getPlatformTag(): string {
|
||||
else if (platform === "linux" && arch === "arm64") key = "linux-arm64";
|
||||
else if (platform === "darwin" && arch === "arm64") key = "darwin-arm64";
|
||||
else if (platform === "darwin" && arch === "x64") key = "darwin-x64";
|
||||
else if (platform === "win32" && arch === "x64") key = "win32-x64";
|
||||
else {
|
||||
const supported = Object.values(SUPPORTED_PLATFORMS).join(", ");
|
||||
throw new Error(
|
||||
@@ -92,6 +94,9 @@ export function getBinaryPath(version?: string): string {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(binaryDir, "chrome.exe");
|
||||
}
|
||||
return path.join(binaryDir, "chrome");
|
||||
}
|
||||
|
||||
@@ -102,9 +107,8 @@ export function checkPlatformAvailable(): void {
|
||||
if (!AVAILABLE_PLATFORMS.has(tag)) {
|
||||
const available = [...AVAILABLE_PLATFORMS].sort().join(", ");
|
||||
throw new Error(
|
||||
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n` +
|
||||
`Windows builds are coming soon.\n\n` +
|
||||
`To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH.`
|
||||
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n\n` +
|
||||
`To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,16 +126,22 @@ export const GITHUB_API_URL =
|
||||
export const GITHUB_DOWNLOAD_BASE_URL =
|
||||
"https://github.com/CloakHQ/cloakbrowser/releases/download";
|
||||
|
||||
export function getArchiveExt(): string {
|
||||
return process.platform === "win32" ? ".zip" : ".tar.gz";
|
||||
}
|
||||
|
||||
export function getArchiveName(tag?: string): string {
|
||||
return `cloakbrowser-${tag || getPlatformTag()}${getArchiveExt()}`;
|
||||
}
|
||||
|
||||
export function getDownloadUrl(version?: string): string {
|
||||
const v = version || getChromiumVersion();
|
||||
const tag = getPlatformTag();
|
||||
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
|
||||
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getFallbackDownloadUrl(version?: string): string {
|
||||
const v = version || getChromiumVersion();
|
||||
const tag = getPlatformTag();
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getEffectiveVersion(): string {
|
||||
@@ -198,19 +208,21 @@ export function getDefaultStealthArgs(): string[] {
|
||||
|
||||
if (isMac) {
|
||||
// macOS: run as native Mac browser — GPU/UA match natively
|
||||
return [...base, "--fingerprint-platform=macos"];
|
||||
return [
|
||||
...base,
|
||||
"--fingerprint-platform=macos",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
|
||||
];
|
||||
}
|
||||
|
||||
// Linux: spoof as Windows
|
||||
// Linux/Windows: spoof as Windows desktop
|
||||
// Hardware concurrency, device memory, screen, and window size are
|
||||
// auto-generated by the binary from the seed (v14+).
|
||||
return [
|
||||
...base,
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-device-memory=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--window-size=1920,1080",
|
||||
];
|
||||
}
|
||||
|
||||
+93
-39
@@ -19,6 +19,8 @@ import {
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
WRAPPER_VERSION,
|
||||
checkPlatformAvailable,
|
||||
getArchiveExt,
|
||||
getArchiveName,
|
||||
getBinaryDir,
|
||||
getBinaryPath,
|
||||
getCacheDir,
|
||||
@@ -63,6 +65,7 @@ export async function ensureBinary(): Promise<string> {
|
||||
const binaryPath = getBinaryPath(effective);
|
||||
|
||||
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
|
||||
showWelcome();
|
||||
maybeTriggerUpdateCheck();
|
||||
return binaryPath;
|
||||
}
|
||||
@@ -87,8 +90,8 @@ export async function ensureBinary(): Promise<string> {
|
||||
if (!fs.existsSync(downloadedPath)) {
|
||||
throw new Error(
|
||||
`Download completed but binary not found at expected path: ${downloadedPath}. ` +
|
||||
`This may indicate a packaging issue. Please report at ` +
|
||||
`https://github.com/CloakHQ/cloakbrowser/issues`
|
||||
`This may indicate a packaging issue. Please report at ` +
|
||||
`https://github.com/CloakHQ/cloakbrowser/issues`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,6 +139,28 @@ export async function checkForUpdate(): Promise<string | null> {
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Welcome message (shown once per install)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showWelcome(): void {
|
||||
const marker = path.join(getCacheDir(), ".welcome_shown");
|
||||
if (fs.existsSync(marker)) return;
|
||||
console.log();
|
||||
console.log(" CloakBrowser — stealth Chromium for automation");
|
||||
console.log(" https://github.com/CloakHQ/CloakBrowser");
|
||||
console.log();
|
||||
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
|
||||
console.log(" Star us if CloakBrowser helps your project!");
|
||||
console.log();
|
||||
try {
|
||||
fs.mkdirSync(getCacheDir(), { recursive: true });
|
||||
fs.writeFileSync(marker, "");
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -152,7 +177,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
// Download to temp file (atomic — no partial downloads in cache)
|
||||
const tmpPath = path.join(
|
||||
path.dirname(binaryDir),
|
||||
`_download_${Date.now()}.tar.gz`
|
||||
`_download_${Date.now()}${getArchiveExt()}`
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -175,15 +200,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
}
|
||||
|
||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||
console.log(
|
||||
`[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.`
|
||||
);
|
||||
console.log(
|
||||
`[cloakbrowser] Issues? https://github.com/CloakHQ/CloakBrowser/issues`
|
||||
);
|
||||
console.log(
|
||||
`[cloakbrowser] Star us if CloakBrowser helps: https://github.com/CloakHQ/CloakBrowser`
|
||||
);
|
||||
showWelcome();
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
@@ -194,7 +211,7 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
|
||||
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||
const checksums = await fetchChecksums(version);
|
||||
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
const tarballName = getArchiveName();
|
||||
|
||||
if (!checksums) {
|
||||
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
|
||||
@@ -274,6 +291,9 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
||||
|
||||
// Create file stream early so we can ensure cleanup on error
|
||||
const fileStream = createWriteStream(dest);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -292,7 +312,6 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
let downloaded = 0;
|
||||
let lastLoggedPct = -1;
|
||||
|
||||
const fileStream = createWriteStream(dest);
|
||||
const reader = response.body.getReader();
|
||||
|
||||
// Stream chunks to file with progress logging
|
||||
@@ -316,19 +335,32 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for file stream to finish
|
||||
// Wait for file stream to fully close (not just finish)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fileStream.end(() => resolve());
|
||||
fileStream.end();
|
||||
fileStream.on("close", () => resolve());
|
||||
fileStream.on("error", reject);
|
||||
});
|
||||
|
||||
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
|
||||
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`);
|
||||
} catch (err) {
|
||||
// Ensure file stream is destroyed on error to release the handle
|
||||
if (!fileStream.destroyed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fileStream.destroy();
|
||||
fileStream.on("close", () => resolve());
|
||||
// Safety timeout in case close never fires
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
@@ -342,30 +374,18 @@ async function extractArchive(
|
||||
}
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
// Extract with tar — the 'tar' package handles symlink/traversal safety
|
||||
await tarExtract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
// Security: strip leading path components and reject absolute paths
|
||||
strip: 0,
|
||||
filter: (entryPath: string) => {
|
||||
// Reject absolute paths and path traversal
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (archivePath.endsWith(".zip")) {
|
||||
await extractZip(archivePath, destDir);
|
||||
} else {
|
||||
await extractTar(archivePath, destDir);
|
||||
}
|
||||
|
||||
// Flatten single subdirectory if needed
|
||||
flattenSingleSubdir(destDir);
|
||||
|
||||
// Make binary executable
|
||||
// Make binary executable (skip on Windows — no-op / AV lock risk)
|
||||
const bp = binaryPath || getBinaryPath();
|
||||
if (fs.existsSync(bp)) {
|
||||
if (process.platform !== "win32" && fs.existsSync(bp)) {
|
||||
fs.chmodSync(bp, 0o755);
|
||||
}
|
||||
|
||||
@@ -379,6 +399,40 @@ async function extractArchive(
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTar(archivePath: string, destDir: string): Promise<void> {
|
||||
await tarExtract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
strip: 0,
|
||||
filter: (entryPath: string) => {
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
// Brief delay to ensure OS fully releases file handles (Windows)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
||||
// with recently-closed Node.js file handles. Use ZipFile API directly.
|
||||
execFileSync("powershell", [
|
||||
"-NoProfile", "-Command",
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
|
||||
], { timeout: 120_000 });
|
||||
} else {
|
||||
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If extraction created a single subdirectory, move its contents up.
|
||||
* Many tarballs wrap files in a top-level directory.
|
||||
@@ -452,7 +506,7 @@ export async function getLatestChromiumVersion(): Promise<string | null> {
|
||||
draft: boolean;
|
||||
assets: Array<{ name: string }>;
|
||||
}>;
|
||||
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
const platformTarball = getArchiveName();
|
||||
for (const release of releases) {
|
||||
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
|
||||
const assetNames = new Set(
|
||||
@@ -500,7 +554,7 @@ export async function checkWrapperUpdate(): Promise<void> {
|
||||
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Update available: ${WRAPPER_VERSION} → ${data.version}. ` +
|
||||
`Run: npm install cloakbrowser@latest`
|
||||
`Run: npm install cloakbrowser@latest`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -547,10 +601,10 @@ async function checkAndDownloadUpdate(): Promise<void> {
|
||||
function maybeTriggerUpdateCheck(): void {
|
||||
// Wrapper update: once per process, not rate-limited
|
||||
if (!wrapperUpdateChecked) {
|
||||
checkWrapperUpdate().catch(() => {});
|
||||
checkWrapperUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
// Binary update: rate-limited to once per hour
|
||||
if (!shouldCheckForUpdate()) return;
|
||||
checkAndDownloadUpdate().catch(() => {});
|
||||
checkAndDownloadUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
// Launch functions (Playwright API)
|
||||
export { launch, launchContext } from "./playwright.js";
|
||||
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
|
||||
|
||||
// Binary management
|
||||
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
||||
@@ -25,4 +25,4 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
|
||||
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
// Types
|
||||
export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js";
|
||||
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
|
||||
|
||||
+76
-26
@@ -4,11 +4,23 @@
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext } from "playwright-core";
|
||||
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
|
||||
import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
|
||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||
import { DEFAULT_VIEWPORT } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
|
||||
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */
|
||||
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
||||
if (options.timezoneId != null) {
|
||||
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
|
||||
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
|
||||
delete (merged as any).timezoneId;
|
||||
return merged;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth Chromium browser via Playwright.
|
||||
*
|
||||
@@ -34,7 +46,9 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
@@ -60,9 +74,13 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
export async function launchContext(
|
||||
options: LaunchContextOptions = {}
|
||||
): Promise<BrowserContext> {
|
||||
options = migrateTimezoneId(options);
|
||||
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false });
|
||||
// Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
// context and interferes with Playwright's timezoneId on new contexts.
|
||||
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
@@ -88,6 +106,56 @@ export async function launchContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth browser with a persistent user profile (non-incognito).
|
||||
* Uses Playwright's chromium.launchPersistentContext() under the hood.
|
||||
*
|
||||
* This avoids incognito detection by services like BrowserScan (-10% penalty)
|
||||
* and enables session persistence (cookies, localStorage) across launches.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { launchPersistentContext } from 'cloakbrowser';
|
||||
* const context = await launchPersistentContext({
|
||||
* userDataDir: './chrome-profile',
|
||||
* headless: false,
|
||||
* proxy: 'http://user:pass@host:port',
|
||||
* geoip: true,
|
||||
* });
|
||||
* const page = context.pages()[0] || await context.newPage();
|
||||
* await page.goto('https://example.com');
|
||||
* await context.close();
|
||||
* ```
|
||||
*/
|
||||
export async function launchPersistentContext(
|
||||
options: LaunchPersistentContextOptions
|
||||
): Promise<BrowserContext> {
|
||||
options = migrateTimezoneId(options);
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
const args = buildArgs({ ...options, ...resolved });
|
||||
|
||||
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
||||
...(resolved.locale ? { locale: resolved.locale } : {}),
|
||||
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -99,7 +167,9 @@ async function maybeResolveGeoip(
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
@@ -107,24 +177,4 @@ async function maybeResolveGeoip(
|
||||
}
|
||||
|
||||
/** @internal Exposed for unit tests only. */
|
||||
export function _buildArgsForTest(options: LaunchOptions): string[] {
|
||||
return buildArgs(options);
|
||||
}
|
||||
|
||||
function buildArgs(options: LaunchOptions): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.stealthArgs !== false) {
|
||||
args.push(...getDefaultStealthArgs());
|
||||
}
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
// Timezone/locale flags — always inject when set
|
||||
if (options.timezone) {
|
||||
args.push(`--fingerprint-timezone=${options.timezone}`);
|
||||
}
|
||||
if (options.locale) {
|
||||
args.push(`--lang=${options.locale}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
export { buildArgs as _buildArgsForTest } from "./args.js";
|
||||
|
||||
+24
-22
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { getDefaultStealthArgs } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
|
||||
@@ -34,10 +34,26 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
// so we strip them and use page.authenticate() instead.
|
||||
let proxyAuth: { username: string; password: string } | undefined;
|
||||
if (options.proxy) {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password || "" };
|
||||
if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
} else {
|
||||
// Strip any inline credentials from the server URL — Chromium's
|
||||
// --proxy-server doesn't support them; use page.authenticate() instead.
|
||||
const parsed = parseProxyUrl(options.proxy.server);
|
||||
args.push(`--proxy-server=${parsed.server}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
// Explicit username/password fields take precedence over inline creds
|
||||
const username = options.proxy.username ?? parsed.username;
|
||||
const password = options.proxy.password ?? parsed.password;
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,26 +90,12 @@ async function maybeResolveGeoip(
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
|
||||
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildArgs(options: LaunchOptions): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.stealthArgs !== false) {
|
||||
args.push(...getDefaultStealthArgs());
|
||||
}
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
if (options.timezone) {
|
||||
args.push(`--fingerprint-timezone=${options.timezone}`);
|
||||
}
|
||||
if (options.locale) {
|
||||
args.push(`--lang=${options.locale}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
+13
-3
@@ -5,8 +5,13 @@
|
||||
export interface LaunchOptions {
|
||||
/** Run in headless mode (default: true). */
|
||||
headless?: boolean;
|
||||
/** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */
|
||||
proxy?: string;
|
||||
/**
|
||||
* Proxy server — URL string or Playwright proxy object.
|
||||
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
|
||||
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
|
||||
* — passed directly to Playwright.
|
||||
*/
|
||||
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
|
||||
/** Additional Chromium CLI arguments. */
|
||||
args?: string[];
|
||||
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
|
||||
@@ -28,12 +33,17 @@ export interface LaunchContextOptions extends LaunchOptions {
|
||||
viewport?: { width: number; height: number };
|
||||
/** Browser locale, e.g. "en-US". */
|
||||
locale?: string;
|
||||
/** Timezone, e.g. "America/New_York". */
|
||||
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
|
||||
timezoneId?: string;
|
||||
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
||||
colorScheme?: "light" | "dark" | "no-preference";
|
||||
}
|
||||
|
||||
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||
/** Path to user data directory for persistent profile. */
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
export interface BinaryInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
|
||||
+105
-1
@@ -1,13 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CHROMIUM_VERSION,
|
||||
getArchiveExt,
|
||||
getChromiumVersion,
|
||||
getDefaultStealthArgs,
|
||||
getCacheDir,
|
||||
getBinaryDir,
|
||||
getDownloadUrl,
|
||||
getFallbackDownloadUrl,
|
||||
} from "../src/config.js";
|
||||
import { _buildArgsForTest } from "../src/playwright.js";
|
||||
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
|
||||
|
||||
describe("config", () => {
|
||||
it("CHROMIUM_VERSION matches expected format", () => {
|
||||
@@ -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", () => {
|
||||
it("injects --fingerprint-timezone when timezone is set", () => {
|
||||
const args = _buildArgsForTest({ timezone: "America/New_York" });
|
||||
@@ -98,3 +122,83 @@ describe("buildArgs timezone/locale", () => {
|
||||
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArgs deduplication", () => {
|
||||
it("user --fingerprint overrides default seed", () => {
|
||||
const args = _buildArgsForTest({ args: ["--fingerprint=99887"] });
|
||||
const fpArgs = args.filter(a => a.startsWith("--fingerprint="));
|
||||
expect(fpArgs).toHaveLength(1);
|
||||
expect(fpArgs[0]).toBe("--fingerprint=99887");
|
||||
});
|
||||
|
||||
it("user --fingerprint-platform overrides default", () => {
|
||||
const args = _buildArgsForTest({ args: ["--fingerprint-platform=linux"] });
|
||||
const platArgs = args.filter(a => a.startsWith("--fingerprint-platform="));
|
||||
expect(platArgs).toHaveLength(1);
|
||||
expect(platArgs[0]).toBe("--fingerprint-platform=linux");
|
||||
});
|
||||
|
||||
it("timezone param overrides user --fingerprint-timezone arg", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--fingerprint-timezone=Europe/London"],
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
const tzArgs = args.filter(a => a.startsWith("--fingerprint-timezone="));
|
||||
expect(tzArgs).toHaveLength(1);
|
||||
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
|
||||
});
|
||||
|
||||
it("locale param overrides user --lang arg", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--lang=de-DE"],
|
||||
locale: "en-US",
|
||||
});
|
||||
const langArgs = args.filter(a => a.startsWith("--lang="));
|
||||
expect(langArgs).toHaveLength(1);
|
||||
expect(langArgs[0]).toBe("--lang=en-US");
|
||||
});
|
||||
|
||||
it("no duplicate flag keys in output", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de-DE",
|
||||
});
|
||||
const keys = args.map(a => a.split("=")[0]);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("non-value flags preserved without dedup issues", () => {
|
||||
const args = _buildArgsForTest({ args: ["--disable-gpu", "--no-zygote"] });
|
||||
expect(args).toContain("--disable-gpu");
|
||||
expect(args).toContain("--no-zygote");
|
||||
expect(args).toContain("--no-sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateTimezoneId deprecation", () => {
|
||||
it("migrates timezoneId to timezone", () => {
|
||||
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
|
||||
expect(result.timezone).toBe("Europe/Paris");
|
||||
expect(result).not.toHaveProperty("timezoneId");
|
||||
});
|
||||
|
||||
it("preserves explicit timezone over timezoneId", () => {
|
||||
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" });
|
||||
expect(result.timezone).toBe("UTC");
|
||||
expect(result).not.toHaveProperty("timezoneId");
|
||||
});
|
||||
|
||||
it("returns options unchanged when no timezoneId", () => {
|
||||
const opts = { timezone: "UTC" };
|
||||
const result = migrateTimezoneId(opts);
|
||||
expect(result).toBe(opts); // same reference, no copy
|
||||
expect(result.timezone).toBe("UTC");
|
||||
});
|
||||
|
||||
it("returns options unchanged when neither is set", () => {
|
||||
const opts = {};
|
||||
const result = migrateTimezoneId(opts);
|
||||
expect(result).toBe(opts);
|
||||
});
|
||||
});
|
||||
|
||||
+173
-3
@@ -1,13 +1,13 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { binaryInfo } from "../src/download.js";
|
||||
import { getChromiumVersion } from "../src/config.js";
|
||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||
|
||||
describe("binaryInfo", () => {
|
||||
it("returns correct structure", () => {
|
||||
const info = binaryInfo();
|
||||
|
||||
expect(info.version).toBe(getChromiumVersion());
|
||||
expect(info.platform).toMatch(/^(linux|darwin)-(x64|arm64)$/);
|
||||
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
|
||||
expect(info.binaryPath).toBeTruthy();
|
||||
expect(typeof info.installed).toBe("boolean");
|
||||
expect(info.cacheDir).toContain("cloakbrowser");
|
||||
@@ -37,3 +37,173 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
|
||||
}, 30_000);
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// launchContext / launchPersistentContext unit tests (mock playwright-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("launchContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockBrowser: any;
|
||||
let mockChromium: any;
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
const origClose = vi.fn();
|
||||
mockContext = { close: origClose, _origClose: origClose };
|
||||
mockBrowser = {
|
||||
newContext: vi.fn().mockResolvedValue(mockContext),
|
||||
close: vi.fn(),
|
||||
};
|
||||
mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
|
||||
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (origEnv) {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies DEFAULT_VIEWPORT when no viewport given", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext();
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("uses custom viewport when provided", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
const custom = { width: 1280, height: 720 };
|
||||
await launchContext({ viewport: custom });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(custom);
|
||||
});
|
||||
|
||||
it("forwards userAgent to newContext", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ userAgent: "Custom/1.0" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.userAgent).toBe("Custom/1.0");
|
||||
});
|
||||
|
||||
it("passes timezone to context timezoneId, not to launch", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ timezone: "America/New_York" });
|
||||
|
||||
// launch() called with timezone: undefined (skipped for binary flag)
|
||||
const launchArgs = mockChromium.launch.mock.calls[0][0];
|
||||
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
|
||||
a.startsWith("--fingerprint-timezone=")
|
||||
);
|
||||
expect(hasTimezoneFlag).toBe(false);
|
||||
|
||||
// newContext() gets timezoneId
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.timezoneId).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it("forwards colorScheme to newContext", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ colorScheme: "dark" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.colorScheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("close() also closes browser", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
const ctx = await launchContext();
|
||||
|
||||
await ctx.close();
|
||||
// Original context close called
|
||||
expect(mockContext._origClose).toHaveBeenCalledOnce();
|
||||
// Browser also closed
|
||||
expect(mockBrowser.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchPersistentContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockChromium: any;
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) };
|
||||
mockChromium = {
|
||||
launchPersistentContext: vi.fn().mockResolvedValue(mockContext),
|
||||
};
|
||||
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (origEnv) {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies DEFAULT_VIEWPORT", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("passes timezone and locale to context", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
timezone: "Asia/Tokyo",
|
||||
locale: "ja-JP",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.timezoneId).toBe("Asia/Tokyo");
|
||||
expect(args.locale).toBe("ja-JP");
|
||||
// Also in binary args
|
||||
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
|
||||
expect(args.args).toContain("--lang=ja-JP");
|
||||
});
|
||||
|
||||
it("forwards proxy string", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
proxy: "http://user:pass@proxy:8080",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.proxy.server).toBe("http://proxy:8080");
|
||||
expect(args.proxy.username).toBe("user");
|
||||
expect(args.proxy.password).toBe("pass");
|
||||
});
|
||||
|
||||
it("forwards userAgent and colorScheme", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
userAgent: "Custom/1.0",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.userAgent).toBe("Custom/1.0");
|
||||
expect(args.colorScheme).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProxyUrl } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
it("passes through URL without credentials", () => {
|
||||
@@ -47,3 +48,37 @@ describe("parseProxyUrl", () => {
|
||||
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy dict type", () => {
|
||||
it("accepts string proxy in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
|
||||
expect(typeof opts.proxy).toBe("string");
|
||||
});
|
||||
|
||||
it("accepts dict proxy with bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
|
||||
};
|
||||
expect(typeof opts.proxy).toBe("object");
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.server).toBe("http://proxy:8080");
|
||||
expect(opts.proxy.bypass).toBe(".google.com,localhost");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: {
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
bypass: ".example.com",
|
||||
},
|
||||
};
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.username).toBe("user");
|
||||
expect(opts.proxy.password).toBe("pass");
|
||||
expect(opts.proxy.bypass).toBe(".example.com");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+58
-4
@@ -9,7 +9,11 @@ import {
|
||||
versionNewer,
|
||||
} from "../src/config.js";
|
||||
import {
|
||||
binaryInfo,
|
||||
checkForUpdate,
|
||||
checkWrapperUpdate,
|
||||
clearCache,
|
||||
ensureBinary,
|
||||
getLatestChromiumVersion,
|
||||
parseChecksums,
|
||||
resetWrapperUpdateChecked,
|
||||
@@ -100,7 +104,7 @@ describe("latest version (platform-aware)", () => {
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
|
||||
@@ -116,7 +120,7 @@ describe("latest version (platform-aware)", () => {
|
||||
{
|
||||
tag_name: "chromium-v142.0.7444.175",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
const result = await getLatestChromiumVersion();
|
||||
@@ -133,14 +137,14 @@ describe("latest version (platform-aware)", () => {
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: [{ name: "cloakbrowser-windows-x64.tar.gz" }],
|
||||
assets: [{ name: "cloakbrowser-freebsd-x64.tar.gz" }],
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it("skips draft releases", async () => {
|
||||
const all = ["linux-x64", "darwin-arm64", "darwin-x64"];
|
||||
const all = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"];
|
||||
mockFetch([
|
||||
{ tag_name: "chromium-v999.0.0.0", draft: true, assets: makeAssets(all) },
|
||||
{ tag_name: "chromium-v145.0.7718.0", draft: false, assets: makeAssets(all) },
|
||||
@@ -271,3 +275,53 @@ describe("effective version", () => {
|
||||
expect(getEffectiveVersion()).toBe(getChromiumVersion());
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBinary", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
});
|
||||
|
||||
it("returns local override when set", async () => {
|
||||
// Use this test file as a "binary" that exists
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = __filename;
|
||||
const result = await ensureBinary();
|
||||
expect(result).toBe(__filename);
|
||||
});
|
||||
|
||||
it("throws when local override path missing", async () => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/nonexistent/chrome";
|
||||
await expect(ensureBinary()).rejects.toThrow("does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCache", () => {
|
||||
it("does not throw when cache dir missing", () => {
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = "/tmp/cloakbrowser-test-nonexistent";
|
||||
expect(() => clearCache()).not.toThrow();
|
||||
if (orig) {
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForUpdate", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when no newer version", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as Response);
|
||||
expect(await checkForUpdate()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on network error", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
expect(await checkForUpdate()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+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():
|
||||
@@ -44,3 +46,121 @@ def test_extra_args_preserved():
|
||||
assert "--disable-gpu" in args
|
||||
assert "--fingerprint-timezone=Asia/Tokyo" in args
|
||||
assert "--lang=ja-JP" in args
|
||||
|
||||
|
||||
# --- _migrate_timezone_id deprecation compat ---
|
||||
|
||||
|
||||
def test_migrate_old_param_only():
|
||||
"""timezone_id in kwargs should be promoted to timezone."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id(None, kwargs)
|
||||
assert result == "Europe/Paris"
|
||||
assert "timezone_id" not in kwargs
|
||||
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
|
||||
|
||||
|
||||
def test_migrate_new_param_wins():
|
||||
"""Explicit timezone takes precedence; timezone_id is still popped."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "timezone_id" not in kwargs
|
||||
assert len(w) == 1
|
||||
|
||||
|
||||
def test_migrate_no_old_param():
|
||||
"""No warning when timezone_id is absent."""
|
||||
kwargs = {"other": "value"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "other" in kwargs
|
||||
assert len(w) == 0
|
||||
|
||||
|
||||
def test_migrate_both_none():
|
||||
"""Neither param set — returns None, no warning."""
|
||||
kwargs = {}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id(None, kwargs)
|
||||
assert result is None
|
||||
assert len(w) == 0
|
||||
|
||||
|
||||
# --- Deduplication tests ---
|
||||
|
||||
|
||||
def test_user_fingerprint_overrides_default():
|
||||
"""User --fingerprint should override the random default seed."""
|
||||
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
||||
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
|
||||
assert len(fingerprint_args) == 1
|
||||
assert fingerprint_args[0] == "--fingerprint=99887"
|
||||
|
||||
|
||||
def test_user_platform_overrides_default():
|
||||
"""User --fingerprint-platform should override the default."""
|
||||
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
|
||||
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
|
||||
assert len(platform_args) == 1
|
||||
assert platform_args[0] == "--fingerprint-platform=linux"
|
||||
|
||||
|
||||
def test_timezone_param_overrides_user_arg():
|
||||
"""Dedicated timezone param should override user arg."""
|
||||
args = _build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint-timezone=Europe/London"],
|
||||
timezone="America/New_York",
|
||||
)
|
||||
tz_args = [a for a in args if a.startswith("--fingerprint-timezone=")]
|
||||
assert len(tz_args) == 1
|
||||
assert tz_args[0] == "--fingerprint-timezone=America/New_York"
|
||||
|
||||
|
||||
def test_locale_param_overrides_user_arg():
|
||||
"""Dedicated locale param should override user --lang arg."""
|
||||
args = _build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--lang=de-DE"],
|
||||
locale="en-US",
|
||||
)
|
||||
lang_args = [a for a in args if a.startswith("--lang=")]
|
||||
assert len(lang_args) == 1
|
||||
assert lang_args[0] == "--lang=en-US"
|
||||
|
||||
|
||||
def test_no_duplicate_flags():
|
||||
"""No flag key should appear more than once in the output."""
|
||||
args = _build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||
timezone="Europe/Berlin",
|
||||
locale="de-DE",
|
||||
)
|
||||
keys = [a.split("=", 1)[0] for a in args]
|
||||
assert len(keys) == len(set(keys)), f"Duplicate keys found: {keys}"
|
||||
|
||||
|
||||
def test_non_value_flags_preserved():
|
||||
"""Flags without = should be preserved without dedup issues."""
|
||||
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
|
||||
assert "--disable-gpu" in args
|
||||
assert "--no-zygote" in args
|
||||
assert "--no-sandbox" in args
|
||||
|
||||
|
||||
def test_override_logs_debug(caplog):
|
||||
"""Should log debug message when an override happens."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
|
||||
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
||||
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
|
||||
|
||||
@@ -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.geoip import (
|
||||
COUNTRY_LOCALE_MAP,
|
||||
_is_private_ip,
|
||||
_resolve_proxy_ip,
|
||||
)
|
||||
|
||||
@@ -136,3 +137,23 @@ def test_maybe_resolve_fills_both():
|
||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert loc == "de-DE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_private_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_private_ip_loopback():
|
||||
assert _is_private_ip("127.0.0.1") is True
|
||||
|
||||
|
||||
def test_private_ip_rfc1918():
|
||||
assert _is_private_ip("192.168.1.1") is True
|
||||
assert _is_private_ip("10.0.0.1") is True
|
||||
assert _is_private_ip("172.16.0.1") is True
|
||||
|
||||
|
||||
def test_private_ip_public():
|
||||
assert _is_private_ip("8.8.8.8") is False
|
||||
assert _is_private_ip("64.176.168.43") is False
|
||||
|
||||
@@ -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 patchright 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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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("patchright.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."""
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
|
||||
from unittest.mock import patch
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -48,3 +50,51 @@ class TestBuildProxyKwargs:
|
||||
assert result == {
|
||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
}
|
||||
|
||||
def test_proxy_dict_passthrough(self):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
|
||||
def test_proxy_dict_with_auth(self):
|
||||
proxy_dict = {
|
||||
"server": "http://proxy:8080",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"bypass": ".example.com",
|
||||
}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
|
||||
|
||||
class TestMaybeResolveGeoip:
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
||||
def test_geoip_with_string_proxy(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
assert locale == "en-US"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
|
||||
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
|
||||
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "Europe/London"
|
||||
assert locale == "en-GB"
|
||||
|
||||
def test_geoip_disabled_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
def test_geoip_no_proxy_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(True, None, None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
|
||||
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert locale == "ja-JP"
|
||||
|
||||
+123
-5
@@ -24,6 +24,10 @@ from cloakbrowser.download import (
|
||||
_parse_checksums,
|
||||
_should_check_for_update,
|
||||
_verify_checksum,
|
||||
_write_version_marker,
|
||||
check_for_update,
|
||||
clear_cache,
|
||||
ensure_binary,
|
||||
)
|
||||
|
||||
|
||||
@@ -166,7 +170,7 @@ class TestGetLatestVersion:
|
||||
{
|
||||
"tag_name": "chromium-v145.0.7718.0",
|
||||
"draft": False,
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
@@ -187,7 +191,7 @@ class TestGetLatestVersion:
|
||||
{
|
||||
"tag_name": "chromium-v142.0.7444.175",
|
||||
"draft": False,
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64"]),
|
||||
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
@@ -202,7 +206,7 @@ class TestGetLatestVersion:
|
||||
|
||||
def test_skips_draft_releases(self):
|
||||
mock_response = MagicMock()
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "chromium-v999.0.0.0", "draft": True, "assets": self._make_assets(all_platforms)},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
@@ -215,7 +219,7 @@ class TestGetLatestVersion:
|
||||
|
||||
def test_skips_non_chromium_tags(self):
|
||||
mock_response = MagicMock()
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64"]
|
||||
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
||||
mock_response.json.return_value = [
|
||||
{"tag_name": "v0.2.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
|
||||
@@ -233,7 +237,7 @@ class TestGetLatestVersion:
|
||||
{
|
||||
"tag_name": "chromium-v145.0.7718.0",
|
||||
"draft": False,
|
||||
"assets": [{"name": "cloakbrowser-windows-x64.tar.gz"}],
|
||||
"assets": [{"name": "cloakbrowser-freebsd-x64.tar.gz"}],
|
||||
},
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
@@ -356,3 +360,117 @@ class TestVerifyChecksum:
|
||||
file.write_bytes(b"real content")
|
||||
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
||||
_verify_checksum(file, "0" * 64)
|
||||
|
||||
|
||||
class TestClearCache:
|
||||
def test_removes_dir(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
# Create some content
|
||||
(tmp_path / "chromium-145").mkdir()
|
||||
(tmp_path / "chromium-145" / "chrome").write_bytes(b"binary")
|
||||
clear_cache()
|
||||
assert not tmp_path.exists()
|
||||
|
||||
def test_noop_if_missing(self, tmp_path):
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(nonexistent)}):
|
||||
clear_cache() # Should not raise
|
||||
|
||||
|
||||
class TestCheckForUpdate:
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_returns_none_when_current(self, _mock_update):
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
|
||||
assert check_for_update() is None
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_returns_none_on_network_error(self, _mock_update):
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", side_effect=Exception("timeout")):
|
||||
# _get_latest_chromium_version catches exceptions internally, but
|
||||
# check_for_update itself can also fail — test graceful None return
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value=None):
|
||||
assert check_for_update() is None
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_returns_version_when_newer(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
|
||||
with patch("cloakbrowser.download._download_and_extract"):
|
||||
result = check_for_update()
|
||||
assert result == "999.0.0.0"
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_skips_download_if_already_cached(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
# Create the binary dir so it looks already downloaded
|
||||
binary_dir = tmp_path / "chromium-999.0.0.0"
|
||||
binary_dir.mkdir()
|
||||
with patch("cloakbrowser.download._get_latest_chromium_version", return_value="999.0.0.0"):
|
||||
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
|
||||
result = check_for_update()
|
||||
assert result == "999.0.0.0"
|
||||
mock_dl.assert_not_called()
|
||||
|
||||
|
||||
class TestEnsureBinary:
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_local_override(self, _mock_update, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": str(binary)}):
|
||||
result = ensure_binary()
|
||||
assert result == str(binary)
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_local_override_missing_file(self, _mock_update):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/nonexistent/chrome"}):
|
||||
with pytest.raises(FileNotFoundError, match="does not exist"):
|
||||
ensure_binary()
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_cached_binary_found(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||
"CLOAKBROWSER_BINARY_PATH": "",
|
||||
}):
|
||||
# Create a fake cached binary
|
||||
version = get_chromium_version()
|
||||
with patch("cloakbrowser.download.get_binary_path") as mock_path:
|
||||
fake_binary = tmp_path / "chrome"
|
||||
fake_binary.write_bytes(b"binary")
|
||||
fake_binary.chmod(0o755)
|
||||
mock_path.return_value = fake_binary
|
||||
with patch("cloakbrowser.download.check_platform_available"):
|
||||
result = ensure_binary()
|
||||
assert result == str(fake_binary)
|
||||
|
||||
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
||||
def test_downloads_when_missing(self, _mock_update, tmp_path):
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||
"CLOAKBROWSER_BINARY_PATH": "",
|
||||
}):
|
||||
fake_binary = tmp_path / "chrome"
|
||||
with patch("cloakbrowser.download.check_platform_available"):
|
||||
with patch("cloakbrowser.download.get_binary_path") as mock_path:
|
||||
# effective == platform_version (no marker), so fallback block skipped.
|
||||
# Call 1: get_binary_path(effective) → nonexistent (triggers download)
|
||||
# Call 2: get_binary_path() → fake_binary (post-download verify)
|
||||
mock_path.side_effect = [
|
||||
tmp_path / "nonexistent", # pre-download: not cached
|
||||
fake_binary, # post-download: binary ready
|
||||
]
|
||||
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
|
||||
fake_binary.write_bytes(b"binary")
|
||||
result = ensure_binary()
|
||||
mock_dl.assert_called_once()
|
||||
assert result == str(fake_binary)
|
||||
|
||||
|
||||
class TestWriteVersionMarker:
|
||||
def test_creates_file(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
_write_version_marker("999.0.0.0")
|
||||
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
||||
assert marker.exists()
|
||||
assert marker.read_text() == "999.0.0.0"
|
||||
|
||||
Reference in New Issue
Block a user