mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
fix: every mouse action failed on FF150 — jugglerSendMouseEvent was never landed (#9)
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.
This commit is contained in:
parent
0ac0581747
commit
589c848e07
3 changed files with 284 additions and 1 deletions
38
CHANGELOG.md
Normal file
38
CHANGELOG.md
Normal file
|
|
@ -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 `<browser>` 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
245
tests/test_mouse.py
Normal file
245
tests/test_mouse.py
Normal file
|
|
@ -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(
|
||||
"<button id=b onclick=\"window.__clicked=true;this.textContent='ok'\">x</button>"
|
||||
))
|
||||
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(
|
||||
"<button id=b ondblclick=\"window.__dbl=true\">x</button>"
|
||||
))
|
||||
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(
|
||||
"<div id=d style='width:200px;height:100px;background:red' "
|
||||
"oncontextmenu=\"event.preventDefault();window.__ctx=true\">x</div>"
|
||||
))
|
||||
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(
|
||||
"<button id=b style='width:200px;height:80px;font-size:24px' "
|
||||
"onclick=\"window.__shift=event.shiftKey\">click</button>"
|
||||
))
|
||||
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(
|
||||
"<button id=b onclick=\"this.textContent='clicked'\">x</button>"
|
||||
))
|
||||
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(
|
||||
"<div id=d style='width:600px;height:400px' "
|
||||
"onmousemove=\"window.__n=(window.__n||0)+1\">x</div>"
|
||||
))
|
||||
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(
|
||||
"<div style='height:3000px'>tall</div>"
|
||||
))
|
||||
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(
|
||||
"<div id=h style='width:200px;height:100px;background:red' "
|
||||
"onmouseenter=\"window.__h=true\">x</div>"
|
||||
))
|
||||
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(
|
||||
"<button id=b style='width:200px;height:100px' "
|
||||
"onmousedown=\"window.__d=true\" "
|
||||
"onmouseup=\"window.__u=true\" "
|
||||
"onclick=\"window.__c=true\">x</button>"
|
||||
))
|
||||
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(
|
||||
"<div style='height:3000px'></div>"
|
||||
"<button id=b onclick=\"window.__c=true\">deep</button>"
|
||||
))
|
||||
page.click("#b")
|
||||
assert page.evaluate("window.__c") is True
|
||||
Loading…
Add table
Add a link
Reference in a new issue