Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a32d7c4eae |
@@ -514,6 +514,33 @@ Access the original un-patched Playwright page at `page._original` if you need r
|
||||
|
||||
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
|
||||
|
||||
## Stealth Evaluate
|
||||
|
||||
`page.stealth_evaluate(expression)` runs JavaScript in a CDP isolated world instead of Playwright's main-world `evaluate()`. This produces clean `Error.stack` traces and full variable isolation from page JS — useful when a site's anti-bot scripts inspect execution context.
|
||||
|
||||
```python
|
||||
browser = launch()
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
|
||||
# Stealth — clean stack trace, invisible to page JS
|
||||
title = page.stealth_evaluate("document.title")
|
||||
rect = page.stealth_evaluate("document.querySelector('#btn').getBoundingClientRect().toJSON()")
|
||||
|
||||
# Regular evaluate — unchanged, use for DOM writes
|
||||
page.evaluate("document.body.style.display = 'none'")
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://example.com');
|
||||
|
||||
const title = await page.stealthEvaluate('document.title');
|
||||
```
|
||||
|
||||
Always available on every page — no flag needed. Returns JSON-serializable values only. The isolated world context auto-recreates after navigation.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env Variable | Default | Description |
|
||||
|
||||
@@ -138,6 +138,10 @@ def launch(
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_browser(browser, cfg)
|
||||
|
||||
# Stealth evaluate — always attached
|
||||
from .stealth_eval import patch_browser_stealth_eval
|
||||
patch_browser_stealth_eval(browser, is_async=False)
|
||||
|
||||
return browser
|
||||
|
||||
|
||||
@@ -227,6 +231,10 @@ async def launch_async( # noqa: C901
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_browser_async(browser, cfg)
|
||||
|
||||
# Stealth evaluate — always attached
|
||||
from .stealth_eval import patch_browser_stealth_eval
|
||||
patch_browser_stealth_eval(browser, is_async=True)
|
||||
|
||||
return browser
|
||||
|
||||
|
||||
@@ -344,6 +352,10 @@ def launch_persistent_context(
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_context(context, cfg)
|
||||
|
||||
# Stealth evaluate — always attached
|
||||
from .stealth_eval import patch_context_stealth_eval
|
||||
patch_context_stealth_eval(context, is_async=False)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -463,6 +475,10 @@ async def launch_persistent_context_async(
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_context_async(context, cfg)
|
||||
|
||||
# Stealth evaluate — always attached
|
||||
from .stealth_eval import patch_context_stealth_eval
|
||||
patch_context_stealth_eval(context, is_async=True)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -556,6 +572,10 @@ def launch_context(
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_context(context, cfg)
|
||||
|
||||
# Stealth evaluate — always attached
|
||||
from .stealth_eval import patch_context_stealth_eval
|
||||
patch_context_stealth_eval(context, is_async=False)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
+13
-141
@@ -26,6 +26,7 @@ from .scroll import scroll_to_element
|
||||
from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle
|
||||
from .keyboard_async import AsyncRawKeyboard, async_human_type
|
||||
from .scroll_async import async_scroll_to_element
|
||||
from ..stealth_eval import _SyncIsolatedWorld, _AsyncIsolatedWorld
|
||||
|
||||
_SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a"
|
||||
|
||||
@@ -44,142 +45,9 @@ logger = logging.getLogger("cloakbrowser.human")
|
||||
# CDP Isolated World — stealth DOM evaluation
|
||||
# ============================================================================
|
||||
|
||||
class _SyncIsolatedWorld:
|
||||
"""Manages a CDP isolated execution context for DOM reads (sync).
|
||||
|
||||
Produces clean Error.stack traces (no 'eval at evaluate :302:')
|
||||
and is invisible to querySelector monkey-patches in the main world.
|
||||
Context ID is invalidated on navigation and auto-recreated on next call.
|
||||
"""
|
||||
|
||||
__slots__ = ("_page", "_cdp", "_context_id")
|
||||
|
||||
def __init__(self, page: Any):
|
||||
self._page = page
|
||||
self._cdp: Any = None
|
||||
self._context_id: Optional[int] = None
|
||||
|
||||
def _ensure_cdp(self) -> Any:
|
||||
if self._cdp is None:
|
||||
self._cdp = self._page.context.new_cdp_session(self._page)
|
||||
return self._cdp
|
||||
|
||||
def _create_world(self) -> int:
|
||||
cdp = self._ensure_cdp()
|
||||
tree = cdp.send("Page.getFrameTree")
|
||||
frame_id = tree["frameTree"]["frame"]["id"]
|
||||
result = cdp.send("Page.createIsolatedWorld", {
|
||||
"frameId": frame_id,
|
||||
"worldName": "",
|
||||
"grantUniveralAccess": True,
|
||||
})
|
||||
self._context_id = result["executionContextId"]
|
||||
return self._context_id
|
||||
|
||||
def evaluate(self, expression: str) -> Any:
|
||||
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||
if self._context_id is None:
|
||||
self._create_world()
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = self._cdp.send("Runtime.evaluate", {
|
||||
"expression": expression,
|
||||
"contextId": self._context_id,
|
||||
"returnByValue": True,
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
if attempt == 0:
|
||||
self._create_world()
|
||||
continue
|
||||
return None
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception:
|
||||
if attempt == 0:
|
||||
self._context_id = None
|
||||
try:
|
||||
self._create_world()
|
||||
except Exception:
|
||||
return None
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Mark context as stale — call after navigation."""
|
||||
self._context_id = None
|
||||
|
||||
def get_cdp_session(self) -> Any:
|
||||
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||
return self._ensure_cdp()
|
||||
|
||||
|
||||
class _AsyncIsolatedWorld:
|
||||
"""Manages a CDP isolated execution context for DOM reads (async).
|
||||
|
||||
Same as _SyncIsolatedWorld but uses await for all CDP calls.
|
||||
"""
|
||||
|
||||
__slots__ = ("_page", "_cdp", "_context_id")
|
||||
|
||||
def __init__(self, page: Any):
|
||||
self._page = page
|
||||
self._cdp: Any = None
|
||||
self._context_id: Optional[int] = None
|
||||
|
||||
async def _ensure_cdp(self) -> Any:
|
||||
if self._cdp is None:
|
||||
self._cdp = await self._page.context.new_cdp_session(self._page)
|
||||
return self._cdp
|
||||
|
||||
async def _create_world(self) -> int:
|
||||
cdp = await self._ensure_cdp()
|
||||
tree = await cdp.send("Page.getFrameTree")
|
||||
frame_id = tree["frameTree"]["frame"]["id"]
|
||||
result = await cdp.send("Page.createIsolatedWorld", {
|
||||
"frameId": frame_id,
|
||||
"worldName": "",
|
||||
"grantUniveralAccess": True,
|
||||
})
|
||||
self._context_id = result["executionContextId"]
|
||||
return self._context_id
|
||||
|
||||
async def evaluate(self, expression: str) -> Any:
|
||||
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||
if self._context_id is None:
|
||||
await self._create_world()
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = await self._cdp.send("Runtime.evaluate", {
|
||||
"expression": expression,
|
||||
"contextId": self._context_id,
|
||||
"returnByValue": True,
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
if attempt == 0:
|
||||
await self._create_world()
|
||||
continue
|
||||
return None
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception:
|
||||
if attempt == 0:
|
||||
self._context_id = None
|
||||
try:
|
||||
await self._create_world()
|
||||
except Exception:
|
||||
return None
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Mark context as stale — call after navigation."""
|
||||
self._context_id = None
|
||||
|
||||
async def get_cdp_session(self) -> Any:
|
||||
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||
return await self._ensure_cdp()
|
||||
# _SyncIsolatedWorld and _AsyncIsolatedWorld are defined in
|
||||
# cloakbrowser.stealth_eval and imported at the top of this file.
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -739,10 +607,12 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
|
||||
# --- Stealth infrastructure ---
|
||||
# --- Stealth infrastructure (reuse if already attached by stealth_eval) ---
|
||||
try:
|
||||
stealth = _SyncIsolatedWorld(page)
|
||||
page._stealth_world = stealth
|
||||
stealth = getattr(page, '_stealth_world', None)
|
||||
if not isinstance(stealth, _SyncIsolatedWorld):
|
||||
stealth = _SyncIsolatedWorld(page)
|
||||
page._stealth_world = stealth
|
||||
cdp_session = stealth.get_cdp_session()
|
||||
except Exception:
|
||||
stealth = None
|
||||
@@ -1104,9 +974,11 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
|
||||
# --- Stealth infrastructure (lazy-initialized, async) ---
|
||||
stealth = _AsyncIsolatedWorld(page)
|
||||
page._stealth_world = stealth
|
||||
# --- Stealth infrastructure (reuse if already attached by stealth_eval) ---
|
||||
stealth = getattr(page, '_stealth_world', None)
|
||||
if not isinstance(stealth, _AsyncIsolatedWorld):
|
||||
stealth = _AsyncIsolatedWorld(page)
|
||||
page._stealth_world = stealth
|
||||
cdp_session_holder: list[Any] = [None] # mutable container for closure
|
||||
|
||||
async def _ensure_cdp() -> Any:
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Stealth evaluate — run JS in a CDP isolated world.
|
||||
|
||||
Provides page.stealth_evaluate(expression) on every page returned by
|
||||
cloakbrowser launch functions. Produces clean Error.stack traces (no
|
||||
``eval at evaluate :302:`` leak) and full variable isolation from main
|
||||
world JS. Context auto-recreates after navigation.
|
||||
|
||||
The same isolated world instances are reused by the humanize layer
|
||||
(human/__init__.py) for stealth DOM queries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("cloakbrowser.stealth_eval")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Isolated world classes
|
||||
# ============================================================================
|
||||
|
||||
class _SyncIsolatedWorld:
|
||||
"""CDP isolated execution context for DOM reads (sync).
|
||||
|
||||
Produces clean Error.stack traces and is invisible to
|
||||
querySelector monkey-patches in the main world.
|
||||
Context ID is invalidated on navigation and auto-recreated.
|
||||
"""
|
||||
|
||||
__slots__ = ("_page", "_cdp", "_context_id")
|
||||
|
||||
def __init__(self, page: Any):
|
||||
self._page = page
|
||||
self._cdp: Any = None
|
||||
self._context_id: Optional[int] = None
|
||||
|
||||
def _ensure_cdp(self) -> Any:
|
||||
if self._cdp is None:
|
||||
self._cdp = self._page.context.new_cdp_session(self._page)
|
||||
return self._cdp
|
||||
|
||||
def _create_world(self) -> int:
|
||||
cdp = self._ensure_cdp()
|
||||
tree = cdp.send("Page.getFrameTree")
|
||||
frame_id = tree["frameTree"]["frame"]["id"]
|
||||
result = cdp.send("Page.createIsolatedWorld", {
|
||||
"frameId": frame_id,
|
||||
"worldName": "",
|
||||
"grantUniveralAccess": True,
|
||||
})
|
||||
self._context_id = result["executionContextId"]
|
||||
return self._context_id
|
||||
|
||||
def evaluate(self, expression: str) -> Any:
|
||||
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||
if self._context_id is None:
|
||||
try:
|
||||
self._create_world()
|
||||
except Exception:
|
||||
logger.debug("stealth_evaluate: failed to create isolated world")
|
||||
return None
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = self._cdp.send("Runtime.evaluate", {
|
||||
"expression": expression,
|
||||
"contextId": self._context_id,
|
||||
"returnByValue": True,
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
if attempt == 0:
|
||||
self._create_world()
|
||||
continue
|
||||
logger.debug("stealth_evaluate: JS exception: %s",
|
||||
result["exceptionDetails"].get("text", "unknown"))
|
||||
return None
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception:
|
||||
if attempt == 0:
|
||||
self._context_id = None
|
||||
try:
|
||||
self._create_world()
|
||||
except Exception:
|
||||
logger.debug("stealth_evaluate: failed to recreate isolated world")
|
||||
return None
|
||||
continue
|
||||
logger.debug("stealth_evaluate: CDP evaluate failed after retry")
|
||||
return None
|
||||
return None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Mark context as stale — call after navigation."""
|
||||
self._context_id = None
|
||||
|
||||
def get_cdp_session(self) -> Any:
|
||||
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||
return self._ensure_cdp()
|
||||
|
||||
|
||||
class _AsyncIsolatedWorld:
|
||||
"""CDP isolated execution context for DOM reads (async).
|
||||
|
||||
Same as _SyncIsolatedWorld but uses await for all CDP calls.
|
||||
"""
|
||||
|
||||
__slots__ = ("_page", "_cdp", "_context_id")
|
||||
|
||||
def __init__(self, page: Any):
|
||||
self._page = page
|
||||
self._cdp: Any = None
|
||||
self._context_id: Optional[int] = None
|
||||
|
||||
async def _ensure_cdp(self) -> Any:
|
||||
if self._cdp is None:
|
||||
self._cdp = await self._page.context.new_cdp_session(self._page)
|
||||
return self._cdp
|
||||
|
||||
async def _create_world(self) -> int:
|
||||
cdp = await self._ensure_cdp()
|
||||
tree = await cdp.send("Page.getFrameTree")
|
||||
frame_id = tree["frameTree"]["frame"]["id"]
|
||||
result = await cdp.send("Page.createIsolatedWorld", {
|
||||
"frameId": frame_id,
|
||||
"worldName": "",
|
||||
"grantUniveralAccess": True,
|
||||
})
|
||||
self._context_id = result["executionContextId"]
|
||||
return self._context_id
|
||||
|
||||
async def evaluate(self, expression: str) -> Any:
|
||||
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
|
||||
if self._context_id is None:
|
||||
try:
|
||||
await self._create_world()
|
||||
except Exception:
|
||||
logger.debug("stealth_evaluate: failed to create isolated world")
|
||||
return None
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = await self._cdp.send("Runtime.evaluate", {
|
||||
"expression": expression,
|
||||
"contextId": self._context_id,
|
||||
"returnByValue": True,
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
if attempt == 0:
|
||||
await self._create_world()
|
||||
continue
|
||||
logger.debug("stealth_evaluate: JS exception: %s",
|
||||
result["exceptionDetails"].get("text", "unknown"))
|
||||
return None
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception:
|
||||
if attempt == 0:
|
||||
self._context_id = None
|
||||
try:
|
||||
await self._create_world()
|
||||
except Exception:
|
||||
logger.debug("stealth_evaluate: failed to recreate isolated world")
|
||||
return None
|
||||
continue
|
||||
logger.debug("stealth_evaluate: CDP evaluate failed after retry")
|
||||
return None
|
||||
return None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Mark context as stale — call after navigation."""
|
||||
self._context_id = None
|
||||
|
||||
async def get_cdp_session(self) -> Any:
|
||||
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
|
||||
return await self._ensure_cdp()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Page / context / browser patching
|
||||
# ============================================================================
|
||||
|
||||
def _patch_page_sync(page: Any) -> None:
|
||||
"""Attach page.stealth_evaluate() using a sync isolated world."""
|
||||
if hasattr(page, "stealth_evaluate"):
|
||||
return
|
||||
existing = getattr(page, "_stealth_world", None)
|
||||
if isinstance(existing, _SyncIsolatedWorld):
|
||||
world = existing
|
||||
else:
|
||||
world = _SyncIsolatedWorld(page)
|
||||
page._stealth_world = world
|
||||
page.stealth_evaluate = world.evaluate
|
||||
|
||||
|
||||
def _patch_page_async(page: Any) -> None:
|
||||
"""Attach page.stealth_evaluate() using an async isolated world."""
|
||||
if hasattr(page, "stealth_evaluate"):
|
||||
return
|
||||
existing = getattr(page, "_stealth_world", None)
|
||||
if isinstance(existing, _AsyncIsolatedWorld):
|
||||
world = existing
|
||||
else:
|
||||
world = _AsyncIsolatedWorld(page)
|
||||
page._stealth_world = world
|
||||
page.stealth_evaluate = world.evaluate
|
||||
|
||||
|
||||
def patch_context_stealth_eval(context: Any, *, is_async: bool = False) -> None:
|
||||
"""Patch existing pages + hook new_page() for stealth_evaluate."""
|
||||
if getattr(context, "_stealth_eval_patched", False):
|
||||
return
|
||||
context._stealth_eval_patched = True
|
||||
patch_fn = _patch_page_async if is_async else _patch_page_sync
|
||||
|
||||
for p in context.pages:
|
||||
patch_fn(p)
|
||||
|
||||
orig_new_page = context.new_page
|
||||
|
||||
if is_async:
|
||||
async def _patched_new_page(*args: Any, **kwargs: Any) -> Any:
|
||||
page = await orig_new_page(*args, **kwargs)
|
||||
patch_fn(page)
|
||||
return page
|
||||
else:
|
||||
def _patched_new_page(*args: Any, **kwargs: Any) -> Any:
|
||||
page = orig_new_page(*args, **kwargs)
|
||||
patch_fn(page)
|
||||
return page
|
||||
|
||||
context.new_page = _patched_new_page
|
||||
context.on("page", lambda p: patch_fn(p))
|
||||
|
||||
|
||||
def patch_browser_stealth_eval(browser: Any, *, is_async: bool = False) -> None:
|
||||
"""Patch browser factory methods for stealth_evaluate."""
|
||||
patch_fn = _patch_page_async if is_async else _patch_page_sync
|
||||
|
||||
# Hook new_context()
|
||||
orig_new_context = browser.new_context
|
||||
|
||||
if is_async:
|
||||
async def _patched_new_context(*args: Any, **kwargs: Any) -> Any:
|
||||
ctx = await orig_new_context(*args, **kwargs)
|
||||
patch_context_stealth_eval(ctx, is_async=True)
|
||||
return ctx
|
||||
else:
|
||||
def _patched_new_context(*args: Any, **kwargs: Any) -> Any:
|
||||
ctx = orig_new_context(*args, **kwargs)
|
||||
patch_context_stealth_eval(ctx, is_async=False)
|
||||
return ctx
|
||||
|
||||
browser.new_context = _patched_new_context
|
||||
|
||||
# Hook new_page()
|
||||
orig_new_page = browser.new_page
|
||||
|
||||
if is_async:
|
||||
async def _patched_new_page(*args: Any, **kwargs: Any) -> Any:
|
||||
page = await orig_new_page(*args, **kwargs)
|
||||
patch_context_stealth_eval(page.context, is_async=True)
|
||||
patch_fn(page)
|
||||
return page
|
||||
else:
|
||||
def _patched_new_page(*args: Any, **kwargs: Any) -> Any:
|
||||
page = orig_new_page(*args, **kwargs)
|
||||
patch_context_stealth_eval(page.context, is_async=False)
|
||||
patch_fn(page)
|
||||
return page
|
||||
|
||||
browser.new_page = _patched_new_page
|
||||
@@ -177,6 +177,24 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** |
|
||||
| | | **Tested against 30+ detection sites** |
|
||||
|
||||
## Stealth Evaluate
|
||||
|
||||
`page.stealthEvaluate(expression)` runs JavaScript in a CDP isolated world instead of Playwright's main-world `evaluate()`. This produces clean `Error.stack` traces and full variable isolation from page JS.
|
||||
|
||||
```typescript
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://example.com');
|
||||
|
||||
// Stealth — clean stack trace, invisible to page JS
|
||||
const title = await page.stealthEvaluate('document.title');
|
||||
|
||||
// Regular evaluate — unchanged, use for DOM writes
|
||||
await page.evaluate(() => { document.body.style.display = 'none'; });
|
||||
```
|
||||
|
||||
Always available on every page — no flag needed. Returns JSON-serializable values only. The isolated world context auto-recreates after navigation.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env Variable | Default | Description |
|
||||
|
||||
+9
-101
@@ -19,6 +19,7 @@ import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js'
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement } from './scroll.js';
|
||||
import { StealthEval } from '../stealth-eval.js';
|
||||
|
||||
export { HumanConfig, resolveConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
@@ -29,102 +30,7 @@ export { scrollToElement } from './scroll.js';
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CDP Isolated World — stealth DOM evaluation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Manages a CDP isolated execution context for DOM reads.
|
||||
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
|
||||
* and is invisible to querySelector monkey-patches in the main world.
|
||||
*
|
||||
* Context ID is invalidated on navigation and auto-recreated on next call.
|
||||
*/
|
||||
class StealthEval {
|
||||
private cdp: CDPSession | null = null;
|
||||
private contextId: number | null = null;
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
private async ensureCdp(): Promise<CDPSession> {
|
||||
if (!this.cdp) {
|
||||
this.cdp = await this.page.context().newCDPSession(this.page);
|
||||
}
|
||||
return this.cdp;
|
||||
}
|
||||
|
||||
private async createWorld(): Promise<number> {
|
||||
const cdp = await this.ensureCdp();
|
||||
const tree = await cdp.send('Page.getFrameTree');
|
||||
const frameId = tree.frameTree.frame.id;
|
||||
const result = await cdp.send('Page.createIsolatedWorld', {
|
||||
frameId,
|
||||
worldName: '',
|
||||
grantUniveralAccess: true,
|
||||
});
|
||||
const ctxId = result.executionContextId;
|
||||
this.contextId = ctxId;
|
||||
return ctxId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a JS expression in the isolated world.
|
||||
* Auto-recreates the world if the context was invalidated (navigation).
|
||||
* Returns the result value, or undefined on failure.
|
||||
*/
|
||||
async evaluate(expression: string): Promise<any> {
|
||||
if (this.contextId === null) {
|
||||
await this.createWorld();
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const cdp = await this.ensureCdp();
|
||||
const result = await cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
contextId: this.contextId!,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
// Context was likely invalidated by navigation
|
||||
if (attempt === 0) {
|
||||
await this.createWorld();
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.result?.value;
|
||||
} catch {
|
||||
if (attempt === 0) {
|
||||
this.contextId = null;
|
||||
try {
|
||||
await this.createWorld();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Mark context as stale — call after navigation. */
|
||||
invalidate(): void {
|
||||
this.contextId = null;
|
||||
}
|
||||
|
||||
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
|
||||
async getCdpSession(): Promise<CDPSession> {
|
||||
return this.ensureCdp();
|
||||
}
|
||||
}
|
||||
// StealthEval is defined in stealth-eval.ts and imported at the top of this file.
|
||||
|
||||
|
||||
// ============================================================================
|
||||
@@ -163,7 +69,8 @@ async function isInputElement(
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
})()
|
||||
`);
|
||||
return !!result;
|
||||
if (result !== undefined && result !== null) return !!result;
|
||||
// undefined/null = CDP failed, fall through to page.evaluate
|
||||
} catch {
|
||||
// Fall through to page.evaluate
|
||||
}
|
||||
@@ -197,7 +104,8 @@ async function isSelectorFocused(
|
||||
return el === document.activeElement;
|
||||
})()
|
||||
`);
|
||||
return !!result;
|
||||
if (result !== undefined && result !== null) return !!result;
|
||||
// undefined/null = CDP failed, fall through to page.evaluate
|
||||
} catch {
|
||||
// Fall through to page.evaluate
|
||||
}
|
||||
@@ -246,9 +154,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
(page as any)._original = originals;
|
||||
(page as any)._humanCfg = cfg;
|
||||
|
||||
// --- Stealth infrastructure ---
|
||||
const stealth = new StealthEval(page);
|
||||
(page as any)._stealth = stealth;
|
||||
// --- Stealth infrastructure (reuse if already attached by stealth-eval) ---
|
||||
const stealth = (page as any)._stealthWorld ?? new StealthEval(page);
|
||||
(page as any)._stealthWorld = stealth;
|
||||
|
||||
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
|
||||
let cdpSession: CDPSession | null = null;
|
||||
|
||||
@@ -67,6 +67,10 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
patchBrowser(browser, cfg);
|
||||
}
|
||||
|
||||
// Stealth evaluate — always attached
|
||||
const { patchBrowser: patchStealthEval } = await import('./stealth-eval.js');
|
||||
patchStealthEval(browser);
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
@@ -132,6 +136,10 @@ export async function launchContext(
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
// Stealth evaluate — always attached
|
||||
const { patchContext: patchStealthEvalCtx } = await import('./stealth-eval.js');
|
||||
patchStealthEvalCtx(context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -197,6 +205,10 @@ export async function launchPersistentContext(
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
// Stealth evaluate — always attached
|
||||
const { patchContext: patchStealthEvalCtx2 } = await import('./stealth-eval.js');
|
||||
patchStealthEvalCtx2(context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Stealth evaluate — run JS in a CDP isolated world.
|
||||
*
|
||||
* Provides page.stealthEvaluate(expression) on every page returned by
|
||||
* cloakbrowser launch functions. Produces clean Error.stack traces (no
|
||||
* `eval at evaluate :302:` leak) and full variable isolation from main
|
||||
* world JS. Context auto-recreates after navigation.
|
||||
*
|
||||
* The same StealthEval instances are reused by the humanize layer
|
||||
* (human/index.ts) for stealth DOM queries.
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, Page, CDPSession } from 'playwright-core';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Isolated world class
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Manages a CDP isolated execution context for DOM reads.
|
||||
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
|
||||
* and is invisible to querySelector monkey-patches in the main world.
|
||||
*
|
||||
* Context ID is invalidated on navigation and auto-recreated on next call.
|
||||
*/
|
||||
export class StealthEval {
|
||||
private cdp: CDPSession | null = null;
|
||||
private contextId: number | null = null;
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
private async ensureCdp(): Promise<CDPSession> {
|
||||
if (!this.cdp) {
|
||||
this.cdp = await this.page.context().newCDPSession(this.page);
|
||||
}
|
||||
return this.cdp;
|
||||
}
|
||||
|
||||
private async createWorld(): Promise<number> {
|
||||
const cdp = await this.ensureCdp();
|
||||
const tree = await cdp.send('Page.getFrameTree');
|
||||
const frameId = tree.frameTree.frame.id;
|
||||
const result = await cdp.send('Page.createIsolatedWorld', {
|
||||
frameId,
|
||||
worldName: '',
|
||||
grantUniveralAccess: true,
|
||||
});
|
||||
const ctxId = result.executionContextId;
|
||||
this.contextId = ctxId;
|
||||
return ctxId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a JS expression in the isolated world.
|
||||
* Auto-recreates the world if the context was invalidated (navigation).
|
||||
* Returns the result value, or undefined on failure.
|
||||
*/
|
||||
async evaluate(expression: string): Promise<any> {
|
||||
if (this.contextId === null) {
|
||||
try {
|
||||
await this.createWorld();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const cdp = await this.ensureCdp();
|
||||
const result = await cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
contextId: this.contextId!,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
if (attempt === 0) {
|
||||
await this.createWorld();
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.result?.value;
|
||||
} catch {
|
||||
if (attempt === 0) {
|
||||
this.contextId = null;
|
||||
try {
|
||||
await this.createWorld();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Mark context as stale — call after navigation. */
|
||||
invalidate(): void {
|
||||
this.contextId = null;
|
||||
}
|
||||
|
||||
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
|
||||
async getCdpSession(): Promise<CDPSession> {
|
||||
return this.ensureCdp();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Page / context / browser patching
|
||||
// ============================================================================
|
||||
|
||||
function patchPage(page: Page): void {
|
||||
if ((page as any).stealthEvaluate) return;
|
||||
const existing = (page as any)._stealthWorld;
|
||||
const stealth = existing instanceof StealthEval ? existing : new StealthEval(page);
|
||||
(page as any)._stealthWorld = stealth;
|
||||
(page as any).stealthEvaluate = stealth.evaluate.bind(stealth);
|
||||
}
|
||||
|
||||
export function patchContext(context: BrowserContext): void {
|
||||
if ((context as any)._stealthEvalPatched) return;
|
||||
(context as any)._stealthEvalPatched = true;
|
||||
for (const p of context.pages()) {
|
||||
patchPage(p);
|
||||
}
|
||||
|
||||
const origNewPage = context.newPage.bind(context);
|
||||
context.newPage = async (...args: Parameters<BrowserContext['newPage']>) => {
|
||||
const page = await origNewPage(...args);
|
||||
patchPage(page);
|
||||
return page;
|
||||
};
|
||||
|
||||
context.on('page', (page: Page) => patchPage(page));
|
||||
}
|
||||
|
||||
export function patchBrowser(browser: Browser): void {
|
||||
const origNewContext = browser.newContext.bind(browser);
|
||||
browser.newContext = async (...args: Parameters<Browser['newContext']>) => {
|
||||
const ctx = await origNewContext(...args);
|
||||
patchContext(ctx);
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
browser.newPage = async (...args: Parameters<Browser['newPage']>) => {
|
||||
const page = await origNewPage(...args);
|
||||
patchContext(page.context());
|
||||
patchPage(page);
|
||||
return page;
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,13 @@
|
||||
* Shared types for cloakbrowser launch wrappers.
|
||||
*/
|
||||
|
||||
declare module 'playwright-core' {
|
||||
interface Page {
|
||||
/** Evaluate JS in a CDP isolated world — clean stack traces, invisible to main-world monkey-patches. */
|
||||
stealthEvaluate(expression: string): Promise<any>;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LaunchOptions {
|
||||
/** Run in headless mode (default: true). */
|
||||
headless?: boolean;
|
||||
|
||||
+89
-8
@@ -46,14 +46,17 @@ describe("launchContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockBrowser: any;
|
||||
let mockChromium: any;
|
||||
let origNewContext: any;
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
const origClose = vi.fn();
|
||||
mockContext = { close: origClose, _origClose: origClose };
|
||||
mockContext = { close: origClose, _origClose: origClose, newPage: vi.fn(), on: vi.fn(), pages: vi.fn().mockReturnValue([]) };
|
||||
origNewContext = vi.fn().mockResolvedValue(mockContext);
|
||||
mockBrowser = {
|
||||
newContext: vi.fn().mockResolvedValue(mockContext),
|
||||
newContext: origNewContext,
|
||||
newPage: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
|
||||
@@ -75,7 +78,7 @@ describe("launchContext (unit)", () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext();
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
const ctxArgs = origNewContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
@@ -84,7 +87,7 @@ describe("launchContext (unit)", () => {
|
||||
const custom = { width: 1280, height: 720 };
|
||||
await launchContext({ viewport: custom });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
const ctxArgs = origNewContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(custom);
|
||||
});
|
||||
|
||||
@@ -92,7 +95,7 @@ describe("launchContext (unit)", () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ userAgent: "Custom/1.0" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
const ctxArgs = origNewContext.mock.calls[0][0];
|
||||
expect(ctxArgs.userAgent).toBe("Custom/1.0");
|
||||
});
|
||||
|
||||
@@ -108,7 +111,7 @@ describe("launchContext (unit)", () => {
|
||||
expect(hasTimezoneFlag).toBe(true);
|
||||
|
||||
// NOT in newContext() — no CDP emulation
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
const ctxArgs = origNewContext.mock.calls[0][0];
|
||||
expect(ctxArgs.timezoneId).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -116,7 +119,7 @@ describe("launchContext (unit)", () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ colorScheme: "dark" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
const ctxArgs = origNewContext.mock.calls[0][0];
|
||||
expect(ctxArgs.colorScheme).toBe("dark");
|
||||
});
|
||||
|
||||
@@ -132,6 +135,84 @@ describe("launchContext (unit)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stealth_evaluate patching unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("stealthEvaluate patching (unit)", () => {
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
let mockPage: any;
|
||||
let mockContext: any;
|
||||
let mockBrowser: any;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
|
||||
// Page mock with context() returning the implicit context
|
||||
mockPage = {
|
||||
context: vi.fn(),
|
||||
};
|
||||
// Implicit context created by browser.newPage()
|
||||
mockContext = {
|
||||
pages: vi.fn().mockReturnValue([]),
|
||||
newPage: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
mockPage.context.mockReturnValue(mockContext);
|
||||
|
||||
mockBrowser = {
|
||||
newContext: vi.fn().mockResolvedValue(mockContext),
|
||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (origEnv) {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("page.stealthEvaluate exists after launch + browser.newPage", async () => {
|
||||
const { launch } = await import("../src/playwright.js");
|
||||
const browser = await launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
expect(typeof (page as any).stealthEvaluate).toBe("function");
|
||||
});
|
||||
|
||||
it("implicit context from browser.newPage is patched for future pages", async () => {
|
||||
const { launch } = await import("../src/playwright.js");
|
||||
const browser = await launch({ headless: true });
|
||||
await browser.newPage();
|
||||
|
||||
// The 'page' event listener should be registered on the implicit context
|
||||
expect(mockContext.on).toHaveBeenCalledWith("page", expect.any(Function));
|
||||
// The context should be marked as patched
|
||||
expect((mockContext as any)._stealthEvalPatched).toBe(true);
|
||||
});
|
||||
|
||||
it("context from browser.newContext patches pages with stealthEvaluate", async () => {
|
||||
const { launch } = await import("../src/playwright.js");
|
||||
const browser = await launch({ headless: true });
|
||||
|
||||
const mockPage2: any = { context: vi.fn().mockReturnValue(mockContext) };
|
||||
mockContext.newPage.mockResolvedValue(mockPage2);
|
||||
mockContext.pages.mockReturnValue([]);
|
||||
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
|
||||
expect(typeof (page as any).stealthEvaluate).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchPersistentContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockChromium: any;
|
||||
@@ -139,7 +220,7 @@ describe("launchPersistentContext (unit)", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) };
|
||||
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]), newPage: vi.fn(), on: vi.fn() };
|
||||
mockChromium = {
|
||||
launchPersistentContext: vi.fn().mockResolvedValue(mockContext),
|
||||
};
|
||||
|
||||
+12
-12
@@ -470,7 +470,7 @@ describe("humanType mixed text with CDP", () => {
|
||||
// patchPage stealth wiring
|
||||
// =========================================================================
|
||||
describe("patchPage stealth infrastructure", () => {
|
||||
it("page._stealth is a StealthEval instance after patching", async () => {
|
||||
it("page._stealthWorld is a StealthEval instance after patching", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const page = buildMockPage();
|
||||
@@ -478,10 +478,10 @@ describe("patchPage stealth infrastructure", () => {
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
expect((page as any)._stealth).toBeDefined();
|
||||
expect(typeof (page as any)._stealth.evaluate).toBe("function");
|
||||
expect(typeof (page as any)._stealth.invalidate).toBe("function");
|
||||
expect(typeof (page as any)._stealth.getCdpSession).toBe("function");
|
||||
expect((page as any)._stealthWorld).toBeDefined();
|
||||
expect(typeof (page as any)._stealthWorld.evaluate).toBe("function");
|
||||
expect(typeof (page as any)._stealthWorld.invalidate).toBe("function");
|
||||
expect(typeof (page as any)._stealthWorld.getCdpSession).toBe("function");
|
||||
});
|
||||
|
||||
it("page._original and page._humanCfg are set", async () => {
|
||||
@@ -504,7 +504,7 @@ describe("patchPage stealth infrastructure", () => {
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const stealth = (page as any)._stealth;
|
||||
const stealth = (page as any)._stealthWorld;
|
||||
const invalidateSpy = vi.spyOn(stealth, "invalidate");
|
||||
|
||||
await page.goto("https://example.com");
|
||||
@@ -540,7 +540,7 @@ describe("StealthEval lifecycle", () => {
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const stealth = (page as any)._stealth;
|
||||
const stealth = (page as any)._stealthWorld;
|
||||
expect(() => stealth.invalidate()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -552,7 +552,7 @@ describe("StealthEval lifecycle", () => {
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const stealth = (page as any)._stealth;
|
||||
const stealth = (page as any)._stealthWorld;
|
||||
const session = await stealth.getCdpSession();
|
||||
expect(session).toBeDefined();
|
||||
expect(typeof session.send).toBe("function");
|
||||
@@ -587,7 +587,7 @@ describe("StealthEval lifecycle", () => {
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const stealth = (page as any)._stealth;
|
||||
const stealth = (page as any)._stealthWorld;
|
||||
const result = await stealth.evaluate("1 + 1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -626,7 +626,7 @@ describe("StealthEval lifecycle", () => {
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const stealth = (page as any)._stealth;
|
||||
const stealth = (page as any)._stealthWorld;
|
||||
const result = await stealth.evaluate("test");
|
||||
expect(result).toBe("recovered");
|
||||
});
|
||||
@@ -660,7 +660,7 @@ describe("StealthEval lifecycle", () => {
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const stealth = (page as any)._stealth;
|
||||
const stealth = (page as any)._stealthWorld;
|
||||
const result = await stealth.evaluate("broken");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -975,7 +975,7 @@ describeIfSlow("stealth browser: navigation invalidation", () => {
|
||||
const browser = await launch({ headless: true, args: ['--humanize'] });
|
||||
const page = await browser.newPage();
|
||||
|
||||
expect((page as any)._stealth).toBeDefined();
|
||||
expect((page as any)._stealthWorld).toBeDefined();
|
||||
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await sleep(1000);
|
||||
|
||||
Reference in New Issue
Block a user