mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
- Add profile_dir= kwarg to InvisiblePlaywright (sync + async). Maps to firefox.launch_persistent_context(); returns a BrowserContext. Cookies / localStorage / extensions / cache / prefs all persisted. - Drop the firefox-4 era workaround that filtered locale + timezone_id out of the persistent kwargs. firefox-5 ships the C++ docShell.overrideTimezone IDL method (50 LOC patch in docshell/base/nsIDocShell.idl + nsDocShell.cpp, see patch.md section 19 in feder-cr/invisible-firefox), so per-realm overrides land without crashing the launch handshake. - Bump BINARY_VERSION firefox-4 -> firefox-5. - Sentinel unit tests added: persistent kwargs MUST include locale + timezone_id (defends against re-introducing the workaround) and must NOT include timezone_id when timezone="" is the "host TZ" sentinel. Validation: smoke test against the local firefox-5 build, persistent context UP in 21s (was 180s timeout), Intl.timeZone == Europe/London, hardwareConcurrency / screen / DPR / locale all reflect the PIN.
271 lines
11 KiB
Python
271 lines
11 KiB
Python
"""Launcher helpers that don't require launching the actual browser."""
|
|
import pytest
|
|
|
|
from invisible_playwright.launcher import (
|
|
InvisiblePlaywright,
|
|
_IANA_TO_POSIX_TZ,
|
|
_tz_env,
|
|
_CHROME_W,
|
|
_CHROME_H,
|
|
_TASKBAR_H,
|
|
)
|
|
|
|
|
|
def test_tz_env_known_iana_returns_posix():
|
|
assert _tz_env("America/New_York") == "EST5EDT"
|
|
assert _tz_env("America/Chicago") == "CST6CDT"
|
|
assert _tz_env("America/Los_Angeles") == "PST8PDT"
|
|
|
|
|
|
def test_tz_env_arizona_no_dst():
|
|
"""America/Phoenix must NOT have a DST suffix — Arizona doesn't observe DST."""
|
|
assert _tz_env("America/Phoenix") == "MST7"
|
|
|
|
|
|
def test_tz_env_hawaii_no_dst():
|
|
assert _tz_env("Pacific/Honolulu") == "HST10"
|
|
|
|
|
|
def test_tz_env_unknown_iana_passes_through():
|
|
"""Linux glibc parses IANA names directly via /usr/share/zoneinfo,
|
|
so unknown zones should fall through unchanged."""
|
|
assert _tz_env("Europe/Berlin") == "Europe/Berlin"
|
|
assert _tz_env("Asia/Tokyo") == "Asia/Tokyo"
|
|
|
|
|
|
def test_iana_to_posix_table_well_formed():
|
|
for iana, posix in _IANA_TO_POSIX_TZ.items():
|
|
assert "/" in iana, f"{iana} is not an IANA zone identifier"
|
|
assert "/" not in posix, f"{posix} should be POSIX format, no slashes"
|
|
assert posix[0].isalpha(), f"{posix} should start with a letter"
|
|
|
|
|
|
def test_chrome_offsets_are_positive_ints():
|
|
"""These pad the spoofed viewport to fit inside the spoofed screen.
|
|
Any zero/negative value would let viewport bleed past screen bounds."""
|
|
assert _CHROME_W > 0
|
|
assert _CHROME_H > 0
|
|
assert _TASKBAR_H > 0
|
|
|
|
|
|
def test_invisible_playwright_constructs_without_launching():
|
|
"""The class should be instantiable for inspection without entering
|
|
the context manager (which would try to download the binary)."""
|
|
obj = InvisiblePlaywright(seed=42)
|
|
assert obj is not None
|
|
obj2 = InvisiblePlaywright(seed=42, headless=True)
|
|
assert obj2 is not None
|
|
|
|
|
|
# ─── profile_dir kwarg — persistent context support ─────────────────────── #
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_profile_dir_none_by_default():
|
|
"""No persistent profile unless explicitly opted in. Prevents accidental
|
|
state-leak between scripts that share the same seed."""
|
|
obj = InvisiblePlaywright(seed=42)
|
|
assert obj._profile_dir is None
|
|
assert obj._persistent_context is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_profile_dir_string_is_coerced_to_path(tmp_path):
|
|
"""Accept str or Path. Always store as Path internally."""
|
|
obj = InvisiblePlaywright(seed=42, profile_dir=str(tmp_path))
|
|
assert isinstance(obj._profile_dir, Path)
|
|
assert obj._profile_dir == tmp_path
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_profile_dir_path_is_stored_as_is(tmp_path):
|
|
obj = InvisiblePlaywright(seed=42, profile_dir=tmp_path)
|
|
assert obj._profile_dir == tmp_path
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_profile_dir_does_not_create_dir_until_enter(tmp_path):
|
|
"""Construction must not touch the filesystem. Directory creation only
|
|
happens when the user actually enters the context manager — otherwise
|
|
a typo at instantiation would silently spawn dirs."""
|
|
target = tmp_path / "nonexistent"
|
|
assert not target.exists()
|
|
InvisiblePlaywright(seed=42, profile_dir=target)
|
|
assert not target.exists()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_persistent_context_kwargs_match_default_exactly():
|
|
"""Persistent kwargs must be IDENTICAL to non-persistent default
|
|
kwargs. From firefox-5 (C7 closure) the docShell.overrideTimezone
|
|
method is present in the patched binary, so the per-realm overrides
|
|
Playwright applies for `locale=`/`timezone_id=` land successfully and
|
|
no longer hang the persistent context launch handshake.
|
|
|
|
Before firefox-5 we had to filter these out (180s timeout otherwise).
|
|
A future refactor that re-introduces that filter would silently lose
|
|
timezone/locale isolation in persistent sessions — this test is the
|
|
sentinel that catches the regression at the unit level."""
|
|
obj = InvisiblePlaywright(seed=42, locale="en-GB", timezone="Europe/London",
|
|
profile_dir="/tmp/x")
|
|
persistent = obj._persistent_context_kwargs()
|
|
default = obj._default_context_kwargs()
|
|
assert persistent == default, (
|
|
"persistent_context kwargs must match default_context kwargs since "
|
|
f"firefox-5.\n persistent: {persistent!r}\n default: {default!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_persistent_context_kwargs_INCLUDES_locale_and_timezone():
|
|
"""Sentinel for the C7 closure: firefox-5 ships the C++ overrideTimezone
|
|
IDL method, so locale + timezone_id MUST be passed through to
|
|
launch_persistent_context. If they're not, the wrapper is silently
|
|
dropping per-context isolation — two sessions with different
|
|
`timezone=` would end up sharing whatever TZ the env var set.
|
|
|
|
Regression-defense: do NOT re-add the firefox-4-era filter."""
|
|
obj = InvisiblePlaywright(seed=42, locale="en-GB", timezone="Europe/London",
|
|
profile_dir="/tmp/x")
|
|
kw = obj._persistent_context_kwargs()
|
|
assert kw.get("locale") == "en-GB", (
|
|
f"locale must be in persistent kwargs (firefox-5+ supports it via "
|
|
f"docShell.languageOverride). Got: {kw.get('locale')!r}"
|
|
)
|
|
assert kw.get("timezone_id") == "Europe/London", (
|
|
f"timezone_id must be in persistent kwargs (firefox-5+ supports it "
|
|
f"via docShell.overrideTimezone IDL method, patch.md section 19). "
|
|
f"Got: {kw.get('timezone_id')!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_persistent_context_kwargs_omits_timezone_when_empty_string():
|
|
"""Empty timezone='' is the 'use host TZ' sentinel — must NOT pass
|
|
timezone_id to Playwright in that case (would pin to literal '' and
|
|
break Intl)."""
|
|
obj = InvisiblePlaywright(seed=42, timezone="", profile_dir="/tmp/x")
|
|
kw = obj._persistent_context_kwargs()
|
|
assert "timezone_id" not in kw
|
|
|
|
|
|
# ─── Mocked __enter__ flow — confirms the right Playwright call is made ── #
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_enter_with_profile_dir_calls_launch_persistent_context(tmp_path, monkeypatch):
|
|
"""When profile_dir is set, __enter__ must call
|
|
`firefox.launch_persistent_context(user_data_dir=...)` and NOT
|
|
`firefox.launch(...)`. This is the structural test that the persistent
|
|
branch is wired correctly — without it, profile_dir would be silently
|
|
accepted but ignored."""
|
|
from unittest.mock import MagicMock
|
|
# Mock ensure_binary so we don't hit the network
|
|
monkeypatch.setattr("invisible_playwright.launcher.ensure_binary",
|
|
lambda: tmp_path / "firefox")
|
|
|
|
# Mock sync_playwright().start() → fake playwright with our recording firefox
|
|
fake_ctx = MagicMock(name="persistent_context")
|
|
fake_firefox = MagicMock()
|
|
fake_firefox.launch_persistent_context.return_value = fake_ctx
|
|
fake_playwright = MagicMock()
|
|
fake_playwright.firefox = fake_firefox
|
|
fake_pw = MagicMock()
|
|
fake_pw.start.return_value = fake_playwright
|
|
|
|
monkeypatch.setattr("invisible_playwright.launcher.sync_playwright",
|
|
lambda: fake_pw)
|
|
|
|
profile = tmp_path / "myprofile"
|
|
obj = InvisiblePlaywright(seed=42, profile_dir=profile)
|
|
returned = obj.__enter__()
|
|
|
|
# The persistent branch was taken
|
|
fake_firefox.launch_persistent_context.assert_called_once()
|
|
fake_firefox.launch.assert_not_called()
|
|
|
|
# The user_data_dir was passed verbatim
|
|
call_kwargs = fake_firefox.launch_persistent_context.call_args.kwargs
|
|
assert call_kwargs["user_data_dir"] == str(profile)
|
|
|
|
# The directory was created on disk (Playwright fails otherwise)
|
|
assert profile.exists() and profile.is_dir()
|
|
|
|
# __enter__ returned the BrowserContext, not a Browser
|
|
assert returned is fake_ctx
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_enter_without_profile_dir_calls_launch_not_persistent(tmp_path, monkeypatch):
|
|
"""Default path: profile_dir=None → firefox.launch, not
|
|
launch_persistent_context. Sentinel that the non-persistent flow
|
|
isn't accidentally rerouted."""
|
|
from unittest.mock import MagicMock
|
|
monkeypatch.setattr("invisible_playwright.launcher.ensure_binary",
|
|
lambda: tmp_path / "firefox")
|
|
|
|
fake_browser = MagicMock(name="browser")
|
|
fake_browser.new_context = MagicMock()
|
|
fake_firefox = MagicMock()
|
|
fake_firefox.launch.return_value = fake_browser
|
|
fake_playwright = MagicMock()
|
|
fake_playwright.firefox = fake_firefox
|
|
fake_pw = MagicMock()
|
|
fake_pw.start.return_value = fake_playwright
|
|
|
|
monkeypatch.setattr("invisible_playwright.launcher.sync_playwright",
|
|
lambda: fake_pw)
|
|
|
|
obj = InvisiblePlaywright(seed=42)
|
|
returned = obj.__enter__()
|
|
|
|
fake_firefox.launch.assert_called_once()
|
|
fake_firefox.launch_persistent_context.assert_not_called()
|
|
assert returned is fake_browser
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_persistent_context_user_data_dir_is_created_if_missing(tmp_path, monkeypatch):
|
|
"""First-run scenario: the directory the user names doesn't exist yet.
|
|
__enter__ must mkdir -p it (Playwright won't, and would crash with
|
|
'user_data_dir does not exist')."""
|
|
from unittest.mock import MagicMock
|
|
monkeypatch.setattr("invisible_playwright.launcher.ensure_binary",
|
|
lambda: tmp_path / "firefox")
|
|
fake_pw = MagicMock()
|
|
fake_pw.start.return_value = MagicMock()
|
|
fake_pw.start.return_value.firefox.launch_persistent_context = MagicMock(
|
|
return_value=MagicMock()
|
|
)
|
|
monkeypatch.setattr("invisible_playwright.launcher.sync_playwright",
|
|
lambda: fake_pw)
|
|
|
|
nested = tmp_path / "a" / "b" / "c" / "profile"
|
|
assert not nested.parent.exists() # parent doesn't exist either
|
|
obj = InvisiblePlaywright(seed=42, profile_dir=nested)
|
|
obj.__enter__()
|
|
assert nested.is_dir()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_teardown_closes_persistent_context(tmp_path, monkeypatch):
|
|
"""The teardown must close the persistent context. Forgetting this
|
|
leaves Firefox + Playwright running until the parent process exits,
|
|
which on long-running tools (job orchestrators, MCP servers) leaks
|
|
handles indefinitely."""
|
|
from unittest.mock import MagicMock
|
|
monkeypatch.setattr("invisible_playwright.launcher.ensure_binary",
|
|
lambda: tmp_path / "firefox")
|
|
fake_ctx = MagicMock(name="persistent_context")
|
|
fake_pw = MagicMock()
|
|
fake_pw.start.return_value.firefox.launch_persistent_context.return_value = fake_ctx
|
|
monkeypatch.setattr("invisible_playwright.launcher.sync_playwright",
|
|
lambda: fake_pw)
|
|
|
|
obj = InvisiblePlaywright(seed=42, profile_dir=tmp_path / "p")
|
|
obj.__enter__()
|
|
obj.__exit__(None, None, None)
|
|
fake_ctx.close.assert_called_once()
|