From b34ecf2a21c19d068fde6bcb63cc682a8b08d440 Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:31:31 +0200 Subject: [PATCH] fix: humanize pref namespace + async headless cloak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/invisible_playwright/async_api.py | 24 +++++++++++++++----- src/invisible_playwright/config.py | 5 +++-- src/invisible_playwright/launcher.py | 8 +++++-- tests/test_mouse.py | 32 ++++++++++++++++++++------- tests/unit/test_config_public.py | 16 +++++++------- 5 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/invisible_playwright/async_api.py b/src/invisible_playwright/async_api.py index ce08608..e8e1f2b 100644 --- a/src/invisible_playwright/async_api.py +++ b/src/invisible_playwright/async_api.py @@ -10,7 +10,7 @@ from playwright.async_api import Browser, BrowserContext, Playwright, async_play from ._fpforge import Profile, generate_profile 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 .download import ensure_binary from .launcher import _CHROME_H, _CHROME_W, _TASKBAR_H, _tz_env @@ -95,10 +95,19 @@ class InvisiblePlaywright: extra_prefs=self._extra_prefs, 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: 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) pw_headless = self._resolve_headless() env = self._build_env() @@ -223,8 +232,13 @@ class InvisiblePlaywright: if not self._headless: return False vd = make_virtual_display() - vd.start() - self._virtual_display = vd + # Linux: Xvfb to start. Windows/macOS: make_virtual_display() returns + # 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 diff --git a/src/invisible_playwright/config.py b/src/invisible_playwright/config.py index c411512..2a7300e 100644 --- a/src/invisible_playwright/config.py +++ b/src/invisible_playwright/config.py @@ -91,10 +91,11 @@ def get_default_stealth_prefs( extra_prefs=extra_prefs, 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: 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 diff --git a/src/invisible_playwright/launcher.py b/src/invisible_playwright/launcher.py index c07238b..bcee7ae 100644 --- a/src/invisible_playwright/launcher.py +++ b/src/invisible_playwright/launcher.py @@ -346,9 +346,13 @@ class InvisiblePlaywright: if self._headless and _sys.platform in ("win32", "darwin"): for _k, _v in cloak_prefs().items(): 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: - prefs["invisible_playwright.humanize.maxTime"] = str(self._humanize_max_seconds()) + prefs["stealthfox.humanize.maxTime"] = str(self._humanize_max_seconds()) return prefs def _build_env(self) -> Dict[str, str]: diff --git a/tests/test_mouse.py b/tests/test_mouse.py index d915b9e..03d3206 100644 --- a/tests/test_mouse.py +++ b/tests/test_mouse.py @@ -132,12 +132,9 @@ def test_mouse_move_outside_viewport_does_not_raise(firefox_binary): # ──────────────────────────────────────────────────────────────────── -@pytest.mark.e2e -def test_humanize_emits_intermediate_moves(firefox_binary): - """A long mouse.move from one corner to another should fire several - 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: +def _humanize_move_count(firefox_binary, humanize): + """Count page mousemove events fired by ONE long mouse.move.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=humanize) as browser: page = browser.new_page() page.goto(_data_url( "
= 1, f"expected at least 1 mousemove event, got {moves}" + return page.evaluate("window.__n") + + +@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." + ) # ──────────────────────────────────────────────────────────────────── diff --git a/tests/unit/test_config_public.py b/tests/unit/test_config_public.py index 0e26e36..f589271 100644 --- a/tests/unit/test_config_public.py +++ b/tests/unit/test_config_public.py @@ -29,8 +29,8 @@ def test_get_default_stealth_prefs_random_seed_returns_dict(): assert isinstance(prefs, dict) assert len(prefs) > 0 # humanize toggle is always set explicitly - assert "invisible_playwright.humanize" in prefs - assert prefs["invisible_playwright.humanize"] is True + assert "stealthfox.humanize" in prefs + assert prefs["stealthfox.humanize"] is True 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(): """humanize=False removes the maxTime knob and flips the toggle to False.""" prefs = get_default_stealth_prefs(seed=42, humanize=False) - assert prefs["invisible_playwright.humanize"] is False - assert "invisible_playwright.humanize.maxTime" not in prefs + assert prefs["stealthfox.humanize"] is False + assert "stealthfox.humanize.maxTime" not in prefs def test_humanize_default_sets_max_time_1_5(): """humanize=True -> default maxTime is 1.5s, stored as string.""" prefs = get_default_stealth_prefs(seed=42, humanize=True) - assert prefs["invisible_playwright.humanize"] is True - assert prefs["invisible_playwright.humanize.maxTime"] == "1.5" + assert prefs["stealthfox.humanize"] is True + assert prefs["stealthfox.humanize.maxTime"] == "1.5" def test_humanize_float_overrides_max_time(): """Float for humanize is the explicit cap in seconds.""" prefs = get_default_stealth_prefs(seed=42, humanize=3.0) - assert prefs["invisible_playwright.humanize"] is True - assert prefs["invisible_playwright.humanize.maxTime"] == "3.0" + assert prefs["stealthfox.humanize"] is True + assert prefs["stealthfox.humanize.maxTime"] == "3.0" def test_extra_prefs_overlay_takes_precedence():