mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35afec09a |
@@ -0,0 +1,62 @@
|
|||||||
|
name: issue-knowledge-responder
|
||||||
|
description: First-responder bot that finds related docs and past issues for new GitHub issues
|
||||||
|
model: openai/gpt-4.1
|
||||||
|
modelParameters:
|
||||||
|
maxCompletionTokens: 1500
|
||||||
|
temperature: 0.3
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: |
|
||||||
|
You are a first-responder bot for CloakBrowser, an open-source stealth Chromium wrapper.
|
||||||
|
Your ONLY job: find related resources from the provided context and point the user to them.
|
||||||
|
You are NOT a maintainer. You do NOT answer questions yourself.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- NEVER answer the question yourself. Only point to existing resources.
|
||||||
|
- NEVER reveal internal patch details, CDP stealth methods, or bypass techniques.
|
||||||
|
- Keep it short. 5-10 lines max.
|
||||||
|
- Only include resources that are ACTUALLY relevant, not vaguely similar.
|
||||||
|
- If the issue is about financial, government, or healthcare targets, output RESPOND: NO
|
||||||
|
- Maximum 5 related issues. Prefer closed+resolved over open.
|
||||||
|
|
||||||
|
- role: user
|
||||||
|
content: |
|
||||||
|
## The new issue
|
||||||
|
{{issue_body}}
|
||||||
|
|
||||||
|
## Past issues found by search
|
||||||
|
{{past_issues}}
|
||||||
|
|
||||||
|
## Past issue details (body + comments from top matches)
|
||||||
|
{{issue_details}}
|
||||||
|
|
||||||
|
## README.md (excerpt)
|
||||||
|
{{readme}}
|
||||||
|
|
||||||
|
## js/README.md (excerpt)
|
||||||
|
{{js_readme}}
|
||||||
|
|
||||||
|
## CHANGELOG.md (recent)
|
||||||
|
{{changelog}}
|
||||||
|
|
||||||
|
## Your task
|
||||||
|
|
||||||
|
Based on the context above, decide:
|
||||||
|
|
||||||
|
**If you found related resources** (doc sections, past issues, or both), output EXACTLY this format:
|
||||||
|
|
||||||
|
RESPOND: YES
|
||||||
|
---
|
||||||
|
While you wait for the team to respond, I found some resources that might be related:
|
||||||
|
|
||||||
|
**From the docs:**
|
||||||
|
- [description of relevant section] (in `README.md` / `js/README.md`)
|
||||||
|
|
||||||
|
**Related issues:**
|
||||||
|
- #123 — [title] (closed/open) — [one-line summary of resolution or discussion]
|
||||||
|
|
||||||
|
Hope this helps! The team will follow up soon.
|
||||||
|
---
|
||||||
|
|
||||||
|
**If you found NOTHING relevant**, output EXACTLY:
|
||||||
|
RESPOND: NO
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
name: Issue Knowledge Responder
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
respond:
|
||||||
|
if: github.event.issue.user.type != 'Bot'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
models: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Gather context
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/ctx
|
||||||
|
|
||||||
|
# 1. Read the full issue
|
||||||
|
gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title,body,labels --jq '
|
||||||
|
"Title: " + .title + "\n\nBody:\n" + (.body // "No body") + "\n\nLabels: " + ([.labels[].name] | join(", "))
|
||||||
|
' > /tmp/ctx/issue_body.txt
|
||||||
|
|
||||||
|
# 2. Extract keywords from title + body
|
||||||
|
TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq '.title')
|
||||||
|
BODY_TEXT=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' | head -20)
|
||||||
|
STOPWORDS='^(the|a|an|is|in|on|at|to|for|of|with|and|or|not|bug|issue|error|problem|help|please|how|why|what|does|can|i|my|me|it|this|that|when|from|have|has|been|are|was|were|be|do|did|just|but|if|so|no|yes|all|any|some|like|get|got|use|using|used|try|tried|also|want|need|would|could|should|about|into|after|before|only|very|too|more|most|much|own|same|other|than|then|there|here|each|every|both|few|many|such|these|those|its|new|old|first|last|long|great|little|right|big|high|different|small|large|next|early|young|important|public|bad|same|able)$'
|
||||||
|
TITLE_KW=$(echo "$TITLE" | tr -cs 'a-zA-Z0-9' ' ' | tr ' ' '\n' | \
|
||||||
|
grep -v -i -E "$STOPWORDS" | head -5 | tr '\n' ' ')
|
||||||
|
BODY_KW=$(echo "$BODY_TEXT" | tr -cs 'a-zA-Z0-9' ' ' | tr ' ' '\n' | \
|
||||||
|
grep -v -i -E "$STOPWORDS" | sort | uniq -c | sort -rn | awk '{print $2}' | head -5 | tr '\n' ' ')
|
||||||
|
|
||||||
|
# 3. Search past issues — 5 strategies, deduplicated
|
||||||
|
# A: full title
|
||||||
|
gh issue list --repo "$REPO" --state all --search "$TITLE" --limit 10 \
|
||||||
|
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
|
||||||
|
2>/dev/null > /tmp/ctx/search_results.txt || true
|
||||||
|
|
||||||
|
# B: title keywords
|
||||||
|
gh issue list --repo "$REPO" --state all --search "$TITLE_KW" --limit 10 \
|
||||||
|
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
|
||||||
|
2>/dev/null >> /tmp/ctx/search_results.txt || true
|
||||||
|
|
||||||
|
# C: body keywords (most frequent terms from body)
|
||||||
|
if [ -n "$BODY_KW" ]; then
|
||||||
|
gh issue list --repo "$REPO" --state all --search "$BODY_KW" --limit 10 \
|
||||||
|
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
|
||||||
|
2>/dev/null >> /tmp/ctx/search_results.txt || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# D: search by labels if the new issue has any
|
||||||
|
LABELS=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json labels --jq '[.labels[].name] | join(",")')
|
||||||
|
if [ -n "$LABELS" ]; then
|
||||||
|
IFS=',' read -ra LABEL_ARR <<< "$LABELS"
|
||||||
|
for LBL in "${LABEL_ARR[@]}"; do
|
||||||
|
gh issue list --repo "$REPO" --state all --label "$LBL" --limit 10 \
|
||||||
|
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
|
||||||
|
2>/dev/null >> /tmp/ctx/search_results.txt || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# E: search for error messages / code snippets in body (first backtick block or quoted line)
|
||||||
|
ERROR_MSG=$(echo "$BODY_TEXT" | grep -oP '`[^`]{5,80}`' | head -1 | tr -d '`')
|
||||||
|
if [ -n "$ERROR_MSG" ]; then
|
||||||
|
gh issue list --repo "$REPO" --state all --search "$ERROR_MSG" --limit 5 \
|
||||||
|
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
|
||||||
|
2>/dev/null >> /tmp/ctx/search_results.txt || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deduplicate, exclude self, keep top 20
|
||||||
|
sort -u /tmp/ctx/search_results.txt | \
|
||||||
|
grep -v "^#${ISSUE_NUMBER} " | head -20 > /tmp/ctx/past_issues.txt
|
||||||
|
|
||||||
|
# 4. Read top 7 issue details (body + comments)
|
||||||
|
echo "" > /tmp/ctx/issue_details.txt
|
||||||
|
for NUM in $(grep -oP '#\K[0-9]+' /tmp/ctx/past_issues.txt | head -7); do
|
||||||
|
gh issue view "$NUM" --repo "$REPO" --json title,state,body,comments --jq '
|
||||||
|
"### #'"$NUM"' — " + .title + " (" + .state + ")\n" +
|
||||||
|
(.body // "")[:500] + "\n\nComments:\n" +
|
||||||
|
([.comments[:3][] | .body[:300]] | join("\n---\n"))
|
||||||
|
' >> /tmp/ctx/issue_details.txt 2>/dev/null || true
|
||||||
|
echo -e "\n---\n" >> /tmp/ctx/issue_details.txt
|
||||||
|
done
|
||||||
|
|
||||||
|
# 5. Read docs
|
||||||
|
head -200 README.md > /tmp/ctx/readme.txt 2>/dev/null || echo "No README" > /tmp/ctx/readme.txt
|
||||||
|
head -150 js/README.md > /tmp/ctx/js_readme.txt 2>/dev/null || echo "No JS README" > /tmp/ctx/js_readme.txt
|
||||||
|
head -100 CHANGELOG.md > /tmp/ctx/changelog.txt 2>/dev/null || echo "No CHANGELOG" > /tmp/ctx/changelog.txt
|
||||||
|
|
||||||
|
# Debug: show what we gathered
|
||||||
|
echo "=== Issue ===" && head -5 /tmp/ctx/issue_body.txt
|
||||||
|
echo "=== Past issues ===" && cat /tmp/ctx/past_issues.txt
|
||||||
|
echo "=== Keywords: $KEYWORDS ==="
|
||||||
|
|
||||||
|
- name: AI analysis
|
||||||
|
id: ai
|
||||||
|
uses: actions/ai-inference@v1
|
||||||
|
with:
|
||||||
|
prompt-file: .github/prompts/issue-responder.prompt.yml
|
||||||
|
file_input: |
|
||||||
|
issue_body: /tmp/ctx/issue_body.txt
|
||||||
|
past_issues: /tmp/ctx/past_issues.txt
|
||||||
|
issue_details: /tmp/ctx/issue_details.txt
|
||||||
|
readme: /tmp/ctx/readme.txt
|
||||||
|
js_readme: /tmp/ctx/js_readme.txt
|
||||||
|
changelog: /tmp/ctx/changelog.txt
|
||||||
|
|
||||||
|
- name: Parse and post
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
RESPONSE=$(cat "${{ steps.ai.outputs.response-file }}")
|
||||||
|
echo "=== AI Response ==="
|
||||||
|
echo "$RESPONSE"
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | grep -q "RESPOND: YES"; then
|
||||||
|
# Extract content between --- markers
|
||||||
|
COMMENT=$(echo "$RESPONSE" | sed -n '/^---$/,/^---$/p' | sed '1d;$d')
|
||||||
|
|
||||||
|
if [ -z "$COMMENT" ]; then
|
||||||
|
# Fallback: everything after "RESPOND: YES", strip --- lines
|
||||||
|
COMMENT=$(echo "$RESPONSE" | sed '1,/RESPOND: YES/d' | sed '/^---$/d')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$COMMENT" ]; then
|
||||||
|
echo "$COMMENT" > /tmp/comment.txt
|
||||||
|
gh issue comment "${{ github.event.issue.number }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--body-file /tmp/comment.txt
|
||||||
|
echo "✅ Comment posted"
|
||||||
|
else
|
||||||
|
echo "⚠️ RESPOND: YES but empty comment body, skipping"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ℹ️ No relevant resources found, staying silent"
|
||||||
|
fi
|
||||||
+1
-11
@@ -6,20 +6,10 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [0.3.25] — 2026-04-16
|
|
||||||
|
|
||||||
- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141.
|
|
||||||
- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`.
|
|
||||||
- **[wrapper]** Fix `humanConfig` TypeScript typing (#151).
|
|
||||||
- **[binary]** New build 146.0.7680.177.3 for Linux x64 + arm64 — 57 source-level fingerprint patches (up from 49): WebAuthn capabilities, AAC audio encoder, and window position spoofing; WebGL and canvas format consistency fixes; SOCKS5 warm connection pool auth fix for credentialed proxies.
|
|
||||||
- **[docs]** Add recommended anti-bot config and SOCKS5 tips to troubleshooting.
|
|
||||||
|
|
||||||
## [0.3.24] — 2026-04-10
|
## [0.3.24] — 2026-04-10
|
||||||
|
|
||||||
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
|
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
|
||||||
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@evelaa123](https://github.com/evelaa123), #133)
|
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@lilos](https://github.com/lilos), #133)
|
||||||
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
|
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
|
||||||
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
|
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
|
||||||
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
|
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
|
||||||
|
|||||||
@@ -128,13 +128,11 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Latest: v0.3.25 (Chromium 146.0.7680.177.3)
|
## Latest: v0.3.24 (Chromium 146.0.7680.177.2)
|
||||||
|
|
||||||
- **`launch_context_async()`** — async counterpart to `launch_context()`. Forwards kwargs to `browser.new_context()` for `storage_state`, `permissions`, `extra_http_headers` without a persistent profile folder.
|
|
||||||
- **JS `contextOptions` escape hatch** — forward arbitrary options (including `storageState`) to Playwright's `newContext()` from `launchContext()` / `launchPersistentContext()`.
|
|
||||||
- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE.
|
- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE.
|
||||||
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
|
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
|
||||||
- **57 fingerprint patches** — additional detection-vector coverage (WebAuthn, AAC audio, window position) and WebGL/canvas consistency fixes
|
- **49 fingerprint patches** — Linux arm64 now matches Linux x64 on Chromium 146
|
||||||
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
|
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
|
||||||
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
|
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
|
||||||
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
|
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
|
||||||
@@ -318,38 +316,6 @@ page.goto("https://protected-site.com")
|
|||||||
context.close()
|
context.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
Extra kwargs are forwarded to Playwright's `browser.new_context()` — use this for `storage_state`, `permissions`, `extra_http_headers`, etc. without needing a persistent profile folder:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from cloakbrowser import launch_context
|
|
||||||
|
|
||||||
# Restore a saved session (cookies, localStorage) from a JSON file
|
|
||||||
context = launch_context(storage_state="state.json")
|
|
||||||
page = context.new_page()
|
|
||||||
page.goto("https://example.com")
|
|
||||||
# Save state back for next run
|
|
||||||
context.storage_state(path="state.json")
|
|
||||||
context.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
### `launch_context_async()`
|
|
||||||
|
|
||||||
Async counterpart to `launch_context()`. Same signature and kwargs forwarding:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
from cloakbrowser import launch_context_async
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
ctx = await launch_context_async(storage_state="state.json")
|
|
||||||
page = await ctx.new_page()
|
|
||||||
await page.goto("https://example.com")
|
|
||||||
await ctx.storage_state(path="state.json")
|
|
||||||
await ctx.close()
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
### `launch_persistent_context()`
|
### `launch_persistent_context()`
|
||||||
|
|
||||||
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
|
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
|
||||||
@@ -406,7 +372,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
|
|||||||
|
|
||||||
# Check binary installation status
|
# Check binary installation status
|
||||||
print(binary_info())
|
print(binary_info())
|
||||||
# {'version': '146.0.7680.177.3', 'platform': 'linux-x64', 'installed': True, ...}
|
# {'version': '146.0.7680.177.2', 'platform': 'linux-x64', 'installed': True, ...}
|
||||||
|
|
||||||
# Force re-download
|
# Force re-download
|
||||||
clear_cache()
|
clear_cache()
|
||||||
@@ -908,39 +874,7 @@ page.goto("https://heavily-protected-site.com") # passes DataDome, etc.
|
|||||||
browser.close()
|
browser.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combine with the recommended config below for maximum stealth.
|
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Recommended config for anti-bot sites
|
|
||||||
|
|
||||||
Most blocks come from missing one of these three things, not from browser fingerprint detection:
|
|
||||||
|
|
||||||
```python
|
|
||||||
browser = launch(
|
|
||||||
proxy="http://your-residential-proxy:port", # residential IP — datacenter IPs get blocked by reputation alone
|
|
||||||
geoip=True, # matches timezone + locale to proxy exit IP (without this: UTC + en-US = bot signal)
|
|
||||||
headless=False, # headed mode — some sites detect headless even with C++ patches
|
|
||||||
humanize=True, # human-like mouse, keyboard, scroll behavior
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const browser = await launch({
|
|
||||||
proxy: 'http://your-residential-proxy:port',
|
|
||||||
geoip: true,
|
|
||||||
headless: false,
|
|
||||||
humanize: true,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
If your proxy supports SOCKS5, use it for better compatibility — SOCKS5 tunnels raw TCP, avoiding HTTP CONNECT issues that some proxies have with HTTP/2:
|
|
||||||
|
|
||||||
```python
|
|
||||||
browser = launch(proxy="socks5://user:pass@proxy:1080", geoip=True, headless=False, humanize=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
If you're still blocked after this, the issue is almost always IP reputation — try a different proxy region or test from a home ISP to confirm the browser itself is clean.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -976,7 +910,7 @@ await ctx.close();
|
|||||||
ctx = await launchPersistentContext({ userDataDir: './profile' });
|
ctx = await launchPersistentContext({ userDataDir: './profile' });
|
||||||
```
|
```
|
||||||
|
|
||||||
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2. If your proxy supports SOCKS5, use `proxy="socks5://user:pass@host:port"` instead — SOCKS5 bypasses HTTP CONNECT entirely.
|
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1126,7 +1060,7 @@ All releases are signed for supply chain verification.
|
|||||||
```bash
|
```bash
|
||||||
# Verify GPG signature (binary release tag)
|
# Verify GPG signature (binary release tag)
|
||||||
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
|
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
|
||||||
git verify-tag chromium-v146.0.7680.177.3
|
git verify-tag chromium-v146.0.7680.177.2
|
||||||
|
|
||||||
# Verify GitHub binary attestation (Sigstore)
|
# Verify GitHub binary attestation (Sigstore)
|
||||||
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
|
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Usage:
|
|||||||
browser.close()
|
browser.close()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
||||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
@@ -32,7 +32,6 @@ __all__ = [
|
|||||||
"launch",
|
"launch",
|
||||||
"launch_async",
|
"launch_async",
|
||||||
"launch_context",
|
"launch_context",
|
||||||
"launch_context_async",
|
|
||||||
"launch_persistent_context",
|
"launch_persistent_context",
|
||||||
"launch_persistent_context_async",
|
"launch_persistent_context_async",
|
||||||
"ensure_binary",
|
"ensure_binary",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.25"
|
__version__ = "0.3.24"
|
||||||
|
|||||||
+15
-140
@@ -21,7 +21,6 @@ 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
|
|
||||||
|
|
||||||
logger = logging.getLogger("cloakbrowser")
|
logger = logging.getLogger("cloakbrowser")
|
||||||
|
|
||||||
@@ -61,8 +60,8 @@ def launch(
|
|||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: str = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||||
@@ -88,7 +87,7 @@ def launch(
|
|||||||
Override globally with CLOAKBROWSER_BACKEND env var.
|
Override globally with CLOAKBROWSER_BACKEND env var.
|
||||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -156,8 +155,8 @@ async def launch_async( # noqa: C901
|
|||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: str = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Async version of launch(). Returns a Playwright Browser object.
|
"""Async version of launch(). Returns a Playwright Browser object.
|
||||||
@@ -173,7 +172,7 @@ async def launch_async( # noqa: C901
|
|||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -250,8 +249,8 @@ def launch_persistent_context(
|
|||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: str = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||||
@@ -280,7 +279,7 @@ def launch_persistent_context(
|
|||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -374,8 +373,8 @@ async def launch_persistent_context_async(
|
|||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: str = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Async version of launch_persistent_context().
|
"""Async version of launch_persistent_context().
|
||||||
@@ -401,7 +400,7 @@ async def launch_persistent_context_async(
|
|||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -499,8 +498,8 @@ def launch_context(
|
|||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: str = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||||
@@ -524,7 +523,7 @@ def launch_context(
|
|||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed to browser.new_context().
|
**kwargs: Passed to browser.new_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -585,130 +584,6 @@ def launch_context(
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
async def launch_context_async(
|
|
||||||
headless: bool = True,
|
|
||||||
proxy: str | ProxySettings | None = None,
|
|
||||||
args: list[str] | None = None,
|
|
||||||
stealth_args: bool = True,
|
|
||||||
user_agent: str | None = None,
|
|
||||||
viewport: dict | None = _VIEWPORT_UNSET,
|
|
||||||
locale: str | None = None,
|
|
||||||
timezone: str | None = None,
|
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
|
||||||
geoip: bool = False,
|
|
||||||
backend: str | None = None,
|
|
||||||
humanize: bool = False,
|
|
||||||
human_preset: HumanPreset = "default",
|
|
||||||
human_config: HumanConfigOverrides | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Any:
|
|
||||||
"""Async version of launch_context().
|
|
||||||
|
|
||||||
Launch stealth browser and return a BrowserContext with common options pre-set.
|
|
||||||
All extra kwargs are forwarded to ``browser.new_context()`` — use this for
|
|
||||||
``storage_state``, ``permissions``, ``extra_http_headers``, etc. without needing
|
|
||||||
a persistent profile folder.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
headless: Run in headless mode (default True).
|
|
||||||
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
|
||||||
args: Additional Chromium CLI arguments.
|
|
||||||
stealth_args: Include default stealth fingerprint args (default True).
|
|
||||||
user_agent: Custom user agent string.
|
|
||||||
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
|
||||||
Pass None to disable viewport emulation (use OS window size).
|
|
||||||
locale: Browser locale, e.g. "en-US".
|
|
||||||
timezone: IANA timezone (e.g. 'America/New_York').
|
|
||||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
|
||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
|
||||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
|
||||||
**kwargs: Passed to browser.new_context() — e.g. storage_state, permissions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Playwright BrowserContext object (async API).
|
|
||||||
Call ``await .close()`` when done — this also closes the underlying browser.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> import asyncio
|
|
||||||
>>> from cloakbrowser import launch_context_async
|
|
||||||
>>>
|
|
||||||
>>> async def main():
|
|
||||||
... # Load saved session (cookies, localStorage)
|
|
||||||
... ctx = await launch_context_async(
|
|
||||||
... headless=True,
|
|
||||||
... storage_state="state.json",
|
|
||||||
... )
|
|
||||||
... page = await ctx.new_page()
|
|
||||||
... await page.goto("https://example.com")
|
|
||||||
... # Save state back
|
|
||||||
... await ctx.storage_state(path="state.json")
|
|
||||||
... await ctx.close()
|
|
||||||
>>>
|
|
||||||
>>> asyncio.run(main())
|
|
||||||
"""
|
|
||||||
timezone = _resolve_timezone(timezone, kwargs)
|
|
||||||
|
|
||||||
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
|
|
||||||
# resolved values flow to binary flags
|
|
||||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
|
||||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
|
||||||
args = list(args or [])
|
|
||||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
|
||||||
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
|
||||||
# so it applies to ALL contexts, not just the default one.
|
|
||||||
# locale and timezone are set via binary flags only — no CDP emulation.
|
|
||||||
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
|
||||||
timezone=timezone, locale=locale, backend=backend)
|
|
||||||
|
|
||||||
context_kwargs: dict[str, Any] = {}
|
|
||||||
if user_agent:
|
|
||||||
context_kwargs["user_agent"] = user_agent
|
|
||||||
if viewport is _VIEWPORT_UNSET:
|
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
|
||||||
context_kwargs.update(kwargs)
|
|
||||||
|
|
||||||
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
|
||||||
# triggers browser cleanup — otherwise the underlying Chromium process
|
|
||||||
# leaks when the awaiting task is cancelled.
|
|
||||||
try:
|
|
||||||
context = await browser.new_context(**context_kwargs)
|
|
||||||
except BaseException:
|
|
||||||
try:
|
|
||||||
await browser.close()
|
|
||||||
except BaseException:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Patch close() to also close the browser (and its Playwright instance)
|
|
||||||
_original_ctx_close = context.close
|
|
||||||
|
|
||||||
async def _close_context_with_cleanup() -> None:
|
|
||||||
try:
|
|
||||||
await _original_ctx_close()
|
|
||||||
finally:
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
context.close = _close_context_with_cleanup
|
|
||||||
|
|
||||||
# Human-like behavioral patching (async variant)
|
|
||||||
if humanize:
|
|
||||||
from .human import patch_context_async
|
|
||||||
from .human.config import resolve_config
|
|
||||||
cfg = resolve_config(human_preset, human_config)
|
|
||||||
patch_context_async(context, cfg)
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Backend resolution
|
# Backend resolution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ from ._version import __version__
|
|||||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||||
# Use get_chromium_version() for the current platform's actual version.
|
# Use get_chromium_version() for the current platform's actual version.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
CHROMIUM_VERSION = "146.0.7680.177.3"
|
CHROMIUM_VERSION = "146.0.7680.177.1"
|
||||||
|
|
||||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||||
"linux-x64": "146.0.7680.177.3",
|
"linux-x64": "146.0.7680.177.2",
|
||||||
"linux-arm64": "146.0.7680.177.3",
|
"linux-arm64": "146.0.7680.177.2",
|
||||||
"darwin-arm64": "145.0.7632.109.2",
|
"darwin-arm64": "145.0.7632.109.2",
|
||||||
"darwin-x64": "145.0.7632.109.2",
|
"darwin-x64": "145.0.7632.109.2",
|
||||||
"windows-x64": "145.0.7632.159.7",
|
"windows-x64": "145.0.7632.159.7",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import math
|
|||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal, Tuple, TypedDict
|
from typing import Literal, Tuple
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Type alias
|
# Type alias
|
||||||
@@ -20,50 +20,6 @@ Range = Tuple[float, float]
|
|||||||
HumanPreset = Literal["default", "careful"]
|
HumanPreset = Literal["default", "careful"]
|
||||||
|
|
||||||
|
|
||||||
class HumanConfigOverrides(TypedDict, total=False):
|
|
||||||
typing_delay: float
|
|
||||||
typing_delay_spread: float
|
|
||||||
typing_pause_chance: float
|
|
||||||
typing_pause_range: Range
|
|
||||||
shift_down_delay: Range
|
|
||||||
shift_up_delay: Range
|
|
||||||
key_hold: Range
|
|
||||||
field_switch_delay: Range
|
|
||||||
mistype_chance: float
|
|
||||||
mistype_delay_notice: Range
|
|
||||||
mistype_delay_correct: Range
|
|
||||||
mouse_steps_divisor: float
|
|
||||||
mouse_min_steps: int
|
|
||||||
mouse_max_steps: int
|
|
||||||
mouse_wobble_max: float
|
|
||||||
mouse_overshoot_chance: float
|
|
||||||
mouse_overshoot_px: Range
|
|
||||||
mouse_burst_size: Range
|
|
||||||
mouse_burst_pause: Range
|
|
||||||
click_aim_delay_input: Range
|
|
||||||
click_aim_delay_button: Range
|
|
||||||
click_hold_input: Range
|
|
||||||
click_hold_button: Range
|
|
||||||
click_input_x_range: Range
|
|
||||||
idle_drift_px: float
|
|
||||||
idle_pause_range: Range
|
|
||||||
scroll_delta_base: Range
|
|
||||||
scroll_delta_variance: float
|
|
||||||
scroll_pause_fast: Range
|
|
||||||
scroll_pause_slow: Range
|
|
||||||
scroll_accel_steps: Range
|
|
||||||
scroll_decel_steps: Range
|
|
||||||
scroll_overshoot_chance: float
|
|
||||||
scroll_overshoot_px: Range
|
|
||||||
scroll_settle_delay: Range
|
|
||||||
scroll_target_zone: Range
|
|
||||||
scroll_pre_move_delay: Range
|
|
||||||
initial_cursor_x: Range
|
|
||||||
initial_cursor_y: Range
|
|
||||||
idle_between_actions: bool
|
|
||||||
idle_between_duration: Range
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration dataclass
|
# Configuration dataclass
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -174,13 +130,13 @@ _PRESETS: dict[str, HumanConfig] = {
|
|||||||
|
|
||||||
def resolve_config(
|
def resolve_config(
|
||||||
preset: HumanPreset = "default",
|
preset: HumanPreset = "default",
|
||||||
overrides: HumanConfigOverrides | None = None,
|
overrides: dict | None = None,
|
||||||
) -> HumanConfig:
|
) -> HumanConfig:
|
||||||
"""Resolve a preset name + optional overrides into a full HumanConfig.
|
"""Resolve a preset name + optional overrides into a full HumanConfig.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
preset: 'default' or 'careful'.
|
preset: 'default' or 'careful'.
|
||||||
overrides: Typed mapping of HumanConfig field names to override values.
|
overrides: Dict of field names to override values.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A new HumanConfig instance.
|
A new HumanConfig instance.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.25",
|
"version": "0.3.24",
|
||||||
"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",
|
||||||
|
|||||||
+3
-3
@@ -27,11 +27,11 @@ export { WRAPPER_VERSION };
|
|||||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||||
// Use getChromiumVersion() for the current platform's actual version.
|
// Use getChromiumVersion() for the current platform's actual version.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const CHROMIUM_VERSION = "146.0.7680.177.3";
|
export const CHROMIUM_VERSION = "146.0.7680.177.1";
|
||||||
|
|
||||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||||
"linux-x64": "146.0.7680.177.3",
|
"linux-x64": "146.0.7680.177.2",
|
||||||
"linux-arm64": "146.0.7680.177.3",
|
"linux-arm64": "146.0.7680.177.2",
|
||||||
"darwin-arm64": "145.0.7632.109.2",
|
"darwin-arm64": "145.0.7632.109.2",
|
||||||
"darwin-x64": "145.0.7632.109.2",
|
"darwin-x64": "145.0.7632.109.2",
|
||||||
"windows-x64": "145.0.7632.159.7",
|
"windows-x64": "145.0.7632.159.7",
|
||||||
|
|||||||
+7
-36
@@ -3,7 +3,7 @@
|
|||||||
* Mirrors Python cloakbrowser/browser.py.
|
* Mirrors Python cloakbrowser/browser.py.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core";
|
import type { Browser, BrowserContext } from "playwright-core";
|
||||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||||
import { buildArgs } from "./args.js";
|
import { buildArgs } from "./args.js";
|
||||||
@@ -21,29 +21,6 @@ export function resolveTimezone<T extends { timezone?: string; timezoneId?: stri
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip `locale` and `timezoneId` from user-provided contextOptions — both route
|
|
||||||
* through detectable CDP emulation. The wrapper's top-level `locale`/`timezone`
|
|
||||||
* fields use binary flags instead (undetectable). Warn so users notice.
|
|
||||||
*/
|
|
||||||
function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserContextOptions> {
|
|
||||||
if (!ctx) return {};
|
|
||||||
const { locale, timezoneId, ...rest } = ctx;
|
|
||||||
if (locale !== undefined) {
|
|
||||||
console.warn(
|
|
||||||
"[cloakbrowser] contextOptions.locale ignored — use top-level `locale` " +
|
|
||||||
"instead (routes through binary flag, avoids detectable CDP emulation)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (timezoneId !== undefined) {
|
|
||||||
console.warn(
|
|
||||||
"[cloakbrowser] contextOptions.timezoneId ignored — use top-level `timezone` " +
|
|
||||||
"instead (routes through binary flag, avoids detectable CDP emulation)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch stealth Chromium browser via Playwright.
|
* Launch stealth Chromium browser via Playwright.
|
||||||
*
|
*
|
||||||
@@ -83,8 +60,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
const { patchBrowser } = await import('./human/index.js');
|
const { patchBrowser } = await import('./human/index.js');
|
||||||
const { resolveConfig } = await import('./human/config.js');
|
const { resolveConfig } = await import('./human/config.js');
|
||||||
const cfg = resolveConfig(
|
const cfg = resolveConfig(
|
||||||
options.humanPreset ?? 'default',
|
(options.humanPreset as any) ?? 'default',
|
||||||
options.humanConfig,
|
options.humanConfig as any,
|
||||||
);
|
);
|
||||||
patchBrowser(browser, cfg);
|
patchBrowser(browser, cfg);
|
||||||
}
|
}
|
||||||
@@ -127,9 +104,6 @@ export async function launchContext(
|
|||||||
let context: BrowserContext;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
context = await browser.newContext({
|
context = await browser.newContext({
|
||||||
// contextOptions first — explicit wrapper fields below override it.
|
|
||||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
|
||||||
...filterStealthCtxOptions(options.contextOptions),
|
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
@@ -151,8 +125,8 @@ export async function launchContext(
|
|||||||
const { patchContext } = await import('./human/index.js');
|
const { patchContext } = await import('./human/index.js');
|
||||||
const { resolveConfig } = await import('./human/config.js');
|
const { resolveConfig } = await import('./human/config.js');
|
||||||
const cfg = resolveConfig(
|
const cfg = resolveConfig(
|
||||||
options.humanPreset ?? 'default',
|
(options.humanPreset as any) ?? 'default',
|
||||||
options.humanConfig,
|
options.humanConfig as any,
|
||||||
);
|
);
|
||||||
patchContext(context, cfg);
|
patchContext(context, cfg);
|
||||||
}
|
}
|
||||||
@@ -204,9 +178,6 @@ 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.
|
|
||||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
|
||||||
...filterStealthCtxOptions(options.contextOptions),
|
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
@@ -218,8 +189,8 @@ export async function launchPersistentContext(
|
|||||||
const { patchContext } = await import('./human/index.js');
|
const { patchContext } = await import('./human/index.js');
|
||||||
const { resolveConfig } = await import('./human/config.js');
|
const { resolveConfig } = await import('./human/config.js');
|
||||||
const cfg = resolveConfig(
|
const cfg = resolveConfig(
|
||||||
options.humanPreset ?? 'default',
|
(options.humanPreset as any) ?? 'default',
|
||||||
options.humanConfig,
|
options.humanConfig as any,
|
||||||
);
|
);
|
||||||
patchContext(context, cfg);
|
patchContext(context, cfg);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -94,8 +94,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
const { patchBrowser } = await import('./human-puppeteer/index.js');
|
const { patchBrowser } = await import('./human-puppeteer/index.js');
|
||||||
const { resolveConfig } = await import('./human/config.js');
|
const { resolveConfig } = await import('./human/config.js');
|
||||||
const cfg = resolveConfig(
|
const cfg = resolveConfig(
|
||||||
options.humanPreset ?? 'default',
|
(options.humanPreset as any) ?? 'default',
|
||||||
options.humanConfig,
|
options.humanConfig as any,
|
||||||
);
|
);
|
||||||
patchBrowser(browser, cfg);
|
patchBrowser(browser, cfg);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-14
@@ -2,9 +2,6 @@
|
|||||||
* Shared types for cloakbrowser launch wrappers.
|
* Shared types for cloakbrowser launch wrappers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from "playwright-core";
|
|
||||||
import type { HumanConfig, HumanPreset } from "./human/config.js";
|
|
||||||
|
|
||||||
export interface LaunchOptions {
|
export interface LaunchOptions {
|
||||||
/** Run in headless mode (default: true). */
|
/** Run in headless mode (default: true). */
|
||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
@@ -30,9 +27,9 @@ export interface LaunchOptions {
|
|||||||
/** Enable human-like mouse, keyboard, and scroll behavior. */
|
/** Enable human-like mouse, keyboard, and scroll behavior. */
|
||||||
humanize?: boolean;
|
humanize?: boolean;
|
||||||
/** Human behavior preset: 'default' or 'careful'. */
|
/** Human behavior preset: 'default' or 'careful'. */
|
||||||
humanPreset?: HumanPreset;
|
humanPreset?: 'default' | 'careful';
|
||||||
/** Override individual human behavior parameters. */
|
/** Override individual human behavior parameters. */
|
||||||
humanConfig?: Partial<HumanConfig>;
|
humanConfig?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchContextOptions extends LaunchOptions {
|
export interface LaunchContextOptions extends LaunchOptions {
|
||||||
@@ -46,15 +43,6 @@ export interface LaunchContextOptions extends LaunchOptions {
|
|||||||
timezoneId?: string;
|
timezoneId?: string;
|
||||||
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
||||||
colorScheme?: "light" | "dark" | "no-preference";
|
colorScheme?: "light" | "dark" | "no-preference";
|
||||||
/**
|
|
||||||
* Extra options forwarded directly to Playwright's `browser.newContext()` —
|
|
||||||
* e.g. `storageState`, `permissions`, `geolocation`, `extraHTTPHeaders`,
|
|
||||||
* `httpCredentials`. Use this for context-level options not surfaced as
|
|
||||||
* top-level fields. `locale` and `timezoneId` are stripped here to avoid
|
|
||||||
* detectable CDP emulation — use the top-level `locale` and `timezone`
|
|
||||||
* wrapper fields instead (they route through undetectable binary flags).
|
|
||||||
*/
|
|
||||||
contextOptions?: BrowserContextOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||||
|
|||||||
@@ -130,60 +130,6 @@ describe("launchContext (unit)", () => {
|
|||||||
// Browser also closed
|
// Browser also closed
|
||||||
expect(mockBrowser.close).toHaveBeenCalledOnce();
|
expect(mockBrowser.close).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards contextOptions to newContext (storageState, etc.)", async () => {
|
|
||||||
const { launchContext } = await import("../src/playwright.js");
|
|
||||||
await launchContext({
|
|
||||||
contextOptions: {
|
|
||||||
storageState: "state.json",
|
|
||||||
permissions: ["geolocation"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
|
||||||
expect(ctxArgs.storageState).toBe("state.json");
|
|
||||||
expect(ctxArgs.permissions).toEqual(["geolocation"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("explicit top-level fields win over contextOptions on collision", async () => {
|
|
||||||
const { launchContext } = await import("../src/playwright.js");
|
|
||||||
await launchContext({
|
|
||||||
userAgent: "Explicit/1.0",
|
|
||||||
viewport: { width: 1280, height: 720 },
|
|
||||||
colorScheme: "dark",
|
|
||||||
contextOptions: {
|
|
||||||
userAgent: "ShouldBeOverridden/9.9",
|
|
||||||
viewport: { width: 9999, height: 9999 },
|
|
||||||
colorScheme: "light",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
|
||||||
expect(ctxArgs.userAgent).toBe("Explicit/1.0");
|
|
||||||
expect(ctxArgs.viewport).toEqual({ width: 1280, height: 720 });
|
|
||||||
expect(ctxArgs.colorScheme).toBe("dark");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips locale and timezoneId from contextOptions (stealth-sensitive)", async () => {
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
const { launchContext } = await import("../src/playwright.js");
|
|
||||||
await launchContext({
|
|
||||||
contextOptions: {
|
|
||||||
storageState: "state.json",
|
|
||||||
locale: "de-DE",
|
|
||||||
timezoneId: "Europe/Berlin",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
|
||||||
// Stealth-sensitive keys stripped — they would reintroduce detectable CDP emulation.
|
|
||||||
expect(ctxArgs.locale).toBeUndefined();
|
|
||||||
expect(ctxArgs.timezoneId).toBeUndefined();
|
|
||||||
// Benign keys preserved
|
|
||||||
expect(ctxArgs.storageState).toBe("state.json");
|
|
||||||
// Warning was logged for both stripped keys
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("launchPersistentContext (unit)", () => {
|
describe("launchPersistentContext (unit)", () => {
|
||||||
@@ -261,53 +207,4 @@ describe("launchPersistentContext (unit)", () => {
|
|||||||
expect(args.userAgent).toBe("Custom/1.0");
|
expect(args.userAgent).toBe("Custom/1.0");
|
||||||
expect(args.colorScheme).toBe("dark");
|
expect(args.colorScheme).toBe("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards contextOptions to launchPersistentContext", async () => {
|
|
||||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
|
||||||
await launchPersistentContext({
|
|
||||||
userDataDir: "/tmp/profile",
|
|
||||||
contextOptions: {
|
|
||||||
permissions: ["geolocation"],
|
|
||||||
extraHTTPHeaders: { "X-Custom": "1" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
|
||||||
expect(args.permissions).toEqual(["geolocation"]);
|
|
||||||
expect(args.extraHTTPHeaders).toEqual({ "X-Custom": "1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("explicit top-level fields win over contextOptions in persistent context", async () => {
|
|
||||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
|
||||||
await launchPersistentContext({
|
|
||||||
userDataDir: "/tmp/profile",
|
|
||||||
userAgent: "Explicit/1.0",
|
|
||||||
viewport: { width: 1280, height: 720 },
|
|
||||||
contextOptions: {
|
|
||||||
userAgent: "ShouldBeOverridden/9.9",
|
|
||||||
viewport: { width: 9999, height: 9999 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
|
||||||
expect(args.userAgent).toBe("Explicit/1.0");
|
|
||||||
expect(args.viewport).toEqual({ width: 1280, height: 720 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips locale and timezoneId from contextOptions (persistent context)", async () => {
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
|
||||||
await launchPersistentContext({
|
|
||||||
userDataDir: "/tmp/profile",
|
|
||||||
contextOptions: {
|
|
||||||
locale: "de-DE",
|
|
||||||
timezoneId: "Europe/Berlin",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
|
||||||
expect(args.locale).toBeUndefined();
|
|
||||||
expect(args.timezoneId).toBeUndefined();
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
|
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -207,125 +207,3 @@ def test_kwargs_passthrough(mock_launch, _mock_bin):
|
|||||||
# Verify kwarg did NOT leak to launch()
|
# Verify kwarg did NOT leak to launch()
|
||||||
launch_kwargs = mock_launch.call_args[1]
|
launch_kwargs = mock_launch.call_args[1]
|
||||||
assert "record_video_dir" not in launch_kwargs
|
assert "record_video_dir" not in launch_kwargs
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Async: launch_context_async()
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_async_browser():
|
|
||||||
"""Create a mock async browser whose new_context() returns a mock context."""
|
|
||||||
browser = AsyncMock()
|
|
||||||
context = AsyncMock()
|
|
||||||
browser.new_context.return_value = context
|
|
||||||
return browser, context
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
||||||
@patch("cloakbrowser.browser.launch_async")
|
|
||||||
async def test_async_storage_state_forwarded(mock_launch_async, _mock_bin):
|
|
||||||
"""storage_state kwarg forwarded to browser.new_context() in async path.
|
|
||||||
|
|
||||||
This is the motivating use case from issue #141.
|
|
||||||
"""
|
|
||||||
browser, context = _make_mock_async_browser()
|
|
||||||
mock_launch_async.return_value = browser
|
|
||||||
|
|
||||||
from cloakbrowser.browser import launch_context_async
|
|
||||||
await launch_context_async(storage_state="state.json")
|
|
||||||
|
|
||||||
ctx_kwargs = browser.new_context.call_args
|
|
||||||
assert ctx_kwargs[1]["storage_state"] == "state.json"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
||||||
@patch("cloakbrowser.browser.launch_async")
|
|
||||||
async def test_async_default_viewport(mock_launch_async, _mock_bin):
|
|
||||||
"""DEFAULT_VIEWPORT applied when no viewport given (async)."""
|
|
||||||
browser, context = _make_mock_async_browser()
|
|
||||||
mock_launch_async.return_value = browser
|
|
||||||
|
|
||||||
from cloakbrowser.browser import launch_context_async
|
|
||||||
await launch_context_async()
|
|
||||||
|
|
||||||
ctx_kwargs = browser.new_context.call_args
|
|
||||||
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
||||||
@patch("cloakbrowser.browser.launch_async")
|
|
||||||
async def test_async_locale_flows_to_binary_not_cdp(mock_launch_async, _mock_bin):
|
|
||||||
"""locale flows to launch_async() for --lang flag, NOT to new_context() CDP."""
|
|
||||||
browser, context = _make_mock_async_browser()
|
|
||||||
mock_launch_async.return_value = browser
|
|
||||||
|
|
||||||
from cloakbrowser.browser import launch_context_async
|
|
||||||
await launch_context_async(locale="de-DE", timezone="Europe/Berlin")
|
|
||||||
|
|
||||||
# Binary flags
|
|
||||||
assert mock_launch_async.call_args[1]["locale"] == "de-DE"
|
|
||||||
assert mock_launch_async.call_args[1]["timezone"] == "Europe/Berlin"
|
|
||||||
# Not in context — would trigger detectable CDP emulation
|
|
||||||
ctx_kwargs = browser.new_context.call_args
|
|
||||||
assert "locale" not in ctx_kwargs[1]
|
|
||||||
assert "timezone_id" not in ctx_kwargs[1]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
||||||
@patch("cloakbrowser.browser.launch_async")
|
|
||||||
async def test_async_close_closes_browser(mock_launch_async, _mock_bin):
|
|
||||||
"""await ctx.close() also closes the underlying browser."""
|
|
||||||
browser, context = _make_mock_async_browser()
|
|
||||||
original_ctx_close = context.close
|
|
||||||
mock_launch_async.return_value = browser
|
|
||||||
|
|
||||||
from cloakbrowser.browser import launch_context_async
|
|
||||||
ctx = await launch_context_async()
|
|
||||||
|
|
||||||
await ctx.close()
|
|
||||||
original_ctx_close.assert_called_once()
|
|
||||||
browser.close.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
||||||
@patch("cloakbrowser.browser.launch_async")
|
|
||||||
async def test_async_error_closes_browser(mock_launch_async, _mock_bin):
|
|
||||||
"""If new_context() raises in async path, browser is still closed."""
|
|
||||||
browser = AsyncMock()
|
|
||||||
browser.new_context.side_effect = RuntimeError("context creation failed")
|
|
||||||
mock_launch_async.return_value = browser
|
|
||||||
|
|
||||||
from cloakbrowser.browser import launch_context_async
|
|
||||||
with pytest.raises(RuntimeError, match="context creation failed"):
|
|
||||||
await launch_context_async()
|
|
||||||
|
|
||||||
browser.close.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
|
||||||
@patch("cloakbrowser.browser.launch_async")
|
|
||||||
async def test_async_cancellation_closes_browser(mock_launch_async, _mock_bin):
|
|
||||||
"""asyncio.CancelledError during new_context() still closes browser.
|
|
||||||
|
|
||||||
CancelledError derives from BaseException (not Exception) in Python 3.8+,
|
|
||||||
so the cleanup must catch BaseException to prevent browser process leaks
|
|
||||||
when the awaiting task is cancelled.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
browser = AsyncMock()
|
|
||||||
browser.new_context.side_effect = asyncio.CancelledError()
|
|
||||||
mock_launch_async.return_value = browser
|
|
||||||
|
|
||||||
from cloakbrowser.browser import launch_context_async
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await launch_context_async()
|
|
||||||
|
|
||||||
browser.close.assert_called_once()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user