diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index cf7982d..537562e 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -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 diff --git a/pythonlib/tests/test_executable_path_version_warning.py b/pythonlib/tests/test_executable_path_version_warning.py new file mode 100644 index 0000000..67a1e2f --- /dev/null +++ b/pythonlib/tests/test_executable_path_version_warning.py @@ -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)]