mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 00:00:26 +00:00
Prerelease on every tested merge, promote by tag, and pair each library release with its browser (#810)
* Pair each library release with the browser build it was tested with
Nothing tied a library release to a browser build: `camoufox fetch` took the
newest build in a channel, and a launch used whatever config.json marked
active, so an upgraded library could run a browser it was never tested with,
and an old library would pick up a newer, incompatible browser.
A released package now carries browser-pin.json, naming the browser release
built from the same sources. With it, and no explicit choice by the user:
- fetch installs exactly that build (no prerelease prompt: it is the build
this release was tested with, prerelease or not);
- a launch uses exactly that build, whatever else is installed or active,
and reports it as not installed rather than falling back to another;
- the fetcher's automatic install (TypeScript's first run) takes only it.
An explicit `camoufox set` still wins, with a one-time warning at launch;
`camoufox set --release` returns to the paired build, and `camoufox active`
says which is in use. The checked-in pin is `{}`, so development checkouts
follow their channel as before.
Also: prerelease library versions (0.5.8b1, 0.5.8-beta.1) parse as their
release; they were read as 0.5.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Release a prerelease on every tested merge; promote to stable by tag
Every merge to main whose tests pass now publishes a prerelease of all
three artifacts, and pushing vX.Y.Z on a tested main commit promotes it.
- Build and Release runs after Tests on main. It builds the browser only
when its sources changed (ci.browser_inputs.source_digest: every browser
input, not counting the release number). Each build gets the next unused
beta.N on a release commit beside main -- main is protected -- and is
published as a GitHub prerelease, not a draft, with its source digest in
the notes.
- Publish to pypi follows it: <next>bN on PyPI, then Publish to npm puts
<next>-beta.N under the `next` dist-tag. Both are stamped with the browser
release built from the same sources.
- A vX.Y.Z tag is refused unless the commit is on main and `All tests
passed` succeeded on it. The paired browser prerelease then becomes the
stable, latest release (no rebuild, so users get the tested binaries), and
X.Y.Z goes to PyPI and npm `latest`.
The tested commit travels between workflows as an artifact: a workflow_run
is told main's head, so two quick merges would otherwise publish the second,
untested one. ci/release.py holds the planning, stamping and promotion,
unit-tested in ci/tests/test_release.py.
Also fixes two checks that failed the manual release already: vermin
targeted Python 3.8 exactly, against a package that declares ^3.10 and a
code base that needs 3.9, and check-pack compared npm and PyPI prerelease
versions as strings, although each registry spells them differently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test driver-only pull requests against the release paired with their sources
The scope step matched the release tag named by upstream.sh. With release
numbers now allocated per build, that number is a floor, not a release, so
driver-only pull requests would nearly always rebuild, or fetch a build other
than the one their sources produce. It now asks `ci.release paired` for the
release built from exactly this tree's browser sources, and fetch-browser
installs it through the same pin a released package carries.
Documents the release flow in ci/README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* pythonlib: replace asyncio.to_thread so the 3.8 vermin gate passes
publish-pypi.yml checks the package with
`vermin . --eval-annotations --target=3.8 --violations camoufox/`, and
asyncio.to_thread (Python 3.9+) in _resolve_proxy_geo failed it, stopping
the 0.5.7 release. loop.run_in_executor does the same off-loop lookup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Pair with releases cut before the digest marker, and test the pairing against the step
The scope step now asks ci.release paired, which only knew releases whose notes
carry a source digest. None does yet: v156.0.1-beta.32, the release built from
main's sources, predates the marker. So every driver-only pull request would have
rebuilt the browser, the first merge would have cut a duplicate beta.33, and the
two scope tests in ci/tests/test_ci.py -- which ran the step in a scratch repo
where ci.release did not import -- failed.
find_paired falls back to the tag upstream.sh names when that release is
published (a prerelease counts; a draft does not) and no browser source changed
since, listing the files that did when they have. browser-plan and promote use
the same lookup. paired takes --root and --releases so the tests run the
workflow's own step against a scratch repo and a fixed release list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Release from one workflow, with trusted publishing
The release chain was four workflows linked by workflow_run, with the
tested commit carried between them as an artifact; a browser release
number committed beside main; pairing data in HTML comments in release
notes; a stored PyPI token; and packages rebuilt at each publish.
release.yml now does all of it with `needs`:
- On a push to main it calls tests.yml on the pushed commit (tests.yml
loses its own push trigger), then builds the browser only when its
sources changed, and publishes a library prerelease only when something
a package ships changed. Docs- and CI-only merges publish nothing.
- A browser release's number lives only in its tag, which points at the
tested main commit; `ci.release set-build` writes it into the build's
working tree. Nothing is committed.
- Each browser release carries a manifest.json asset (source digest,
commit), which is what a library pairs by. Builds are attested with
actions/attest-build-provenance.
- Both packages are built once, in build-library, and the publish jobs
upload exactly those files. PyPI and npm use trusted publishing; no
credential is stored.
- A vX.Y.Z tag builds and checks both packages before promoting the
paired browser and publishing.
- Every published library version is tagged (vX.Y.ZbN for a prerelease),
which is how the next merge tells whether the library changed.
- A failed publish is retried with "Re-run failed jobs"; the retry-only
workflow_dispatch path is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0cb8c60bb9
commit
11969fa44a
+23
-7
@@ -125,9 +125,19 @@ Synced 26 versions from 2 repos.
|
||||
|
||||
<hr width=50>
|
||||
|
||||
### Which browser build is used
|
||||
|
||||
Each camoufox release is paired with the one browser build it was built and tested with. By default, `camoufox fetch` installs exactly that build and every launch uses it. That holds even when other builds are installed, and even when the paired build is a prerelease (a prerelease of this package pairs with a prerelease browser). Upgrading the package therefore never runs a browser it was not tested with. Run `camoufox fetch` after upgrading to install the new pairing.
|
||||
|
||||
Choosing a channel or a build with `camoufox set` overrides the pairing. The choice is kept, and a launch warns that the build differs from the paired one. `camoufox set --release` goes back to the paired build.
|
||||
|
||||
A development checkout (installed from the repository, not from PyPI) is paired with nothing and follows its channel, `official/stable` by default.
|
||||
|
||||
<hr width=50>
|
||||
|
||||
### `set`
|
||||
|
||||
Choose a version channel or pin a specific version. Can also be called with a specifier to activate directly.
|
||||
Choose a version channel or pin a specific version, overriding the paired build. Can also be called with a specifier to activate directly.
|
||||
|
||||
Interactive selector:
|
||||
|
||||
@@ -135,10 +145,10 @@ Interactive selector:
|
||||
> camoufox set
|
||||
```
|
||||
|
||||
You can also pass a specifier to pin a specific version or choose a channel to follow directly. This will pull the latest stable version from the official repo on `camoufox fetch`.
|
||||
You can also pass a specifier to pin a specific version or choose a channel to follow directly. Following `official/stable` pulls the latest stable version from the official repo on `camoufox fetch`:
|
||||
|
||||
```bash
|
||||
> camoufox set official/stable # Default setting
|
||||
> camoufox set official/stable
|
||||
```
|
||||
|
||||
Follow latest prerelease version from the official repo, if applicable:
|
||||
@@ -153,6 +163,12 @@ Pin a specific version:
|
||||
> camoufox set official/stable/134.0.2-beta.20
|
||||
```
|
||||
|
||||
Go back to the build this release is paired with:
|
||||
|
||||
```bash
|
||||
> camoufox set --release
|
||||
```
|
||||
|
||||
<hr width=50>
|
||||
|
||||
### `active`
|
||||
@@ -160,8 +176,8 @@ Pin a specific version:
|
||||
Prints the current active version string:
|
||||
|
||||
```bash
|
||||
> camoufox active # Default channel is active
|
||||
official/stable
|
||||
> camoufox active # A released package uses its paired build by default
|
||||
official/prerelease/156.0.1-beta.33 (1a2b3c4d) (paired with this release)
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -177,10 +193,10 @@ coryking/stable/142.0.1-fork.26 (not installed)
|
||||
|
||||
### `fetch`
|
||||
|
||||
Install the latest version from the active channel. By default, this is official/stable. This will also automatically sync repository assets.
|
||||
Install the browser build this release is paired with, or, after `camoufox set`, the latest version from the chosen channel. This will also automatically sync repository assets.
|
||||
|
||||
```bash
|
||||
> camoufox fetch # Install the latest in the channel
|
||||
> camoufox fetch # Install the paired build (or the latest in the chosen channel)
|
||||
```
|
||||
|
||||
To download the latest from a different channel, or pin a version:
|
||||
|
||||
@@ -38,6 +38,7 @@ from .multiversion import (
|
||||
save_config,
|
||||
save_repo_cache,
|
||||
)
|
||||
from .browser_pin import effective_pin, load_pin
|
||||
from .pkgman import (
|
||||
INSTALL_DIR,
|
||||
AvailableVersion,
|
||||
@@ -268,6 +269,7 @@ def fetch(version):
|
||||
repo_name = None
|
||||
repo_data = None
|
||||
ver_data = None
|
||||
paired = False
|
||||
if version:
|
||||
parts = version.lower().split("/")
|
||||
if len(parts) == 1:
|
||||
@@ -286,6 +288,17 @@ def fetch(version):
|
||||
repo_data = _repo_data(cache, repo_name)
|
||||
if repo_data is not None:
|
||||
ver_data = _pin_target(repo_data, config["pinned"], config.get("pinned_sha"))
|
||||
elif pin := effective_pin(config):
|
||||
# This library release pairs with exactly one browser build.
|
||||
repo_name = pin.repo_name
|
||||
repo_data = _repo_data(cache, repo_name)
|
||||
if repo_data is not None:
|
||||
ver_data = _pin_target(repo_data, pin.spec, None)
|
||||
if ver_data is None:
|
||||
rprint(f"{pin.tag}, the browser this camoufox release pairs with, is not in "
|
||||
f"{repo_name}'s releases. Run 'camoufox sync' and try again.", fg="red")
|
||||
return
|
||||
paired = True
|
||||
else:
|
||||
channel = config.get("channel") or get_default_channel()
|
||||
repo_name, ctype = (channel.split("/", 1) + ["stable"])[:2]
|
||||
@@ -312,7 +325,11 @@ def fetch(version):
|
||||
)
|
||||
repo_config = RepoConfig.find_by_name(repo_data["name"])
|
||||
try:
|
||||
CamoufoxUpdate(repo_config=repo_config, selected_version=selected).update()
|
||||
# The paired build is what this release was tested with; installing it
|
||||
# needs no "this is a prerelease" confirmation even when it is one.
|
||||
CamoufoxUpdate(repo_config=repo_config, selected_version=selected).update(
|
||||
i_know_what_im_doing=paired
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "404" in msg or "Not Found" in msg:
|
||||
@@ -426,7 +443,11 @@ def _set_pinned(repo_name: str, channel_type: str, ver_data: dict, inst, sha: Op
|
||||
@cli.command(name="set")
|
||||
@click.argument("specifier", required=False)
|
||||
@click.option("--geoip", is_flag=True, help="Select GeoIP source instead")
|
||||
def set_cmd(specifier, geoip):
|
||||
@click.option(
|
||||
"--release", "paired", is_flag=True,
|
||||
help="Forget any explicit choice and use the browser this camoufox release pairs with",
|
||||
)
|
||||
def set_cmd(specifier, geoip, paired):
|
||||
"""
|
||||
\b
|
||||
Set the active Camoufox version to use & fetch.
|
||||
@@ -441,6 +462,19 @@ def set_cmd(specifier, geoip):
|
||||
_select_geoip_source()
|
||||
return
|
||||
|
||||
if paired:
|
||||
pin = load_pin()
|
||||
if pin is None:
|
||||
rprint("This camoufox is a development copy; it pairs with no particular browser.", fg="yellow")
|
||||
return
|
||||
config = load_config()
|
||||
for key in ("channel", "pinned", "pinned_sha", "active_version"):
|
||||
config.pop(key, None)
|
||||
save_config(config)
|
||||
click.secho(f"Using the paired browser: {pin.repo_name} {pin.spec} ({pin.tag})", fg="green")
|
||||
click.secho("Run 'camoufox fetch' if it is not installed.", fg="yellow")
|
||||
return
|
||||
|
||||
if specifier:
|
||||
parts = specifier.lower().split("/")
|
||||
|
||||
@@ -1020,6 +1054,16 @@ def active_cmd():
|
||||
sha8 = (v.sha256 or "")[:8]
|
||||
return f"{v.channel_path} ({sha8})" if sha8 else v.channel_path
|
||||
|
||||
pin = effective_pin(config)
|
||||
if pin:
|
||||
target = _find_installed(f"{pin.repo_name}/{pin.spec}")
|
||||
if target:
|
||||
click.echo(f"{_label(target)} (paired with this release)")
|
||||
else:
|
||||
click.echo(f"{pin.repo_name}/{pin.spec} (paired with this release) ", nl=False)
|
||||
rprint("(not fetched)", fg="yellow")
|
||||
return
|
||||
|
||||
if pinned:
|
||||
pinned_sha = config.get("pinned_sha")
|
||||
if pinned_sha:
|
||||
|
||||
@@ -180,7 +180,9 @@ async def _launch(
|
||||
|
||||
async def _resolve_proxy_geo(proxy: Dict[str, str]) -> Tuple[str, str]:
|
||||
"""The proxy's exit IP and timezone, looked up off the event loop."""
|
||||
return await asyncio.to_thread(proxy_exit_geo, Proxy(**proxy).as_string())
|
||||
# run_in_executor rather than asyncio.to_thread, which needs Python 3.9.
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, proxy_exit_geo, Proxy(**proxy).as_string())
|
||||
|
||||
|
||||
async def AsyncNewContext(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
The browser build this copy of the library was released with.
|
||||
|
||||
Every published camoufox package (PyPI and npm) is stamped at release time with
|
||||
the one browser release built from the same sources: `browser-pin.json` names
|
||||
it. By default the library fetches and launches exactly that build, so a
|
||||
library upgrade can never silently run against a browser whose Juggler, prefs
|
||||
or patches it was not tested with -- and a new browser release can never be
|
||||
picked up by an older library.
|
||||
|
||||
A user who explicitly chooses something else (`camoufox set official/stable`,
|
||||
`camoufox set 156.0.1-beta.33`, ...) keeps that choice; launching then warns
|
||||
that the build differs from the one this library was released with.
|
||||
|
||||
A development checkout carries an empty pin (`{}`): nothing is pinned and the
|
||||
channel logic applies unchanged, because there is no release to pair with yet.
|
||||
The release workflow writes the real file (ci/release.py stamp).
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import orjson
|
||||
|
||||
PIN_FILE = Path(__file__).parent / "browser-pin.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserPin:
|
||||
tag: str
|
||||
repo: str
|
||||
repo_name: str
|
||||
version: str
|
||||
build: str
|
||||
|
||||
@property
|
||||
def spec(self) -> str:
|
||||
"""`<firefox version>-<build>`, the form `camoufox set` and the cache use."""
|
||||
return f"{self.version}-{self.build}"
|
||||
|
||||
|
||||
def load_pin(path: Optional[Path] = None) -> Optional[BrowserPin]:
|
||||
"""The stamped pin, or None for an unpinned (development) copy."""
|
||||
try:
|
||||
data: Dict[str, Any] = orjson.loads((path or PIN_FILE).read_bytes())
|
||||
except (FileNotFoundError, orjson.JSONDecodeError):
|
||||
return None
|
||||
if not data or not data.get("tag"):
|
||||
return None
|
||||
return BrowserPin(
|
||||
tag=data["tag"],
|
||||
repo=data["repo"],
|
||||
repo_name=data["repo_name"].lower(),
|
||||
version=data["version"],
|
||||
build=data["build"],
|
||||
)
|
||||
|
||||
|
||||
def is_explicit_choice(config: Dict[str, Any]) -> bool:
|
||||
"""Whether the user chose a channel or a build themselves."""
|
||||
return bool(config.get("channel") or config.get("pinned"))
|
||||
|
||||
|
||||
def effective_pin(config: Optional[Dict[str, Any]] = None) -> Optional[BrowserPin]:
|
||||
"""The package pin, unless the user explicitly chose otherwise."""
|
||||
pin = load_pin()
|
||||
if pin is None:
|
||||
return None
|
||||
if config is None:
|
||||
from .multiversion import load_config
|
||||
|
||||
config = load_config()
|
||||
return None if is_explicit_choice(config) else pin
|
||||
|
||||
|
||||
def matches(pin: BrowserPin, repo_name: str, version: str, build: str) -> bool:
|
||||
return (repo_name.lower(), version, build) == (pin.repo_name, pin.version, pin.build)
|
||||
|
||||
|
||||
_warned = False
|
||||
|
||||
|
||||
def warn_if_unpaired(repo_name: str, version: str, build: str) -> None:
|
||||
"""Warn once when an explicitly chosen build is not the one this library pairs with."""
|
||||
global _warned
|
||||
pin = load_pin()
|
||||
if _warned or pin is None or matches(pin, repo_name, version, build):
|
||||
return
|
||||
_warned = True
|
||||
warnings.warn(
|
||||
f"Launching {repo_name.lower()} {version}-{build}, which you selected explicitly. "
|
||||
f"This camoufox release was built and tested with {pin.repo_name} {pin.spec} "
|
||||
f"({pin.tag}); other builds may not be compatible. "
|
||||
"Run `camoufox set --release` to go back to the paired build.",
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
@@ -323,6 +323,20 @@ def get_active_path() -> Optional[Path]:
|
||||
config = load_config()
|
||||
active = config.get('active_version')
|
||||
|
||||
# A released library launches the build it was released with, whatever
|
||||
# happens to be marked active, unless the user explicitly chose otherwise.
|
||||
from .browser_pin import effective_pin, matches
|
||||
|
||||
pin = effective_pin(config)
|
||||
if pin:
|
||||
for inst in list_installed():
|
||||
if matches(pin, inst.repo_name, inst.version.version or '', inst.version.build):
|
||||
if active != inst.relative_path:
|
||||
config['active_version'] = inst.relative_path
|
||||
save_config(config)
|
||||
return inst.path
|
||||
return None
|
||||
|
||||
if active:
|
||||
path = INSTALL_DIR / active
|
||||
if path.exists() and (path / 'version.json').exists():
|
||||
|
||||
@@ -128,6 +128,10 @@ def _parse_semver(version: str) -> Tuple[int, ...]:
|
||||
Parse a semver string into a comparable tuple
|
||||
"""
|
||||
version = version.lstrip('^~')
|
||||
# A prerelease compares as its release: 0.5.8b1 (PEP 440) and 0.5.8-beta.1
|
||||
# (semver) are both 0.5.8 -- not 0.5.0, which is what splitting on '.'
|
||||
# alone made of them.
|
||||
version = re.split(r'(?<=\d)(?:[-+]|(?:a|b|rc|dev|\.dev|\.post)\d*)', version, maxsplit=1)[0]
|
||||
parts = []
|
||||
for part in version.split('.'):
|
||||
try:
|
||||
@@ -580,8 +584,15 @@ class CamoufoxFetcher(GitHubDownloader):
|
||||
return None
|
||||
|
||||
version = Version(build=match['build'], version=match['version'])
|
||||
# A released library installs exactly the browser it was released with
|
||||
# (browser_pin.py), unless the user explicitly chose something else.
|
||||
from .browser_pin import effective_pin, matches
|
||||
|
||||
pin = effective_pin()
|
||||
if pin and not matches(pin, self.repo_config.name, match['version'], match['build']):
|
||||
return None
|
||||
is_prerelease = bool(release and release.get('prerelease')) or version.is_alpha
|
||||
if not self.repo_config.is_version_supported(version, is_prerelease):
|
||||
if not pin and not self.repo_config.is_version_supported(version, is_prerelease):
|
||||
return None
|
||||
|
||||
digest = asset.get('digest') or ''
|
||||
@@ -732,6 +743,22 @@ def list_available_versions(
|
||||
return versions
|
||||
|
||||
|
||||
def _not_installed_message() -> str:
|
||||
"""What is missing -- the paired build, a pin, or a channel -- and how to get it."""
|
||||
from .browser_pin import effective_pin
|
||||
from .multiversion import get_default_channel, load_config
|
||||
|
||||
config = load_config()
|
||||
pin = effective_pin(config)
|
||||
pinned = config.get("pinned")
|
||||
channel = config.get("channel") or get_default_channel()
|
||||
if pin:
|
||||
missing = f"{pin.repo_name} {pin.spec}, the browser this camoufox release pairs with,"
|
||||
else:
|
||||
missing = f"{channel}/{pinned}" if pinned else channel
|
||||
return f"{missing} is not installed. Please run `camoufox fetch` to install."
|
||||
|
||||
|
||||
def installed_verstr() -> str:
|
||||
"""
|
||||
Get the full version string of the active install
|
||||
@@ -740,16 +767,13 @@ def installed_verstr() -> str:
|
||||
|
||||
active = get_active_path()
|
||||
if active is None:
|
||||
from .multiversion import get_default_channel, load_config
|
||||
raise CamoufoxNotInstalled(_not_installed_message())
|
||||
version = Version.from_path(active)
|
||||
if active.parent.parent.name == "browsers":
|
||||
from .browser_pin import warn_if_unpaired
|
||||
|
||||
config = load_config()
|
||||
pinned = config.get("pinned")
|
||||
channel = config.get("channel") or get_default_channel()
|
||||
active_display = f"{channel}/{pinned}" if pinned else channel
|
||||
raise CamoufoxNotInstalled(
|
||||
f"{active_display} is not installed. " f"Please run `camoufox fetch` to install."
|
||||
)
|
||||
return Version.from_path(active).full_string
|
||||
warn_if_unpaired(active.parent.name, version.version or "", version.build)
|
||||
return version.full_string
|
||||
|
||||
|
||||
def _root_install_supported() -> bool:
|
||||
@@ -787,24 +811,18 @@ def camoufox_path(download_if_missing: bool = True) -> Path:
|
||||
|
||||
if not os.path.exists(INSTALL_DIR) or not os.listdir(INSTALL_DIR):
|
||||
if not download_if_missing:
|
||||
from .multiversion import load_config, get_default_channel
|
||||
|
||||
config = load_config()
|
||||
pinned = config.get("pinned")
|
||||
channel = config.get("channel") or get_default_channel()
|
||||
if pinned:
|
||||
active_display = f"{channel}/{pinned}"
|
||||
else:
|
||||
active_display = channel
|
||||
raise CamoufoxNotInstalled(
|
||||
f"{active_display} is not installed. " f"Please run `camoufox fetch` to install."
|
||||
)
|
||||
raise CamoufoxNotInstalled(_not_installed_message())
|
||||
|
||||
elif os.path.exists(INSTALL_DIR) and _root_install_supported():
|
||||
return INSTALL_DIR
|
||||
|
||||
else:
|
||||
if not download_if_missing:
|
||||
from .browser_pin import effective_pin
|
||||
|
||||
# Other builds are installed, but not the one this release pairs with.
|
||||
if effective_pin():
|
||||
raise CamoufoxNotInstalled(_not_installed_message())
|
||||
raise UnsupportedVersion("Camoufox executable is outdated.")
|
||||
|
||||
CamoufoxFetcher().install()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""A released library runs the browser it was released with, and nothing else by default.
|
||||
|
||||
Each published package is stamped with `browser-pin.json` naming the browser
|
||||
release built from the same sources. These tests hold the three places that
|
||||
decide which build is used -- the active install at launch, the asset the
|
||||
fetcher accepts, and the explicit-choice escape hatch -- to that pairing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from camoufox import browser_pin, multiversion, pkgman
|
||||
from camoufox.exceptions import CamoufoxNotInstalled
|
||||
|
||||
PIN = {
|
||||
"tag": "v156.0.1-beta.33",
|
||||
"repo": "daijro/camoufox",
|
||||
"repo_name": "Official",
|
||||
"version": "156.0.1",
|
||||
"build": "beta.33",
|
||||
}
|
||||
|
||||
|
||||
def _setup(tmp_path, monkeypatch, *, pin, installed, config):
|
||||
root = tmp_path / "cache"
|
||||
root.mkdir()
|
||||
(root / ".0.5_FLAG").write_text("")
|
||||
(root / "repo_cache.json").write_text("{}")
|
||||
(root / "config.json").write_text(json.dumps(config))
|
||||
for spec in installed:
|
||||
version, build = spec.split("-", 1)
|
||||
d = root / "browsers" / "official" / spec
|
||||
d.mkdir(parents=True)
|
||||
(d / "version.json").write_text(json.dumps({"version": version, "build": build}))
|
||||
pin_file = tmp_path / "browser-pin.json"
|
||||
pin_file.write_text(json.dumps(pin))
|
||||
monkeypatch.setattr(browser_pin, "PIN_FILE", pin_file)
|
||||
monkeypatch.setattr(browser_pin, "_warned", False)
|
||||
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")
|
||||
return root
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", ["{}", '{"tag": null}', "", "not json"])
|
||||
def test_an_empty_or_missing_pin_pins_nothing(tmp_path, content):
|
||||
f = tmp_path / "browser-pin.json"
|
||||
f.write_text(content)
|
||||
assert browser_pin.load_pin(f) is None
|
||||
assert browser_pin.load_pin(tmp_path / "absent.json") is None
|
||||
|
||||
|
||||
def test_the_checked_in_pin_is_empty():
|
||||
"""main pins nothing; only the release workflow writes a real pin."""
|
||||
assert json.loads(browser_pin.PIN_FILE.read_text()) == {}
|
||||
|
||||
|
||||
def test_launch_uses_the_paired_build_even_when_another_is_marked_active(tmp_path, monkeypatch):
|
||||
root = _setup(
|
||||
tmp_path, monkeypatch, pin=PIN,
|
||||
installed=["156.0.1-beta.33", "157.0-beta.34"],
|
||||
config={"active_version": "browsers/official/157.0-beta.34"},
|
||||
)
|
||||
assert multiversion.get_active_path() == root / "browsers/official/156.0.1-beta.33"
|
||||
assert pkgman.installed_verstr() == "156.0.1-beta.33"
|
||||
|
||||
|
||||
def test_a_newer_build_is_not_used_when_the_paired_one_is_missing(tmp_path, monkeypatch):
|
||||
_setup(tmp_path, monkeypatch, pin=PIN, installed=["157.0-beta.34"], config={})
|
||||
assert multiversion.get_active_path() is None
|
||||
with pytest.raises(CamoufoxNotInstalled, match="156.0.1-beta.33"):
|
||||
pkgman.installed_verstr()
|
||||
|
||||
|
||||
def test_an_explicit_choice_is_kept_and_warned_about(tmp_path, monkeypatch):
|
||||
root = _setup(
|
||||
tmp_path, monkeypatch, pin=PIN,
|
||||
installed=["156.0.1-beta.33", "157.0-beta.34"],
|
||||
config={"channel": "official/stable", "active_version": "browsers/official/157.0-beta.34"},
|
||||
)
|
||||
assert multiversion.get_active_path() == root / "browsers/official/157.0-beta.34"
|
||||
with pytest.warns(RuntimeWarning, match="v156.0.1-beta.33"):
|
||||
assert pkgman.installed_verstr() == "157.0-beta.34"
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
pkgman.installed_verstr() # once per process, not per launch
|
||||
|
||||
|
||||
def test_an_explicit_choice_of_the_paired_build_does_not_warn(tmp_path, monkeypatch):
|
||||
_setup(
|
||||
tmp_path, monkeypatch, pin=PIN, installed=["156.0.1-beta.33"],
|
||||
config={"pinned": "156.0.1-beta.33", "channel": "official/prerelease",
|
||||
"active_version": "browsers/official/156.0.1-beta.33"},
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
assert pkgman.installed_verstr() == "156.0.1-beta.33"
|
||||
|
||||
|
||||
def test_without_a_pin_the_channel_logic_is_unchanged(tmp_path, monkeypatch):
|
||||
root = _setup(
|
||||
tmp_path, monkeypatch, pin={}, installed=["156.0.1-beta.33", "157.0-beta.34"],
|
||||
config={"active_version": "browsers/official/157.0-beta.34"},
|
||||
)
|
||||
assert multiversion.get_active_path() == root / "browsers/official/157.0-beta.34"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config,accepts_newer", [({}, False), ({"channel": "official/stable"}, True)])
|
||||
def test_the_fetcher_accepts_only_the_paired_asset_by_default(tmp_path, monkeypatch, config, accepts_newer):
|
||||
_setup(tmp_path, monkeypatch, pin=PIN, installed=[], config=config)
|
||||
fetcher = pkgman.CamoufoxFetcher.__new__(pkgman.CamoufoxFetcher)
|
||||
fetcher.repo_config = pkgman.RepoConfig.get_default()
|
||||
fetcher.pattern = fetcher.repo_config.build_pattern(spoof_os="lin", spoof_arch="x86_64")
|
||||
fetcher.installed_sha256 = None
|
||||
fetcher.installed_created_at = None
|
||||
|
||||
def asset(version, build):
|
||||
return {"name": f"camoufox-{version}-{build}-lin.x86_64.zip",
|
||||
"browser_download_url": f"https://example.invalid/{version}-{build}.zip"}
|
||||
|
||||
assert fetcher.check_asset(asset("156.0.1", "beta.33"), {"prerelease": True}) is not None
|
||||
newer = fetcher.check_asset(asset("157.0", "beta.34"), {"prerelease": False})
|
||||
assert (newer is not None) is accepts_newer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("0.5.8", (0, 5, 8)),
|
||||
("0.5.8b1", (0, 5, 8)),
|
||||
("0.5.8rc2", (0, 5, 8)),
|
||||
("0.5.8.dev3", (0, 5, 8)),
|
||||
("0.5.8-beta.1", (0, 5, 8)),
|
||||
("^0.5.0", (0, 5, 0)),
|
||||
("1", (1, 0, 0)),
|
||||
])
|
||||
def test_prerelease_library_versions_parse_as_their_release(raw, expected):
|
||||
assert pkgman._parse_semver(raw) == expected
|
||||
|
||||
|
||||
def test_a_missing_paired_build_is_reported_as_not_installed_not_outdated(tmp_path, monkeypatch):
|
||||
_setup(tmp_path, monkeypatch, pin=PIN, installed=["157.0-beta.34"], config={})
|
||||
with pytest.raises(CamoufoxNotInstalled, match="pairs with"):
|
||||
pkgman.camoufox_path(download_if_missing=False)
|
||||
Reference in New Issue
Block a user