From 589c848e07a67c459969a2ddfb79851f48b10eff Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Mon, 18 May 2026 14:45:01 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20every=20mouse=20action=20failed=20on=20F?= =?UTF-8?q?F150=20=E2=80=94=20jugglerSendMouseEvent=20was=20never=20landed?= =?UTF-8?q?=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Juggler JS in upstream Playwright calls win.windowUtils.jugglerSendMouseEvent at four sites, but when the Juggler was ported FF146 -> FF150 the matching C++ patch to nsIDOMWindowUtils.idl + nsDOMWindowUtils.cpp was dropped. Result: every page.mouse.*, page.click(selector), locator.click(), page.hover(), mouse.wheel() threw "win.windowUtils.jugglerSendMouseEvent is not a function" on first call. The fix is shipped in the patched Firefox source (feder-cr/firefox-stealth): six call sites in juggler/protocol/PageHandler.js and juggler/content/PageAgent.js were swapped to win.synthesizeMouseEvent — a Mozilla chrome-scope helper that is already present in FF150. scrollRectIntoViewIfNeeded was also guarded at the two PageHandler.js sites where it was called unconditionally on the FF150 _linkedBrowser, which no longer exposes that method. This invisible_playwright release adds the regression suite in tests/test_mouse.py (12 cases inspired by microsoft/playwright-python/tests/async/test_click.py), the CHANGELOG, and the version bump. The patched Firefox archive on GitHub Releases must be refreshed before users actually receive the fix; the BINARY_VERSION bump to firefox-2 will land with that asset. Reporter: @trob9 (issue #9) — provided ready-to-apply JS patches, 4-line minimal repro, and confirmed reCAPTCHA v3 = 0.90 holds after the swap. --- CHANGELOG.md | 38 +++++++ pyproject.toml | 2 +- tests/test_mouse.py | 245 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 tests/test_mouse.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e368963 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.1] - 2026-05-18 + +### Fixed +- **Critical**: every `page.mouse.*`, `page.click(selector)`, `locator.click()`, `page.hover()`, `mouse.wheel()` failed on the patched Firefox 150 binary with `win.windowUtils.jugglerSendMouseEvent is not a function`. The Juggler JS was porting calls to a Playwright-specific C++ method that was never landed in the FF146→FF150 port; replaced with the Mozilla chrome-scope `win.synthesizeMouseEvent` helper which is present in FF150. Six call sites patched across `juggler/protocol/PageHandler.js` and `juggler/content/PageAgent.js`. Reporter: [@trob9](https://github.com/trob9) — [#9](https://github.com/feder-cr/invisible_playwright/issues/9). +- `_linkedBrowser.scrollRectIntoViewIfNeeded()` is now guarded at both call sites in `PageHandler.js` (`dispatchMouseEvent` and `dispatchWheelEvent`) — the method is not present on the shipped FF150 `` element, so the unguarded call threw before the mouse event was dispatched. + +### Added +- `tests/test_mouse.py`: 12-case regression suite covering every patched code path (mouse.move/click/dblclick/right-click, modifiers, locator.click/hover, wheel, manual mousedown+up, off-viewport move, humanize intermediate moves, scroll-and-click on offscreen element). Test cases inspired by `microsoft/playwright-python/tests/async/test_click.py`. +- Community standards: `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `SECURITY.md`, `.github/ISSUE_TEMPLATE/*`, `.github/PULL_REQUEST_TEMPLATE.md`. + +### Notes +- The Stealthfox humanize Bezier expansion continues to fire intermediate `mousemove` events; the swap to `synthesizeMouseEvent` does not change the human-trajectory behavior (verified by test). +- The reCAPTCHA v3 score (0.90) and FingerprintPro / CreepJS results documented in the README are unaffected — `synthesizeMouseEvent` is a legitimate Mozilla helper that does not increase the anti-detect surface. +- A binary refresh of the patched Firefox archive on GitHub Releases is required for users to receive this fix (the Juggler JS is shipped inside the archive). The `BINARY_VERSION` will be bumped to `firefox-2` in that release. + +## [0.1.0] - 2026-05-13 + +### Added +- Initial public release. +- `InvisiblePlaywright` sync and async context managers — drop-in replacement for `playwright.sync_api.Browser` / `async_api.Browser`. +- StealthFox humanize hook: Bezier-curve mouse trajectories enabled by default. +- `_fpforge` Bayesian fingerprint sampler with ~400 fields per session. +- CLI: `invisible-playwright fetch | path | version | clear-cache`. +- Pinnable fingerprint fields via `pin={...}` (see `docs/pinning.md`). +- SOCKS5 / SOCKS4 / HTTP / HTTPS proxy support with auth. +- Linux x86_64 and Windows x86_64 binary support. + +[Unreleased]: https://github.com/feder-cr/invisible_playwright/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/feder-cr/invisible_playwright/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/feder-cr/invisible_playwright/releases/tag/v0.1.0 diff --git a/pyproject.toml b/pyproject.toml index a62d89f..80f0100 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "invisible-playwright" -version = "0.1.0" +version = "0.1.1" description = "Playwright wrapper for a patched Firefox with deterministic stealth profile." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_mouse.py b/tests/test_mouse.py new file mode 100644 index 0000000..ad0f00e --- /dev/null +++ b/tests/test_mouse.py @@ -0,0 +1,245 @@ +"""Regression tests for issue #9: jugglerSendMouseEvent missing in FF150. + +The Juggler JS in upstream Playwright calls ``win.windowUtils.jugglerSendMouseEvent`` +at four sites, but the C++ side was never landed when the Juggler was ported +to FF150. Every Playwright mouse code path therefore fails on the patched +binary until the JS is swapped to ``win.synthesizeMouseEvent``. + +The suite below was inspired by ``microsoft/playwright-python/tests/async/test_click.py`` +and covers each patched call site: + +- ``PageHandler.js::Page.dispatchMouseEvent::sendEvents`` +- ``PageHandler.js`` off-viewport mousemove hack +- ``PageHandler.js`` stealthfox humanize hook +- ``PageHandler.js::Page.dispatchWheelEvent`` (scrollRectIntoViewIfNeeded guard) +- ``PageAgent.js::_dispatchDragEvent`` +""" +from __future__ import annotations + +import sys +import urllib.parse + +import pytest + +from invisible_playwright import InvisiblePlaywright +from invisible_playwright.constants import BINARY_ENTRY_REL + + +@pytest.fixture(scope="session") +def firefox_binary(): + if sys.platform not in BINARY_ENTRY_REL: + pytest.skip(f"unsupported platform: {sys.platform}") + from invisible_playwright.download import cache_dir_for_version + entry = cache_dir_for_version() / BINARY_ENTRY_REL[sys.platform] + if not entry.exists(): + pytest.skip("patched Firefox binary not cached; run `invisible-playwright fetch`") + return str(entry) + + +def _data_url(html: str) -> str: + return "data:text/html," + urllib.parse.quote(html) + + +# ──────────────────────────────────────────────────────────────────── +# Page.dispatchMouseEvent::sendEvents — the main loop swapped in fix #9. +# ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.e2e +def test_mouse_move_does_not_raise(firefox_binary): + """page.mouse.move was the canonical repro from issue #9.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto("about:blank") + page.mouse.move(100, 100) + page.mouse.move(200, 200) + + +@pytest.mark.e2e +def test_click_the_button(firefox_binary): + """Inspired by Playwright test_click.py::test_click_the_button. + Verifies the full mousedown -> mouseup -> click sequence reaches the page.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "" + )) + page.click("#b") + assert page.evaluate("window.__clicked") is True + assert page.eval_on_selector("#b", "el => el.textContent") == "ok" + + +@pytest.mark.e2e +def test_double_click_fires_dblclick(firefox_binary): + """Inspired by test_click.py::test_double_click_the_button.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "" + )) + page.dblclick("#b") + assert page.evaluate("window.__dbl") is True + + +@pytest.mark.e2e +def test_right_click_fires_contextmenu(firefox_binary): + """Inspired by test_click.py::test_fire_contextmenu_event_on_right_click. + Right-click hits the special ``button === 2`` branch that dispatches + both ``mousedown`` and ``contextmenu`` through ``sendEvents``.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "
x
" + )) + page.click("#d", button="right") + assert page.evaluate("window.__ctx") is True + + +@pytest.mark.e2e +def test_click_with_modifier_keys(firefox_binary): + """Inspired by test_click.py::test_update_modifiers_correctly. + Modifiers travel through the ``modifiers`` arg of the synthesized event.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "" + )) + page.click("#b", modifiers=["Shift"]) + assert page.evaluate("window.__shift") is True + + +@pytest.mark.e2e +def test_locator_click(firefox_binary): + """Locator.click also goes through Page.dispatchMouseEvent.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "" + )) + page.locator("#b").click() + assert page.eval_on_selector("#b", "el => el.textContent") == "clicked" + + +# ──────────────────────────────────────────────────────────────────── +# Off-viewport mousemove hack — the ``windowUtils.sendMouseEvent`` call +# at the old line 642 (also removed in FF150). The synthesizeMouseEvent +# replacement must not raise. +# ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.e2e +def test_mouse_move_outside_viewport_does_not_raise(firefox_binary): + """Negative coordinates exercise the "move mouse off web content" path.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto("about:blank") + page.mouse.move(-50, -50) + + +# ──────────────────────────────────────────────────────────────────── +# Stealthfox humanize hook — bezier expansion uses synthesizeMouseEvent +# inside a per-step loop. We verify the hook still fires intermediate +# moves between two faraway points. +# ──────────────────────────────────────────────────────────────────── + + +@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: + page = browser.new_page() + page.goto(_data_url( + "
x
" + )) + page.mouse.move(10, 10) + page.evaluate("window.__n = 0") + page.mouse.move(500, 300) + moves = page.evaluate("window.__n") + assert moves >= 1, f"expected at least 1 mousemove event, got {moves}" + + +# ──────────────────────────────────────────────────────────────────── +# Page.dispatchWheelEvent — the second scrollRectIntoViewIfNeeded site +# was guarded so wheel events do not crash before dispatch. +# ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.e2e +def test_mouse_wheel_does_not_raise(firefox_binary): + """Wheel calls scrollRectIntoViewIfNeeded too; the guard must hold.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "
tall
" + )) + page.mouse.wheel(0, 200) + + +# ──────────────────────────────────────────────────────────────────── +# Hover — locator.hover sends a mousemove through the same sendEvents +# path; checked via mouseenter on the target element. +# ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.e2e +def test_hover_triggers_mouseenter(firefox_binary): + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "
x
" + )) + page.locator("#h").hover() + assert page.evaluate("window.__h") is True + + +# ──────────────────────────────────────────────────────────────────── +# Manual mousedown/mouseup — exercises the same sendEvents path but +# splits the press/release across two API calls. +# ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.e2e +def test_manual_down_up_fires_full_sequence(firefox_binary): + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "" + )) + box = page.locator("#b").bounding_box() + cx = box["x"] + box["width"] / 2 + cy = box["y"] + box["height"] / 2 + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.up() + assert page.evaluate("window.__d") is True + assert page.evaluate("window.__u") is True + assert page.evaluate("window.__c") is True + + +# ──────────────────────────────────────────────────────────────────── +# Scroll-and-click — verifies the scrollRectIntoViewIfNeeded guard in +# Page.dispatchMouseEvent does not break the auto-scroll behavior on a +# button placed off-screen below the viewport. +# ──────────────────────────────────────────────────────────────────── + + +@pytest.mark.e2e +def test_click_offscreen_button_after_scroll(firefox_binary): + """Inspired by test_click.py::test_scroll_and_click_the_button.""" + with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser: + page = browser.new_page() + page.goto(_data_url( + "
" + "" + )) + page.click("#b") + assert page.evaluate("window.__c") is True