From da677752576da3c1747ea01111ca49e464da5093 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Mon, 31 Aug 2026 13:13:56 -0600 Subject: [PATCH] fix(python): reach the fetch path when the installed build is below the floor Raising CONSTRAINTS.MIN_VERSION is how this library has always forced a browser upgrade (beta.12 -> beta.15 -> beta.17 -> beta.18 -> beta.19); the floor only became 'alpha.1' incidentally, in an unrelated PR. That left the branch dead, and it had rotted: camoufox_path() probed INSTALL_DIR/version.json, which only the pre-multiversion flat layout ever wrote. With a versioned install below the floor it raised FileNotFoundError instead of falling through to a fetch, so raising the floor would have crashed every existing user rather than upgrading them. Treat a missing root version.json as "no legacy install here" so the caller falls through to CamoufoxFetcher().install() as intended. Co-Authored-By: Claude Opus 5 (1M context) --- pythonlib/camoufox/pkgman.py | 18 +++- pythonlib/tests/test_version_floor_upgrade.py | 97 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 pythonlib/tests/test_version_floor_upgrade.py diff --git a/pythonlib/camoufox/pkgman.py b/pythonlib/camoufox/pkgman.py index 7000282..d988f0c 100644 --- a/pythonlib/camoufox/pkgman.py +++ b/pythonlib/camoufox/pkgman.py @@ -755,6 +755,22 @@ def installed_verstr() -> str: return Version.from_path(active).full_string +def _root_install_supported() -> bool: + """ + Whether INSTALL_DIR's root holds a supported build. + + Only the pre-multiversion flat layout wrote version.json at the root; the + versioned layout keeps it under browsers///. A missing root + version.json means "no legacy install here", so the caller should fall + through to a fetch rather than raise. The alpha.1 floor masked this: no + install was ever unsupported, so this branch was never reached. + """ + try: + return Version.from_path().is_supported() + except FileNotFoundError: + return False + + def camoufox_path(download_if_missing: bool = True) -> Path: """ Full path to the active camoufox folder @@ -787,7 +803,7 @@ def camoufox_path(download_if_missing: bool = True) -> Path: f"{active_display} is not installed. " f"Please run `camoufox fetch` to install." ) - elif os.path.exists(INSTALL_DIR) and Version.from_path().is_supported(): + elif os.path.exists(INSTALL_DIR) and _root_install_supported(): return INSTALL_DIR else: diff --git a/pythonlib/tests/test_version_floor_upgrade.py b/pythonlib/tests/test_version_floor_upgrade.py new file mode 100644 index 0000000..831f6eb --- /dev/null +++ b/pythonlib/tests/test_version_floor_upgrade.py @@ -0,0 +1,97 @@ +"""Regression coverage for raising the supported browser floor. + +Raising ``CONSTRAINTS.MIN_VERSION`` is how the library forces an existing +install to upgrade when it can no longer talk to the old browser. That path +had been dead since the floor was dropped to ``alpha.1``, and it hid a bug: +``camoufox_path`` probed ``INSTALL_DIR/version.json``, which only the +pre-multiversion flat layout ever wrote, so a below-floor versioned install +raised ``FileNotFoundError`` instead of falling through to a fetch. +""" + +import json + +import pytest + +from camoufox import multiversion, pkgman +from camoufox.exceptions import UnsupportedVersion + + +def _install(tmp_path, monkeypatch, layout, build, floor): + """Build an install dir in the given layout and point the library at it.""" + root = tmp_path / "cache" + root.mkdir() + (root / ".0.5_FLAG").write_text("") + (root / "repo_cache.json").write_text("{}") + + if layout == "versioned": + relative = f"browsers/official/152.0.4-{build}" + (root / "config.json").write_text(json.dumps({"active_version": relative})) + version_dir = root / relative + version_dir.mkdir(parents=True) + else: + (root / "config.json").write_text("{}") + version_dir = root + (version_dir / "version.json").write_text( + json.dumps({"version": "152.0.4", "build": build}) + ) + + for module in (pkgman, multiversion): + monkeypatch.setattr(module, "INSTALL_DIR", root) + monkeypatch.setattr(multiversion, "BROWSERS_DIR", root / "browsers") + monkeypatch.setattr(multiversion, "CONFIG_FILE", root / "config.json") + monkeypatch.setattr(multiversion, "COMPAT_FLAG", root / ".0.5_FLAG") + monkeypatch.setattr(pkgman, "VERSION_MIN", pkgman.Version(build=floor)) + return root + + +@pytest.mark.parametrize("layout", ["versioned", "legacy"]) +def test_below_floor_install_is_reported_as_outdated(tmp_path, monkeypatch, layout): + """A build under the floor must report as outdated, never as missing.""" + _install(tmp_path, monkeypatch, layout, build="beta.29", floor="beta.30") + + with pytest.raises(UnsupportedVersion): + pkgman.camoufox_path(download_if_missing=False) + + +@pytest.mark.parametrize("layout", ["versioned", "legacy"]) +def test_at_floor_install_is_kept(tmp_path, monkeypatch, layout): + """A build at the floor is still served, in either layout.""" + root = _install(tmp_path, monkeypatch, layout, build="beta.30", floor="beta.30") + + resolved = pkgman.camoufox_path(download_if_missing=False) + + expected = root if layout == "legacy" else root / "browsers/official/152.0.4-beta.30" + assert resolved == expected + + +def test_below_floor_install_triggers_a_fetch(tmp_path, monkeypatch): + """The default path upgrades the install instead of raising.""" + root = _install(tmp_path, monkeypatch, "versioned", build="beta.29", floor="beta.30") + installed = [] + + class StubFetcher: + def install(self): + installed.append(True) + relative = "browsers/official/152.0.4-beta.30" + version_dir = root / relative + version_dir.mkdir(parents=True) + (version_dir / "version.json").write_text( + json.dumps({"version": "152.0.4", "build": "beta.30"}) + ) + (root / "config.json").write_text(json.dumps({"active_version": relative})) + + monkeypatch.setattr(pkgman, "CamoufoxFetcher", StubFetcher) + + resolved = pkgman.camoufox_path() + + assert installed == [True] + assert resolved == root / "browsers/official/152.0.4-beta.30" + + +def test_root_probe_tolerates_the_versioned_layout(tmp_path, monkeypatch): + """The root probe reports False, rather than raising, with no root file.""" + root = tmp_path / "cache" + root.mkdir() + monkeypatch.setattr(pkgman, "INSTALL_DIR", root) + + assert pkgman._root_install_supported() is False