mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776630e08b | ||
|
|
402a884088 | ||
|
|
39db492b04 | ||
|
|
b06499b0c1 | ||
|
|
a6b1363244 | ||
|
|
b4a4ad21ab | ||
|
|
dcf9ba55d6 | ||
|
|
14ec2ebf5f | ||
|
|
0caa14bf7b | ||
|
|
2a99081850 | ||
|
|
7fc577e5c6 | ||
|
|
12d02c3547 | ||
|
|
243c1385a0 | ||
|
|
0f3dc7201b | ||
|
|
34d2f78e87 | ||
|
|
41be4e0e30 | ||
|
|
58ccdb683c | ||
|
|
8028ddefef | ||
|
|
864cae2493 |
@@ -8,3 +8,21 @@ updates:
|
|||||||
actions:
|
actions:
|
||||||
patterns:
|
patterns:
|
||||||
- "*"
|
- "*"
|
||||||
|
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
python:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/js"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
javascript:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
python:
|
python:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
javascript:
|
javascript:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
|
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
|
||||||
@@ -100,20 +100,20 @@ jobs:
|
|||||||
attestations: write
|
attestations: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_PAT }}
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.32] — 2026-06-20
|
||||||
|
|
||||||
|
- **[wrapper]** **Security**: Windows binary extraction — pass archive/destination paths to PowerShell via env vars instead of interpolating into the `-Command` string, closing a code-injection shape on paths containing single quotes (e.g. `C:\Users\O'Brien`)
|
||||||
|
- **[wrapper]** Widevine: auto-seed CDM hint file for persistent contexts on Linux, so DRM playback works without manual pre-seeding
|
||||||
|
- **[wrapper]** `cloakserve`: rewrite CDP WebSocket URLs so clients connect through the proxy correctly (thanks [@honor2030](https://github.com/honor2030), #234)
|
||||||
|
- **[wrapper]** `cloakserve`: add idle cleanup for seeded profiles (thanks [@Kumario1](https://github.com/Kumario1), #352)
|
||||||
|
- **[meta]** Fix `recaptcha_score.py` example — wait for the reCAPTCHA score to render before screenshot (thanks [@igo](https://github.com/igo) for the report, #374)
|
||||||
|
- **[meta]** Bump GitHub Actions in the actions group (#358)
|
||||||
|
|
||||||
|
## [0.3.31] — 2026-05-26
|
||||||
|
|
||||||
|
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
|
||||||
|
- **[wrapper]** JS: export `buildContextOptions` helper for custom context creation (thanks [@honor2030](https://github.com/honor2030), #262)
|
||||||
|
- **[wrapper]** Humanize: fix iframe coordinate offset in pointer-events check (thanks [@eofreternal](https://github.com/eofreternal), #303)
|
||||||
|
- **[wrapper]** Humanize: use shared deadline for timeout budget in frame and ElementHandle methods (#307)
|
||||||
|
- **[docker]** Clean up stale Xvfb lock so container survives restarts (thanks [@sparanoid](https://github.com/sparanoid), #284)
|
||||||
|
- **[meta]** Add pip and npm ecosystems to Dependabot, bump GitHub Actions (#309)
|
||||||
|
|
||||||
## [0.3.30] — 2026-05-21
|
## [0.3.30] — 2026-05-21
|
||||||
|
|
||||||
- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57)
|
- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ from cloakbrowser import launch
|
|||||||
|
|
||||||
browser = launch()
|
browser = launch()
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
page.goto("https://protected-site.com") # no more blocks
|
page.goto("https://example.com")
|
||||||
browser.close()
|
browser.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,12 +69,34 @@ import { launch } from 'cloakbrowser';
|
|||||||
|
|
||||||
const browser = await launch();
|
const browser = await launch();
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('https://protected-site.com');
|
await page.goto('https://example.com');
|
||||||
await browser.close();
|
await browser.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
|
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
|
||||||
|
|
||||||
|
**For sites with anti-bot protection**, add a residential proxy and these flags:
|
||||||
|
|
||||||
|
```python
|
||||||
|
browser = launch(
|
||||||
|
proxy="http://user:pass@residential-proxy:port", # residential IP, not datacenter
|
||||||
|
geoip=True, # match timezone + locale to proxy IP
|
||||||
|
headless=False, # some sites detect headless even with C++ patches
|
||||||
|
humanize=True, # human-like mouse, keyboard, scroll
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const browser = await launch({
|
||||||
|
proxy: 'http://user:pass@residential-proxy:port',
|
||||||
|
geoip: true,
|
||||||
|
headless: false,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Troubleshooting](#troubleshooting) for site-specific issues (FingerprintJS, Kasada, reCAPTCHA).
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
**Python:**
|
**Python:**
|
||||||
@@ -128,7 +150,7 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Latest: v0.3.30 (Chromium 146.0.7680.177.5)
|
## Latest: v0.3.32 (Chromium 146.0.7680.177.5)
|
||||||
|
|
||||||
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
|
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
|
||||||
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
|
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
|
||||||
@@ -361,6 +383,7 @@ Use this when you need to:
|
|||||||
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
|
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
|
||||||
- **Load Chrome extensions** (extensions only work from a real user data dir)
|
- **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)
|
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
|
||||||
|
- **Play DRM-protected video** (Widevine) — with a sideloaded CDM, the wrapper enables Widevine on the first launch (see [Widevine / DRM](#widevine--drm))
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from cloakbrowser import launch_persistent_context
|
from cloakbrowser import launch_persistent_context
|
||||||
@@ -397,6 +420,26 @@ ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quo
|
|||||||
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
|
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
|
||||||
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
|
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
|
||||||
|
|
||||||
|
### Widevine / DRM
|
||||||
|
|
||||||
|
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
|
||||||
|
```
|
||||||
|
|
||||||
|
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support as a real-browser signal.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cloakbrowser import launch_persistent_context
|
||||||
|
|
||||||
|
# WidevineCdm sideloaded next to the binary -> Widevine works on first launch
|
||||||
|
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there.
|
||||||
|
- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`.
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
Pre-download the binary or check installation status from the command line:
|
Pre-download the binary or check installation status from the command line:
|
||||||
@@ -580,6 +623,8 @@ Access the original un-patched Playwright page at `page._original` if you need r
|
|||||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
||||||
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
|
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
|
||||||
|
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
|
||||||
|
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
||||||
|
|
||||||
## Fingerprint Management
|
## Fingerprint Management
|
||||||
|
|
||||||
@@ -817,6 +862,26 @@ print(page.title())
|
|||||||
browser.close()
|
browser.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your framework needs a direct WebSocket endpoint, fetch Chrome's discovery document and use the rewritten `webSocketDebuggerUrl`. The URL points back through `cloakserve` so the CDP proxy can keep per-seed routing intact:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl
|
||||||
|
# ws://localhost:9222/devtools/browser/<browser-id>
|
||||||
|
|
||||||
|
curl 'http://localhost:9222/json/version?fingerprint=11111' | jq -r .webSocketDebuggerUrl
|
||||||
|
# ws://localhost:9222/fingerprint/11111/devtools/browser/<browser-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
When `cloakserve` runs behind a reverse proxy or TLS terminator, forward the public host/protocol headers so generated WebSocket URLs use the address clients can actually reach:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
```
|
||||||
|
|
||||||
|
With those headers, `/json/version` returns public endpoints such as `wss://cdp.example.com/fingerprint/11111/devtools/browser/<browser-id>` instead of an internal container host.
|
||||||
|
|
||||||
Pass extra flags to the browser:
|
Pass extra flags to the browser:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -827,6 +892,10 @@ docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
|
|||||||
# Headed mode (renders to Xvfb inside container)
|
# Headed mode (renders to Xvfb inside container)
|
||||||
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
|
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
|
||||||
cloakserve --headless=false
|
cloakserve --headless=false
|
||||||
|
|
||||||
|
# Reap disconnected per-seed browser processes after 5 minutes
|
||||||
|
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
|
||||||
|
cloakserve --idle-timeout=300
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop the server:
|
Stop the server:
|
||||||
@@ -878,7 +947,9 @@ b4 = pw.chromium.connect_over_cdp(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible). Check active processes at `GET /` (returns JSON with PIDs, ports, and connection counts).
|
Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible).
|
||||||
|
|
||||||
|
By default, per-seed processes stay alive until `cloakserve` exits. If clients create many unique seeds, set `--idle-timeout=SECONDS` or `CLOAKSERVE_IDLE_TIMEOUT=SECONDS` to automatically terminate a seed's Chrome process after its last CDP WebSocket disconnects. `0`, `off`, `false`, `none`, or `disabled` disable idle cleanup. When cleanup runs, the seed's temporary profile directory under `--data-dir` is removed too. Check active processes at `GET /` (returns JSON with PIDs, ports, connection counts, idle timeout, and pending cleanup status).
|
||||||
|
|
||||||
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
|
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
|
||||||
|
|
||||||
@@ -986,6 +1057,53 @@ If you're still blocked after this, check the font setup below.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Detected by FingerprintJS?
|
||||||
|
|
||||||
|
FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each detection has a specific cause:
|
||||||
|
|
||||||
|
| Detection | Cause | Fix |
|
||||||
|
|-----------|-------|-----|
|
||||||
|
| **`nodriver` / bad bot** | IP reputation or missing flags | Residential proxy + config below |
|
||||||
|
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
|
||||||
|
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
|
||||||
|
| **Incognito** | Storage quota normalized to ~500MB | Expected tradeoff — see below |
|
||||||
|
|
||||||
|
Config that passes FPJS (verified on v0.3.30, Linux + Windows):
|
||||||
|
|
||||||
|
```python
|
||||||
|
browser = launch(
|
||||||
|
headless=False,
|
||||||
|
proxy="http://user:pass@residential-proxy:port",
|
||||||
|
geoip=True,
|
||||||
|
args=[
|
||||||
|
"--fingerprint-noise=false", # prevents tampering detection
|
||||||
|
"--fingerprint-screen-width=1920", # match your viewport
|
||||||
|
"--fingerprint-screen-height=1080",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const browser = await launch({
|
||||||
|
headless: false,
|
||||||
|
proxy: 'http://user:pass@residential-proxy:port',
|
||||||
|
geoip: true,
|
||||||
|
args: [
|
||||||
|
'--fingerprint-noise=false',
|
||||||
|
'--fingerprint-screen-width=1920',
|
||||||
|
'--fingerprint-screen-height=1080',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
|
||||||
|
|
||||||
|
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. With quota alone you can't satisfy both — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
|
||||||
|
|
||||||
|
**Resolving the tradeoff (Linux):** Sideloading the Widevine CDM lets a persistent context pass FPJS at a higher quota, so you can satisfy both at once. See [Widevine / DRM](#widevine--drm).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Blocked on Kasada / Akamai sites despite correct config?
|
### Blocked on Kasada / Akamai sites despite correct config?
|
||||||
|
|
||||||
On minimal Linux environments, missing font packages cause canvas emoji rendering to produce hashes that anti-bot systems don't recognize. This is the most common cause of blocks on aggressive sites after proxy, geoip, and headed mode are already set up correctly.
|
On minimal Linux environments, missing font packages cause canvas emoji rendering to produce hashes that anti-bot systems don't recognize. This is the most common cause of blocks on aggressive sites after proxy, geoip, and headed mode are already set up correctly.
|
||||||
@@ -1202,7 +1320,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
|
|||||||
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
|
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
|
||||||
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
|
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
|
||||||
- [@kitiho](https://github.com/kitiho) — null viewport fix
|
- [@kitiho](https://github.com/kitiho) — null viewport fix
|
||||||
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types
|
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types, iframe pointer-events fix
|
||||||
- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard
|
- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard
|
||||||
- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging
|
- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging
|
||||||
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration, cold-start hardening
|
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration, cold-start hardening
|
||||||
@@ -1211,5 +1329,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
|
|||||||
- [@aaronjmars](https://github.com/aaronjmars) — security fixes (shell injection, dep bumps)
|
- [@aaronjmars](https://github.com/aaronjmars) — security fixes (shell injection, dep bumps)
|
||||||
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
|
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
|
||||||
- [@245678000000](https://github.com/245678000000) — package-lock sync
|
- [@245678000000](https://github.com/245678000000) — package-lock sync
|
||||||
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, composable JS launch helpers
|
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, CDP WebSocket URL rewrite, composable JS launch helpers
|
||||||
|
- [@sparanoid](https://github.com/sparanoid) — Docker Xvfb lock cleanup
|
||||||
|
- [@Kumario1](https://github.com/Kumario1) — cloakserve idle cleanup for seeded profiles
|
||||||
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
|
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
|
||||||
|
|||||||
+97
-2
@@ -181,6 +181,7 @@ class ChromePool:
|
|||||||
default_seed: str | None = None,
|
default_seed: str | None = None,
|
||||||
default_locale: str | None = None,
|
default_locale: str | None = None,
|
||||||
default_timezone: str | None = None,
|
default_timezone: str | None = None,
|
||||||
|
idle_timeout: float = 0.0,
|
||||||
):
|
):
|
||||||
self._binary = binary
|
self._binary = binary
|
||||||
self._global_args = global_args
|
self._global_args = global_args
|
||||||
@@ -189,12 +190,14 @@ class ChromePool:
|
|||||||
self._default_seed = default_seed
|
self._default_seed = default_seed
|
||||||
self._default_locale = default_locale
|
self._default_locale = default_locale
|
||||||
self._default_timezone = default_timezone
|
self._default_timezone = default_timezone
|
||||||
|
self._idle_timeout = idle_timeout
|
||||||
self._processes: dict[str, ChromeProcess] = {}
|
self._processes: dict[str, ChromeProcess] = {}
|
||||||
self._default: ChromeProcess | None = None
|
self._default: ChromeProcess | None = None
|
||||||
self._locks: dict[str, asyncio.Lock] = {}
|
self._locks: dict[str, asyncio.Lock] = {}
|
||||||
self._next_port = BASE_CDP_PORT
|
self._next_port = BASE_CDP_PORT
|
||||||
# Connection refcounting for status reporting
|
# Connection refcounting for status reporting
|
||||||
self._connections: dict[str, int] = {}
|
self._connections: dict[str, int] = {}
|
||||||
|
self._idle_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
def _get_lock(self, seed: str) -> asyncio.Lock:
|
def _get_lock(self, seed: str) -> asyncio.Lock:
|
||||||
if seed not in self._locks:
|
if seed not in self._locks:
|
||||||
@@ -224,6 +227,7 @@ class ChromePool:
|
|||||||
|
|
||||||
def connect(self, seed_key: str) -> None:
|
def connect(self, seed_key: str) -> None:
|
||||||
"""Increment connection refcount for a seed."""
|
"""Increment connection refcount for a seed."""
|
||||||
|
self._cancel_idle_cleanup(seed_key)
|
||||||
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
|
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
|
||||||
|
|
||||||
def disconnect(self, seed_key: str) -> None:
|
def disconnect(self, seed_key: str) -> None:
|
||||||
@@ -231,9 +235,54 @@ class ChromePool:
|
|||||||
count = self._connections.get(seed_key, 0) - 1
|
count = self._connections.get(seed_key, 0) - 1
|
||||||
if count <= 0:
|
if count <= 0:
|
||||||
self._connections.pop(seed_key, None)
|
self._connections.pop(seed_key, None)
|
||||||
|
self._schedule_idle_cleanup(seed_key)
|
||||||
else:
|
else:
|
||||||
self._connections[seed_key] = count
|
self._connections[seed_key] = count
|
||||||
|
|
||||||
|
def _cancel_idle_cleanup(self, seed_key: str) -> None:
|
||||||
|
task = self._idle_tasks.pop(seed_key, None)
|
||||||
|
if task is None or task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
current_task = asyncio.current_task()
|
||||||
|
except RuntimeError:
|
||||||
|
current_task = None
|
||||||
|
if task is not current_task:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
def _discard_idle_task(self, seed_key: str, task: asyncio.Task) -> None:
|
||||||
|
if self._idle_tasks.get(seed_key) is task:
|
||||||
|
self._idle_tasks.pop(seed_key, None)
|
||||||
|
|
||||||
|
def _schedule_idle_cleanup(self, seed_key: str) -> None:
|
||||||
|
if self._idle_timeout <= 0 or seed_key not in self._processes:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._cancel_idle_cleanup(seed_key)
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
task = loop.create_task(
|
||||||
|
self._cleanup_after_idle(seed_key, self._idle_timeout),
|
||||||
|
name=f"cloakserve-idle-cleanup-{seed_key}",
|
||||||
|
)
|
||||||
|
self._idle_tasks[seed_key] = task
|
||||||
|
task.add_done_callback(lambda done_task: self._discard_idle_task(seed_key, done_task))
|
||||||
|
|
||||||
|
async def _cleanup_after_idle(self, seed_key: str, timeout: float) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(timeout)
|
||||||
|
if self._connections.get(seed_key, 0) > 0 or seed_key not in self._processes:
|
||||||
|
return
|
||||||
|
logger.info("Cleaning up idle Chrome process (seed=%s)", seed_key)
|
||||||
|
await self._cleanup_process(seed_key)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Idle cleanup failed for seed=%s", seed_key)
|
||||||
|
|
||||||
async def get_or_launch(
|
async def get_or_launch(
|
||||||
self,
|
self,
|
||||||
seed: str | None,
|
seed: str | None,
|
||||||
@@ -271,6 +320,8 @@ class ChromePool:
|
|||||||
if seed_key in self._processes:
|
if seed_key in self._processes:
|
||||||
proc = self._processes[seed_key]
|
proc = self._processes[seed_key]
|
||||||
if proc.process.poll() is None:
|
if proc.process.poll() is None:
|
||||||
|
if seed_key in self._idle_tasks:
|
||||||
|
self._schedule_idle_cleanup(seed_key)
|
||||||
if any([extra_args, timezone, locale, proxy, geoip]):
|
if any([extra_args, timezone, locale, proxy, geoip]):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
|
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
|
||||||
@@ -360,6 +411,7 @@ class ChromePool:
|
|||||||
|
|
||||||
async def _cleanup_process(self, key: str) -> None:
|
async def _cleanup_process(self, key: str) -> None:
|
||||||
"""Terminate a Chrome process and clean up."""
|
"""Terminate a Chrome process and clean up."""
|
||||||
|
self._cancel_idle_cleanup(key)
|
||||||
proc = self._processes.pop(key, None)
|
proc = self._processes.pop(key, None)
|
||||||
if not proc:
|
if not proc:
|
||||||
return
|
return
|
||||||
@@ -377,6 +429,13 @@ class ChromePool:
|
|||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Terminate all Chrome processes."""
|
"""Terminate all Chrome processes."""
|
||||||
|
idle_tasks = list(self._idle_tasks.values())
|
||||||
|
self._idle_tasks.clear()
|
||||||
|
for task in idle_tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
if idle_tasks:
|
||||||
|
await asyncio.gather(*idle_tasks, return_exceptions=True)
|
||||||
for key in list(self._processes.keys()):
|
for key in list(self._processes.keys()):
|
||||||
await self._cleanup_process(key)
|
await self._cleanup_process(key)
|
||||||
logger.info("All Chrome processes terminated")
|
logger.info("All Chrome processes terminated")
|
||||||
@@ -453,9 +512,21 @@ def parse_connection_params(query_string: str) -> dict:
|
|||||||
def _ws_scheme(request: web.Request) -> str:
|
def _ws_scheme(request: web.Request) -> str:
|
||||||
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
|
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
|
||||||
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||||
|
proto = proto.split(",", 1)[0].strip().lower()
|
||||||
return "wss" if proto == "https" else "ws"
|
return "wss" if proto == "https" else "ws"
|
||||||
|
|
||||||
|
|
||||||
|
def _external_host(request: web.Request) -> str:
|
||||||
|
"""Return the public host to use in rewritten CDP WebSocket URLs."""
|
||||||
|
fallback_host = request.headers.get("Host") or f"localhost:{request.app['port']}"
|
||||||
|
forwarded_host = request.headers.get("X-Forwarded-Host")
|
||||||
|
if forwarded_host:
|
||||||
|
public_host = forwarded_host.split(",", 1)[0].strip()
|
||||||
|
if public_host:
|
||||||
|
return public_host
|
||||||
|
return fallback_host
|
||||||
|
|
||||||
|
|
||||||
async def handle_root(request: web.Request) -> web.Response:
|
async def handle_root(request: web.Request) -> web.Response:
|
||||||
"""Health check / process status."""
|
"""Health check / process status."""
|
||||||
pool: ChromePool = request.app["pool"]
|
pool: ChromePool = request.app["pool"]
|
||||||
@@ -467,6 +538,7 @@ async def handle_root(request: web.Request) -> web.Response:
|
|||||||
"port": proc.cdp_port,
|
"port": proc.cdp_port,
|
||||||
"seed": proc.seed,
|
"seed": proc.seed,
|
||||||
"connections": pool._connections.get(key, 0),
|
"connections": pool._connections.get(key, 0),
|
||||||
|
"idle_cleanup_pending": key in pool._idle_tasks,
|
||||||
"timezone": proc.timezone,
|
"timezone": proc.timezone,
|
||||||
"locale": proc.locale,
|
"locale": proc.locale,
|
||||||
"proxy": proc.proxy,
|
"proxy": proc.proxy,
|
||||||
@@ -474,6 +546,7 @@ async def handle_root(request: web.Request) -> web.Response:
|
|||||||
return web.json_response({
|
return web.json_response({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"active": len(processes),
|
"active": len(processes),
|
||||||
|
"idle_timeout": pool._idle_timeout,
|
||||||
"processes": processes,
|
"processes": processes,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -504,7 +577,7 @@ async def handle_json_version(request: web.Request) -> web.Response:
|
|||||||
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||||
|
|
||||||
# Rewrite webSocketDebuggerUrl to route through our multiplexer
|
# Rewrite webSocketDebuggerUrl to route through our multiplexer
|
||||||
host = request.headers.get("Host", f"localhost:{request.app['port']}")
|
host = _external_host(request)
|
||||||
seed_key = params["seed"]
|
seed_key = params["seed"]
|
||||||
if seed_key:
|
if seed_key:
|
||||||
ws_path = f"fingerprint/{seed_key}/devtools/browser"
|
ws_path = f"fingerprint/{seed_key}/devtools/browser"
|
||||||
@@ -545,7 +618,7 @@ async def handle_json_list(request: web.Request) -> web.Response:
|
|||||||
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||||
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||||
|
|
||||||
host = request.headers.get("Host", f"localhost:{request.app['port']}")
|
host = _external_host(request)
|
||||||
scheme = _ws_scheme(request)
|
scheme = _ws_scheme(request)
|
||||||
seed_key = params["seed"]
|
seed_key = params["seed"]
|
||||||
|
|
||||||
@@ -675,6 +748,23 @@ def _default_data_dir() -> str:
|
|||||||
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_idle_timeout(value: str) -> float:
|
||||||
|
value = value.strip()
|
||||||
|
if value.lower() in {"0", "false", "off", "none", "disabled"}:
|
||||||
|
return 0.0
|
||||||
|
timeout = float(value)
|
||||||
|
if timeout < 0:
|
||||||
|
raise ValueError("--idle-timeout must be greater than or equal to 0")
|
||||||
|
return timeout
|
||||||
|
|
||||||
|
|
||||||
|
def _default_idle_timeout() -> float:
|
||||||
|
value = os.environ.get("CLOAKSERVE_IDLE_TIMEOUT")
|
||||||
|
if value is None:
|
||||||
|
return 0.0
|
||||||
|
return _parse_idle_timeout(value)
|
||||||
|
|
||||||
|
|
||||||
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
||||||
"""Parse cloakserve-specific args, return (config, passthrough_args).
|
"""Parse cloakserve-specific args, return (config, passthrough_args).
|
||||||
|
|
||||||
@@ -690,12 +780,14 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
|||||||
"default_seed": None,
|
"default_seed": None,
|
||||||
"default_locale": None,
|
"default_locale": None,
|
||||||
"default_timezone": None,
|
"default_timezone": None,
|
||||||
|
"idle_timeout": _default_idle_timeout(),
|
||||||
}
|
}
|
||||||
passthrough = []
|
passthrough = []
|
||||||
# Flags consumed by cloakserve (not passed to Chrome)
|
# Flags consumed by cloakserve (not passed to Chrome)
|
||||||
consumed_prefixes = (
|
consumed_prefixes = (
|
||||||
"--port=",
|
"--port=",
|
||||||
"--data-dir=",
|
"--data-dir=",
|
||||||
|
"--idle-timeout=",
|
||||||
"--remote-debugging-port=",
|
"--remote-debugging-port=",
|
||||||
"--remote-debugging-address=",
|
"--remote-debugging-address=",
|
||||||
)
|
)
|
||||||
@@ -705,6 +797,8 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
|||||||
config["port"] = int(arg.split("=", 1)[1])
|
config["port"] = int(arg.split("=", 1)[1])
|
||||||
elif arg.startswith("--data-dir="):
|
elif arg.startswith("--data-dir="):
|
||||||
config["data_dir"] = arg.split("=", 1)[1]
|
config["data_dir"] = arg.split("=", 1)[1]
|
||||||
|
elif arg.startswith("--idle-timeout="):
|
||||||
|
config["idle_timeout"] = _parse_idle_timeout(arg.split("=", 1)[1])
|
||||||
elif arg == "--headless=false" or arg == "--headless=False":
|
elif arg == "--headless=false" or arg == "--headless=False":
|
||||||
config["headless"] = False
|
config["headless"] = False
|
||||||
passthrough.append(arg)
|
passthrough.append(arg)
|
||||||
@@ -749,6 +843,7 @@ def main() -> None:
|
|||||||
default_seed=config["default_seed"],
|
default_seed=config["default_seed"],
|
||||||
default_locale=config["default_locale"],
|
default_locale=config["default_locale"],
|
||||||
default_timezone=config["default_timezone"],
|
default_timezone=config["default_timezone"],
|
||||||
|
idle_timeout=config["idle_timeout"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Clean up any stale Xvfb lock left behind by a previous container instance.
|
||||||
|
# `/tmp` is not a tmpfs in this image, so on `docker restart` the previous
|
||||||
|
# container's `/tmp/.X99-lock` survives, and Xvfb refuses to start with an
|
||||||
|
# existing lock — leaving the container with no X server, every Chrome
|
||||||
|
# launch dying with "Missing X server or $DISPLAY", and `cloakserve`
|
||||||
|
# returning 502 forever. See CloakHQ/CloakBrowser#283.
|
||||||
|
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
|
||||||
|
|
||||||
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
||||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.30"
|
__version__ = "0.3.32"
|
||||||
|
|||||||
+101
-7
@@ -22,6 +22,7 @@ from urllib.parse import quote, unquote, urlparse, urlunparse
|
|||||||
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
||||||
from .download import ensure_binary
|
from .download import ensure_binary
|
||||||
from .human.config import HumanConfigOverrides, HumanPreset
|
from .human.config import HumanConfigOverrides, HumanPreset
|
||||||
|
from .widevine import seed_widevine_hint
|
||||||
|
|
||||||
logger = logging.getLogger("cloakbrowser")
|
logger = logging.getLogger("cloakbrowser")
|
||||||
|
|
||||||
@@ -336,6 +337,8 @@ def launch_persistent_context(
|
|||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
|
||||||
|
seed_widevine_hint(user_data_dir, binary_path)
|
||||||
|
|
||||||
pw = sync_playwright().start()
|
pw = sync_playwright().start()
|
||||||
context = pw.chromium.launch_persistent_context(
|
context = pw.chromium.launch_persistent_context(
|
||||||
user_data_dir=os.fspath(user_data_dir),
|
user_data_dir=os.fspath(user_data_dir),
|
||||||
@@ -464,6 +467,8 @@ async def launch_persistent_context_async(
|
|||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
|
||||||
|
seed_widevine_hint(user_data_dir, binary_path)
|
||||||
|
|
||||||
pw = await async_playwright().start()
|
pw = await async_playwright().start()
|
||||||
context = await pw.chromium.launch_persistent_context(
|
context = await pw.chromium.launch_persistent_context(
|
||||||
user_data_dir=os.fspath(user_data_dir),
|
user_data_dir=os.fspath(user_data_dir),
|
||||||
@@ -774,7 +779,7 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
|
|||||||
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
||||||
|
|
||||||
|
|
||||||
def _assemble_socks_url(
|
def _assemble_proxy_url(
|
||||||
scheme: str,
|
scheme: str,
|
||||||
host: str,
|
host: str,
|
||||||
port: int | None,
|
port: int | None,
|
||||||
@@ -785,7 +790,7 @@ def _assemble_socks_url(
|
|||||||
query: str = "",
|
query: str = "",
|
||||||
fragment: str = "",
|
fragment: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a SOCKS URL from already-percent-encoded credentials and host parts.
|
"""Build a proxy URL from already-percent-encoded credentials and host parts.
|
||||||
|
|
||||||
``enc_pass is None`` means no password (no colon in userinfo). Empty string
|
``enc_pass is None`` means no password (no colon in userinfo). Empty string
|
||||||
means present-but-empty (colon preserved). This mirrors the distinction
|
means present-but-empty (colon preserved). This mirrors the distinction
|
||||||
@@ -816,7 +821,7 @@ def _reconstruct_socks_url(proxy: ProxySettings) -> str:
|
|||||||
enc_user = quote(username, safe="")
|
enc_user = quote(username, safe="")
|
||||||
# Dict convention: empty/missing password → no colon.
|
# Dict convention: empty/missing password → no colon.
|
||||||
enc_pass = quote(password, safe="") if password else None
|
enc_pass = quote(password, safe="") if password else None
|
||||||
return _assemble_socks_url(
|
return _assemble_proxy_url(
|
||||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||||
enc_user, enc_pass, parsed.path,
|
enc_user, enc_pass, parsed.path,
|
||||||
)
|
)
|
||||||
@@ -856,7 +861,7 @@ def _normalize_socks_string_url(url: str) -> str:
|
|||||||
else:
|
else:
|
||||||
raw_pass = None
|
raw_pass = None
|
||||||
enc_pass = None
|
enc_pass = None
|
||||||
normalized = _assemble_socks_url(
|
normalized = _assemble_proxy_url(
|
||||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||||
enc_user, enc_pass,
|
enc_user, enc_pass,
|
||||||
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
||||||
@@ -1061,6 +1066,81 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _has_credentials(proxy: str | ProxySettings) -> bool:
|
||||||
|
"""Check if the proxy has inline or dict-level credentials."""
|
||||||
|
if isinstance(proxy, dict):
|
||||||
|
return bool(proxy.get("username"))
|
||||||
|
return "@" in proxy
|
||||||
|
|
||||||
|
|
||||||
|
def _reconstruct_http_url(proxy: ProxySettings) -> str:
|
||||||
|
"""Reconstruct an HTTP(S) proxy URL with inline credentials from a Playwright proxy dict."""
|
||||||
|
server = proxy.get("server", "")
|
||||||
|
username = proxy.get("username", "")
|
||||||
|
password = proxy.get("password", "")
|
||||||
|
if not username:
|
||||||
|
return server
|
||||||
|
parsed = urlparse(_ensure_proxy_scheme(server))
|
||||||
|
enc_user = quote(username, safe="")
|
||||||
|
enc_pass = quote(password, safe="") if password else None
|
||||||
|
return _assemble_proxy_url(
|
||||||
|
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||||
|
enc_user, enc_pass, parsed.path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_http_string_url(url: str) -> str:
|
||||||
|
"""Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
|
||||||
|
|
||||||
|
Same pattern as ``_normalize_socks_string_url`` — decode then re-encode to
|
||||||
|
ensure Chromium's proxy URL parser handles special chars correctly.
|
||||||
|
"""
|
||||||
|
normalized = url if "://" in url else f"http://{url}"
|
||||||
|
try:
|
||||||
|
parsed = urlparse(normalized)
|
||||||
|
_ = parsed.port
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning("Malformed HTTP proxy URL, passing through unchanged: %s", e)
|
||||||
|
return normalized
|
||||||
|
if parsed.username is None and parsed.password is None:
|
||||||
|
return normalized
|
||||||
|
raw_user = parsed.username or ""
|
||||||
|
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
|
||||||
|
if parsed.password is not None:
|
||||||
|
raw_pass = parsed.password
|
||||||
|
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
|
||||||
|
else:
|
||||||
|
raw_pass = None
|
||||||
|
enc_pass = None
|
||||||
|
result = _assemble_proxy_url(
|
||||||
|
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||||
|
enc_user, enc_pass,
|
||||||
|
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
||||||
|
)
|
||||||
|
if enc_user != raw_user or enc_pass != raw_pass:
|
||||||
|
logger.info(
|
||||||
|
"Auto URL-encoded HTTP proxy credentials (special characters "
|
||||||
|
"detected). Pre-encode the URL to suppress this notice."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5"
|
||||||
|
_HTTP_PROXY_INLINE_AUTH_PLATFORMS = {"linux-x64", "windows-x64"}
|
||||||
|
|
||||||
|
|
||||||
|
def _supports_http_proxy_inline_auth() -> bool:
|
||||||
|
"""Check if the current platform's binary supports HTTP proxy inline credentials.
|
||||||
|
|
||||||
|
Requires both a supported platform AND a binary version with preemptive proxy auth.
|
||||||
|
"""
|
||||||
|
from .config import get_platform_tag, get_chromium_version, _version_tuple
|
||||||
|
tag = get_platform_tag()
|
||||||
|
if tag not in _HTTP_PROXY_INLINE_AUTH_PLATFORMS:
|
||||||
|
return False
|
||||||
|
return _version_tuple(get_chromium_version()) >= _version_tuple(_HTTP_PROXY_INLINE_AUTH_MIN_VERSION)
|
||||||
|
|
||||||
|
|
||||||
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
|
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
|
||||||
"""Check if the proxy uses SOCKS5 protocol."""
|
"""Check if the proxy uses SOCKS5 protocol."""
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
@@ -1074,8 +1154,9 @@ def _resolve_proxy_config(
|
|||||||
) -> tuple[dict[str, Any], list[str]]:
|
) -> tuple[dict[str, Any], list[str]]:
|
||||||
"""Resolve proxy into Playwright kwargs and Chrome args.
|
"""Resolve proxy into Playwright kwargs and Chrome args.
|
||||||
|
|
||||||
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
Proxies with credentials (SOCKS5 or HTTP/HTTPS) are passed via Chrome's
|
||||||
so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
--proxy-server flag with inline credentials, bypassing Playwright's CDP
|
||||||
|
auth interceptor which breaks on some proxies and Google domains (#182).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
|
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
|
||||||
@@ -1096,7 +1177,20 @@ def _resolve_proxy_config(
|
|||||||
# passwords at '=' and other special chars (#157).
|
# passwords at '=' and other special chars (#157).
|
||||||
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
|
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
|
||||||
|
|
||||||
# HTTP/HTTPS: use Playwright's proxy dict as before
|
# HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
|
||||||
|
# CDP auth interceptor, pass directly to Chrome via --proxy-server with
|
||||||
|
# inline creds. Chrome sends Proxy-Authorization preemptively, avoiding
|
||||||
|
# the 407 round-trip that breaks on some proxies (#182).
|
||||||
|
if _has_credentials(proxy) and _supports_http_proxy_inline_auth():
|
||||||
|
if isinstance(proxy, dict):
|
||||||
|
url = _reconstruct_http_url(proxy)
|
||||||
|
extra_args = [f"--proxy-server={url}"]
|
||||||
|
if proxy.get("bypass"):
|
||||||
|
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
|
||||||
|
return {}, extra_args
|
||||||
|
return {}, [f"--proxy-server={_normalize_http_string_url(proxy)}"]
|
||||||
|
|
||||||
|
# HTTP/HTTPS without credentials: use Playwright's proxy dict
|
||||||
if isinstance(proxy, dict):
|
if isinstance(proxy, dict):
|
||||||
return {"proxy": proxy}, []
|
return {"proxy": proxy}, []
|
||||||
return {"proxy": _parse_proxy_url(proxy)}, []
|
return {"proxy": _parse_proxy_url(proxy)}, []
|
||||||
|
|||||||
@@ -1257,13 +1257,16 @@ def _patch_single_element_handle_sync(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||||
info = _move_to_element(call_cfg)
|
info = _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return _orig_click(**kwargs)
|
return _orig_click(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||||
|
|
||||||
# --- el.dblclick() ---
|
# --- el.dblclick() ---
|
||||||
@@ -1271,13 +1274,16 @@ def _patch_single_element_handle_sync(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||||
info = _move_to_element(call_cfg)
|
info = _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return _orig_dblclick(**kwargs)
|
return _orig_dblclick(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
raw_mouse.down(click_count=2)
|
raw_mouse.down(click_count=2)
|
||||||
sleep_ms(rand(30, 60))
|
sleep_ms(rand(30, 60))
|
||||||
raw_mouse.up(click_count=2)
|
raw_mouse.up(click_count=2)
|
||||||
@@ -1287,8 +1293,11 @@ def _patch_single_element_handle_sync(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
|
||||||
info = _move_to_element(call_cfg)
|
info = _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return _orig_hover(**kwargs)
|
return _orig_hover(**kwargs)
|
||||||
@@ -1298,13 +1307,16 @@ def _patch_single_element_handle_sync(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||||
info = _move_to_element(call_cfg)
|
info = _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return _orig_type(text, **kwargs)
|
return _orig_type(text, **kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||||
sleep_ms(rand(100, 250))
|
sleep_ms(rand(100, 250))
|
||||||
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
|
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
|
||||||
@@ -1314,13 +1326,16 @@ def _patch_single_element_handle_sync(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||||
info = _move_to_element(call_cfg)
|
info = _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return _orig_fill(value, **kwargs)
|
return _orig_fill(value, **kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||||
sleep_ms(rand(100, 250))
|
sleep_ms(rand(100, 250))
|
||||||
originals.keyboard_press(_SELECT_ALL)
|
originals.keyboard_press(_SELECT_ALL)
|
||||||
@@ -1367,8 +1382,11 @@ def _patch_single_element_handle_sync(
|
|||||||
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
|
||||||
info = _move_to_element()
|
info = _move_to_element()
|
||||||
if info is None:
|
if info is None:
|
||||||
return _orig_select_option(value, **kwargs)
|
return _orig_select_option(value, **kwargs)
|
||||||
@@ -1380,8 +1398,11 @@ def _patch_single_element_handle_sync(
|
|||||||
def _human_el_check(**kwargs: Any) -> None:
|
def _human_el_check(**kwargs: Any) -> None:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||||
try:
|
try:
|
||||||
if el.is_checked():
|
if el.is_checked():
|
||||||
return
|
return
|
||||||
@@ -1391,15 +1412,18 @@ def _patch_single_element_handle_sync(
|
|||||||
if info is None:
|
if info is None:
|
||||||
return _orig_check(**kwargs)
|
return _orig_check(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
human_click(raw_mouse, info['is_inp'], cfg)
|
human_click(raw_mouse, info['is_inp'], cfg)
|
||||||
|
|
||||||
# --- el.uncheck() ---
|
# --- el.uncheck() ---
|
||||||
def _human_el_uncheck(**kwargs: Any) -> None:
|
def _human_el_uncheck(**kwargs: Any) -> None:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||||
try:
|
try:
|
||||||
if not el.is_checked():
|
if not el.is_checked():
|
||||||
return
|
return
|
||||||
@@ -1409,15 +1433,18 @@ def _patch_single_element_handle_sync(
|
|||||||
if info is None:
|
if info is None:
|
||||||
return _orig_uncheck(**kwargs)
|
return _orig_uncheck(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
human_click(raw_mouse, info['is_inp'], cfg)
|
human_click(raw_mouse, info['is_inp'], cfg)
|
||||||
|
|
||||||
# --- el.set_checked() ---
|
# --- el.set_checked() ---
|
||||||
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||||
try:
|
try:
|
||||||
current = el.is_checked()
|
current = el.is_checked()
|
||||||
if current == checked:
|
if current == checked:
|
||||||
@@ -1429,7 +1456,7 @@ def _patch_single_element_handle_sync(
|
|||||||
return _orig_set_checked(checked, **kwargs)
|
return _orig_set_checked(checked, **kwargs)
|
||||||
if info:
|
if info:
|
||||||
if not force:
|
if not force:
|
||||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
human_click(raw_mouse, info['is_inp'], cfg)
|
human_click(raw_mouse, info['is_inp'], cfg)
|
||||||
|
|
||||||
# --- el.tap() ---
|
# --- el.tap() ---
|
||||||
@@ -2158,13 +2185,16 @@ def _patch_single_element_handle_async(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||||
info = await _move_to_element(call_cfg)
|
info = await _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_click(**kwargs)
|
return await _orig_click(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||||
|
|
||||||
# --- el.dblclick() ---
|
# --- el.dblclick() ---
|
||||||
@@ -2172,13 +2202,16 @@ def _patch_single_element_handle_async(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||||
info = await _move_to_element(call_cfg)
|
info = await _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_dblclick(**kwargs)
|
return await _orig_dblclick(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
await raw_mouse.down(click_count=2)
|
await raw_mouse.down(click_count=2)
|
||||||
await async_sleep_ms(rand(30, 60))
|
await async_sleep_ms(rand(30, 60))
|
||||||
await raw_mouse.up(click_count=2)
|
await raw_mouse.up(click_count=2)
|
||||||
@@ -2188,8 +2221,11 @@ def _patch_single_element_handle_async(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
|
||||||
info = await _move_to_element(call_cfg)
|
info = await _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_hover(**kwargs)
|
return await _orig_hover(**kwargs)
|
||||||
@@ -2199,13 +2235,16 @@ def _patch_single_element_handle_async(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||||
info = await _move_to_element(call_cfg)
|
info = await _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_type(text, **kwargs)
|
return await _orig_type(text, **kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||||
await async_sleep_ms(rand(100, 250))
|
await async_sleep_ms(rand(100, 250))
|
||||||
cdp = await _get_cdp()
|
cdp = await _get_cdp()
|
||||||
@@ -2216,13 +2255,16 @@ def _patch_single_element_handle_async(
|
|||||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||||
info = await _move_to_element(call_cfg)
|
info = await _move_to_element(call_cfg)
|
||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_fill(value, **kwargs)
|
return await _orig_fill(value, **kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||||
await async_sleep_ms(rand(100, 250))
|
await async_sleep_ms(rand(100, 250))
|
||||||
await originals.keyboard_press(_SELECT_ALL)
|
await originals.keyboard_press(_SELECT_ALL)
|
||||||
@@ -2269,8 +2311,11 @@ def _patch_single_element_handle_async(
|
|||||||
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
|
||||||
info = await _move_to_element()
|
info = await _move_to_element()
|
||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_select_option(value, **kwargs)
|
return await _orig_select_option(value, **kwargs)
|
||||||
@@ -2282,8 +2327,11 @@ def _patch_single_element_handle_async(
|
|||||||
async def _human_el_check(**kwargs: Any) -> None:
|
async def _human_el_check(**kwargs: Any) -> None:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||||
try:
|
try:
|
||||||
if await el.is_checked():
|
if await el.is_checked():
|
||||||
return
|
return
|
||||||
@@ -2293,15 +2341,18 @@ def _patch_single_element_handle_async(
|
|||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_check(**kwargs)
|
return await _orig_check(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||||
|
|
||||||
# --- el.uncheck() ---
|
# --- el.uncheck() ---
|
||||||
async def _human_el_uncheck(**kwargs: Any) -> None:
|
async def _human_el_uncheck(**kwargs: Any) -> None:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||||
try:
|
try:
|
||||||
if not await el.is_checked():
|
if not await el.is_checked():
|
||||||
return
|
return
|
||||||
@@ -2311,15 +2362,18 @@ def _patch_single_element_handle_async(
|
|||||||
if info is None:
|
if info is None:
|
||||||
return await _orig_uncheck(**kwargs)
|
return await _orig_uncheck(**kwargs)
|
||||||
if not force:
|
if not force:
|
||||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||||
|
|
||||||
# --- el.set_checked() ---
|
# --- el.set_checked() ---
|
||||||
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
||||||
force = kwargs.get("force", False)
|
force = kwargs.get("force", False)
|
||||||
timeout = kwargs.get("timeout", 30000)
|
timeout = kwargs.get("timeout", 30000)
|
||||||
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
|
def _remaining_ms():
|
||||||
|
return max(0, (deadline - time.monotonic()) * 1000)
|
||||||
if not force:
|
if not force:
|
||||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||||
try:
|
try:
|
||||||
current = await el.is_checked()
|
current = await el.is_checked()
|
||||||
if current == checked:
|
if current == checked:
|
||||||
@@ -2331,7 +2385,7 @@ def _patch_single_element_handle_async(
|
|||||||
return await _orig_set_checked(checked, **kwargs)
|
return await _orig_set_checked(checked, **kwargs)
|
||||||
if info:
|
if info:
|
||||||
if not force:
|
if not force:
|
||||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||||
|
|
||||||
# --- el.tap() ---
|
# --- el.tap() ---
|
||||||
|
|||||||
@@ -196,8 +196,14 @@ def ensure_stable(
|
|||||||
# Pointer-events check (post-scroll, at actual click coordinates)
|
# Pointer-events check (post-scroll, at actual click coordinates)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
|
# data.box is page-space (from bounding_box); rect is frame-local. Their delta
|
||||||
const target = document.elementFromPoint(coords.x, coords.y);
|
# is the iframe offset, needed to map page-space click coords into the frame's
|
||||||
|
# own viewport before elementFromPoint. For main-frame elements the offset is 0.
|
||||||
|
_POINTER_EVENTS_LOCATOR_JS = """(expected, data) => {
|
||||||
|
const rect = expected.getBoundingClientRect();
|
||||||
|
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||||
|
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||||
|
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||||
let node = target;
|
let node = target;
|
||||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||||
@@ -205,8 +211,11 @@ _POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
|
|||||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
_POINTER_EVENTS_HANDLE_JS = """(expected, coords) => {
|
_POINTER_EVENTS_HANDLE_JS = """(expected, data) => {
|
||||||
const target = document.elementFromPoint(coords.x, coords.y);
|
const rect = expected.getBoundingClientRect();
|
||||||
|
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||||
|
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||||
|
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||||
let node = target;
|
let node = target;
|
||||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||||
@@ -230,17 +239,19 @@ def check_pointer_events(
|
|||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout / 1000.0
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
attempt = 0
|
attempt = 0
|
||||||
coords = {"x": x, "y": y}
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
loc = page.locator(selector).first
|
loc = page.locator(selector).first
|
||||||
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
|
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
|
||||||
|
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
if result and result.get("hit", False):
|
# Proceed if the check confirms a hit, or if it could not be determined
|
||||||
|
# (None) — failing closed would block legitimate clicks.
|
||||||
|
if result is None or result.get("hit", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
covering = (result or {}).get("covering", "unknown")
|
covering = (result or {}).get("covering", "unknown")
|
||||||
@@ -322,15 +333,16 @@ def check_pointer_events_handle(
|
|||||||
deadline = time.monotonic() + timeout / 1000.0
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
attempt = 0
|
attempt = 0
|
||||||
|
|
||||||
coords = {"x": x, "y": y}
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
|
box = el.bounding_box()
|
||||||
|
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||||
except Exception:
|
except Exception:
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
if result and result.get("hit", False):
|
# Proceed if the check confirms a hit, or if it could not be determined
|
||||||
|
# (None) — failing closed would block legitimate clicks.
|
||||||
|
if result is None or result.get("hit", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
covering = (result or {}).get("covering", "unknown")
|
covering = (result or {}).get("covering", "unknown")
|
||||||
|
|||||||
@@ -140,17 +140,19 @@ async def async_check_pointer_events(
|
|||||||
) -> None:
|
) -> None:
|
||||||
deadline = time.monotonic() + timeout / 1000.0
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
attempt = 0
|
attempt = 0
|
||||||
coords = {"x": x, "y": y}
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
loc = page.locator(selector).first
|
loc = page.locator(selector).first
|
||||||
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
|
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
|
||||||
|
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
if result and result.get("hit", False):
|
# Proceed if the check confirms a hit, or if it could not be determined
|
||||||
|
# (None) — failing closed would block legitimate clicks.
|
||||||
|
if result is None or result.get("hit", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
covering = (result or {}).get("covering", "unknown")
|
covering = (result or {}).get("covering", "unknown")
|
||||||
@@ -227,15 +229,16 @@ async def async_check_pointer_events_handle(
|
|||||||
deadline = time.monotonic() + timeout / 1000.0
|
deadline = time.monotonic() + timeout / 1000.0
|
||||||
attempt = 0
|
attempt = 0
|
||||||
|
|
||||||
coords = {"x": x, "y": y}
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
|
box = await el.bounding_box()
|
||||||
|
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||||
except Exception:
|
except Exception:
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
if result and result.get("hit", False):
|
# Proceed if the check confirms a hit, or if it could not be determined
|
||||||
|
# (None) — failing closed would block legitimate clicks.
|
||||||
|
if result is None or result.get("hit", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
covering = (result or {}).get("covering", "unknown")
|
covering = (result or {}).get("covering", "unknown")
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Widevine CDM hint-file seeding for persistent contexts.
|
||||||
|
|
||||||
|
CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
|
||||||
|
is a proprietary Google binary we can't redistribute). Users sideload it by
|
||||||
|
copying a ``WidevineCdm/`` directory from a real Chrome install next to the
|
||||||
|
binary (see issue #96).
|
||||||
|
|
||||||
|
Chromium discovers a sideloaded CDM in two phases: an early-startup pass that
|
||||||
|
reads a "hint file" from the user-data-dir, and a later async component-updater
|
||||||
|
pass that writes that hint file. On a fresh profile the hint file doesn't exist
|
||||||
|
on the first launch, and Playwright passes ``--disable-component-update``, so the
|
||||||
|
updater never writes it — Widevine only works after a manual two-launch dance.
|
||||||
|
|
||||||
|
This module pre-seeds the hint file before launch so a sideloaded CDM works on
|
||||||
|
the very first launch. It never bundles, downloads, or copies the CDM itself —
|
||||||
|
it only writes the hint when a CDM the user provided is already present.
|
||||||
|
|
||||||
|
Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows
|
||||||
|
the CDM can't initialise (DRM host verification), and macOS uses a different CDM
|
||||||
|
layout, so seeding is a no-op there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger("cloakbrowser")
|
||||||
|
|
||||||
|
# Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
|
||||||
|
_HINT_FILENAME = "latest-component-updated-widevine-cdm"
|
||||||
|
|
||||||
|
|
||||||
|
def _seeding_disabled() -> bool:
|
||||||
|
"""True if CLOAKBROWSER_WIDEVINE is set to a falsey value (kill switch)."""
|
||||||
|
val = os.environ.get("CLOAKBROWSER_WIDEVINE", "").strip().lower()
|
||||||
|
return val in ("0", "false", "off", "no")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
|
||||||
|
"""Locate a sideloaded Widevine CDM directory, or None if absent.
|
||||||
|
|
||||||
|
Resolution:
|
||||||
|
- If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
|
||||||
|
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
|
||||||
|
- Otherwise, ``<dir of the chrome binary>/WidevineCdm`` — where a user
|
||||||
|
naturally drops it, and where it ends up for both downloaded and
|
||||||
|
CLOAKBROWSER_BINARY_PATH (local build / Docker mount) binaries.
|
||||||
|
|
||||||
|
A directory counts only if it contains ``manifest.json`` (so we don't seed a
|
||||||
|
hint pointing at a bogus path). The returned path is absolute and
|
||||||
|
symlink-resolved (``Path.resolve()``).
|
||||||
|
"""
|
||||||
|
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
|
||||||
|
# `is not None` (not truthiness): a present-but-empty env var is "set" and
|
||||||
|
# used exclusively — it resolves to an invalid path and skips seeding.
|
||||||
|
cdm_dir = Path(custom) if custom is not None else Path(os.fspath(binary_path)).parent / "WidevineCdm"
|
||||||
|
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
|
||||||
|
"""Write the Widevine CDM hint file into a persistent profile before launch.
|
||||||
|
|
||||||
|
``binary_path`` is the resolved chrome executable; the CDM is looked for next
|
||||||
|
to it. No-op on non-Linux platforms, when seeding is disabled via
|
||||||
|
CLOAKBROWSER_WIDEVINE, or when no sideloaded CDM is present. Never raises —
|
||||||
|
a failure here must not break the browser launch.
|
||||||
|
"""
|
||||||
|
if platform.system() != "Linux":
|
||||||
|
return
|
||||||
|
if _seeding_disabled():
|
||||||
|
logger.debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE")
|
||||||
|
return
|
||||||
|
if not user_data_dir:
|
||||||
|
# Empty user_data_dir = Playwright's ephemeral profile (its own temp dir);
|
||||||
|
# a persistent hint can't be placed there, and "" would pollute the CWD.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Everything below is best-effort and must never break the browser launch,
|
||||||
|
# so the whole body (resolution + write) is guarded.
|
||||||
|
try:
|
||||||
|
cdm_dir = resolve_widevine_cdm_dir(binary_path)
|
||||||
|
if cdm_dir is None:
|
||||||
|
if os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") is not None:
|
||||||
|
logger.warning(
|
||||||
|
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; "
|
||||||
|
"skipping Widevine hint seeding"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("No sideloaded Widevine CDM found; skipping hint seeding")
|
||||||
|
return
|
||||||
|
|
||||||
|
hint_dir = Path(os.fspath(user_data_dir)) / "WidevineCdm"
|
||||||
|
hint_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
hint_file = hint_dir / _HINT_FILENAME
|
||||||
|
# cdm_dir is already absolute/resolved. Compact separators + ensure_ascii=False
|
||||||
|
# byte-match the JS wrapper's JSON.stringify (UTF-8) output.
|
||||||
|
content = json.dumps({"Path": str(cdm_dir)}, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hint_file.is_file() and hint_file.read_text(encoding="utf-8") == content:
|
||||||
|
return # already seeded correctly
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Existing Widevine hint unreadable; rewriting")
|
||||||
|
|
||||||
|
hint_file.write_text(content, encoding="utf-8")
|
||||||
|
logger.info("Seeded Widevine CDM hint -> %s", cdm_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to seed Widevine CDM hint file: %s", e)
|
||||||
+12
-11
@@ -5,7 +5,7 @@ Expected: 0.9 (human-level) with cloakbrowser.
|
|||||||
Default Playwright typically scores 0.1-0.3.
|
Default Playwright typically scores 0.1-0.3.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import re
|
||||||
|
|
||||||
from cloakbrowser import launch
|
from cloakbrowser import launch
|
||||||
|
|
||||||
@@ -13,19 +13,20 @@ print("Launching stealth browser...", flush=True)
|
|||||||
browser = launch(headless=True)
|
browser = launch(headless=True)
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
|
|
||||||
# Google's official reCAPTCHA v3 demo
|
# Google's official reCAPTCHA v3 demo — scores automatically on page load.
|
||||||
page.goto("https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php")
|
page.goto("https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php")
|
||||||
page.wait_for_load_state("networkidle")
|
|
||||||
|
|
||||||
# Click to trigger reCAPTCHA scoring
|
# The score renders only after an async token + backend-verify round-trip,
|
||||||
button = page.query_selector("button")
|
# which can finish *after* "networkidle". Wait for the actual result text
|
||||||
if button:
|
# instead of a proxy signal, or the screenshot races the scoring.
|
||||||
button.click()
|
page.wait_for_function(
|
||||||
time.sleep(3)
|
"() => document.body.innerText.includes('Received response from our backend')",
|
||||||
|
timeout=20000,
|
||||||
|
)
|
||||||
|
|
||||||
# Extract score from page
|
# Extract score from the rendered response
|
||||||
content = page.content()
|
match = re.search(r'"score":\s*([0-9.]+)', page.inner_text("body"))
|
||||||
print("Page loaded. Check the score in the response.")
|
print(f"reCAPTCHA v3 score: {match.group(1) if match else 'not found'}")
|
||||||
print(f"URL: {page.url}")
|
print(f"URL: {page.url}")
|
||||||
|
|
||||||
# Take screenshot as proof
|
# Take screenshot as proof
|
||||||
|
|||||||
+28
-3
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
||||||
|
|
||||||
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
|
- **58 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
|
||||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||||
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
||||||
@@ -39,11 +39,24 @@ import { launch } from 'cloakbrowser';
|
|||||||
|
|
||||||
const browser = await launch();
|
const browser = await launch();
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('https://protected-site.com');
|
await page.goto('https://example.com');
|
||||||
console.log(await page.title());
|
console.log(await page.title());
|
||||||
await browser.close();
|
await browser.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**For sites with anti-bot protection**, add a residential proxy and these flags:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const browser = await launch({
|
||||||
|
proxy: 'http://user:pass@residential-proxy:port',
|
||||||
|
geoip: true, // match timezone + locale to proxy IP
|
||||||
|
headless: false, // some sites detect headless even with C++ patches
|
||||||
|
humanize: true, // human-like mouse, keyboard, scroll
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [main README](https://github.com/CloakHQ/CloakBrowser#troubleshooting) for site-specific troubleshooting (FingerprintJS, Kasada, reCAPTCHA).
|
||||||
|
|
||||||
### Puppeteer
|
### Puppeteer
|
||||||
|
|
||||||
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
|
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
|
||||||
@@ -53,7 +66,7 @@ import { launch } from 'cloakbrowser/puppeteer';
|
|||||||
|
|
||||||
const browser = await launch();
|
const browser = await launch();
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('https://protected-site.com');
|
await page.goto('https://example.com');
|
||||||
console.log(await page.title());
|
console.log(await page.title());
|
||||||
await browser.close();
|
await browser.close();
|
||||||
```
|
```
|
||||||
@@ -189,6 +202,18 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
|||||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
|
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
|
||||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
||||||
|
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary) |
|
||||||
|
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
||||||
|
|
||||||
|
### Widevine / DRM
|
||||||
|
|
||||||
|
The binary supports Widevine, but the CDM is proprietary and can't be redistributed. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
|
||||||
|
```
|
||||||
|
|
||||||
|
With the CDM in place, `launchPersistentContext()` enables Widevine on the **first** launch — the wrapper auto-seeds the CDM hint file into the profile. This plays DRM-protected video (Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support. **Linux only.** A sideloaded CDM is the opt-in (no flag); set `CLOAKBROWSER_WIDEVINE_CDM` for a custom path or `CLOAKBROWSER_WIDEVINE=0` to disable. See the [main README](https://github.com/CloakHQ/CloakBrowser#widevine--drm) for details.
|
||||||
|
|
||||||
## Migrate From Playwright
|
## Migrate From Playwright
|
||||||
|
|
||||||
|
|||||||
Generated
+211
-602
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.30",
|
"version": "0.3.32",
|
||||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -81,12 +81,12 @@
|
|||||||
"tar": "^7.0.0"
|
"tar": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^25.9.1",
|
||||||
"mmdb-lib": "^3.0.2",
|
"mmdb-lib": "^3.0.2",
|
||||||
|
"playwright-core": "1.60",
|
||||||
|
"puppeteer-core": "^25.0.4",
|
||||||
"socks-proxy-agent": "^10.0.0",
|
"socks-proxy-agent": "^10.0.0",
|
||||||
"playwright-core": "^1.53.0",
|
"typescript": "^6.0.3",
|
||||||
"puppeteer-core": "^21.0.0",
|
|
||||||
"typescript": "^5.3.0",
|
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+7
-2
@@ -425,11 +425,16 @@ async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
|||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
||||||
// with recently-closed Node.js file handles. Use ZipFile API directly.
|
// with recently-closed Node.js file handles. Use ZipFile API directly.
|
||||||
|
// Pass paths via env vars (not interpolated into the script) so a quote or
|
||||||
|
// other special char in the path can't break out and be parsed as code.
|
||||||
execFileSync("powershell", [
|
execFileSync("powershell", [
|
||||||
"-NoProfile", "-Command",
|
"-NoProfile", "-Command",
|
||||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
|
`[System.IO.Compression.ZipFile]::ExtractToDirectory($env:CB_ARCHIVE, $env:CB_DEST)`,
|
||||||
], { timeout: 120_000 });
|
], {
|
||||||
|
timeout: 120_000,
|
||||||
|
env: { ...process.env, CB_ARCHIVE: archivePath, CB_DEST: destDir },
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,8 +197,11 @@ export async function ensureStable(
|
|||||||
// Pointer-events check (post-scroll, at actual click coordinates)
|
// Pointer-events check (post-scroll, at actual click coordinates)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
|
const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => {
|
||||||
const target = document.elementFromPoint(coords.x, coords.y);
|
const rect = expected.getBoundingClientRect();
|
||||||
|
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||||
|
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||||
|
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||||
let node = target;
|
let node = target;
|
||||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||||
@@ -206,8 +209,11 @@ const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
|
|||||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const POINTER_EVENTS_HANDLE_JS = `(expected, coords) => {
|
const POINTER_EVENTS_HANDLE_JS = `(expected, data) => {
|
||||||
const target = document.elementFromPoint(coords.x, coords.y);
|
const rect = expected.getBoundingClientRect();
|
||||||
|
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||||
|
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||||
|
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||||
let node = target;
|
let node = target;
|
||||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||||
@@ -225,18 +231,18 @@ export async function checkPointerEvents(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const coords = { x, y };
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let result: any = null;
|
let result: any = null;
|
||||||
try {
|
try {
|
||||||
const loc = pageOrFrame.locator(selector).first();
|
const loc = pageOrFrame.locator(selector).first();
|
||||||
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, coords);
|
const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) });
|
||||||
|
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box });
|
||||||
} catch {
|
} catch {
|
||||||
result = null;
|
result = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result && result.hit) return;
|
if (!result || result.hit) return;
|
||||||
const covering = (result as any)?.covering ?? 'unknown';
|
const covering = (result as any)?.covering ?? 'unknown';
|
||||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
|
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
|
||||||
|
|
||||||
@@ -317,17 +323,16 @@ export async function checkPointerEventsHandle(
|
|||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
const coords = { x, y };
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let result: any;
|
let result: any;
|
||||||
try {
|
try {
|
||||||
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, coords);
|
const box = await el.boundingBox();
|
||||||
|
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box });
|
||||||
} catch {
|
} catch {
|
||||||
result = null;
|
result = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result && result.hit) return;
|
if (!result || result.hit) return;
|
||||||
|
|
||||||
const covering = (result as any)?.covering ?? 'unknown';
|
const covering = (result as any)?.covering ?? 'unknown';
|
||||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
|
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
|
||||||
|
|||||||
@@ -196,10 +196,12 @@ export function patchSingleElementHandle(
|
|||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElClick(options);
|
if (!info) return origElClick(options);
|
||||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,10 +218,12 @@ export function patchSingleElementHandle(
|
|||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElDblclick(options);
|
if (!info) return origElDblclick(options);
|
||||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||||
await raw.down({ clickCount: 2 });
|
await raw.down({ clickCount: 2 });
|
||||||
await sleep(rand(30, 60));
|
await sleep(rand(30, 60));
|
||||||
await raw.up({ clickCount: 2 });
|
await raw.up({ clickCount: 2 });
|
||||||
@@ -235,7 +239,9 @@ export function patchSingleElementHandle(
|
|||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElHover(options);
|
if (!info) return origElHover(options);
|
||||||
};
|
};
|
||||||
@@ -248,10 +254,12 @@ export function patchSingleElementHandle(
|
|||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const force = (options as any)?.force ?? false;
|
const force = (options as any)?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElType(text, options);
|
if (!info) return origElType(text, options);
|
||||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
let cdpSession: CDPSession | null = null;
|
let cdpSession: CDPSession | null = null;
|
||||||
@@ -267,10 +275,12 @@ export function patchSingleElementHandle(
|
|||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElFill(value, options);
|
if (!info) return origElFill(value, options);
|
||||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
await originals.keyboardPress(SELECT_ALL);
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
@@ -298,7 +308,9 @@ export function patchSingleElementHandle(
|
|||||||
}) => {
|
}) => {
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force);
|
||||||
const info = await moveToElement();
|
const info = await moveToElement();
|
||||||
if (!info) return origElSelectOption(values, options);
|
if (!info) return origElSelectOption(values, options);
|
||||||
await humanClick(raw, false, cfg);
|
await humanClick(raw, false, cfg);
|
||||||
@@ -316,14 +328,16 @@ export function patchSingleElementHandle(
|
|||||||
}) => {
|
}) => {
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
|
||||||
try {
|
try {
|
||||||
const checked = await el.isChecked();
|
const checked = await el.isChecked();
|
||||||
if (checked) return;
|
if (checked) return;
|
||||||
} catch {}
|
} catch {}
|
||||||
const info = await moveToElement();
|
const info = await moveToElement();
|
||||||
if (!info) return origElCheck(options);
|
if (!info) return origElCheck(options);
|
||||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||||
await humanClick(raw, info.isInp, cfg);
|
await humanClick(raw, info.isInp, cfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -337,14 +351,16 @@ export function patchSingleElementHandle(
|
|||||||
}) => {
|
}) => {
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
|
||||||
try {
|
try {
|
||||||
const checked = await el.isChecked();
|
const checked = await el.isChecked();
|
||||||
if (!checked) return;
|
if (!checked) return;
|
||||||
} catch {}
|
} catch {}
|
||||||
const info = await moveToElement();
|
const info = await moveToElement();
|
||||||
if (!info) return origElUncheck(options);
|
if (!info) return origElUncheck(options);
|
||||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||||
await humanClick(raw, info.isInp, cfg);
|
await humanClick(raw, info.isInp, cfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,14 +375,16 @@ export function patchSingleElementHandle(
|
|||||||
}) => {
|
}) => {
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
const timeout = options?.timeout ?? 30000;
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
|
||||||
try {
|
try {
|
||||||
const current = await el.isChecked();
|
const current = await el.isChecked();
|
||||||
if (current === checked) return;
|
if (current === checked) return;
|
||||||
} catch {}
|
} catch {}
|
||||||
const info = await moveToElement();
|
const info = await moveToElement();
|
||||||
if (!info) return origElSetChecked(checked, options);
|
if (!info) return origElSetChecked(checked, options);
|
||||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||||
await humanClick(raw, info.isInp, cfg);
|
await humanClick(raw, info.isInp, cfg);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -691,7 +691,12 @@ function patchSingleFrame(
|
|||||||
const origFrameTap = (frame as any).tap?.bind(frame);
|
const origFrameTap = (frame as any).tap?.bind(frame);
|
||||||
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||||
|
|
||||||
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
|
const moveToFrameSelector = async (
|
||||||
|
selector: string,
|
||||||
|
options: HumanActionOptions | undefined,
|
||||||
|
inputBias: boolean,
|
||||||
|
remainingMs: () => number,
|
||||||
|
) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
@@ -699,9 +704,9 @@ function patchSingleFrame(
|
|||||||
|
|
||||||
const locator = firstFrameLocator(frame, selector);
|
const locator = firstFrameLocator(frame, selector);
|
||||||
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
|
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
|
||||||
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined);
|
await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
|
||||||
if (!box) return null;
|
if (!box) return null;
|
||||||
|
|
||||||
const isInput = inputBias || await isFrameInputElement(frame, selector);
|
const isInput = inputBias || await isFrameInputElement(frame, selector);
|
||||||
@@ -713,23 +718,32 @@ function patchSingleFrame(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const frameClick = async (selector: string, options?: HumanActionOptions) => {
|
const frameClick = async (selector: string, options?: HumanActionOptions) => {
|
||||||
const moved = await moveToFrameSelector(selector, options);
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!moved) return origFrameClick(selector, options);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
|
||||||
|
if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||||
await humanClick(raw, moved.isInput, moved.callCfg);
|
await humanClick(raw, moved.isInput, moved.callCfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
|
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
|
||||||
|
|
||||||
const frameHover = async (selector: string, options?: HumanActionOptions) => {
|
const frameHover = async (selector: string, options?: HumanActionOptions) => {
|
||||||
const moved = await moveToFrameSelector(selector, options, false);
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!moved) return origFrameHover(selector, options);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
|
||||||
|
if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||||
};
|
};
|
||||||
|
|
||||||
(frame as any).click = frameClick;
|
(frame as any).click = frameClick;
|
||||||
|
|
||||||
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
|
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
|
||||||
const moved = await moveToFrameSelector(selector, options);
|
const timeout = options?.timeout ?? 30000;
|
||||||
if (!moved) return origFrameDblclick(selector, options);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||||
|
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
|
||||||
|
if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||||
await raw.down({ clickCount: 2 });
|
await raw.down({ clickCount: 2 });
|
||||||
await sleep(rand(30, 60));
|
await sleep(rand(30, 60));
|
||||||
await raw.up({ clickCount: 2 });
|
await raw.up({ clickCount: 2 });
|
||||||
@@ -820,8 +834,11 @@ function patchSingleFrame(
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
trial?: boolean;
|
trial?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
const timeout = options?.timeout ?? 30000;
|
||||||
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
const deadline = Date.now() + timeout;
|
||||||
|
const remainingMs = () => Math.max(1, deadline - Date.now());
|
||||||
|
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null);
|
||||||
|
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null);
|
||||||
|
|
||||||
if (srcBox && tgtBox) {
|
if (srcBox && tgtBox) {
|
||||||
const sx = srcBox.x + srcBox.width / 2;
|
const sx = srcBox.x + srcBox.width / 2;
|
||||||
@@ -837,7 +854,7 @@ function patchSingleFrame(
|
|||||||
await sleep(rand(80, 150));
|
await sleep(rand(80, 150));
|
||||||
await originals.mouseUp();
|
await originals.mouseUp();
|
||||||
} else {
|
} else {
|
||||||
return origFrameDragAndDrop(source, target, options);
|
return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Launch functions (Playwright API)
|
// Launch functions (Playwright API)
|
||||||
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, humanizeBrowser } from "./playwright.js";
|
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
|
||||||
|
|
||||||
// Binary management
|
// Binary management
|
||||||
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
||||||
|
|||||||
+25
-14
@@ -10,6 +10,7 @@ import { buildArgs } from "./args.js";
|
|||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { resolveProxyConfig } from "./proxy.js";
|
import { resolveProxyConfig } from "./proxy.js";
|
||||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||||
|
import { seedWidevineHint } from "./widevine.js";
|
||||||
|
|
||||||
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
||||||
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
||||||
@@ -44,6 +45,26 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
|
|||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Playwright BrowserContext options for CloakBrowser without launching a browser
|
||||||
|
* or creating a context.
|
||||||
|
*
|
||||||
|
* Useful when integrating CloakBrowser with an existing Playwright Browser while
|
||||||
|
* keeping the wrapper's stealth-safe defaults for `newContext()`.
|
||||||
|
*/
|
||||||
|
export function buildContextOptions(
|
||||||
|
options: LaunchContextOptions = {}
|
||||||
|
): BrowserContextOptions {
|
||||||
|
return {
|
||||||
|
// contextOptions first — explicit wrapper fields below override it.
|
||||||
|
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||||
|
...filterStealthCtxOptions(options.contextOptions),
|
||||||
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
|
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||||
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
|
} as BrowserContextOptions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Playwright launch options for CloakBrowser without starting Chromium.
|
* Build Playwright launch options for CloakBrowser without starting Chromium.
|
||||||
*
|
*
|
||||||
@@ -144,14 +165,7 @@ export async function launchContext(
|
|||||||
|
|
||||||
let context: BrowserContext;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
context = await browser.newContext({
|
context = await browser.newContext(buildContextOptions(options));
|
||||||
// contextOptions first — explicit wrapper fields below override it.
|
|
||||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
|
||||||
...filterStealthCtxOptions(options.contextOptions),
|
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
throw err;
|
throw err;
|
||||||
@@ -214,6 +228,8 @@ export async function launchPersistentContext(
|
|||||||
}
|
}
|
||||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||||
|
|
||||||
|
seedWidevineHint(options.userDataDir, binaryPath);
|
||||||
|
|
||||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||||
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
||||||
@@ -222,12 +238,7 @@ export async function launchPersistentContext(
|
|||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||||
// contextOptions before explicit wrapper fields so explicit wins.
|
...buildContextOptions(options),
|
||||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
|
||||||
...filterStealthCtxOptions(options.contextOptions),
|
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
|
||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+112
-3
@@ -2,6 +2,8 @@
|
|||||||
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
|
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getChromiumVersion, getPlatformTag, parseVersion } from "./config.js";
|
||||||
|
|
||||||
export interface ParsedProxy {
|
export interface ParsedProxy {
|
||||||
server: string;
|
server: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -155,11 +157,106 @@ export function normalizeSocksStringUrl(urlStr: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5";
|
||||||
|
const HTTP_PROXY_INLINE_AUTH_PLATFORMS = new Set(["linux-x64", "windows-x64"]);
|
||||||
|
|
||||||
|
export function supportsHttpProxyInlineAuth(): boolean {
|
||||||
|
try {
|
||||||
|
const tag = getPlatformTag();
|
||||||
|
if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false;
|
||||||
|
const current = parseVersion(getChromiumVersion());
|
||||||
|
const minimum = parseVersion(HTTP_PROXY_INLINE_AUTH_MIN_VERSION);
|
||||||
|
for (let i = 0; i < Math.max(current.length, minimum.length); i++) {
|
||||||
|
if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true;
|
||||||
|
if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false;
|
||||||
|
}
|
||||||
|
return true; // equal = supported
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCredentials(proxy: string | ProxyDict): boolean {
|
||||||
|
if (typeof proxy === "string") return proxy.includes("@");
|
||||||
|
return !!proxy.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
|
||||||
|
*/
|
||||||
|
export function reconstructHttpUrl(proxy: ProxyDict): string {
|
||||||
|
if (!proxy.username) return proxy.server;
|
||||||
|
const url = new URL(ensureProxyScheme(proxy.server));
|
||||||
|
url.username = encodeURIComponent(proxy.username);
|
||||||
|
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
||||||
|
return url.href.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
|
||||||
|
* Same pattern as normalizeSocksStringUrl.
|
||||||
|
*/
|
||||||
|
export function normalizeHttpStringUrl(urlStr: string): string {
|
||||||
|
const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
|
||||||
|
const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
|
||||||
|
if (!schemeMatch) return normalized;
|
||||||
|
const [, scheme, rest] = schemeMatch;
|
||||||
|
const hostStart = rest.search(/[/?#]/);
|
||||||
|
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
|
||||||
|
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
|
||||||
|
const atIdx = authority.lastIndexOf("@");
|
||||||
|
if (atIdx === -1) return normalized;
|
||||||
|
const userinfo = authority.slice(0, atIdx);
|
||||||
|
const hostPart = authority.slice(atIdx + 1);
|
||||||
|
const bracketEnd = hostPart.lastIndexOf("]");
|
||||||
|
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
|
||||||
|
if (portColonIdx !== -1) {
|
||||||
|
const portStr = hostPart.slice(portColonIdx + 1);
|
||||||
|
if (portStr && !/^\d+$/.test(portStr)) {
|
||||||
|
console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hostAndRest = hostPart + suffix;
|
||||||
|
const colonIdx = userinfo.indexOf(":");
|
||||||
|
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
|
||||||
|
const hasPassword = colonIdx !== -1;
|
||||||
|
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
|
||||||
|
try {
|
||||||
|
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
|
||||||
|
const encPass = hasPassword
|
||||||
|
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
||||||
|
: null;
|
||||||
|
let userinfoPart: string;
|
||||||
|
if (encPass !== null) {
|
||||||
|
userinfoPart = `${encUser}:${encPass}@`;
|
||||||
|
} else if (encUser) {
|
||||||
|
userinfoPart = `${encUser}@`;
|
||||||
|
} else {
|
||||||
|
userinfoPart = "";
|
||||||
|
}
|
||||||
|
const result = `${scheme}://${userinfoPart}${hostAndRest}`;
|
||||||
|
const credsChanged = encUser !== rawUserEnc
|
||||||
|
|| (hasPassword ? encPass !== rawPassEnc : false);
|
||||||
|
if (credsChanged) {
|
||||||
|
console.info(
|
||||||
|
"[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
|
||||||
|
"characters detected). Pre-encode the URL to suppress this notice.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve proxy into Playwright option and/or Chrome args.
|
* Resolve proxy into Playwright option and/or Chrome args.
|
||||||
*
|
*
|
||||||
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
* Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
|
||||||
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
* passed via Chrome's --proxy-server flag with inline credentials, bypassing
|
||||||
|
* Playwright's CDP auth interceptor which breaks on some proxies (#182).
|
||||||
*/
|
*/
|
||||||
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
||||||
if (!proxy) return { proxyArgs: [] };
|
if (!proxy) return { proxyArgs: [] };
|
||||||
@@ -177,7 +274,19 @@ export function resolveProxyConfig(proxy: string | ProxyDict | undefined): Proxy
|
|||||||
return { proxyArgs: args };
|
return { proxyArgs: args };
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP/HTTPS: use Playwright's proxy dict
|
// HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
|
||||||
|
// CDP auth interceptor, use Chrome's preemptive Proxy-Authorization (#182).
|
||||||
|
if (hasCredentials(proxy) && supportsHttpProxyInlineAuth()) {
|
||||||
|
if (typeof proxy === "string") {
|
||||||
|
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
|
||||||
|
}
|
||||||
|
const httpUrl = reconstructHttpUrl(proxy);
|
||||||
|
const args = [`--proxy-server=${httpUrl}`];
|
||||||
|
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
||||||
|
return { proxyArgs: args };
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
|
||||||
if (typeof proxy === "string") {
|
if (typeof proxy === "string") {
|
||||||
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-5
@@ -9,8 +9,9 @@ import type { LaunchOptions } from "./types.js";
|
|||||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||||
import { buildArgs } from "./args.js";
|
import { buildArgs } from "./args.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
||||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||||
|
import { seedWidevineHint } from "./widevine.js";
|
||||||
|
|
||||||
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||||
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
||||||
@@ -26,9 +27,9 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
|
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
|
||||||
* SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
|
* SOCKS5: Chrome handles inline credentials natively (RFC 1929 auth).
|
||||||
* HTTP: Chrome does NOT support inline credentials — strip them and
|
* HTTP on supported platforms: inline credentials via --proxy-server.
|
||||||
* use page.authenticate() for Proxy-Authorization headers instead.
|
* HTTP on unsupported platforms: strip credentials, use page.authenticate() fallback.
|
||||||
*/
|
*/
|
||||||
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
|
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
|
||||||
if (!options.proxy) return undefined;
|
if (!options.proxy) return undefined;
|
||||||
@@ -39,6 +40,23 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On supported platforms: pass full URL with inline creds to --proxy-server
|
||||||
|
if (supportsHttpProxyInlineAuth()) {
|
||||||
|
if (typeof options.proxy === "string") {
|
||||||
|
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const url = options.proxy.username
|
||||||
|
? reconstructHttpUrl(options.proxy)
|
||||||
|
: options.proxy.server;
|
||||||
|
args.push(`--proxy-server=${url}`);
|
||||||
|
if (options.proxy.bypass) {
|
||||||
|
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported platform: strip credentials, fall back to page.authenticate()
|
||||||
if (typeof options.proxy === "string") {
|
if (typeof options.proxy === "string") {
|
||||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||||
args.push(`--proxy-server=${server}`);
|
args.push(`--proxy-server=${server}`);
|
||||||
@@ -55,7 +73,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
|
|||||||
return username ? { username, password: password ?? "" } : undefined;
|
return username ? { username, password: password ?? "" } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply proxy auth monkey-patch and humanize behavioral patching. */
|
/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */
|
||||||
async function applyPostLaunch(
|
async function applyPostLaunch(
|
||||||
browser: Browser,
|
browser: Browser,
|
||||||
options: LaunchOptions,
|
options: LaunchOptions,
|
||||||
@@ -138,6 +156,8 @@ export async function launchPersistentContext(
|
|||||||
const { binaryPath, args } = await resolveArgs(options);
|
const { binaryPath, args } = await resolveArgs(options);
|
||||||
const proxyAuth = resolveProxy(options, args);
|
const proxyAuth = resolveProxy(options, args);
|
||||||
|
|
||||||
|
seedWidevineHint(options.userDataDir, binaryPath);
|
||||||
|
|
||||||
const browser = await puppeteer.default.launch({
|
const browser = await puppeteer.default.launch({
|
||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
executablePath: binaryPath,
|
executablePath: binaryPath,
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Widevine CDM hint-file seeding for persistent contexts.
|
||||||
|
* Mirrors Python cloakbrowser/widevine.py.
|
||||||
|
*
|
||||||
|
* CloakBrowser's binary supports Widevine but ships no CDM (proprietary, can't
|
||||||
|
* redistribute). Users sideload it by copying a `WidevineCdm/` directory from a
|
||||||
|
* real Chrome install next to the binary (see issue #96). Chromium reads a
|
||||||
|
* "hint file" from the user-data-dir at early startup to register the CDM, but
|
||||||
|
* on a fresh profile it doesn't exist yet, and Playwright disables the component
|
||||||
|
* updater that would write it. This seeds the hint file before launch so a
|
||||||
|
* sideloaded CDM works on the first run. It never bundles, downloads, or copies
|
||||||
|
* the CDM — only writes the hint when a user-provided CDM is already present.
|
||||||
|
*
|
||||||
|
* Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
|
||||||
|
|
||||||
|
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
|
||||||
|
function isFile(file: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(file).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute, symlink-resolved path (mirrors Python's Path.resolve()). */
|
||||||
|
function realPath(p: string): string {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(p);
|
||||||
|
} catch {
|
||||||
|
return path.resolve(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedingDisabled(): boolean {
|
||||||
|
const val = (process.env.CLOAKBROWSER_WIDEVINE ?? "").trim().toLowerCase();
|
||||||
|
return val === "0" || val === "false" || val === "off" || val === "no";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate a sideloaded Widevine CDM directory, or null if absent.
|
||||||
|
*
|
||||||
|
* Resolution:
|
||||||
|
* - If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
|
||||||
|
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
|
||||||
|
* - Otherwise, `<dir of the chrome binary>/WidevineCdm` — where a user naturally
|
||||||
|
* drops it, and where it lives for both downloaded and CLOAKBROWSER_BINARY_PATH binaries.
|
||||||
|
*
|
||||||
|
* A directory counts only if it contains `manifest.json`. The returned path is
|
||||||
|
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
|
||||||
|
* @internal Exported for testing.
|
||||||
|
*/
|
||||||
|
export function resolveWidevineCdmDir(binaryPath: string): string | null {
|
||||||
|
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
|
// `!== undefined` (not truthiness): a present-but-empty env var is "set" and
|
||||||
|
// used exclusively — it resolves to an invalid path and skips seeding.
|
||||||
|
const cdmDir = custom !== undefined ? custom : path.join(path.dirname(binaryPath), "WidevineCdm");
|
||||||
|
return isFile(path.join(cdmDir, "manifest.json")) ? realPath(cdmDir) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the Widevine CDM hint file into a persistent profile before launch.
|
||||||
|
* `binaryPath` is the resolved chrome executable; the CDM is looked for next to
|
||||||
|
* it. No-op on non-Linux, when disabled via CLOAKBROWSER_WIDEVINE, or when no
|
||||||
|
* sideloaded CDM is present. Never throws — a failure must not break launch.
|
||||||
|
*/
|
||||||
|
export function seedWidevineHint(userDataDir: string, binaryPath: string): void {
|
||||||
|
if (process.platform !== "linux") return;
|
||||||
|
if (seedingDisabled()) return;
|
||||||
|
// Empty userDataDir = Playwright's ephemeral profile (its own temp dir);
|
||||||
|
// a persistent hint can't be placed there, and "" would pollute the CWD.
|
||||||
|
if (!userDataDir) return;
|
||||||
|
|
||||||
|
// Everything below is best-effort and must never break the browser launch,
|
||||||
|
// so the whole body (resolution + write) is guarded.
|
||||||
|
try {
|
||||||
|
const cdmDir = resolveWidevineCdmDir(binaryPath);
|
||||||
|
if (cdmDir === null) {
|
||||||
|
if (process.env.CLOAKBROWSER_WIDEVINE_CDM !== undefined) {
|
||||||
|
console.warn(
|
||||||
|
"[cloakbrowser] CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " +
|
||||||
|
"skipping Widevine hint seeding",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintDir = path.join(userDataDir, "WidevineCdm");
|
||||||
|
fs.mkdirSync(hintDir, { recursive: true });
|
||||||
|
const hintFile = path.join(hintDir, HINT_FILENAME);
|
||||||
|
// cdmDir is already absolute/resolved.
|
||||||
|
const content = JSON.stringify({ Path: cdmDir });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isFile(hintFile) && fs.readFileSync(hintFile, "utf-8") === content) {
|
||||||
|
return; // already seeded correctly
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn("[cloakbrowser] Existing Widevine hint unreadable; rewriting");
|
||||||
|
}
|
||||||
|
fs.writeFileSync(hintFile, content);
|
||||||
|
} catch (e) {
|
||||||
|
// Best-effort: never break the launch, but surface the failure.
|
||||||
|
console.warn("[cloakbrowser] Failed to seed Widevine CDM hint file:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1479,3 +1479,123 @@ describe("el.scrollIntoViewIfNeeded humanization", () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Issue #307: frame.click timeout should not multiply
|
||||||
|
// =========================================================================
|
||||||
|
describe("frame.click timeout budget (#307)", () => {
|
||||||
|
it("total wait time should not exceed the specified timeout", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 500;
|
||||||
|
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Build a frame where the element does NOT exist:
|
||||||
|
// scrollIntoViewIfNeeded and boundingBox each wait until their
|
||||||
|
// individual timeout before failing, and origFrameClick does the same.
|
||||||
|
const frameLoc: any = {
|
||||||
|
boundingBox: vi.fn(async (opts?: { timeout?: number }) => {
|
||||||
|
await delay(opts?.timeout ?? 30000);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
scrollIntoViewIfNeeded: vi.fn(async (opts?: { timeout?: number }) => {
|
||||||
|
await delay(opts?.timeout ?? 30000);
|
||||||
|
throw new Error("timeout");
|
||||||
|
}),
|
||||||
|
evaluate: vi.fn(async () => ({ hit: true })),
|
||||||
|
isChecked: vi.fn(async () => false),
|
||||||
|
};
|
||||||
|
frameLoc.first = vi.fn(() => frameLoc);
|
||||||
|
|
||||||
|
const origClickFn = vi.fn(async (_sel: string, opts?: any) => {
|
||||||
|
await delay(opts?.timeout ?? 30000);
|
||||||
|
throw new Error("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
const childFrame: any = {
|
||||||
|
click: origClickFn,
|
||||||
|
dblclick: vi.fn(async () => {}),
|
||||||
|
hover: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
fill: vi.fn(async () => {}),
|
||||||
|
check: vi.fn(async () => {}),
|
||||||
|
uncheck: vi.fn(async () => {}),
|
||||||
|
selectOption: vi.fn(async () => {}),
|
||||||
|
press: vi.fn(async () => {}),
|
||||||
|
pressSequentially: vi.fn(async () => {}),
|
||||||
|
tap: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
dragAndDrop: vi.fn(async () => {}),
|
||||||
|
locator: vi.fn(() => frameLoc),
|
||||||
|
childFrames: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainFrame = {
|
||||||
|
...buildMockFrame(),
|
||||||
|
childFrames: vi.fn(() => [childFrame]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = buildMockPage({ mainFrameReturn: mainFrame });
|
||||||
|
const cfg = resolveConfig("default", {
|
||||||
|
mouse_min_steps: 1,
|
||||||
|
mouse_max_steps: 1,
|
||||||
|
idle_between_actions: false,
|
||||||
|
});
|
||||||
|
const cursor = { x: 0, y: 0, initialized: true };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await (childFrame as any).click("#does-not-exist", { timeout: TIMEOUT_MS });
|
||||||
|
} catch {
|
||||||
|
// expected — element doesn't exist
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
// With the bug, elapsed ≈ 3 * TIMEOUT_MS (scrollIntoView + boundingBox + origClick).
|
||||||
|
// Fixed: elapsed should be ≈ 1 * TIMEOUT_MS (shared deadline).
|
||||||
|
// Allow 1.8x as upper bound to account for test overhead but catch the 3x bug.
|
||||||
|
expect(elapsed).toBeLessThan(TIMEOUT_MS * 1.8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pointer-events check fail-open", () => {
|
||||||
|
// When the check itself cannot run (evaluate / boundingBox throws -> result
|
||||||
|
// null), proceed with the click instead of blocking it until the timeout.
|
||||||
|
it("checkPointerEventsHandle returns promptly when evaluate throws", async () => {
|
||||||
|
const { checkPointerEventsHandle } = await import("../src/human/actionability.js");
|
||||||
|
const el = {
|
||||||
|
boundingBox: vi.fn().mockRejectedValue(new Error("stale handle")),
|
||||||
|
evaluate: vi.fn().mockRejectedValue(new Error("execution context destroyed")),
|
||||||
|
};
|
||||||
|
const start = Date.now();
|
||||||
|
await checkPointerEventsHandle(el as any, 100, 100, 2000); // must not throw
|
||||||
|
expect(Date.now() - start).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkPointerEvents returns promptly when evaluate throws", async () => {
|
||||||
|
const { checkPointerEvents } = await import("../src/human/actionability.js");
|
||||||
|
const loc = {
|
||||||
|
first: () => loc,
|
||||||
|
boundingBox: vi.fn().mockRejectedValue(new Error("no element")),
|
||||||
|
evaluate: vi.fn().mockRejectedValue(new Error("no element")),
|
||||||
|
};
|
||||||
|
const page = { locator: vi.fn().mockReturnValue(loc) };
|
||||||
|
const start = Date.now();
|
||||||
|
await checkPointerEvents(page as any, "#x", 100, 100, null, 2000); // must not throw
|
||||||
|
expect(Date.now() - start).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkPointerEventsHandle still throws when genuinely covered", async () => {
|
||||||
|
const { checkPointerEventsHandle, ElementNotReceivingEventsError } =
|
||||||
|
await import("../src/human/actionability.js");
|
||||||
|
const el = {
|
||||||
|
boundingBox: vi.fn().mockResolvedValue({ x: 0, y: 0, width: 10, height: 10 }),
|
||||||
|
evaluate: vi.fn().mockResolvedValue({ hit: false, covering: "DIV" }),
|
||||||
|
};
|
||||||
|
await expect(checkPointerEventsHandle(el as any, 5, 5, 200)).rejects.toBeInstanceOf(
|
||||||
|
ElementNotReceivingEventsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+77
-27
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
import { binaryInfo } from "../src/download.js";
|
import { binaryInfo } from "../src/download.js";
|
||||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||||
|
import * as config from "../src/config.js";
|
||||||
|
|
||||||
describe("binaryInfo", () => {
|
describe("binaryInfo", () => {
|
||||||
it("returns correct structure", () => {
|
it("returns correct structure", () => {
|
||||||
@@ -39,33 +40,76 @@ describe("composable Playwright launch helpers", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports buildLaunchOptions and humanizeBrowser from the package entrypoint", async () => {
|
it("exports composable helpers from the package entrypoint", async () => {
|
||||||
const entry = await import("../src/index.js");
|
const entry = await import("../src/index.js");
|
||||||
|
|
||||||
expect(entry.buildLaunchOptions).toBeTypeOf("function");
|
expect(entry.buildLaunchOptions).toBeTypeOf("function");
|
||||||
|
expect(entry.buildContextOptions).toBeTypeOf("function");
|
||||||
expect(entry.humanizeBrowser).toBeTypeOf("function");
|
expect(entry.humanizeBrowser).toBeTypeOf("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("buildContextOptions returns Playwright context options without launching a browser", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const { buildContextOptions } = await import("../src/index.js");
|
||||||
|
|
||||||
|
const options = buildContextOptions({
|
||||||
|
userAgent: "Explicit/1.0",
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
colorScheme: "dark",
|
||||||
|
contextOptions: {
|
||||||
|
userAgent: "Context/9.9",
|
||||||
|
viewport: { width: 9999, height: 9999 },
|
||||||
|
colorScheme: "light",
|
||||||
|
storageState: "state.json",
|
||||||
|
locale: "de-DE",
|
||||||
|
timezoneId: "Europe/Berlin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
userAgent: "Explicit/1.0",
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
colorScheme: "dark",
|
||||||
|
storageState: "state.json",
|
||||||
|
});
|
||||||
|
expect(options.locale).toBeUndefined();
|
||||||
|
expect(options.timezoneId).toBeUndefined();
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildContextOptions applies DEFAULT_VIEWPORT by default and allows null viewport", async () => {
|
||||||
|
const { buildContextOptions } = await import("../src/index.js");
|
||||||
|
|
||||||
|
expect(buildContextOptions().viewport).toEqual(DEFAULT_VIEWPORT);
|
||||||
|
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
|
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
|
||||||
const { buildLaunchOptions } = await import("../src/index.js");
|
const freshConfig = await import("../src/config.js");
|
||||||
|
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
|
try {
|
||||||
|
const { buildLaunchOptions } = await import("../src/index.js");
|
||||||
|
|
||||||
const options = await buildLaunchOptions({
|
const options = await buildLaunchOptions({
|
||||||
headless: false,
|
headless: false,
|
||||||
proxy: "http://user:pass@proxy.example:8080",
|
proxy: "http://user:pass@proxy.example:8080",
|
||||||
args: ["--custom-flag"],
|
args: ["--custom-flag"],
|
||||||
launchOptions: { timeout: 1234 },
|
launchOptions: { timeout: 1234 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(options.executablePath).toBe("/fake/chrome");
|
expect(options.executablePath).toBe("/fake/chrome");
|
||||||
expect(options.headless).toBe(false);
|
expect(options.headless).toBe(false);
|
||||||
expect(options.args).toContain("--custom-flag");
|
expect(options.args).toContain("--custom-flag");
|
||||||
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
|
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
|
||||||
expect(options.proxy).toEqual({
|
expect(options.proxy).toEqual({
|
||||||
server: "http://proxy.example:8080",
|
server: "http://proxy.example:8080",
|
||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
});
|
});
|
||||||
expect(options.timeout).toBe(1234);
|
expect(options.timeout).toBe(1234);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("humanizeBrowser patches an existing browser only when requested", async () => {
|
it("humanizeBrowser patches an existing browser only when requested", async () => {
|
||||||
@@ -307,16 +351,22 @@ describe("launchPersistentContext (unit)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards proxy string", async () => {
|
it("forwards proxy string", async () => {
|
||||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
const freshConfig = await import("../src/config.js");
|
||||||
await launchPersistentContext({
|
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
userDataDir: "/tmp/profile",
|
try {
|
||||||
proxy: "http://user:pass@proxy:8080",
|
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];
|
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||||
expect(args.proxy.server).toBe("http://proxy:8080");
|
expect(args.proxy.server).toBe("http://proxy:8080");
|
||||||
expect(args.proxy.username).toBe("user");
|
expect(args.proxy.username).toBe("user");
|
||||||
expect(args.proxy.password).toBe("pass");
|
expect(args.proxy.password).toBe("pass");
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards userAgent and colorScheme", async () => {
|
it("forwards userAgent and colorScheme", async () => {
|
||||||
|
|||||||
+103
-5
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
|
||||||
|
import * as config from "../src/config.js";
|
||||||
import type { LaunchOptions } from "../src/types.js";
|
import type { LaunchOptions } from "../src/types.js";
|
||||||
|
|
||||||
describe("parseProxyUrl", () => {
|
describe("parseProxyUrl", () => {
|
||||||
@@ -153,10 +154,15 @@ describe("resolveProxyConfig", () => {
|
|||||||
expect(proxyArgs).toEqual([]);
|
expect(proxyArgs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns playwright dict for http string", () => {
|
it("returns playwright dict for http string on unsupported platform", () => {
|
||||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
try {
|
||||||
expect(proxyArgs).toEqual([]);
|
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||||
|
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||||
|
expect(proxyArgs).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns playwright dict for http dict", () => {
|
it("returns playwright dict for http dict", () => {
|
||||||
@@ -321,4 +327,96 @@ describe("resolveProxyConfig", () => {
|
|||||||
debugSpy.mockRestore();
|
debugSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- HTTP with credentials → --proxy-server (supported platform + version) ---
|
||||||
|
|
||||||
|
it("routes http string with creds through --proxy-server on linux-x64 v177.5", () => {
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||||
|
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||||
|
try {
|
||||||
|
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||||
|
expect(proxyOption).toBeUndefined();
|
||||||
|
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes http dict with creds through --proxy-server on linux-x64 v177.5", () => {
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||||
|
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||||
|
try {
|
||||||
|
const { proxyOption, proxyArgs } = resolveProxyConfig({
|
||||||
|
server: "http://proxy:8080",
|
||||||
|
username: "user",
|
||||||
|
password: "pass",
|
||||||
|
});
|
||||||
|
expect(proxyOption).toBeUndefined();
|
||||||
|
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes bypass for http dict with creds on windows-x64 v177.5", () => {
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("windows-x64");
|
||||||
|
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||||
|
try {
|
||||||
|
const { proxyArgs } = resolveProxyConfig({
|
||||||
|
server: "http://proxy:8080",
|
||||||
|
username: "user",
|
||||||
|
password: "pass",
|
||||||
|
bypass: ".google.com",
|
||||||
|
});
|
||||||
|
expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080");
|
||||||
|
expect(proxyArgs).toContain("--proxy-bypass-list=.google.com");
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes special chars in http proxy password on supported platform v177.5", () => {
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||||
|
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||||
|
try {
|
||||||
|
const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080");
|
||||||
|
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back on linux-x64 with old version (pre-inline-auth)", () => {
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||||
|
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.3");
|
||||||
|
try {
|
||||||
|
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||||
|
expect(proxyOption).toBeDefined();
|
||||||
|
expect(proxyArgs).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to playwright dict for http with creds on darwin-arm64", () => {
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
|
try {
|
||||||
|
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||||
|
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||||
|
expect(proxyArgs).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to playwright dict for http with creds on linux-arm64", () => {
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64");
|
||||||
|
try {
|
||||||
|
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||||
|
expect(proxyOption).toBeDefined();
|
||||||
|
expect(proxyArgs).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+49
-20
@@ -84,16 +84,39 @@ describe("puppeteer launch", () => {
|
|||||||
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
|
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("monkey-patches newPage for proxy auth", async () => {
|
it("uses page.authenticate fallback for http proxy on unsupported platform", async () => {
|
||||||
const { launch } = await import("../src/puppeteer.js");
|
const config = await import("../src/config.js");
|
||||||
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
|
try {
|
||||||
|
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();
|
||||||
const page = await browser.newPage();
|
expect(page.authenticate).toHaveBeenCalledWith({
|
||||||
expect(page.authenticate).toHaveBeenCalledWith({
|
username: "user",
|
||||||
username: "user",
|
password: "pass",
|
||||||
password: "pass",
|
});
|
||||||
});
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes inline creds via --proxy-server on supported platform (no page.authenticate)", async () => {
|
||||||
|
const config = await import("../src/config.js");
|
||||||
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||||
|
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||||
|
try {
|
||||||
|
const { launch } = await import("../src/puppeteer.js");
|
||||||
|
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
|
||||||
|
|
||||||
|
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||||
|
expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080");
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
expect(page.authenticate).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("injects timezone and locale as binary flags", async () => {
|
it("injects timezone and locale as binary flags", async () => {
|
||||||
@@ -189,18 +212,24 @@ describe("puppeteer launchPersistentContext", () => {
|
|||||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles proxy auth with persistent context", async () => {
|
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
|
||||||
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
const config = await import("../src/config.js");
|
||||||
const browser = await launchPersistentContext({
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
userDataDir: "./my-profile",
|
try {
|
||||||
proxy: "http://user:pass@proxy:8080",
|
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||||
});
|
const browser = await launchPersistentContext({
|
||||||
|
userDataDir: "./my-profile",
|
||||||
|
proxy: "http://user:pass@proxy:8080",
|
||||||
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
expect(page.authenticate).toHaveBeenCalledWith({
|
expect(page.authenticate).toHaveBeenCalledWith({
|
||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
|
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ describe("humanType mixed text with CDP", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("password-like text 'SecurePass!123' uses CDP for '!'", async () => {
|
it("password-like text 'SecurePass!123' uses CDP for '!'", async () => {
|
||||||
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
const cfg = resolveConfig("default", { mistype_chance: 0, typing_delay: 0 });
|
||||||
const { raw } = buildRawKeyboard();
|
const { raw } = buildRawKeyboard();
|
||||||
const page = buildMockPage();
|
const page = buildMockPage();
|
||||||
const cdpCalls: Array<[string, any]> = [];
|
const cdpCalls: Array<[string, any]> = [];
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Assert the persistent-context launchers actually invoke seedWidevineHint,
|
||||||
|
// so accidental removal of the wiring fails CI (parity with the Python
|
||||||
|
// test_persistent_context_seeds_widevine tests).
|
||||||
|
|
||||||
|
vi.mock("../src/widevine.js", () => ({
|
||||||
|
seedWidevineHint: vi.fn(),
|
||||||
|
resolveWidevineCdmDir: 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 }),
|
||||||
|
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
||||||
|
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
|
||||||
|
}));
|
||||||
|
vi.mock("playwright-core", () => ({ chromium: { launchPersistentContext: vi.fn() } }));
|
||||||
|
vi.mock("puppeteer-core", () => ({ default: { launch: vi.fn() } }));
|
||||||
|
|
||||||
|
describe("persistent context seeds Widevine (integration)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Playwright launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
|
||||||
|
const pw = await import("playwright-core");
|
||||||
|
vi.mocked(pw.chromium.launchPersistentContext).mockResolvedValue({
|
||||||
|
close: vi.fn(),
|
||||||
|
pages: () => [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { seedWidevineHint } = await import("../src/widevine.js");
|
||||||
|
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||||
|
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||||
|
|
||||||
|
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Puppeteer launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
|
||||||
|
const pptr = await import("puppeteer-core");
|
||||||
|
vi.mocked(pptr.default.launch).mockResolvedValue({
|
||||||
|
newPage: vi.fn().mockResolvedValue({ authenticate: vi.fn() }),
|
||||||
|
close: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { seedWidevineHint } = await import("../src/widevine.js");
|
||||||
|
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||||
|
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||||
|
|
||||||
|
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveWidevineCdmDir, seedWidevineHint } from "../src/widevine.js";
|
||||||
|
|
||||||
|
const HINT = "WidevineCdm/latest-component-updated-widevine-cdm";
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
const origPlatform = process.platform;
|
||||||
|
|
||||||
|
function tmpDir(prefix: string): string {
|
||||||
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
tempDirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCdm(dir: string): string {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, "manifest.json"), '{"version":"4.10.3050.0"}');
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake chrome binary path inside its own dir. */
|
||||||
|
function fakeBinary(): string {
|
||||||
|
const bdir = path.join(tmpDir("cloak-bin-"), "bin");
|
||||||
|
fs.mkdirSync(bdir, { recursive: true });
|
||||||
|
return path.join(bdir, "chrome");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlatform(value: string) {
|
||||||
|
Object.defineProperty(process, "platform", { value, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
|
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveWidevineCdmDir", () => {
|
||||||
|
it("returns env-var dir when it has manifest.json", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
expect(resolveWidevineCdmDir(fakeBinary())).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when dir lacks manifest.json", () => {
|
||||||
|
const bogus = path.join(tmpDir("cloak-wv-"), "WidevineCdm");
|
||||||
|
fs.mkdirSync(bogus, { recursive: true });
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
|
||||||
|
expect(resolveWidevineCdmDir(fakeBinary())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to <binary dir>/WidevineCdm", () => {
|
||||||
|
const binary = fakeBinary();
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBeNull(); // no CDM yet
|
||||||
|
const cdm = makeCdm(path.join(path.dirname(binary), "WidevineCdm"));
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
|
||||||
|
const binary = fakeBinary();
|
||||||
|
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||||
|
const bogus = path.join(tmpDir("cloak-wv-"), "bogus");
|
||||||
|
fs.mkdirSync(bogus, { recursive: true }); // set but no manifest.json
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty env var is exclusive — no fallback to binary dir", () => {
|
||||||
|
const binary = fakeBinary();
|
||||||
|
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seedWidevineHint", () => {
|
||||||
|
it("writes the hint file with the absolute CDM path", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
|
||||||
|
const hint = path.join(profile, HINT);
|
||||||
|
expect(fs.existsSync(hint)).toBe(true);
|
||||||
|
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when no CDM present", () => {
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kill switch CLOAKBROWSER_WIDEVINE=0 disables seeding", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE = "0";
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(JSON.parse(fs.readFileSync(path.join(profile, HINT), "utf-8")).Path).toBe(
|
||||||
|
fs.realpathSync(cdm),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops on non-Linux", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty userDataDir (no CWD pollution)", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
seedWidevineHint("", fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(process.cwd(), "WidevineCdm"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on write failure", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
// Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
|
||||||
|
fs.writeFileSync(path.join(profile, "WidevineCdm"), "not a dir");
|
||||||
|
expect(() => seedWidevineHint(profile, fakeBinary())).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites a mismatched existing hint", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
const hint = path.join(profile, HINT);
|
||||||
|
fs.mkdirSync(path.dirname(hint), { recursive: true });
|
||||||
|
fs.writeFileSync(hint, '{"Path":"/stale/path"}');
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
});
|
||||||
+251
-1
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib.machinery
|
import importlib.machinery
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -24,6 +25,8 @@ parse_connection_params = _mod.parse_connection_params
|
|||||||
parse_cli_args = _mod.parse_cli_args
|
parse_cli_args = _mod.parse_cli_args
|
||||||
ChromePool = _mod.ChromePool
|
ChromePool = _mod.ChromePool
|
||||||
_default_data_dir = _mod._default_data_dir
|
_default_data_dir = _mod._default_data_dir
|
||||||
|
_external_host = _mod._external_host
|
||||||
|
_ws_scheme = _mod._ws_scheme
|
||||||
SAFE_SEED_RE = _mod.SAFE_SEED_RE
|
SAFE_SEED_RE = _mod.SAFE_SEED_RE
|
||||||
RESERVED_SEEDS = _mod.RESERVED_SEEDS
|
RESERVED_SEEDS = _mod.RESERVED_SEEDS
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ class TestParseCliArgs:
|
|||||||
assert config["port"] == 9222
|
assert config["port"] == 9222
|
||||||
assert config["headless"] is True
|
assert config["headless"] is True
|
||||||
assert config["data_dir"] is not None
|
assert config["data_dir"] is not None
|
||||||
|
assert config["idle_timeout"] == 0.0
|
||||||
assert passthrough == []
|
assert passthrough == []
|
||||||
|
|
||||||
def test_custom_port(self):
|
def test_custom_port(self):
|
||||||
@@ -128,6 +132,31 @@ class TestParseCliArgs:
|
|||||||
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
|
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
|
||||||
assert not any(a.startswith("--data-dir=") for a in passthrough)
|
assert not any(a.startswith("--data-dir=") for a in passthrough)
|
||||||
|
|
||||||
|
def test_idle_timeout_not_in_passthrough(self):
|
||||||
|
config, passthrough = parse_cli_args(["--idle-timeout=30", "--no-sandbox"])
|
||||||
|
assert config["idle_timeout"] == 30.0
|
||||||
|
assert "--idle-timeout=30" not in passthrough
|
||||||
|
assert "--no-sandbox" in passthrough
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["0", "off", "false", "none", "disabled"])
|
||||||
|
def test_idle_timeout_disabled_values(self, value):
|
||||||
|
config, _ = parse_cli_args([f"--idle-timeout={value}"])
|
||||||
|
assert config["idle_timeout"] == 0.0
|
||||||
|
|
||||||
|
def test_idle_timeout_env_default(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
|
||||||
|
config, _ = parse_cli_args([])
|
||||||
|
assert config["idle_timeout"] == 2.5
|
||||||
|
|
||||||
|
def test_idle_timeout_cli_overrides_env(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
|
||||||
|
config, _ = parse_cli_args(["--idle-timeout=9"])
|
||||||
|
assert config["idle_timeout"] == 9.0
|
||||||
|
|
||||||
|
def test_idle_timeout_rejects_negative_values(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_cli_args(["--idle-timeout=-1"])
|
||||||
|
|
||||||
@patch("os.path.exists", return_value=True)
|
@patch("os.path.exists", return_value=True)
|
||||||
def test_default_data_dir_docker(self, _mock):
|
def test_default_data_dir_docker(self, _mock):
|
||||||
assert _default_data_dir() == "/tmp/cloakserve"
|
assert _default_data_dir() == "/tmp/cloakserve"
|
||||||
@@ -138,6 +167,141 @@ class TestParseCliArgs:
|
|||||||
assert result.endswith(".cloakbrowser/cloakserve")
|
assert result.endswith(".cloakbrowser/cloakserve")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# External host detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalHost:
|
||||||
|
"""Test public host selection for rewritten CDP WebSocket URLs."""
|
||||||
|
|
||||||
|
class _Request:
|
||||||
|
def __init__(self, headers, port=9222, scheme="http", query_string=""):
|
||||||
|
self.headers = headers
|
||||||
|
self.app = {"port": port}
|
||||||
|
self.scheme = scheme
|
||||||
|
self.query_string = query_string
|
||||||
|
|
||||||
|
def test_forwarded_host_overrides_internal_host(self):
|
||||||
|
request = self._Request({
|
||||||
|
"Host": "localhost:8080",
|
||||||
|
"X-Forwarded-Host": "cdp.example.com:443",
|
||||||
|
})
|
||||||
|
assert _external_host(request) == "cdp.example.com:443"
|
||||||
|
|
||||||
|
def test_forwarded_host_uses_first_value(self):
|
||||||
|
request = self._Request({
|
||||||
|
"Host": "internal:9222",
|
||||||
|
"X-Forwarded-Host": "public.example.com, internal:9222",
|
||||||
|
})
|
||||||
|
assert _external_host(request) == "public.example.com"
|
||||||
|
|
||||||
|
def test_blank_forwarded_host_falls_back_to_host_header(self):
|
||||||
|
request = self._Request({
|
||||||
|
"Host": "internal:9222",
|
||||||
|
"X-Forwarded-Host": " ",
|
||||||
|
})
|
||||||
|
assert _external_host(request) == "internal:9222"
|
||||||
|
|
||||||
|
def test_falls_back_to_host_header(self):
|
||||||
|
request = self._Request({"Host": "localhost:9222"})
|
||||||
|
assert _external_host(request) == "localhost:9222"
|
||||||
|
|
||||||
|
def test_falls_back_to_app_port_without_host_header(self):
|
||||||
|
request = self._Request({}, port=9333)
|
||||||
|
assert _external_host(request) == "localhost:9333"
|
||||||
|
|
||||||
|
def test_forwarded_proto_selects_wss(self):
|
||||||
|
request = self._Request({"X-Forwarded-Proto": "https"}, scheme="http")
|
||||||
|
assert _ws_scheme(request) == "wss"
|
||||||
|
|
||||||
|
def test_forwarded_proto_uses_first_value(self):
|
||||||
|
request = self._Request({"X-Forwarded-Proto": "https, http"}, scheme="http")
|
||||||
|
assert _ws_scheme(request) == "wss"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandlerURLRewriting:
|
||||||
|
"""Verify handlers rewrite CDP WebSocket URLs to the public cloakserve endpoint."""
|
||||||
|
|
||||||
|
class _Request:
|
||||||
|
def __init__(self, headers, query_string="fingerprint=seed1", port=9222, scheme="http"):
|
||||||
|
self.headers = headers
|
||||||
|
self.query_string = query_string
|
||||||
|
self.scheme = scheme
|
||||||
|
self.app = {"port": port, "pool": self._Pool()}
|
||||||
|
|
||||||
|
class _Pool:
|
||||||
|
async def get_or_launch(self, **_kwargs):
|
||||||
|
return SimpleNamespace(cdp_port=5100)
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, data):
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_exc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self, data):
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_exc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get(self, *_args, **_kwargs):
|
||||||
|
return TestHandlerURLRewriting._FakeResponse(self._data)
|
||||||
|
|
||||||
|
def _patch_session(self, monkeypatch, data):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_mod.aiohttp,
|
||||||
|
"ClientSession",
|
||||||
|
lambda *_args, **_kwargs: self._FakeSession(data),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_json_version_uses_forwarded_host_and_proto(self, monkeypatch):
|
||||||
|
self._patch_session(monkeypatch, {
|
||||||
|
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/browser/browser-guid",
|
||||||
|
})
|
||||||
|
request = self._Request({
|
||||||
|
"Host": "internal:9222",
|
||||||
|
"X-Forwarded-Host": "cdp.example.com",
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = asyncio.run(_mod.handle_json_version(request))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["webSocketDebuggerUrl"] == (
|
||||||
|
"wss://cdp.example.com/fingerprint/seed1/devtools/browser/browser-guid"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_json_list_uses_forwarded_host_and_proto(self, monkeypatch):
|
||||||
|
self._patch_session(monkeypatch, [{
|
||||||
|
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/page/page-guid",
|
||||||
|
}])
|
||||||
|
request = self._Request({
|
||||||
|
"Host": "internal:9222",
|
||||||
|
"X-Forwarded-Host": "cdp.example.com",
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = asyncio.run(_mod.handle_json_list(request))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload[0]["webSocketDebuggerUrl"] == (
|
||||||
|
"wss://cdp.example.com/fingerprint/seed1/devtools/page/page-guid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# URL rewriting logic (pure string manipulation, extracted from handlers)
|
# URL rewriting logic (pure string manipulation, extracted from handlers)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -293,12 +457,21 @@ class TestHandlerURLRewriting:
|
|||||||
class TestConnectionTracking:
|
class TestConnectionTracking:
|
||||||
"""Test ChromePool.connect() / disconnect() without real Chrome."""
|
"""Test ChromePool.connect() / disconnect() without real Chrome."""
|
||||||
|
|
||||||
def _make_pool(self):
|
def _make_pool(self, idle_timeout: float = 0.0):
|
||||||
return ChromePool(
|
return ChromePool(
|
||||||
binary="/fake/chrome",
|
binary="/fake/chrome",
|
||||||
global_args=[],
|
global_args=[],
|
||||||
headless=True,
|
headless=True,
|
||||||
data_dir="/tmp/test-cloakserve",
|
data_dir="/tmp/test-cloakserve",
|
||||||
|
idle_timeout=idle_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _track_process(self, pool, seed="seed1"):
|
||||||
|
pool._processes[seed] = SimpleNamespace()
|
||||||
|
|
||||||
|
def _track_live_process(self, pool, seed="seed1"):
|
||||||
|
pool._processes[seed] = SimpleNamespace(
|
||||||
|
process=SimpleNamespace(poll=lambda: None),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_connect_increments(self):
|
def test_connect_increments(self):
|
||||||
@@ -335,6 +508,83 @@ class TestConnectionTracking:
|
|||||||
assert pool._connections["a"] == 1
|
assert pool._connections["a"] == 1
|
||||||
assert pool._connections["b"] == 1
|
assert pool._connections["b"] == 1
|
||||||
|
|
||||||
|
def test_idle_cleanup_disabled_by_default(self):
|
||||||
|
async def run():
|
||||||
|
pool = self._make_pool()
|
||||||
|
self._track_process(pool)
|
||||||
|
|
||||||
|
pool.connect("seed1")
|
||||||
|
pool.disconnect("seed1")
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert pool._idle_tasks == {}
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_disconnect_to_zero_schedules_idle_cleanup(self):
|
||||||
|
async def run():
|
||||||
|
pool = self._make_pool(idle_timeout=0.01)
|
||||||
|
self._track_process(pool)
|
||||||
|
cleaned = []
|
||||||
|
|
||||||
|
async def fake_cleanup(seed):
|
||||||
|
cleaned.append(seed)
|
||||||
|
pool._processes.pop(seed, None)
|
||||||
|
|
||||||
|
pool._cleanup_process = fake_cleanup
|
||||||
|
pool.connect("seed1")
|
||||||
|
pool.disconnect("seed1")
|
||||||
|
|
||||||
|
assert "seed1" in pool._idle_tasks
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert cleaned == ["seed1"]
|
||||||
|
assert "seed1" not in pool._idle_tasks
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_reconnect_cancels_pending_idle_cleanup(self):
|
||||||
|
async def run():
|
||||||
|
pool = self._make_pool(idle_timeout=0.03)
|
||||||
|
self._track_process(pool)
|
||||||
|
cleaned = []
|
||||||
|
|
||||||
|
async def fake_cleanup(seed):
|
||||||
|
cleaned.append(seed)
|
||||||
|
pool._processes.pop(seed, None)
|
||||||
|
|
||||||
|
pool._cleanup_process = fake_cleanup
|
||||||
|
pool.connect("seed1")
|
||||||
|
pool.disconnect("seed1")
|
||||||
|
assert "seed1" in pool._idle_tasks
|
||||||
|
|
||||||
|
pool.connect("seed1")
|
||||||
|
await asyncio.sleep(0.06)
|
||||||
|
|
||||||
|
assert cleaned == []
|
||||||
|
assert pool._connections["seed1"] == 1
|
||||||
|
assert "seed1" not in pool._idle_tasks
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_discovery_refreshes_pending_idle_cleanup(self):
|
||||||
|
async def run():
|
||||||
|
pool = self._make_pool(idle_timeout=1.0)
|
||||||
|
self._track_live_process(pool)
|
||||||
|
|
||||||
|
pool.connect("seed1")
|
||||||
|
pool.disconnect("seed1")
|
||||||
|
first_task = pool._idle_tasks["seed1"]
|
||||||
|
|
||||||
|
await pool.get_or_launch("seed1")
|
||||||
|
second_task = pool._idle_tasks["seed1"]
|
||||||
|
|
||||||
|
assert second_task is not first_task
|
||||||
|
pool._cancel_idle_cleanup("seed1")
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert "seed1" not in pool._idle_tasks
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Seed validation (CVE fix — path traversal via fingerprint param)
|
# Seed validation (CVE fix — path traversal via fingerprint param)
|
||||||
|
|||||||
+114
-2
@@ -708,7 +708,7 @@ class TestBrowserBotDetection:
|
|||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
page.locator('#password').fill('SecurePass!123')
|
page.locator('#password').fill('SecurePass!123')
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
page.locator('button[type="submit"]').click()
|
page.locator('#loginForm button[type="submit"]').click()
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
body = page.locator('body').text_content()
|
body = page.locator('body').text_content()
|
||||||
assert '"superHumanSpeed": true' not in body
|
assert '"superHumanSpeed": true' not in body
|
||||||
@@ -725,7 +725,7 @@ class TestBrowserBotDetection:
|
|||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
page.locator('#email').fill('test@example.com')
|
page.locator('#email').fill('test@example.com')
|
||||||
page.locator('#password').fill('MyPassword!99')
|
page.locator('#password').fill('MyPassword!99')
|
||||||
page.locator('button[type="submit"]').click()
|
page.locator('#loginForm button[type="submit"]').click()
|
||||||
elapsed_ms = int((time.time() - t0) * 1000)
|
elapsed_ms = int((time.time() - t0) * 1000)
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
assert elapsed_ms > 3000
|
assert elapsed_ms > 3000
|
||||||
@@ -1828,6 +1828,118 @@ class TestScrollIntoViewIfNeeded:
|
|||||||
assert cursor.x == 200 and cursor.y == 200
|
assert cursor.x == 200 and cursor.y == 200
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Issue #307: frame/page click timeout should not multiply
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestTimeoutBudget307:
|
||||||
|
"""Verify timeout budget is shared across sequential operations."""
|
||||||
|
|
||||||
|
def test_page_click_total_time_within_budget(self):
|
||||||
|
"""page.click on a missing element should not exceed ~1x the timeout."""
|
||||||
|
import cloakbrowser.human as h
|
||||||
|
from cloakbrowser.human import _CursorState
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
TIMEOUT_MS = 500
|
||||||
|
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||||
|
cursor = _CursorState()
|
||||||
|
cursor.initialized = True
|
||||||
|
cursor.x = 100
|
||||||
|
cursor.y = 100
|
||||||
|
|
||||||
|
page = MagicMock()
|
||||||
|
page.click = MagicMock()
|
||||||
|
page.dblclick = MagicMock()
|
||||||
|
page.hover = MagicMock()
|
||||||
|
page.type = MagicMock()
|
||||||
|
page.fill = MagicMock()
|
||||||
|
page.goto = MagicMock()
|
||||||
|
page.is_checked = MagicMock(return_value=False)
|
||||||
|
page.viewport_size = {"width": 1280, "height": 720}
|
||||||
|
page.evaluate = MagicMock(return_value={"hit": True})
|
||||||
|
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||||
|
page.mouse = MagicMock()
|
||||||
|
page.keyboard = MagicMock()
|
||||||
|
page.query_selector = MagicMock(return_value=None)
|
||||||
|
page.query_selector_all = MagicMock(return_value=[])
|
||||||
|
page.wait_for_selector = MagicMock(return_value=None)
|
||||||
|
page.main_frame = MagicMock()
|
||||||
|
page.main_frame.child_frames = []
|
||||||
|
|
||||||
|
loc = MagicMock()
|
||||||
|
loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0))
|
||||||
|
loc.is_visible = MagicMock(return_value=False)
|
||||||
|
loc.first = loc
|
||||||
|
page.locator = MagicMock(return_value=loc)
|
||||||
|
|
||||||
|
h.patch_page(page, cfg, cursor)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
page.click("#does-not-exist", timeout=TIMEOUT_MS)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
|
||||||
|
assert elapsed_ms < TIMEOUT_MS * 1.8, (
|
||||||
|
f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPointerEventsFailOpen:
|
||||||
|
"""The pointer-events check must fail open: when it cannot run (evaluate /
|
||||||
|
bounding_box throws -> result None), proceed with the click instead of
|
||||||
|
blocking it until the timeout expires."""
|
||||||
|
|
||||||
|
def test_handle_failopen_returns_on_evaluate_error(self):
|
||||||
|
from cloakbrowser.human.actionability import check_pointer_events_handle
|
||||||
|
el = MagicMock()
|
||||||
|
el.bounding_box = MagicMock(side_effect=Exception("stale handle"))
|
||||||
|
el.evaluate = MagicMock(side_effect=Exception("execution context destroyed"))
|
||||||
|
start = time.monotonic()
|
||||||
|
check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000) # must not raise
|
||||||
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
|
||||||
|
|
||||||
|
def test_locator_failopen_returns_on_evaluate_error(self):
|
||||||
|
from cloakbrowser.human.actionability import check_pointer_events
|
||||||
|
page = MagicMock()
|
||||||
|
loc = MagicMock()
|
||||||
|
loc.first = loc
|
||||||
|
loc.bounding_box = MagicMock(side_effect=Exception("no element"))
|
||||||
|
loc.evaluate = MagicMock(side_effect=Exception("no element"))
|
||||||
|
page.locator = MagicMock(return_value=loc)
|
||||||
|
start = time.monotonic()
|
||||||
|
check_pointer_events(page, "#x", 100, 100, timeout=2000) # must not raise
|
||||||
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
|
||||||
|
|
||||||
|
def test_handle_still_raises_when_covered(self):
|
||||||
|
"""A genuine 'covered' result (not None) must still raise — fail-open
|
||||||
|
only applies when the check could not be determined."""
|
||||||
|
from cloakbrowser.human.actionability import (
|
||||||
|
check_pointer_events_handle, ElementNotReceivingEventsError,
|
||||||
|
)
|
||||||
|
el = MagicMock()
|
||||||
|
el.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10})
|
||||||
|
el.evaluate = MagicMock(return_value={"hit": False, "covering": "DIV"})
|
||||||
|
with pytest.raises(ElementNotReceivingEventsError):
|
||||||
|
check_pointer_events_handle(MagicMock(), el, 5, 5, timeout=200)
|
||||||
|
|
||||||
|
def test_async_handle_failopen_returns_on_evaluate_error(self):
|
||||||
|
from cloakbrowser.human.actionability_async import async_check_pointer_events_handle
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
el = MagicMock()
|
||||||
|
el.bounding_box = AsyncMock(side_effect=Exception("stale handle"))
|
||||||
|
el.evaluate = AsyncMock(side_effect=Exception("execution context destroyed"))
|
||||||
|
start = time.monotonic()
|
||||||
|
asyncio.run(async_check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000))
|
||||||
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Direct runner (backwards compat)
|
# Direct runner (backwards compat)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -165,10 +165,11 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
|
|||||||
pw.stop.assert_called_once()
|
pw.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
|
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin, _mock_platform):
|
||||||
"""Proxy string parsed and passed."""
|
"""Proxy string parsed and passed (unsupported platform → Playwright dict)."""
|
||||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
|
|
||||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||||
@@ -257,3 +258,32 @@ async def test_persistent_context_async_timezone_id_alias(_mock_bin):
|
|||||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||||
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
|
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
|
||||||
assert "timezone_id" not in call_kwargs
|
assert "timezone_id" not in call_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
|
@patch("cloakbrowser.browser.seed_widevine_hint")
|
||||||
|
def test_persistent_context_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
|
||||||
|
"""Sync persistent launch seeds the Widevine hint with the profile path."""
|
||||||
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
|
|
||||||
|
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||||
|
from cloakbrowser.browser import launch_persistent_context
|
||||||
|
launch_persistent_context("/tmp/profile")
|
||||||
|
|
||||||
|
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
|
@patch("cloakbrowser.browser.seed_widevine_hint")
|
||||||
|
async def test_persistent_context_async_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
|
||||||
|
"""Async persistent launch seeds the Widevine hint with the profile path."""
|
||||||
|
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||||
|
|
||||||
|
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||||
|
from cloakbrowser.browser import launch_persistent_context_async
|
||||||
|
await launch_persistent_context_async("/tmp/profile")
|
||||||
|
|
||||||
|
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
|
||||||
|
|||||||
+118
-14
@@ -55,12 +55,12 @@ class TestBuildProxyKwargs:
|
|||||||
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
|
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
|
||||||
assert args == []
|
assert args == []
|
||||||
|
|
||||||
def test_proxy_with_auth(self):
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_proxy_with_auth(self, *_):
|
||||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
assert kwargs == {
|
assert kwargs == {}
|
||||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||||
}
|
|
||||||
assert args == []
|
|
||||||
|
|
||||||
def test_proxy_dict_passthrough(self):
|
def test_proxy_dict_passthrough(self):
|
||||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||||
@@ -68,7 +68,9 @@ class TestBuildProxyKwargs:
|
|||||||
assert kwargs == {"proxy": proxy_dict}
|
assert kwargs == {"proxy": proxy_dict}
|
||||||
assert args == []
|
assert args == []
|
||||||
|
|
||||||
def test_proxy_dict_with_auth(self):
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_proxy_dict_with_auth(self, *_):
|
||||||
proxy_dict = {
|
proxy_dict = {
|
||||||
"server": "http://proxy:8080",
|
"server": "http://proxy:8080",
|
||||||
"username": "user",
|
"username": "user",
|
||||||
@@ -76,8 +78,11 @@ class TestBuildProxyKwargs:
|
|||||||
"bypass": ".example.com",
|
"bypass": ".example.com",
|
||||||
}
|
}
|
||||||
kwargs, args = _resolve_proxy_config(proxy_dict)
|
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||||
assert kwargs == {"proxy": proxy_dict}
|
assert kwargs == {}
|
||||||
assert args == []
|
assert args == [
|
||||||
|
"--proxy-server=http://user:pass@proxy:8080",
|
||||||
|
"--proxy-bypass-list=.example.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestMaybeResolveGeoip:
|
class TestMaybeResolveGeoip:
|
||||||
@@ -183,11 +188,12 @@ class TestBareProxyFormat:
|
|||||||
r = _parse_proxy_url("proxy:8080")
|
r = _parse_proxy_url("proxy:8080")
|
||||||
assert r == {"server": "proxy:8080"}
|
assert r == {"server": "proxy:8080"}
|
||||||
|
|
||||||
def test_resolve_proxy_config_bare(self):
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_resolve_proxy_config_bare(self, *_):
|
||||||
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
|
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
|
||||||
assert kwargs["proxy"]["username"] == "user"
|
assert kwargs == {}
|
||||||
assert kwargs["proxy"]["password"] == "pass"
|
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||||
assert "user" not in kwargs["proxy"]["server"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsSocksProxy:
|
class TestIsSocksProxy:
|
||||||
@@ -219,11 +225,17 @@ class TestResolveProxyConfig:
|
|||||||
assert kwargs == {}
|
assert kwargs == {}
|
||||||
assert args == []
|
assert args == []
|
||||||
|
|
||||||
def test_http_string_returns_playwright_dict(self):
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_http_string_with_creds_returns_chrome_arg(self, *_):
|
||||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||||
|
|
||||||
|
def test_http_string_no_creds_returns_playwright_dict(self):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://proxy:8080")
|
||||||
assert "proxy" in kwargs
|
assert "proxy" in kwargs
|
||||||
assert kwargs["proxy"]["server"] == "http://proxy:8080"
|
assert kwargs["proxy"]["server"] == "http://proxy:8080"
|
||||||
assert kwargs["proxy"]["username"] == "user"
|
|
||||||
assert args == []
|
assert args == []
|
||||||
|
|
||||||
def test_http_dict_passthrough(self):
|
def test_http_dict_passthrough(self):
|
||||||
@@ -374,3 +386,95 @@ class TestResolveProxyConfig:
|
|||||||
# Port 0 is an unusual but valid URL component; don't silently strip it.
|
# Port 0 is an unusual but valid URL component; don't silently strip it.
|
||||||
_, args = _resolve_proxy_config("socks5://user:pass=1@host:0")
|
_, args = _resolve_proxy_config("socks5://user:pass=1@host:0")
|
||||||
assert args[0] == "--proxy-server=socks5://user:pass%3D1@host:0"
|
assert args[0] == "--proxy-server=socks5://user:pass%3D1@host:0"
|
||||||
|
|
||||||
|
# --- HTTP with credentials → --proxy-server (supported platforms + version) ---
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_http_string_with_creds_on_supported_platform(self, *_):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_http_dict_with_creds_on_supported_platform(self, *_):
|
||||||
|
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_http_dict_with_creds_and_bypass(self, *_):
|
||||||
|
proxy = {
|
||||||
|
"server": "http://proxy:8080",
|
||||||
|
"username": "user",
|
||||||
|
"password": "pass",
|
||||||
|
"bypass": ".google.com",
|
||||||
|
}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert "--proxy-server=http://user:pass@proxy:8080" in args
|
||||||
|
assert "--proxy-bypass-list=.google.com" in args
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_http_string_encodes_special_chars_in_password(self, *_):
|
||||||
|
_, args = _resolve_proxy_config("http://user:pass=123@proxy:8080")
|
||||||
|
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_http_string_encoding_idempotent(self, *_):
|
||||||
|
_, args = _resolve_proxy_config("http://user:pass%3D123@proxy:8080")
|
||||||
|
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="windows-x64")
|
||||||
|
def test_http_string_with_creds_on_windows(self, *_):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.3")
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||||
|
def test_http_with_creds_old_version_falls_back(self, *_):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
|
assert "proxy" in kwargs
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
# --- HTTP with credentials on unsupported platform → fallback to Playwright ---
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
|
||||||
|
def test_http_string_with_creds_on_macos_falls_back(self, _mock):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
|
assert "proxy" in kwargs
|
||||||
|
assert kwargs["proxy"]["username"] == "user"
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
|
||||||
|
def test_http_dict_with_creds_on_macos_falls_back(self, _mock):
|
||||||
|
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {"proxy": proxy}
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-arm64")
|
||||||
|
def test_http_string_with_creds_on_linux_arm_falls_back(self, _mock):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
|
assert "proxy" in kwargs
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
# --- HTTP without credentials (all platforms) ---
|
||||||
|
|
||||||
|
def test_http_no_creds_returns_playwright_dict(self):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://proxy:8080")
|
||||||
|
assert "proxy" in kwargs
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_http_dict_no_creds_returns_playwright_dict(self):
|
||||||
|
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {"proxy": proxy}
|
||||||
|
assert args == []
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Unit tests for Widevine CDM hint-file seeding (cloakbrowser/widevine.py)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cloakbrowser import widevine
|
||||||
|
from cloakbrowser.widevine import resolve_widevine_cdm_dir, seed_widevine_hint
|
||||||
|
|
||||||
|
_HINT = "WidevineCdm/latest-component-updated-widevine-cdm"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _force_linux(monkeypatch):
|
||||||
|
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
|
||||||
|
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
|
||||||
|
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
|
||||||
|
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cdm(dirpath):
|
||||||
|
"""Create a fake WidevineCdm dir with a manifest.json."""
|
||||||
|
dirpath.mkdir(parents=True, exist_ok=True)
|
||||||
|
(dirpath / "manifest.json").write_text('{"version": "4.10.3050.0"}')
|
||||||
|
return dirpath
|
||||||
|
|
||||||
|
|
||||||
|
def _binary(tmp_path):
|
||||||
|
"""Return a fake chrome binary path inside its own dir."""
|
||||||
|
bdir = tmp_path / "bin"
|
||||||
|
bdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return bdir / "chrome"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_hint_next_to_binary(tmp_path):
|
||||||
|
"""CDM in <binary dir>/WidevineCdm -> hint file written with abs Path."""
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
cdm = _make_cdm(binary.parent / "WidevineCdm")
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, binary)
|
||||||
|
|
||||||
|
hint = profile / _HINT
|
||||||
|
assert hint.is_file()
|
||||||
|
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_hint_from_env_var(tmp_path, monkeypatch):
|
||||||
|
"""CLOAKBROWSER_WIDEVINE_CDM takes priority and is used as the Path."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
|
||||||
|
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_cdm_no_file(tmp_path):
|
||||||
|
"""No CDM present -> nothing written, no exception."""
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
assert not (profile / _HINT).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kill_switch_disables(tmp_path, monkeypatch):
|
||||||
|
"""CLOAKBROWSER_WIDEVINE=0 disables seeding even when a CDM exists."""
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "custom_cdm")))
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE", "0")
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
assert not (profile / _HINT).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent(tmp_path, monkeypatch):
|
||||||
|
"""Seeding twice leaves the same correct content and doesn't error."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
seed_widevine_hint(profile, binary)
|
||||||
|
seed_widevine_hint(profile, binary)
|
||||||
|
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop_on_non_linux(tmp_path, monkeypatch):
|
||||||
|
"""On non-Linux, seeding is a no-op even with a CDM present."""
|
||||||
|
monkeypatch.setattr(widevine.platform, "system", lambda: "Windows")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "cdm")))
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
assert not (profile / _HINT).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_requires_manifest(tmp_path, monkeypatch):
|
||||||
|
"""A WidevineCdm dir without manifest.json is not treated as a CDM."""
|
||||||
|
bogus = tmp_path / "custom_cdm"
|
||||||
|
bogus.mkdir()
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
|
||||||
|
assert resolve_widevine_cdm_dir(_binary(tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_var_is_exclusive(tmp_path, monkeypatch):
|
||||||
|
"""An invalid CLOAKBROWSER_WIDEVINE_CDM skips seeding — no fallback to binary dir."""
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
||||||
|
bogus = tmp_path / "bogus"
|
||||||
|
bogus.mkdir() # set but no manifest.json
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
|
||||||
|
assert resolve_widevine_cdm_dir(binary) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
|
||||||
|
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM is exclusive — no binary-dir fallback."""
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
|
||||||
|
monkeypatch.chdir(tmp_path) # so a stray ./manifest.json can't match
|
||||||
|
assert resolve_widevine_cdm_dir(binary) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_user_data_dir_skips(tmp_path, monkeypatch):
|
||||||
|
"""Empty user_data_dir (ephemeral profile) -> no CWD pollution, no seeding."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
seed_widevine_hint("", _binary(tmp_path))
|
||||||
|
assert not (tmp_path / "WidevineCdm").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_raises_on_write_failure(tmp_path, monkeypatch):
|
||||||
|
"""A write failure (hint dir path is a file) must not raise — launch must not break."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
profile.mkdir()
|
||||||
|
# Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
|
||||||
|
(profile / "WidevineCdm").write_text("not a dir")
|
||||||
|
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrites_corrupt_existing_hint(tmp_path, monkeypatch):
|
||||||
|
"""A non-UTF8 / mismatched existing hint is overwritten, without raising."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
hint = profile / "WidevineCdm" / _HINT.split("/")[-1]
|
||||||
|
hint.parent.mkdir(parents=True)
|
||||||
|
hint.write_bytes(b"\xff\xfe not valid utf-8")
|
||||||
|
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
|
||||||
|
|
||||||
|
# corrupt content replaced with a valid hint pointing at the CDM
|
||||||
|
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
|
||||||
Reference in New Issue
Block a user