feat(python): warn when a supplied binary predates the Playwright in use

A managed install below the version floor is upgraded by pkgman, but
executable_path deliberately bypasses that -- the caller supplied the binary,
so we neither replace it nor download another. That left one pairing nothing
checked: an old build driven by Playwright >= 1.61, which sends viewport fields
the older Juggler schema rejects. The user saw a bare

    Protocol error (Browser.setDefaultViewport)

with nothing naming the cause.

Warn rather than raise, because the pairing is not always fatal. Camoufox
defaults to no_viewport when it spoofs window dimensions (sync_api), and
Playwright then never sends Browser.setDefaultViewport -- so the default path
works fine on an old build. Measured against a real beta.29 binary on
Playwright 1.62:

    default path                     WORKS
    new_context(viewport=...)        BREAKS
    new_context(no_viewport=False)   BREAKS
    new_context(viewport=..., is_mobile=False)  BREAKS

Refusing to launch would break the setups in the first row. A build with no
version.json beside it -- an unpackaged objdir build -- tells us nothing, so it
is left alone rather than nagged about.

Verified end to end: warns on the real beta.29 build under Playwright 1.62,
silent on beta.30.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-08-31 19:34:52 -06:00
co-authored by Claude Opus 5
parent b68a4fb940
commit 8cb7914328
2 changed files with 125 additions and 0 deletions
+56
View File
@@ -25,9 +25,13 @@ from .fingerprints import from_browserforge, from_preset, generate_fingerprint,
from .geolocation import geoip_allowed, get_geolocation
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
from .locales import handle_locales
import warnings
from .pkgman import (
INSTALL_DIR,
OS_NAME,
Version,
effective_version_min,
ensure_browser_profile_dir,
get_path,
installed_verstr,
@@ -86,6 +90,57 @@ def _generate_fontconfig(fontconfig_path: str, path: Optional[Path] = None) -> s
return runtime_conf
def warn_if_executable_predates_playwright(path: Optional[Path]) -> None:
"""Warn when a caller's own binary is older than their Playwright needs.
A managed install below the floor is simply upgraded (pkgman resolves it),
but `executable_path` deliberately bypasses that -- the caller supplied the
binary, so we neither replace it nor download another. That leaves the one
pairing nothing checks: an old build driven by Playwright >= 1.61, which
sends viewport fields the older Juggler schema rejects.
This warns rather than raises, because the pairing is not always fatal.
Camoufox defaults to no_viewport when it spoofs window dimensions
(sync_api), and Playwright then never sends Browser.setDefaultViewport --
so the default path works on an old build. It breaks only when a viewport
is set explicitly, and then the error is a bare "Protocol error
(Browser.setDefaultViewport)" with nothing pointing at the real cause.
Refusing to launch would break setups that currently work.
A build with no version.json beside it -- an unpackaged objdir build, say --
tells us nothing, so it is left alone.
"""
if path is None:
return
try:
installed = Version.from_path(Path(path).parent)
except (FileNotFoundError, KeyError, ValueError):
return
required = effective_version_min()
if installed >= required:
return
warnings.warn(
f"The Camoufox build at {path} is {installed.build}, but Playwright "
f"{_resolved_playwright_version_str()} needs at least {required.build}. "
"Contexts created with an explicit viewport will fail with "
'"Protocol error (Browser.setDefaultViewport)". Update the build, or pin '
"playwright<1.61.",
RuntimeWarning,
stacklevel=3,
)
def _resolved_playwright_version_str() -> str:
from importlib.metadata import version
try:
return version('playwright')
except Exception:
return 'the installed version'
def get_env_vars(
config_map: Dict[str, str],
user_agent_os: str,
@@ -951,6 +1006,7 @@ def launch_options(
pprint(config)
# Validate the config
warn_if_executable_predates_playwright(executable_path)
validate_config(config, path=executable_path)
# Prepare environment variables to pass to Camoufox
@@ -0,0 +1,69 @@
"""A caller's own binary is never replaced -- but it should still be checked.
A managed install below the floor gets upgraded by pkgman. `executable_path`
deliberately bypasses that, which leaves one pairing nothing checks: an old
build driven by Playwright >= 1.61, whose viewport fields the older Juggler
schema rejects. Without this the user sees a bare "Protocol error
(Browser.setDefaultViewport)" and nothing naming the cause.
It warns rather than raises on purpose: camoufox defaults to no_viewport when
it spoofs window dimensions, so the default path works on an old build. Only an
explicit viewport breaks, so refusing to launch would break working setups.
"""
import json
import pytest
from camoufox import pkgman, utils
def _bundle(tmp_path, build):
"""A browser directory with version.json beside the binary, as a release has."""
d = tmp_path / f"152.0.4-{build}"
d.mkdir()
(d / "version.json").write_text(json.dumps({"version": "152.0.4", "build": build}))
return d / "camoufox-bin"
@pytest.fixture
def floor_at_beta30(monkeypatch):
monkeypatch.setattr(utils, "effective_version_min", lambda: pkgman.Version(build="beta.30"))
def test_warns_when_the_supplied_build_is_too_old(tmp_path, floor_at_beta30):
exe = _bundle(tmp_path, "beta.29")
with pytest.warns(RuntimeWarning, match=r"beta\.29.*beta\.30"):
utils.warn_if_executable_predates_playwright(exe)
def test_names_the_symptom_the_user_will_actually_see(tmp_path, floor_at_beta30):
exe = _bundle(tmp_path, "beta.29")
with pytest.warns(RuntimeWarning) as caught:
utils.warn_if_executable_predates_playwright(exe)
assert "Browser.setDefaultViewport" in str(caught[0].message)
@pytest.mark.parametrize("build", ["beta.30", "beta.31"])
def test_silent_when_the_build_is_new_enough(tmp_path, floor_at_beta30, build, recwarn):
utils.warn_if_executable_predates_playwright(_bundle(tmp_path, build))
assert not [w for w in recwarn if issubclass(w.category, RuntimeWarning)]
def test_silent_for_a_custom_build_with_no_version_json(tmp_path, floor_at_beta30, recwarn):
"""An unpackaged objdir build tells us nothing; do not nag about it."""
(tmp_path / "dist").mkdir()
utils.warn_if_executable_predates_playwright(tmp_path / "dist" / "camoufox-bin")
assert not [w for w in recwarn if issubclass(w.category, RuntimeWarning)]
def test_silent_when_no_executable_path_was_given(floor_at_beta30, recwarn):
utils.warn_if_executable_predates_playwright(None)
assert not [w for w in recwarn if issubclass(w.category, RuntimeWarning)]