fix: humanize pref namespace + async headless cloak
humanize: the wrapper wrote invisible_playwright.humanize[.maxTime], but the binary's Juggler reads stealthfox.humanize (PageHandler.js gates the Bezier mouse path on it). The old name was a dead no-op, so humanize never fired and every mouse.move teleported the cursor — an automation tell. Renamed across config.py, launcher.py and async_api.py; the mouse test now asserts the on/off contrast instead of a false-green moves>=1. headless (async): InvisiblePlaywright(headless=True) crashed on Windows/macOS. _resolve_headless called make_virtual_display().start() unconditionally, but on Win/macOS that returns None (the binary self-cloaks via DWMWA_CLOAK; only Linux spawns Xvfb), so it died with AttributeError. It also never injected cloak_prefs(), so the window wouldn't have hidden anyway. Mirror the sync launcher: guard `if vd is not None` + inject cloak_prefs() when headless on win32/darwin. Verified on FF150: headless=True loads, exits clean, window fully hidden (no MainWindowHandle / no taskbar entry).
This commit is contained in:
@@ -10,7 +10,7 @@ from playwright.async_api import Browser, BrowserContext, Playwright, async_play
|
|||||||
|
|
||||||
from ._fpforge import Profile, generate_profile
|
from ._fpforge import Profile, generate_profile
|
||||||
from ._geo import prepare_session_geo
|
from ._geo import prepare_session_geo
|
||||||
from ._headless import make_virtual_display
|
from ._headless import cloak_prefs, make_virtual_display
|
||||||
from ._proxy import configure_proxy as _configure_proxy_shared
|
from ._proxy import configure_proxy as _configure_proxy_shared
|
||||||
from .download import ensure_binary
|
from .download import ensure_binary
|
||||||
from .launcher import _CHROME_H, _CHROME_W, _TASKBAR_H, _tz_env
|
from .launcher import _CHROME_H, _CHROME_W, _TASKBAR_H, _tz_env
|
||||||
@@ -95,10 +95,19 @@ class InvisiblePlaywright:
|
|||||||
extra_prefs=self._extra_prefs,
|
extra_prefs=self._extra_prefs,
|
||||||
virtual_display=bool(self._headless and _sys.platform == "win32"),
|
virtual_display=bool(self._headless and _sys.platform == "win32"),
|
||||||
)
|
)
|
||||||
prefs["invisible_playwright.humanize"] = bool(self._humanize)
|
# Windows & macOS hide the headless window via the binary's own cloak
|
||||||
|
# (DWMWA_CLOAK / NSWindow alpha) — inject the pref so the patched build
|
||||||
|
# cloaks its chrome windows. setdefault: an explicit user override wins.
|
||||||
|
# (Mirrors launcher._build_prefs; the sync path always did this, async
|
||||||
|
# didn't — so async headless=True never cloaked AND crashed below.)
|
||||||
|
if self._headless and _sys.platform in ("win32", "darwin"):
|
||||||
|
for _k, _v in cloak_prefs().items():
|
||||||
|
prefs.setdefault(_k, _v)
|
||||||
|
# stealthfox.* is the namespace the binary's Juggler reads (see launcher.py note).
|
||||||
|
prefs["stealthfox.humanize"] = bool(self._humanize)
|
||||||
if self._humanize:
|
if self._humanize:
|
||||||
cap = 1.5 if self._humanize is True else float(self._humanize)
|
cap = 1.5 if self._humanize is True else float(self._humanize)
|
||||||
prefs["invisible_playwright.humanize.maxTime"] = str(cap)
|
prefs["stealthfox.humanize.maxTime"] = str(cap)
|
||||||
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
|
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
|
||||||
pw_headless = self._resolve_headless()
|
pw_headless = self._resolve_headless()
|
||||||
env = self._build_env()
|
env = self._build_env()
|
||||||
@@ -223,8 +232,13 @@ class InvisiblePlaywright:
|
|||||||
if not self._headless:
|
if not self._headless:
|
||||||
return False
|
return False
|
||||||
vd = make_virtual_display()
|
vd = make_virtual_display()
|
||||||
vd.start()
|
# Linux: Xvfb to start. Windows/macOS: make_virtual_display() returns
|
||||||
self._virtual_display = vd
|
# None (the binary self-cloaks via cloak_prefs injected in __aenter__),
|
||||||
|
# so there is nothing to start — guarding the None was the missing piece
|
||||||
|
# that made async headless=True crash with AttributeError on Windows.
|
||||||
|
if vd is not None:
|
||||||
|
vd.start()
|
||||||
|
self._virtual_display = vd
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,10 +91,11 @@ def get_default_stealth_prefs(
|
|||||||
extra_prefs=extra_prefs,
|
extra_prefs=extra_prefs,
|
||||||
virtual_display=virtual_display,
|
virtual_display=virtual_display,
|
||||||
)
|
)
|
||||||
prefs["invisible_playwright.humanize"] = bool(humanize)
|
# stealthfox.* is the namespace the binary's Juggler reads (see launcher.py note).
|
||||||
|
prefs["stealthfox.humanize"] = bool(humanize)
|
||||||
if humanize:
|
if humanize:
|
||||||
max_seconds = float(humanize) if not isinstance(humanize, bool) else 1.5
|
max_seconds = float(humanize) if not isinstance(humanize, bool) else 1.5
|
||||||
prefs["invisible_playwright.humanize.maxTime"] = str(max_seconds)
|
prefs["stealthfox.humanize.maxTime"] = str(max_seconds)
|
||||||
return prefs
|
return prefs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -346,9 +346,13 @@ class InvisiblePlaywright:
|
|||||||
if self._headless and _sys.platform in ("win32", "darwin"):
|
if self._headless and _sys.platform in ("win32", "darwin"):
|
||||||
for _k, _v in cloak_prefs().items():
|
for _k, _v in cloak_prefs().items():
|
||||||
prefs.setdefault(_k, _v)
|
prefs.setdefault(_k, _v)
|
||||||
prefs["invisible_playwright.humanize"] = bool(self._humanize)
|
# Pref namespace MUST be stealthfox.* — that's what the binary's Juggler
|
||||||
|
# reads (PageHandler.js gates the Bezier mouse path on `stealthfox.humanize`).
|
||||||
|
# The old `invisible_playwright.*` name was a dead no-op (nothing read it), so
|
||||||
|
# humanize silently never fired and every click teleported the cursor.
|
||||||
|
prefs["stealthfox.humanize"] = bool(self._humanize)
|
||||||
if self._humanize:
|
if self._humanize:
|
||||||
prefs["invisible_playwright.humanize.maxTime"] = str(self._humanize_max_seconds())
|
prefs["stealthfox.humanize.maxTime"] = str(self._humanize_max_seconds())
|
||||||
return prefs
|
return prefs
|
||||||
|
|
||||||
def _build_env(self) -> Dict[str, str]:
|
def _build_env(self) -> Dict[str, str]:
|
||||||
|
|||||||
+24
-8
@@ -132,12 +132,9 @@ def test_mouse_move_outside_viewport_does_not_raise(firefox_binary):
|
|||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
def _humanize_move_count(firefox_binary, humanize):
|
||||||
def test_humanize_emits_intermediate_moves(firefox_binary):
|
"""Count page mousemove events fired by ONE long mouse.move."""
|
||||||
"""A long mouse.move from one corner to another should fire several
|
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=humanize) as browser:
|
||||||
mousemove events on the page when the humanize hook is enabled (which
|
|
||||||
is the StealthFox default)."""
|
|
||||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
|
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
page.goto(_data_url(
|
page.goto(_data_url(
|
||||||
"<div id=d style='width:600px;height:400px' "
|
"<div id=d style='width:600px;height:400px' "
|
||||||
@@ -146,8 +143,27 @@ def test_humanize_emits_intermediate_moves(firefox_binary):
|
|||||||
page.mouse.move(10, 10)
|
page.mouse.move(10, 10)
|
||||||
page.evaluate("window.__n = 0")
|
page.evaluate("window.__n = 0")
|
||||||
page.mouse.move(500, 300)
|
page.mouse.move(500, 300)
|
||||||
moves = page.evaluate("window.__n")
|
return page.evaluate("window.__n")
|
||||||
assert moves >= 1, f"expected at least 1 mousemove event, got {moves}"
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_humanize_emits_intermediate_moves(firefox_binary):
|
||||||
|
"""A long mouse.move must expand into MANY intermediate mousemove events when
|
||||||
|
humanize is on (Bezier), and ~1 (a teleport) when off. We assert the on/off
|
||||||
|
CONTRAST: `moves >= 1` alone was a false-green — a teleport already fires 1 —
|
||||||
|
and that false-green hid a pref-namespace bug (wrapper wrote
|
||||||
|
`invisible_playwright.humanize`, the binary's Juggler reads `stealthfox.humanize`)
|
||||||
|
that left humanize silently dead in production. This test now fails if the
|
||||||
|
pref ever stops reaching the binary."""
|
||||||
|
on = _humanize_move_count(firefox_binary, True)
|
||||||
|
off = _humanize_move_count(firefox_binary, False)
|
||||||
|
assert off <= 2, f"humanize OFF should ~teleport (<=2 moves), got {off}"
|
||||||
|
assert on >= 4, (
|
||||||
|
f"humanize ON must expand into many intermediate moves (Bezier); got {on} "
|
||||||
|
f"(off={off}). moves==1 means the cursor teleports — the exact automation "
|
||||||
|
f"tell humanize exists to remove, and a sign the stealthfox.* pref isn't "
|
||||||
|
f"reaching the binary's Juggler."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ def test_get_default_stealth_prefs_random_seed_returns_dict():
|
|||||||
assert isinstance(prefs, dict)
|
assert isinstance(prefs, dict)
|
||||||
assert len(prefs) > 0
|
assert len(prefs) > 0
|
||||||
# humanize toggle is always set explicitly
|
# humanize toggle is always set explicitly
|
||||||
assert "invisible_playwright.humanize" in prefs
|
assert "stealthfox.humanize" in prefs
|
||||||
assert prefs["invisible_playwright.humanize"] is True
|
assert prefs["stealthfox.humanize"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_get_default_stealth_prefs_seed_is_deterministic():
|
def test_get_default_stealth_prefs_seed_is_deterministic():
|
||||||
@@ -50,22 +50,22 @@ def test_get_default_stealth_prefs_different_seeds_differ():
|
|||||||
def test_humanize_false_disables_prefs():
|
def test_humanize_false_disables_prefs():
|
||||||
"""humanize=False removes the maxTime knob and flips the toggle to False."""
|
"""humanize=False removes the maxTime knob and flips the toggle to False."""
|
||||||
prefs = get_default_stealth_prefs(seed=42, humanize=False)
|
prefs = get_default_stealth_prefs(seed=42, humanize=False)
|
||||||
assert prefs["invisible_playwright.humanize"] is False
|
assert prefs["stealthfox.humanize"] is False
|
||||||
assert "invisible_playwright.humanize.maxTime" not in prefs
|
assert "stealthfox.humanize.maxTime" not in prefs
|
||||||
|
|
||||||
|
|
||||||
def test_humanize_default_sets_max_time_1_5():
|
def test_humanize_default_sets_max_time_1_5():
|
||||||
"""humanize=True -> default maxTime is 1.5s, stored as string."""
|
"""humanize=True -> default maxTime is 1.5s, stored as string."""
|
||||||
prefs = get_default_stealth_prefs(seed=42, humanize=True)
|
prefs = get_default_stealth_prefs(seed=42, humanize=True)
|
||||||
assert prefs["invisible_playwright.humanize"] is True
|
assert prefs["stealthfox.humanize"] is True
|
||||||
assert prefs["invisible_playwright.humanize.maxTime"] == "1.5"
|
assert prefs["stealthfox.humanize.maxTime"] == "1.5"
|
||||||
|
|
||||||
|
|
||||||
def test_humanize_float_overrides_max_time():
|
def test_humanize_float_overrides_max_time():
|
||||||
"""Float for humanize is the explicit cap in seconds."""
|
"""Float for humanize is the explicit cap in seconds."""
|
||||||
prefs = get_default_stealth_prefs(seed=42, humanize=3.0)
|
prefs = get_default_stealth_prefs(seed=42, humanize=3.0)
|
||||||
assert prefs["invisible_playwright.humanize"] is True
|
assert prefs["stealthfox.humanize"] is True
|
||||||
assert prefs["invisible_playwright.humanize.maxTime"] == "3.0"
|
assert prefs["stealthfox.humanize.maxTime"] == "3.0"
|
||||||
|
|
||||||
|
|
||||||
def test_extra_prefs_overlay_takes_precedence():
|
def test_extra_prefs_overlay_takes_precedence():
|
||||||
|
|||||||
Reference in New Issue
Block a user