feat: persistent profile dir + C7 closure (firefox-5 / 0.1.6)
- 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.
This commit is contained in:
@@ -55,3 +55,217 @@ def test_invisible_playwright_constructs_without_launching():
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user