Files
spoopyghosty0--frameport/tests/test_updates.py
T
spoopyghosty0 23a47880e0 Lines within 120 columns everywhere (CI lints without exceptions); community and licence files; docs updated
- reflowed long lines (texts unchanged; generated launcher/PowerShell/README texts byte-identical)
- CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, THIRD_PARTY_NOTICES; pyproject metadata (SPDX licence, authors, urls,
  classifiers, keywords)
- maintainer-only notes moved to a git-ignored CLAUDE.local.md; CLAUDE.md facts updated
- README: what kinds of apps run and how; INSTALL: PC VR and Files wording, new labels
2026-10-02 14:18:41 -04:00

249 lines
12 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
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX swap script")
def test_apply_detached_checks_the_script_started(monkeypatch, tmp_path):
import time
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")
updates.apply(app, installed, relaunch=False, pid=2 ** 22 + 2, platform="linux") # returns once the script runs
log = updates.user_data_dir() / "logs/update.log"
for _ in range(100):
if "not relaunching" in log.read_text():
break
time.sleep(0.1)
assert (installed / "data/version.txt").read_text() == "9.9.9"
def test_apply_reports_a_script_that_never_starts(monkeypatch, tmp_path):
installed = tmp_path / "FramePort"
installed.mkdir()
staged = tmp_path / "staged"
staged.mkdir()
monkeypatch.setattr(updates, "START_TIMEOUT", 0.5)
monkeypatch.setattr(updates.subprocess, "Popen", lambda *a, **k: type("P", (), {"poll": lambda self: None})())
with pytest.raises(updates.UpdateError):
updates.apply(staged, installed, relaunch=False, pid=1, platform="linux")