Files
spoopyghosty0--frameport/tests/test_updates.py
T
spoopyghosty0 ba68dc76c8 Self-update for the app and the CLI
FramePort finds new GitHub releases by itself and updates in one click: sidebar card + Library bar, a dialog with
the release notes, "Update and restart" (download, SHA256SUMS check, Windows signer check, swap script, relaunch),
optional automatic installs at the next start. CLI: --version, `frameport update [--check] [--yes]` and a
once-a-day hint. One version source (_version.py); releases also ship the CLI wheel and "What's new" from the
annotated tag; CI runs an update smoke test with each OS's archive.
2026-10-01 22:59:56 -04:00

216 lines
11 KiB
Python

"""Self-update (frameport.updates): version logic, release parsing, checks, staging and the swap script."""
import hashlib
import io
import json
import os
import sys
import tarfile
import zipfile
from pathlib import Path
import pytest
from frameport import __version__, updates
from frameport.core import cache, library
def release(tag="v9.9.9", assets=("FramePort-windows-x64.zip", "FramePort-macos-arm64.zip", "FramePort-linux-x64.tar.gz",
"SHA256SUMS.txt", "frameport-9.9.9-py3-none-any.whl"), **kw):
return {"tag_name": tag, "body": "What's new: things", "html_url": f"https://example.invalid/releases/{tag}",
"draft": False, "prerelease": False, "published_at": "2026-10-02T00:00:00Z",
"assets": [{"name": n, "browser_download_url": f"https://example.invalid/dl/{n}"} for n in assets], **kw}
def test_versions():
assert updates.parse_version("v0.10.1") > updates.parse_version("0.9.9")
assert updates.parse_version("1.0.0") > updates.parse_version("1.0.0-rc1")
assert updates.is_newer("0.2.1", "0.2.0") and not updates.is_newer("0.2.0", "0.2.0")
assert not updates.is_newer("garbage", "0.1.0")
def test_release_parsing_picks_this_platform_and_skips_drafts():
up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz")
assert up.version == "9.9.9" and up.asset_url.endswith("linux-x64.tar.gz") and up.sums_url.endswith("SHA256SUMS.txt")
assert up.wheel_url.endswith(".whl") and "What's new" in up.notes
assert updates.update_from_release(release(draft=True)) is None
assert updates.update_from_release(release(prerelease=True)) is None
assert updates.update_from_release(release(assets=("SHA256SUMS.txt",)), "FramePort-linux-x64.tar.gz").asset is None
def test_check_skip_and_cached_hint(monkeypatch):
calls = []
monkeypatch.setattr(cache, "http_get", lambda url, **kw: calls.append(url) or type(
"R", (), {"text": json.dumps(release()), "raise_for_status": lambda self: None})())
up = updates.check(force=True)
assert up and up.version == "9.9.9" and calls == [updates.LATEST_API]
assert library.setting("update.last_check")
assert updates.cached_update().version == "9.9.9" # read from the cache, no network
updates.skip("9.9.9")
assert updates.check() is None and updates.cached_update() is None # "Later" hides this version
assert updates.check(force=True).version == "9.9.9" # "Check now" still finds it
monkeypatch.setattr(cache, "http_get", lambda url, **kw: (_ for _ in ()).throw(OSError("offline")))
(cache.cache_dir() / "app-release.json").unlink()
assert updates.check(force=True) is None # offline: no update, no error
def test_older_release_is_no_update(monkeypatch):
monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release(tag="v0.0.1"))
assert updates.check(force=True) is None
def test_bundle_layouts():
assert updates.bundle_root(Path("C:/Games/FramePort/FramePort.exe"), "win32") == Path("C:/Games/FramePort")
assert updates.bundle_root(Path("/Applications/FramePort.app/Contents/MacOS/FramePort"), "darwin") == \
Path("/Applications/FramePort.app")
assert updates.bundle_root(Path("/home/u/FramePort/FramePort"), "linux") == Path("/home/u/FramePort")
assert updates.bundle_root(Path("/usr/bin/python3"), "linux") is None
assert updates.bundle_root(Path("/usr/bin/python3.12"), "darwin") is None
def test_install_kind_from_source_checkout():
# the test suite runs from the repo: a git checkout
assert updates.install_kind() == "source"
cmds = updates.upgrade_commands(updates.update_from_release(release(), None), "source")
assert cmds[0][:2] == ["git", "-C"] and cmds[0][-2:] == ["pull", "--ff-only"] and cmds[1][:2] == ["uv", "sync"]
def test_wheel_commands(monkeypatch):
up = updates.update_from_release(release(), None)
monkeypatch.setattr(sys, "prefix", "/home/u/.local/share/uv/tools/frameport")
assert updates.upgrade_commands(up, "wheel") == [["uv", "tool", "install", "--force", up.wheel_url]]
monkeypatch.setattr(sys, "prefix", "/home/u/venv")
assert updates.upgrade_commands(up, "wheel")[0][1:4] == ["-m", "pip", "install"]
def test_sums_parsing():
sums = updates.parse_sums("a" * 64 + " FramePort-linux-x64.tar.gz\n" + "B" * 64 + " *FramePort-windows-x64.zip\njunk\n")
assert sums == {"FramePort-linux-x64.tar.gz": "a" * 64, "FramePort-windows-x64.zip": "b" * 64}
def _fake_downloads(monkeypatch, files: dict[str, bytes], sums: dict[str, str] | None = None):
sums = sums or {name: hashlib.sha256(data).hexdigest() for name, data in files.items()}
text = "".join(f"{h} {n}\n" for n, h in sums.items())
monkeypatch.setattr(cache, "http_get", lambda url, **kw: type("R", (), {"text": text})())
def download(url, dest, progress=None, expected_sha256=None, expected_sha1=None):
data = files[url.rsplit("/", 1)[1]]
if expected_sha256 and hashlib.sha256(data).hexdigest() != expected_sha256:
raise ValueError("checksum mismatch")
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
return dest
monkeypatch.setattr(cache, "download", download)
def _linux_archive(version: str) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as t:
for name, data, mode in (("FramePort/FramePort", b"#!/bin/sh\necho " + version.encode() + b"\n", 0o755),
("FramePort/data/version.txt", version.encode(), 0o644)):
info = tarfile.TarInfo(name)
info.size, info.mode = len(data), mode
t.addfile(info, io.BytesIO(data))
return buf.getvalue()
def test_prepare_verifies_and_unpacks(monkeypatch):
up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz")
_fake_downloads(monkeypatch, {"FramePort-linux-x64.tar.gz": _linux_archive("9.9.9")})
app = updates.prepare(up, platform="linux")
assert (app / "FramePort").is_file() and (app / "data/version.txt").read_text() == "9.9.9"
assert os.access(app / "FramePort", os.X_OK) or sys.platform == "win32"
assert updates.ready_update() == ("9.9.9", app)
def test_prepare_rejects_a_bad_checksum(monkeypatch):
up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz")
_fake_downloads(monkeypatch, {"FramePort-linux-x64.tar.gz": _linux_archive("9.9.9")},
sums={"FramePort-linux-x64.tar.gz": "0" * 64})
with pytest.raises(ValueError):
updates.prepare(up, platform="linux")
assert updates.ready_update() is None
def test_prepare_rejects_wrong_layout(monkeypatch):
up = updates.update_from_release(release(), "FramePort-windows-x64.zip")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("NotFramePort.exe", b"x")
_fake_downloads(monkeypatch, {"FramePort-windows-x64.zip": buf.getvalue()})
with pytest.raises(updates.UpdateError):
updates.prepare(up, platform="win32", current=Path("/nonexistent"))
def test_release_without_sums_is_refused():
up = updates.update_from_release(release(assets=("FramePort-linux-x64.tar.gz",)), "FramePort-linux-x64.tar.gz")
with pytest.raises(updates.UpdateError):
updates.prepare(up, platform="linux")
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX swap script")
def test_swap_script_replaces_the_installed_folder(monkeypatch, tmp_path):
up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz")
_fake_downloads(monkeypatch, {"FramePort-linux-x64.tar.gz": _linux_archive("9.9.9")})
app = updates.prepare(up, platform="linux")
installed = tmp_path / "apps/FramePort"
installed.mkdir(parents=True)
(installed / "FramePort").write_text("old")
(installed / "stale.txt").write_text("old file")
script = updates.apply(app, installed, relaunch=False, pid=2 ** 22 + 1, platform="linux", wait=True)
assert script.exists()
assert (installed / "data/version.txt").read_text() == "9.9.9" and not (installed / "stale.txt").exists()
assert not (tmp_path / "apps/FramePort.old").exists()
log = (updates.user_data_dir() / "logs/update.log").read_text()
assert "update: installed" in log and "not relaunching" in log
def test_windows_script_backs_up_and_relaunches():
text = updates.swap_script(Path("C:/data/updates/9.9.9/staged"), Path("C:/Games/FramePort"), 1234, "win32", True,
Path("C:/data/logs/update.log"))
assert "Get-Process -Id 1234" in text and "Copy-Item -Path (Join-Path $src '*') -Destination $dst" in text
assert "previous" in text and "Start-Process -FilePath 'C:" in text and "FramePort.exe" in text
def test_macos_script_clears_quarantine_and_opens():
text = updates.swap_script(Path("/d/staged/FramePort.app"), Path("/Applications/FramePort.app"), 7, "darwin",
True, Path("/d/update.log"))
assert "xattr -dr com.apple.quarantine '/Applications/FramePort.app'" in text
assert "open '/Applications/FramePort.app'" in text
def test_version_is_single_sourced():
text = (Path(__file__).resolve().parents[1] / "src/frameport/_version.py").read_text()
assert f'"{__version__}"' in text
assert 'dynamic = ["version"]' in (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text()
def test_cli_version_and_update_check(monkeypatch):
from typer.testing import CliRunner
from frameport.cli import app
runner = CliRunner()
assert runner.invoke(app, ["--version"]).output.strip() == f"FramePort {__version__}"
monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release())
res = runner.invoke(app, ["update", "--check"])
assert res.exit_code == 10 and "FramePort 9.9.9 is available" in res.output and "What's new" in res.output
monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release(tag="v0.0.1"))
res = runner.invoke(app, ["update", "--check"])
assert res.exit_code == 0 and "is the latest version" in res.output
def test_cli_hint_once_a_day_without_waiting(monkeypatch):
from typer.testing import CliRunner
from frameport.cli import app
(cache.cache_dir() / "app-release.json").write_text(json.dumps(release()))
monkeypatch.setattr(updates, "refresh_cache", lambda: None) # the cache is fresh anyway: no network
runner = CliRunner()
first = runner.invoke(app, ["list"])
assert "FramePort 9.9.9 is available" in first.output
assert "FramePort 9.9.9 is available" not in runner.invoke(app, ["list"]).output # once a day
monkeypatch.setenv("FRAMEPORT_NO_UPDATE_CHECK", "1")
library.set_setting("update.cli_hint", None)
assert "is available" not in runner.invoke(app, ["list"]).output