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:
feder-cr 2026-05-21 12:19:38 -07:00
parent acd568f5d3
commit 22b1171518
6 changed files with 313 additions and 6 deletions

View file

@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.1.6] - 2026-05-21
### Added
- `profile_dir=` kwarg on `InvisiblePlaywright` (sync + async). When set, the session uses `firefox.launch_persistent_context()` so cookies, localStorage, sessionStorage, extensions, cache and prefs are kept on disk between runs. `__enter__` returns a `BrowserContext` directly: `with InvisiblePlaywright(profile_dir=p) as ctx: ctx.new_page()`. Pair with a stable `seed=` to also pin the fingerprint identity across runs. First run creates the dir; subsequent runs reuse it.
### Fixed
- `launch_persistent_context(timezone_id="…")` no longer times out at 180s. Root cause: `juggler/content/main.js` calls `docShell.overrideTimezone(...)` on every navigation; the patched Firefox up to firefox-4 didn't expose that IDL method on `nsIDocShell`, so the call threw `TypeError: docShell.overrideTimezone is not a function`. On the non-persistent path the error fired *after* launch and was harmless; on the persistent path it blocked the launch handshake. `firefox-5` ships the C++ method (see `patch.md` section 19); this release removes the firefox-4 era Python workaround that was filtering `locale`/`timezone_id` out of the persistent context kwargs.
### Changed
- `BINARY_VERSION` bumped from `firefox-4` to `firefox-5`. The Python source delta is JS/Python only; the new Firefox build adds 50 lines of C++ in `docshell/base/nsIDocShell.idl` + `nsDocShell.cpp`.
## [0.1.5] - 2026-05-20
### Fixed

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "invisible-playwright"
version = "0.1.5"
version = "0.1.6"
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
readme = "README.md"
requires-python = ">=3.11"

View file

