From d09200f01d4a0270d8f41604a16c92841f10dc66 Mon Sep 17 00:00:00 2001 From: dannyward630 Date: Sun, 17 May 2026 21:05:20 -0400 Subject: [PATCH] Add macOS support --- README.md | 4 +- src/invisible_playwright/_headless.py | 14 +++- src/invisible_playwright/constants.py | 14 +++- src/invisible_playwright/prefs.py | 21 +++--- tests/test_async_api.py | 12 ++++ tests/test_constants.py | 42 ++++++++++-- tests/test_download.py | 96 ++++++++++++++++++++++++++- tests/test_e2e.py | 13 ++++ tests/test_headless.py | 17 +++-- tests/test_prefs.py | 57 +++++++++++----- 10 files changed, 240 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 3061a8e..53036b7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ pip install git+https://github.com/feder-cr/invisible_playwright.git python -m invisible_playwright fetch # one-time ~100 MB download, SHA256-verified ``` -Supported platforms: **Windows x86_64**, **Linux x86_64**. +Supported platforms: **Windows x86_64**, **Linux x86_64**, **macOS x86_64**, **macOS arm64**. + +`headless=True` keeps using the hidden headed-mode backends on Windows/Linux. On macOS, launch support is available but hidden headed-mode is not yet implemented, so use `headless=False`. --- diff --git a/src/invisible_playwright/_headless.py b/src/invisible_playwright/_headless.py index 2e4b249..3dd7051 100644 --- a/src/invisible_playwright/_headless.py +++ b/src/invisible_playwright/_headless.py @@ -9,6 +9,8 @@ windows off the user's screen. Linux: spawns its own ``Xvfb`` instance, points ``DISPLAY`` at it. Windows: creates a hidden desktop via ``CreateDesktop`` and binds the calling thread to it, so Playwright's child processes inherit it. +macOS: launch support exists, but there is currently no hidden-display +backend for ``headless=True``. """ from __future__ import annotations @@ -212,14 +214,22 @@ class _WindowsVirtualDesktop: def make_virtual_display(): """Return a started/stoppable virtual-display object for this platform. - InvisiblePlaywright supports Windows x86_64 and Linux x86_64 only. + Hidden-display backends currently exist for Windows and Linux only. + On macOS the wrapper can launch normally, but ``headless=True`` is + not yet supported because there is no invisible headed-mode backend. """ if sys.platform == "win32": return _WindowsVirtualDesktop() if sys.platform.startswith("linux"): return _LinuxVirtualDisplay() + if sys.platform == "darwin": + raise RuntimeError( + "invisible_playwright headless=True is not yet supported on macOS. " + "Use headless=False." + ) raise RuntimeError( - f"invisible_playwright supports Windows and Linux only (got {sys.platform!r})" + f"invisible_playwright headless=True supports Windows and Linux only " + f"(got {sys.platform!r})" ) diff --git a/src/invisible_playwright/constants.py b/src/invisible_playwright/constants.py index c7e2242..707611f 100644 --- a/src/invisible_playwright/constants.py +++ b/src/invisible_playwright/constants.py @@ -19,11 +19,20 @@ BINARY_BASENAME: str = f"firefox-{FIREFOX_UPSTREAM_VERSION}-stealth" def ARCHIVE_NAME(platform_key: str, machine: str) -> str: """Return the platform-specific archive filename. - platform_key: sys.platform ("win32", "linux") - machine: platform.machine() ("AMD64", "x86_64", ...) + platform_key: sys.platform ("win32", "linux", "darwin") + machine: platform.machine() ("AMD64", "x86_64", "arm64", ...) """ pk = platform_key.lower() m = machine.lower() + if pk == "darwin": + if m in {"arm64", "aarch64"}: + arch = "arm64" + elif m in {"amd64", "x86_64"}: + arch = "x86_64" + else: + raise NotImplementedError(f"unsupported arch: {machine}") + return f"{BINARY_BASENAME}-macos-{arch}.tar.gz" + if m in {"amd64", "x86_64"}: arch = "x86_64" else: @@ -40,6 +49,7 @@ def ARCHIVE_NAME(platform_key: str, machine: str) -> str: BINARY_ENTRY_REL = { "win32": "firefox.exe", "linux": "firefox", + "darwin": "Firefox.app/Contents/MacOS/firefox", } # GitHub release URL template. The "TODO" owner is resolved at publication time. diff --git a/src/invisible_playwright/prefs.py b/src/invisible_playwright/prefs.py index 4f0a15d..5bfc5ac 100644 --- a/src/invisible_playwright/prefs.py +++ b/src/invisible_playwright/prefs.py @@ -431,11 +431,11 @@ def _accept_language(locale: str) -> str: def _font_metrics_for_platform(profile_metrics: str) -> str: """Return ``zoom.stealth.font.metrics`` value. - Windows: empty string. The C++ width-scale hook is a no-op and - Firefox renders Arial/Segoe/Calibri/etc. at their native canonical - widths. Applying the Bayesian-sampled per-font factors on a Windows - build would *distort* real metrics and surface as a font_preferences - width anomaly to FP Pro / reCAPTCHA. + Windows/macOS: empty string. The C++ width-scale hook is a no-op and + native desktop builds render Arial/Segoe/Calibri/etc. through their + host text stacks. Applying the Bayesian-sampled per-font factors on a + non-Linux build would *distort* real metrics and surface as a + font_preferences width anomaly to FP Pro / reCAPTCHA. Linux: prepend generic-family compensation factors so DejaVu / Liberation render at the widths Windows JS expects, then append the @@ -446,7 +446,7 @@ def _font_metrics_for_platform(profile_metrics: str) -> str: return "" if sys.platform.startswith("linux"): return _LINUX_GENERIC_FONT_FACTORS + profile_metrics - return "" # Windows: NEVER apply width-scale factors. + return "" # Non-Linux builds: never apply width-scale factors. def translate_profile_to_prefs( @@ -473,13 +473,13 @@ def translate_profile_to_prefs( # GPU / WebGL renderer/vendor. # On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer) # so cross-platform sessions report a consistent Windows GPU identity. - # On Windows, spoofing a different GPU creates a renderer/parameters hash + # On non-Linux hosts, spoofing a different GPU creates a renderer/parameters hash # mismatch: FP Pro hashes all 81 CN-set getParameter() values including # enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750 # parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro # ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine). - # Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware - # (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent. + # Fix: leave renderer/vendor empty on non-Linux builds so the native stack + # reports its own hardware path and keeps the renderer/parameter hash coherent. if sys.platform.startswith("linux"): prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor @@ -489,10 +489,9 @@ def translate_profile_to_prefs( prefs["zoom.stealth.webgl.vendor"] = "" _renderer_lo = "intel" # test hardware is Intel Arc A750 - # MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is + # MSAA: on non-Linux hosts, pin to 4 so gl.SAMPLES is # constant across all sessions. Different MSAA values cause different CN-set # parameters hashes even with the same renderer → detectable variation. - # Vanilla Intel Arc A750 parameters hash (66544db8) verified at msaa=4. _msaa = profile.webgl.msaa_samples if sys.platform.startswith("linux") else 4 prefs["zoom.stealth.webgl.msaa"] = _msaa prefs["webgl.msaa-samples"] = _msaa diff --git a/tests/test_async_api.py b/tests/test_async_api.py index da818ee..3a84e19 100644 --- a/tests/test_async_api.py +++ b/tests/test_async_api.py @@ -81,3 +81,15 @@ def test_async_default_context_kwargs_match_sync(): a = AsyncIP(seed=42, timezone="America/New_York", locale="de-DE") s = SyncIP(seed=42, timezone="America/New_York", locale="de-DE") assert a._default_context_kwargs() == s._default_context_kwargs() + + +@pytest.mark.unit +def test_async_resolve_headless_raises_clear_error_on_macos(monkeypatch): + """The async launcher shares the same hidden-display limitation as the + sync launcher, so ``headless=True`` on macOS should fail the same way.""" + import sys as _sys + monkeypatch.setattr(_sys, "platform", "darwin") + + ip = AsyncIP(seed=42, headless=True) + with pytest.raises(RuntimeError, match="headless=True is not yet supported on macOS"): + ip._resolve_headless() diff --git a/tests/test_constants.py b/tests/test_constants.py index 8d124a7..f4b0f3b 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -31,9 +31,29 @@ def test_archive_name_linux(): @pytest.mark.unit -def test_archive_name_unsupported_raises(): +def test_archive_name_macos_arm64(): + name = ARCHIVE_NAME("darwin", "arm64") + assert name.endswith(".tar.gz") + assert "macos-arm64" in name + + +@pytest.mark.unit +def test_archive_name_macos_x86_64(): + name = ARCHIVE_NAME("darwin", "x86_64") + assert name.endswith(".tar.gz") + assert "macos-x86_64" in name + + +@pytest.mark.unit +def test_archive_name_unsupported_platform_raises(): with pytest.raises(NotImplementedError): - ARCHIVE_NAME("darwin", "arm64") + ARCHIVE_NAME("freebsd14", "x86_64") + + +@pytest.mark.unit +def test_archive_name_unsupported_arch_raises(): + with pytest.raises(NotImplementedError): + ARCHIVE_NAME("darwin", "ppc64") @pytest.mark.unit @@ -57,6 +77,10 @@ def test_binary_basename_format(): ("linux", "AMD64", "linux-x86_64.tar.gz"), # odd but plausible ("Linux", "x86_64", "linux-x86_64.tar.gz"), # case-insensitive platform ("WIN32", "AMD64", "win-x86_64.zip"), # ALL CAPS platform + ("darwin", "arm64", "macos-arm64.tar.gz"), # Apple Silicon + ("darwin", "aarch64", "macos-arm64.tar.gz"), # alternate ARM64 spelling + ("darwin", "AMD64", "macos-x86_64.tar.gz"), # Intel macOS + ("Darwin", "x86_64", "macos-x86_64.tar.gz"), # case-insensitive platform ]) def test_archive_name_accepts_case_variations(platform_key, machine, expected_substring): """sys.platform / platform.machine() return inconsistent casing across @@ -87,7 +111,7 @@ def test_archive_name_arm64_not_yet_supported(machine): @pytest.mark.unit -@pytest.mark.parametrize("platform_key", ["darwin", "freebsd", "cygwin", "openbsd"]) +@pytest.mark.parametrize("platform_key", ["freebsd", "cygwin", "openbsd"]) def test_archive_name_rejects_unsupported_platforms(platform_key): """Same logic — non-Linux/non-Windows platforms must raise, not silently pick one of the two.""" @@ -104,7 +128,7 @@ def test_archive_name_rejects_unsupported_platforms(platform_key): def test_binary_entry_rel_covers_every_supported_platform(): """If ARCHIVE_NAME accepts a platform key, BINARY_ENTRY_REL must declare where the executable lives inside the archive for it.""" - for plat in ["win32", "linux"]: + for plat in ["win32", "linux", "darwin"]: ARCHIVE_NAME(plat, "x86_64") # must not raise assert plat in BINARY_ENTRY_REL, ( f"ARCHIVE_NAME accepts {plat!r} but BINARY_ENTRY_REL has no entry " @@ -114,10 +138,16 @@ def test_binary_entry_rel_covers_every_supported_platform(): @pytest.mark.unit def test_binary_entry_rel_extension_matches_platform(): - """firefox.exe on Windows, plain `firefox` on Linux.""" + """firefox.exe on Windows, plain executable names on Linux and macOS.""" assert BINARY_ENTRY_REL["win32"].endswith(".exe") assert not BINARY_ENTRY_REL["linux"].endswith(".exe") assert BINARY_ENTRY_REL["linux"] == "firefox" + assert not BINARY_ENTRY_REL["darwin"].endswith(".exe") + + +@pytest.mark.unit +def test_macos_binary_entry_points_into_app_bundle(): + assert BINARY_ENTRY_REL["darwin"] == "Firefox.app/Contents/MacOS/firefox" # ---- RELEASE_URL_TEMPLATE shape ------------------------------------------- # @@ -176,7 +206,7 @@ def test_binary_basename_includes_upstream_version(): @pytest.mark.unit -@pytest.mark.parametrize("plat", ["win32", "linux"]) +@pytest.mark.parametrize("plat", ["win32", "linux", "darwin"]) def test_archive_name_includes_upstream_version(plat): """Same desync guard, from the other direction.""" assert FIREFOX_UPSTREAM_VERSION in ARCHIVE_NAME(plat, "x86_64") diff --git a/tests/test_download.py b/tests/test_download.py index b32dced..baa2631 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -321,6 +321,8 @@ def test_ensure_binary_accepts_binary_mode_checksums(tmp_path, monkeypatch): # Force the platform branch the test mocks: monkeypatch.setattr("sys.platform", "win32") + import platform + monkeypatch.setattr(platform, "machine", lambda: "AMD64") out = ensure_binary() # No RuntimeError means the parser accepted the `*`-prefixed key. assert out.exists() @@ -418,9 +420,9 @@ def test_github_token_none_when_unset(monkeypatch): # Bonus coverage: unsupported platform raises NotImplementedError before any HTTP @pytest.mark.unit def test_ensure_binary_unsupported_platform_raises(monkeypatch): - monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("sys.platform", "freebsd14") import platform - monkeypatch.setattr(platform, "machine", lambda: "AMD64") + monkeypatch.setattr(platform, "machine", lambda: "x86_64") with pytest.raises(NotImplementedError, match="unsupported platform"): ensure_binary() @@ -542,6 +544,96 @@ def test_ensure_binary_missing_entry_after_extract_raises_linux(tmp_path, monkey ensure_binary() +# ────────────────────────────────────────────────────────────────────── +# macOS platform tests — exercise the .app bundle path inside a tar.gz +# archive and both download/cache/error paths on darwin arm64. +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +@responses.activate +def test_ensure_binary_downloads_and_verifies_macos_arm64(tmp_path, monkeypatch): + """macOS happy path: tar.gz download -> SHA256 check -> extract -> return + the Firefox executable inside the .app bundle.""" + cache = tmp_path / "cache" + monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache) + + archive_path = tmp_path / "archive.tar.gz" + inner = "Firefox.app/Contents/MacOS/firefox" + archive_bytes = _make_targz(archive_path, inner, b"MACHO!") + archive_sha = hashlib.sha256(archive_bytes).hexdigest() + from invisible_playwright.constants import ARCHIVE_NAME + asset = ARCHIVE_NAME("darwin", "arm64") + + url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}" + url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt" + + responses.add(responses.GET, url_archive, body=archive_bytes, status=200, + content_type="application/gzip") + responses.add(responses.GET, url_sums, + body=f"{archive_sha} {asset}\n", status=200) + + monkeypatch.setattr("sys.platform", "darwin") + import platform + monkeypatch.setattr(platform, "machine", lambda: "arm64") + + path = ensure_binary() + assert Path(path).exists() + assert Path(path).as_posix().endswith("Firefox.app/Contents/MacOS/firefox") + + +@pytest.mark.unit +def test_ensure_binary_cache_hit_skips_http_macos(tmp_path, monkeypatch): + """macOS cache hit short-circuits before any HTTP and returns the + executable nested inside the cached .app bundle.""" + cache = tmp_path / "cache" + version_dir = cache / BINARY_VERSION / "Firefox.app" / "Contents" / "MacOS" + version_dir.mkdir(parents=True) + pre_cached = version_dir / "firefox" + pre_cached.write_text("cached-content") + + monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache) + monkeypatch.setattr("sys.platform", "darwin") + import platform + monkeypatch.setattr(platform, "machine", lambda: "arm64") + + def _fail_get(*args, **kwargs): + raise AssertionError("HTTP must not be called on cache hit") + monkeypatch.setattr("invisible_playwright.download.requests.get", _fail_get) + + path = ensure_binary() + assert path == pre_cached + assert path.read_text() == "cached-content" + + +@pytest.mark.unit +@responses.activate +def test_ensure_binary_missing_entry_after_extract_raises_macos(tmp_path, monkeypatch): + """If the macOS archive extracts without the .app executable, raise + instead of returning a broken path.""" + cache = tmp_path / "cache" + monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache) + + archive_path = tmp_path / "archive.tar.gz" + archive_bytes = _make_targz(archive_path, "Firefox.app/Contents/Info.plist", b"plist") + archive_sha = hashlib.sha256(archive_bytes).hexdigest() + from invisible_playwright.constants import ARCHIVE_NAME + asset = ARCHIVE_NAME("darwin", "arm64") + + url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}" + url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt" + + responses.add(responses.GET, url_archive, body=archive_bytes, status=200) + responses.add(responses.GET, url_sums, body=f"{archive_sha} {asset}\n", status=200) + + monkeypatch.setattr("sys.platform", "darwin") + import platform + monkeypatch.setattr(platform, "machine", lambda: "arm64") + + with pytest.raises(RuntimeError, match="binary not found after extraction"): + ensure_binary() + + # ========================================================================== # # _resolve_asset_url — public-repo direct URL vs private-repo API resolution # ========================================================================== # diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 35fad98..75ddcff 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -241,3 +241,16 @@ def test_e12_linux_resolve_headless_without_xvfb_raises_clear_error(monkeypatch) with pytest.raises(RuntimeError, match="Xvfb"): ip._resolve_headless() assert ip._virtual_display is None + + +@pytest.mark.e2e +def test_e13_darwin_resolve_headless_raises_clear_error(monkeypatch): + """E13: macOS can launch visibly, but ``headless=True`` should fail + early with a clear message until an invisible backend exists.""" + import sys as _sys + monkeypatch.setattr(_sys, "platform", "darwin") + + ip = InvisiblePlaywright(seed=42, headless=True) + with pytest.raises(RuntimeError, match="headless=True is not yet supported on macOS"): + ip._resolve_headless() + assert ip._virtual_display is None diff --git a/tests/test_headless.py b/tests/test_headless.py index d979b34..6228a62 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -7,9 +7,10 @@ construction's later ``start()`` call, and ``_LinuxVirtualDisplay`` calls ``Xvfb`` — both belong in integration/E2E coverage. The dispatcher's job is pure platform routing, which we patch via ``monkeypatch``. -Per scope: Windows-specific + platform-agnostic only. We still cover -the Linux dispatch branch because instantiating ``_LinuxVirtualDisplay`` -does no I/O — Xvfb is only spawned in ``start()``, which we never call. +Per scope: Windows/Linux hidden-display support plus the macOS guardrail. +We still cover the Linux dispatch branch because instantiating +``_LinuxVirtualDisplay`` does no I/O — Xvfb is only spawned in +``start()``, which we never call. """ from __future__ import annotations @@ -51,19 +52,17 @@ def test_make_virtual_display_accepts_linux_variants(monkeypatch): @pytest.mark.unit def test_make_virtual_display_raises_on_darwin(monkeypatch): - """macOS is unsupported — the dispatcher must raise with a clear - message rather than returning a no-op shim. ``InvisiblePlaywright`` - relies on this to bail before launching Firefox on a system where - the patched binary doesn't exist.""" + """macOS can launch visibly, but there is no hidden-display backend + for ``headless=True`` yet. The dispatcher should say that plainly.""" monkeypatch.setattr(headless.sys, "platform", "darwin") - with pytest.raises(RuntimeError, match="Windows and Linux only"): + with pytest.raises(RuntimeError, match="headless=True is not yet supported on macOS"): make_virtual_display() @pytest.mark.unit def test_make_virtual_display_raises_on_unsupported_platform(monkeypatch): monkeypatch.setattr(headless.sys, "platform", "freebsd14") - with pytest.raises(RuntimeError, match="Windows and Linux only"): + with pytest.raises(RuntimeError, match="headless=True supports Windows and Linux only"): make_virtual_display() diff --git a/tests/test_prefs.py b/tests/test_prefs.py index fa27345..3cb16ad 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -82,16 +82,24 @@ def test_accept_language_underscore_normalized(): @pytest.mark.unit -def test_font_metrics_windows_returns_empty(monkeypatch): - # FM2: Windows never applies width-scale factors. - monkeypatch.setattr(sys, "platform", "win32") - assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == "" - - -@pytest.mark.unit -def test_font_metrics_empty_input_returns_empty(): - # FM3: Empty input always returns "" regardless of platform. - assert _font_metrics_for_platform("") == "" +def test_font_metrics_windows_returns_empty(monkeypatch): + # FM2: Windows never applies width-scale factors. + monkeypatch.setattr(sys, "platform", "win32") + assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == "" + + +@pytest.mark.unit +def test_font_metrics_macos_returns_empty(monkeypatch): + # FM2b: macOS follows the same "no width-scale factors" path as + # Windows; only Linux needs the generic-family compensation block. + monkeypatch.setattr(sys, "platform", "darwin") + assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == "" + + +@pytest.mark.unit +def test_font_metrics_empty_input_returns_empty(): + # FM3: Empty input always returns "" regardless of platform. + assert _font_metrics_for_platform("") == "" # ────────────────────────────────────────────────────────────────────── @@ -143,13 +151,28 @@ def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch): @pytest.mark.unit -def test_webgl_extensions_cleared_on_windows(monkeypatch): - # WE2 - monkeypatch.setattr(sys, "platform", "win32") - p = generate_profile(seed=42) - prefs = translate_profile_to_prefs(p) - assert prefs["zoom.stealth.webgl.extensions"] == "" - assert prefs["zoom.stealth.webgl2.extensions"] == "" +def test_webgl_extensions_cleared_on_windows(monkeypatch): + # WE2 + monkeypatch.setattr(sys, "platform", "win32") + p = generate_profile(seed=42) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.webgl.extensions"] == "" + assert prefs["zoom.stealth.webgl2.extensions"] == "" + + +@pytest.mark.unit +def test_macos_uses_non_linux_webgl_defaults(monkeypatch): + # WE2b / PG2b: macOS follows the non-Linux branch for renderer/vendor + # and extension exposure until native macOS tuning lands. + monkeypatch.setattr(sys, "platform", "darwin") + p = generate_profile(seed=42, pin={"webgl.msaa_samples": 8}) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.webgl.renderer"] == "" + assert prefs["zoom.stealth.webgl.vendor"] == "" + assert prefs["zoom.stealth.webgl.extensions"] == "" + assert prefs["zoom.stealth.webgl2.extensions"] == "" + assert prefs["zoom.stealth.webgl.msaa"] == 4 + assert prefs["webgl.msaa-samples"] == 4 # ──────────────────────────────────────────────────────────────────────