From b1fe7227fad207e4040628125b53b1da93da271e Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Mon, 31 Aug 2026 15:08:26 -0600 Subject: [PATCH] fix(python): key the browser floor on Playwright instead of a flat minimum The incompatibility is two-dimensional -- it needs both a Playwright >= 1.61 and a browser < beta.30 -- but MIN_VERSION only knows about the browser. To stay safe a flat floor has to assume the worst Playwright, which means: * every 0.5.6 user re-downloads the browser, including the majority on <1.61 who are in no danger; * installs pinned to an older build lose the pin, and prerelease/alpha users are moved off their channel, since every alpha sorts below beta.30; * the library cannot run at all until the matching browser release is published, making the PyPI-after-release ordering load-bearing. Key it on the resolved Playwright instead. Measured: 1.60 works on beta.29 and beta.30; 1.61 and 1.62 fail on beta.29 and pass on beta.30. playwright <1.61 -> floor alpha.1 -> every install kept playwright >=1.61 -> floor beta.30 -> below-beta.30 installs upgraded version unreadable -> floor alpha.1 -> kept; a spurious forced re-download is worse than leaving a working install Co-Authored-By: Claude Opus 5 (1M context) --- pythonlib/camoufox/__version__.py | 20 +++++++- pythonlib/camoufox/pkgman.py | 30 +++++++++++- pythonlib/tests/test_version_floor_upgrade.py | 46 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/pythonlib/camoufox/__version__.py b/pythonlib/camoufox/__version__.py index c42fa0d..cfad362 100644 --- a/pythonlib/camoufox/__version__.py +++ b/pythonlib/camoufox/__version__.py @@ -8,9 +8,27 @@ class CONSTRAINTS: The minimum and maximum supported versions of the Camoufox browser. """ - MIN_VERSION = 'beta.30' + MIN_VERSION = 'alpha.1' MAX_VERSION = '1' + # The browser floor is conditional on the resolved Playwright, not fixed. + # + # Each entry is (playwright_version, required_browser_build): from that + # Playwright on, the browser must be at least that build. 1.61 began + # sending viewport isMobile/screenSize in Browser.setDefaultViewport and + # Page.setViewportSize; beta.30 is the first build whose Protocol.js schema + # accepts them. Below that pairing every new_context() dies with + # "Protocol error (Browser.setDefaultViewport)". Measured: 1.60 works on + # beta.29 and beta.30; 1.61 and 1.62 fail on beta.29 and pass on beta.30. + # + # A flat MIN_VERSION cannot express this. It only knows about the browser, + # so to stay safe it has to assume the worst Playwright and force *every* + # user to re-download -- including the majority on <1.61, who are in no + # danger -- and it leaves the library unusable until the matching browser + # release is published. Keyed on Playwright, only the users who would + # actually break get moved. + PLAYWRIGHT_BROWSER_FLOORS = (((1, 61), 'beta.30'),) + @staticmethod def as_range() -> str: """ diff --git a/pythonlib/camoufox/pkgman.py b/pythonlib/camoufox/pkgman.py index 485bf8d..67c9ae5 100644 --- a/pythonlib/camoufox/pkgman.py +++ b/pythonlib/camoufox/pkgman.py @@ -367,7 +367,7 @@ class Version: return self.sorted_rel < other.sorted_rel def is_supported(self) -> bool: - return VERSION_MIN <= self < VERSION_MAX + return effective_version_min() <= self < VERSION_MAX @staticmethod def from_path(path: Optional[Path] = None) -> 'Version': @@ -406,6 +406,34 @@ class Version: VERSION_MIN, VERSION_MAX = Version.build_minmax() +def _resolved_playwright_version() -> Optional[Tuple[int, ...]]: + """The installed Playwright version, or None if it cannot be determined.""" + from importlib.metadata import version + + try: + return _parse_semver(version('playwright')) + except Exception: + return None + + +def effective_version_min() -> 'Version': + """The lowest browser build this install can actually talk to. + + VERSION_MIN, raised by whatever the resolved Playwright requires. When the + Playwright version cannot be read we fall back to VERSION_MIN rather than + assuming the worst: a spurious forced re-download is worse than leaving a + working install alone, and pyproject caps Playwright anyway. + """ + floor = VERSION_MIN + playwright_version = _resolved_playwright_version() + if playwright_version is None: + return floor + for required_playwright, build in CONSTRAINTS.PLAYWRIGHT_BROWSER_FLOORS: + if playwright_version >= required_playwright and floor < Version(build=build): + floor = Version(build=build) + return floor + + class GitHubDownloader: """ Manages fetching GitHub releases with fallback repos diff --git a/pythonlib/tests/test_version_floor_upgrade.py b/pythonlib/tests/test_version_floor_upgrade.py index 8c59175..70bb580 100644 --- a/pythonlib/tests/test_version_floor_upgrade.py +++ b/pythonlib/tests/test_version_floor_upgrade.py @@ -119,3 +119,49 @@ def test_unsatisfiable_floor_reports_instead_of_recursing(tmp_path, monkeypatch) pkgman.camoufox_path() assert attempts == [True], "should fetch once, not spin" + + +class TestConditionalFloor: + """The browser floor is keyed on the resolved Playwright, not fixed. + + A flat floor can only speak about the browser, so to be safe it has to + assume the worst Playwright and re-download for everyone -- and it makes + the library unusable until the matching browser release exists. Keyed on + Playwright, only the pairing that actually breaks gets moved. + """ + + @staticmethod + def _with_playwright(monkeypatch, version): + parsed = pkgman._parse_semver(version) if version else None + monkeypatch.setattr(pkgman, "_resolved_playwright_version", lambda: parsed) + + @pytest.mark.parametrize("version", ["1.53.0", "1.60.0"]) + def test_old_playwright_leaves_older_builds_alone(self, monkeypatch, version): + """<1.61 never sends the fields beta.29 rejects, so nothing must move.""" + self._with_playwright(monkeypatch, version) + assert pkgman.effective_version_min() == pkgman.Version(build="alpha.1") + + @pytest.mark.parametrize("version", ["1.61.0", "1.62.0"]) + def test_new_playwright_raises_the_floor(self, monkeypatch, version): + self._with_playwright(monkeypatch, version) + assert pkgman.effective_version_min() == pkgman.Version(build="beta.30") + + def test_unreadable_playwright_falls_back_permissive(self, monkeypatch): + """A spurious forced re-download is worse than leaving a working install.""" + self._with_playwright(monkeypatch, None) + assert pkgman.effective_version_min() == pkgman.Version(build="alpha.1") + + def test_old_playwright_keeps_a_below_floor_install(self, tmp_path, monkeypatch): + _install(tmp_path, monkeypatch, "versioned", build="beta.29", floor="alpha.1") + self._with_playwright(monkeypatch, "1.60.0") + + resolved = pkgman.camoufox_path(download_if_missing=False) + + assert resolved.name == "152.0.4-beta.29" + + def test_new_playwright_moves_a_below_floor_install(self, tmp_path, monkeypatch): + _install(tmp_path, monkeypatch, "versioned", build="beta.29", floor="alpha.1") + self._with_playwright(monkeypatch, "1.62.0") + + with pytest.raises(UnsupportedVersion): + pkgman.camoufox_path(download_if_missing=False)