238 lines
9.2 KiB
Python
238 lines
9.2 KiB
Python
"""Auto-updater: version comparison, release parsing, and the apply/rollback
|
|
dance driven by a fake git (no network, no real repo, nothing checked out)."""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from bolt_pet import updater
|
|
|
|
|
|
# ── version comparison ───────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tag,expected",
|
|
[
|
|
("v1.2.3", (1, 2, 3)),
|
|
("1.2.3", (1, 2, 3)),
|
|
("V0.1.0", (0, 1, 0)),
|
|
("1.2", (1, 2)),
|
|
("1.2.3-beta1", (1, 2, 3)), # suffix ends the parse
|
|
("", ()),
|
|
("nightly", ()),
|
|
],
|
|
)
|
|
def test_parse_version(tag, expected):
|
|
assert updater.parse_version(tag) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"candidate,current",
|
|
[("0.2.0", "0.1.0"), ("1.0.0", "0.9.9"), ("0.1.1", "0.1"), ("v2.0", "1.9.9")],
|
|
)
|
|
def test_is_newer_accepts_newer_versions(candidate, current):
|
|
assert updater.is_newer(candidate, current)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"candidate,current",
|
|
[
|
|
("0.1.0", "0.1.0"),
|
|
("0.1.0", "0.2.0"),
|
|
("0.1", "0.1.0"), # zero-padded: equal, not newer
|
|
("", "0.1.0"),
|
|
("nightly", "0.1.0"), # unparseable is never newer
|
|
],
|
|
)
|
|
def test_is_newer_rejects_same_or_older(candidate, current):
|
|
assert not updater.is_newer(candidate, current)
|
|
|
|
|
|
def test_release_parsing_skips_drafts_and_untagged():
|
|
assert updater.release_from_payload({"tag_name": "v1.0.0", "draft": True}) is None
|
|
assert updater.release_from_payload({"name": "no tag"}) is None
|
|
release = updater.release_from_payload({"tag_name": "v1.0.0", "name": "One", "prerelease": True})
|
|
assert (release.tag, release.name, release.prerelease) == ("v1.0.0", "One", True)
|
|
|
|
|
|
# ── fake git ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class FakeGit:
|
|
"""Records every git invocation and answers from a canned script.
|
|
*failures* maps a leading-args tuple to the (code, output) it should
|
|
return, so a test can make exactly one command fail."""
|
|
|
|
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False,
|
|
head="abc1234", tag_commit="def5678"):
|
|
self.calls = []
|
|
self._ref = ref
|
|
self._dirty = dirty
|
|
self._failures = failures or {}
|
|
self._requirements_changed = requirements_changed
|
|
self._head = head
|
|
self._tag_commit = tag_commit # equal to head = "already on this tag"
|
|
|
|
def __call__(self, args):
|
|
self.calls.append(list(args))
|
|
for prefix, result in self._failures.items():
|
|
if tuple(args[: len(prefix)]) == prefix:
|
|
return result
|
|
head = args[0]
|
|
if head == "rev-parse" and args[1] == "--git-dir":
|
|
return 0, ".git"
|
|
if head == "status":
|
|
return 0, " M bolt_pet/config.py" if self._dirty else ""
|
|
if head == "symbolic-ref":
|
|
return (0, self._ref) if self._ref else (1, "")
|
|
if head == "rev-parse":
|
|
return 0, self._tag_commit if args[1].startswith("tags/") else self._head
|
|
if head == "diff":
|
|
return 0, "requirements.txt" if self._requirements_changed else ""
|
|
return 0, ""
|
|
|
|
def commands(self):
|
|
"""Just the verbs, for asserting on the sequence."""
|
|
return [call[0] for call in self.calls]
|
|
|
|
|
|
def test_apply_update_checks_out_the_tag(tmp_path):
|
|
git = FakeGit()
|
|
previous = updater.apply_update(
|
|
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=lambda repo: None
|
|
)
|
|
assert previous == "main"
|
|
assert ["fetch", "--tags", "--prune", "origin"] in git.calls
|
|
assert ["checkout", "--force", "tags/v1.0.0"] in git.calls
|
|
|
|
|
|
def test_a_dirty_working_tree_is_left_completely_alone(tmp_path):
|
|
git = FakeGit(dirty=True)
|
|
with pytest.raises(updater.UpdateError, match="local changes"):
|
|
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
|
assert "fetch" not in git.commands()
|
|
assert "checkout" not in git.commands()
|
|
|
|
|
|
def test_a_non_git_install_refuses_before_touching_anything(tmp_path):
|
|
git = FakeGit(failures={("rev-parse", "--git-dir"): (128, "not a repository")})
|
|
with pytest.raises(updater.UpdateError, match="not a git clone"):
|
|
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
|
assert "checkout" not in git.commands()
|
|
|
|
|
|
def test_a_failed_smoke_test_rolls_back_to_the_previous_ref(tmp_path):
|
|
git = FakeGit(ref="main")
|
|
|
|
def broken(repo):
|
|
raise updater.UpdateError("the new version failed to import: boom")
|
|
|
|
with pytest.raises(updater.UpdateError, match="rolled back to main"):
|
|
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=broken)
|
|
|
|
checkouts = [call for call in git.calls if call[0] == "checkout"]
|
|
assert checkouts == [["checkout", "--force", "tags/v1.0.0"], ["checkout", "--force", "main"]]
|
|
|
|
|
|
def test_rollback_targets_the_commit_when_head_is_detached(tmp_path):
|
|
# No branch to go back to (symbolic-ref fails) — the SHA is the ref.
|
|
git = FakeGit(ref="")
|
|
|
|
with pytest.raises(updater.UpdateError):
|
|
updater.apply_update(
|
|
"v1.0.0", run=git, repo=tmp_path, install_deps=False,
|
|
verify=lambda repo: (_ for _ in ()).throw(RuntimeError("nope")),
|
|
)
|
|
assert ["checkout", "--force", "abc1234"] in git.calls
|
|
|
|
|
|
def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
|
|
git = FakeGit()
|
|
|
|
def exploding(repo):
|
|
raise ValueError("not even an UpdateError")
|
|
|
|
with pytest.raises(updater.UpdateError, match="rolled back"):
|
|
updater.apply_update(
|
|
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=exploding
|
|
)
|
|
assert ["checkout", "--force", "main"] in git.calls
|
|
|
|
|
|
def test_a_release_tagged_without_bumping_the_version_does_not_loop(tmp_path):
|
|
"""Cut a release but forget to bump __version__ in the tagged commit and
|
|
every check would see the same "newer" tag: check out (a no-op), restart,
|
|
read the old version, repeat — a restart loop every check interval."""
|
|
git = FakeGit(head="same1234", tag_commit="same1234")
|
|
|
|
with pytest.raises(updater.UpdateError, match="bump it in the tagged commit"):
|
|
updater.apply_update("v0.2.1", run=git, repo=tmp_path, install_deps=False,
|
|
verify=lambda repo: None)
|
|
|
|
assert "checkout" not in git.commands() # nothing moved, so nothing to restart into
|
|
|
|
|
|
def test_an_unknown_tag_is_not_mistaken_for_being_already_on_it(tmp_path):
|
|
git = FakeGit(failures={("rev-parse", "tags/"): (128, "unknown revision")})
|
|
# The failure key matches by prefix, so make it explicit that a tag we
|
|
# can't resolve means "not there yet" rather than "already applied".
|
|
assert updater.already_at_tag(git, "v9.9.9") is False
|
|
|
|
|
|
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
|
|
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
|
|
with pytest.raises(updater.UpdateError, match="git fetch failed"):
|
|
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
|
assert "checkout" not in git.commands()
|
|
|
|
|
|
def test_a_stranded_checkout_is_logged_loudly(tmp_path):
|
|
"""Rollback itself failing is the one case a human has to fix by hand."""
|
|
git = FakeGit(failures={("checkout", "--force", "main"): (1, "index locked")})
|
|
logs = []
|
|
|
|
with pytest.raises(updater.UpdateError):
|
|
updater.apply_update(
|
|
"v1.0.0", run=git, repo=tmp_path, install_deps=False, on_log=logs.append,
|
|
verify=lambda repo: (_ for _ in ()).throw(updater.UpdateError("bad build")),
|
|
)
|
|
assert any("ROLLBACK FAILED" in line and "git checkout main" in line for line in logs)
|
|
|
|
|
|
def test_deps_are_only_reinstalled_when_requirements_actually_changed(tmp_path, monkeypatch):
|
|
installs = []
|
|
monkeypatch.setattr(updater, "_install_deps", lambda repo: installs.append(repo))
|
|
|
|
updater.apply_update(
|
|
"v1.0.0", run=FakeGit(requirements_changed=False), repo=tmp_path,
|
|
install_deps=True, verify=lambda repo: None,
|
|
)
|
|
assert installs == []
|
|
|
|
updater.apply_update(
|
|
"v1.0.0", run=FakeGit(requirements_changed=True), repo=tmp_path,
|
|
install_deps=True, verify=lambda repo: None,
|
|
)
|
|
assert installs == [tmp_path]
|
|
|
|
|
|
# ── release checking ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_check_for_update_returns_nothing_when_current(monkeypatch):
|
|
monkeypatch.setattr(
|
|
updater, "fetch_latest_release",
|
|
lambda **kwargs: updater.Release("v0.1.0", "0.1.0", "", False),
|
|
)
|
|
assert updater.check_for_update(current_version="0.1.0") is None
|
|
assert updater.check_for_update(current_version="0.0.9").tag == "v0.1.0"
|
|
|
|
|
|
def test_no_releases_yet_is_not_an_error(monkeypatch):
|
|
monkeypatch.setattr(updater, "fetch_latest_release", lambda **kwargs: None)
|
|
assert updater.check_for_update(current_version="0.1.0") is None
|