@ -3,9 +3,10 @@ from __future__ import annotations
import asyncio
import secrets
from pathlib import Path
from typing import Any, Dict, Optional, Union
from playwright.async_api import Browser, Playwright, async_playwright
from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright
from ._fpforge import Profile, generate_profile
from ._headless import make_virtual_display
@ -49,6 +50,7 @@ class InvisiblePlaywright:
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
binary_path: Optional[str] = None,
profile_dir: Optional[Union[str, Path]] = None,
) -> None:
# See sync launcher: `zoom.stealth.fpp.hw_seed` is int32_t — clamp.
self.seed: int = int(seed) if seed is not None else secrets.randbits(31)
@ -61,12 +63,14 @@ class InvisiblePlaywright:
self._timezone = timezone
self._extra_prefs = extra_prefs
self._binary_path = binary_path
self._profile_dir: Optional[Path] = Path(profile_dir) if profile_dir else None
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
self._pw: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._persistent_context: Optional[BrowserContext] = None
self._virtual_display: Any = None
async def __aenter__(self) -> Browser:
async def __aenter__(self) -> Union[Browser, BrowserContext]:
import sys as _sys
executable = self._binary_path or ensure_binary()
prefs = translate_profile_to_prefs(
@ -85,6 +89,24 @@ class InvisiblePlaywright:
env = self._build_env()
try:
self._pw = await async_playwright().start()
if self._profile_dir is not None:
# See sync launcher for the persistent-context rationale.
self._profile_dir.mkdir(parents=True, exist_ok=True)
# firefox-5 ships the C++ overrideTimezone IDL method (C7
# chiusura), so locale + timezone_id now propagate cleanly
# to the persistent context without hanging the launch.
self._persistent_context = await self._pw.firefox.launch_persistent_context(
user_data_dir=str(self._profile_dir),
executable_path=str(executable),
headless=pw_headless,
firefox_user_prefs=prefs,
proxy=playwright_proxy,
args=self._extra_args,
env=env,
**self._default_context_kwargs(),
)
_patch_new_page_sleep(self._persistent_context)
return self._persistent_context
self._browser = await self._pw.firefox.launch(
executable_path=str(executable),
headless=pw_headless,
@ -134,6 +156,12 @@ class InvisiblePlaywright:
await self._teardown()
async def _teardown(self) -> None:
if self._persistent_context is not None:
try:
await self._persistent_context.close()
except Exception:
pass
self._persistent_context = None
if self._browser is not None:
try:
await self._browser.close()

View file

@ -7,7 +7,7 @@ bugfixes don't force a multi-hour Firefox rebuild.
from __future__ import annotations
# Bump this when a new patched Firefox build is released on GitHub.
BINARY_VERSION: str = "firefox-4"
BINARY_VERSION: str = "firefox-5"
# Underlying Firefox version (for display only; does not drive downloads).
FIREFOX_UPSTREAM_VERSION: str = "150.0.1"

View file

@ -2,9 +2,10 @@
from __future__ import annotations
import secrets
from pathlib import Path
from typing import Any, Dict, Optional, Union
from playwright.sync_api import Browser, Playwright, sync_playwright
from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright
from ._fpforge import Profile, generate_profile
from ._headless import make_virtual_display
@ -111,6 +112,7 @@ class InvisiblePlaywright:
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
binary_path: Optional[str] = None,
profile_dir: Optional[Union[str, Path]] = None,
) -> None:
"""
Args:
@ -137,6 +139,15 @@ class InvisiblePlaywright:
extra_prefs: Optional dict of Firefox prefs overlayed on top
of the generated profile useful for niche tweaks
without monkey-patching the package.
profile_dir: Path to a persistent Firefox profile directory.
When set, the session uses ``launch_persistent_context()``
so cookies, localStorage, sessionStorage, extensions, cache
and prefs are kept on disk between runs. ``__enter__``
returns a ``BrowserContext`` (not a ``Browser``) use it
directly: ``with InvisiblePlaywright(profile_dir=p) as ctx:
page = ctx.new_page()``. First run creates the dir;
subsequent runs reuse it. Pair with a stable ``seed=`` to
also pin the fingerprint identity across runs.
"""
# Constrain to int31 — Firefox's `zoom.stealth.fpp.hw_seed` and
# related stealth prefs are declared as ``int32_t`` in
@ -154,12 +165,14 @@ class InvisiblePlaywright:
self._timezone = timezone
self._extra_prefs = extra_prefs
self._binary_path = binary_path
self._profile_dir: Optional[Path] = Path(profile_dir) if profile_dir else None
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
self._pw: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._persistent_context: Optional[BrowserContext] = None
self._virtual_display: Any = None
def __enter__(self) -> Browser:
def __enter__(self) -> Union[Browser, BrowserContext]:
executable = self._binary_path or ensure_binary()
prefs = self._build_prefs()
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
@ -168,6 +181,25 @@ class InvisiblePlaywright:
try:
self._pw = sync_playwright().start()
if self._profile_dir is not None:
# Persistent context — cookies / localStorage / extensions /
# prefs all live on disk between runs. Stealth prefs are
# re-injected via firefox_user_prefs on every launch (Playwright
# writes them to user.js, which overrides anything in
# prefs.js inside the persistent dir).
self._profile_dir.mkdir(parents=True, exist_ok=True)
self._persistent_context = self._pw.firefox.launch_persistent_context(
user_data_dir=str(self._profile_dir),
executable_path=str(executable),
headless=pw_headless,
firefox_user_prefs=prefs,
proxy=playwright_proxy,
args=self._extra_args,
env=env,
**self._persistent_context_kwargs(),
)
_patch_sync_new_page_sleep(self._persistent_context)
return self._persistent_context
self._browser = self._pw.firefox.launch(
executable_path=str(executable),
headless=pw_headless,
@ -185,6 +217,22 @@ class InvisiblePlaywright:
self._patch_new_context_defaults(self._browser)
return self._browser
def _persistent_context_kwargs(self) -> Dict[str, Any]:
"""Context-level kwargs accepted by launch_persistent_context.
Identical to ``_default_context_kwargs``: viewport / screen / DPR /
color-scheme / locale / timezone_id. Up to firefox-4 we had to drop
locale and timezone_id because Playwright's per-realm overrides
called IDL methods (``docShell.languageOverride``,
``docShell.overrideTimezone``) that weren't exposed by our patched
build, causing launch_persistent_context to hang for 180s. From
firefox-5 (C7 chiusura), the C++ ``overrideTimezone`` method is
present and ``languageOverride`` was already there, so the
per-realm overrides land and the persistent context starts in
~20s like the non-persistent path.
"""
return self._default_context_kwargs()
def _patch_new_context_defaults(self, browser: Browser) -> None:
"""Wrap ``browser.new_context`` so its defaults derive from the
profile (viewport, screen, DPR, color-scheme). Users get a
@ -226,6 +274,12 @@ class InvisiblePlaywright:
self._teardown()
def _teardown(self) -> None:
if self._persistent_context is not None:
try:
self._persistent_context.close()
except Exception:
pass
self._persistent_context = None
if self._browser is not None:
try:
self._browser.close()

View file

@ -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()