"""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 # "Skip this version" hides it 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_snooze_hides_a_version_until_it_runs_out(monkeypatch): monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release()) cache.cache_dir().mkdir(parents=True, exist_ok=True) (cache.cache_dir() / "app-release.json").write_text(json.dumps(release()), encoding="utf-8") now = [1_000_000.0] monkeypatch.setattr(updates.time, "time", lambda: now[0]) updates.snooze("9.9.9", hours=24) assert updates.check() is None and updates.cached_update() is None # "Later": hidden for a day assert updates.check(force=True).version == "9.9.9" # "Check now" still finds it now[0] += 23 * 3600 assert updates.check() is None now[0] += 2 * 3600 # a day later it is offered again assert updates.check().version == "9.9.9" and updates.cached_update().version == "9.9.9" updates.snooze("9.9.8") # a snooze for another version doesn't hide it assert updates.check().version == "9.9.9" assert not updates.hidden("9.9.9") 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") def test_platform_asset_picks_the_arm64_linux_bundle(monkeypatch): monkeypatch.setattr(updates.sys, "platform", "linux") monkeypatch.setattr(updates.platform, "machine", lambda: "aarch64") assert updates.platform_asset() == "FramePort-linux-arm64.tar.gz" monkeypatch.setattr(updates.platform, "machine", lambda: "x86_64") assert updates.platform_asset() == "FramePort-linux-x64.tar.gz" def test_dev_build_versions_sort_between_releases(): import importlib.util from pathlib import Path spec = importlib.util.spec_from_file_location("dev_version", Path(__file__).parents[1] / "scripts/dev_version.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) assert mod.dev_version("0.9.0", 57) == "0.9.1.dev57" pv = updates.parse_version assert pv("0.9.0") < pv("0.9.1.dev57") < pv("0.9.1.dev58") < pv("0.9.1") assert updates.is_newer("0.9.1", "0.9.1.dev58") and not updates.is_newer("0.9.0", "0.9.1.dev58") def test_dev_release_is_only_read_on_request(): release = {"tag_name": "dev", "prerelease": True, "html_url": "https://example/dev", "body": "try X", "assets": [{"name": "frameport-0.9.1.dev57-py3-none-any.whl", "browser_download_url": "w"}, {"name": "FramePort-windows-x64.zip", "browser_download_url": "z"}, {"name": "SHA256SUMS.txt", "browser_download_url": "s"}]} assert updates.update_from_release(release, "FramePort-windows-x64.zip") is None # automatic checks skip it up = updates.update_from_release(release, "FramePort-windows-x64.zip", dev=True) assert up.version == "0.9.1.dev57" and up.asset_url == "z" and up.sums_url == "s" and up.notes == "try X" assert updates.update_from_release({**release, "assets": []}, dev=True) is None # no wheel: no version # ------------------------------------------------------------------------------------------------- changelog FOOTER = (Path(__file__).parents[1] / "packaging" / "release-footer.md").read_text(encoding="utf-8") def ci_notes(tag, bullets): """Release notes as CI writes them (.github/workflows/build.yml).""" return (f"FramePort {tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n" f"{bullets}\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n" + FOOTER) def rel(tag, bullets="- a change", date="2026-10-01", **kw): return {"tag_name": tag, "name": f"FramePort {tag}", "body": ci_notes(tag, bullets), "draft": False, "prerelease": False, "published_at": f"{date}T12:00:00Z", "html_url": f"https://example.invalid/releases/{tag}", "assets": [], **kw} DEV = {"tag_name": "dev", "name": "FramePort dev build 0.9.3.dev7", "prerelease": True, "draft": False, "published_at": "2026-10-07T00:00:00Z", "html_url": "https://example.invalid/releases/dev", "body": "Dev build **0.9.3.dev7** from commit abc1234 (2026-10-07). For testing fixes before\nthe next release; " "you get that release as a normal update.\n\n## Please test\n\nthe changelog\n\n" "## Changes since v0.9.2\n\n- one commit\n", "assets": [{"name": "frameport-0.9.3.dev7-py3-none-any.whl", "browser_download_url": "w"}]} RELEASES = [DEV, rel("v0.9.2", "- newest", "2026-10-05"), rel("v0.9.1", "- middle", "2026-10-03"), rel("v0.9.1-rc1", prerelease=True), rel("v0.9.0", "- old", "2026-10-01"), rel("v0.8.9", draft=True), "garbage", {"tag_name": "nightly"}] def test_whats_new_keeps_only_the_changes(): body = updates.whats_new(ci_notes("v0.9.2", "- **Bold** change\n- [link](https://x.invalid)")) assert body == "- **Bold** change\n- [link](https://x.invalid)" # notes without a "What's new" heading: CI's intro, the update hint and the install footer go plain = updates.whats_new("FramePort v0.3.1: Windows (x64).\n\nFixed things.\n\nAlready have FramePort? It " "offers this update itself.\n\n" + FOOTER) assert plain == "Fixed things." dev = updates.whats_new(DEV["body"]) assert "Please test" in dev and "one commit" in dev and "Dev build" not in dev assert updates.whats_new("") == "" and updates.whats_new(None) == "" def test_changelog_entries_skip_drafts_prereleases_and_bad_data(): entries = updates.changelog_entries(RELEASES) assert [e.version for e in entries] == ["0.9.3.dev7", "0.9.2", "0.9.1", "0.9.0"] newest = entries[1] assert (newest.date, newest.title, newest.body) == ("2026-10-05", "FramePort v0.9.2", "- newest") assert entries[0].dev and not newest.dev assert updates.changelog_entries(None) == [] and updates.changelog_entries({"message": "rate limited"}) == [] def test_changelog_ranges_stable_and_dev(): entries = updates.changelog_entries(RELEASES) def versions(es): return [e.version for e in es] assert versions(updates.between(entries, "0.9.0", "0.9.2")) == ["0.9.2", "0.9.1"] # newest first assert versions(updates.between(entries, "0.9.2", "0.9.2")) == [] assert versions(updates.between(entries, "0.9.1.dev3", "0.9.1")) == ["0.9.1"] # dev → its release assert versions(updates.between(entries, "0.9.1", "0.9.3.dev7")) == ["0.9.3.dev7", "0.9.2"] # choosing dev assert versions(updates.between(entries, "0.9.1", "0.9.3")) == ["0.9.2"] # stable: no dev entry up = updates.update_from_release(rel("v0.9.2", "- newest"), "") assert versions(updates.changelog_for(up, "0.9.0", entries)) == ["0.9.2", "0.9.1"] updates.skip("0.9.1") # a skipped version's changes still reach whoever updates past it assert versions(updates.changelog_for(up, "0.9.0", entries)) == ["0.9.2", "0.9.1"] # the offered release isn't in the (stale) list yet: its own notes come first new = updates.update_from_release(rel("v0.9.4", "- brand new"), "") out = updates.changelog_for(new, "0.9.1", entries) assert versions(out) == ["0.9.4", "0.9.2"] and out[0].body == "- brand new" assert versions(updates.recent_history(entries, "0.9.2", n=2)) == ["0.9.2", "0.9.1"] assert versions(updates.recent_history(entries, "0.9.3.dev7")) == ["0.9.3.dev7", "0.9.2", "0.9.1", "0.9.0"] def test_fetch_changelog_caches_and_survives_offline(monkeypatch): calls = [] def online(url, **kw): calls.append(url) return type("R", (), {"text": json.dumps(RELEASES), "raise_for_status": lambda self: None})() monkeypatch.setattr(cache, "http_get", online) assert [e.version for e in updates.fetch_changelog()][:2] == ["0.9.3.dev7", "0.9.2"] assert calls == [updates.RELEASES_API] updates.fetch_changelog(need="0.9.1") # cached and fresh: no second request assert len(calls) == 1 updates.fetch_changelog(need="0.9.5") # newer than everything cached: asks GitHub again assert len(calls) == 2 monkeypatch.setattr(cache, "http_get", lambda url, **kw: (_ for _ in ()).throw(OSError("offline"))) assert updates.fetch_changelog(force=True)[1].version == "0.9.2" # offline: the cached copy (cache.cache_dir() / "app-releases.json").unlink() assert updates.fetch_changelog(force=True) == [] # offline, nothing cached: empty, no error def test_news_range(): assert updates.news_range(None, "0.9.2") == (False, None) # first start ever assert updates.news_range(None, "0.9.2", used_before=True) == (True, None) # from a version without the setting assert updates.news_range("0.9.0", "0.9.2") == (True, "0.9.0") assert updates.news_range("0.9.2", "0.9.2") == (False, None) assert updates.news_range("0.9.3", "0.9.2") == (False, None) # went back assert updates.news_range("0.9.1.dev4", "0.9.1") == (True, "0.9.1.dev4") def test_pending_news_shows_once_and_waits_while_offline(): entries = updates.changelog_entries(RELEASES) assert updates.pending_news("0.9.2", entries) == [] # first start: just recorded assert library.setting("update.last_seen_version") == "0.9.2" library.set_setting("update.last_seen_version", "0.9.0") assert updates.pending_news("0.9.2", []) == [] # offline: try again next start assert library.setting("update.last_seen_version") == "0.9.0" assert [e.version for e in updates.pending_news("0.9.2", entries)] == ["0.9.2", "0.9.1"] assert library.setting("update.last_seen_version") == "0.9.2" assert updates.pending_news("0.9.2", entries) == [] # only once library.set_setting("update.last_seen_version", None) library.set_setting("update.last_check", 1.0) # updated from an older FramePort assert [e.version for e in updates.pending_news("0.9.2", entries)] == ["0.9.2"] def test_relaunch_environment_drops_the_running_apps_own_variables(): """GitHub #195: the relaunched app inherited the old app's Flet/embedded-Python variables and stayed black.""" from frameport import updates env = updates.clean_env({"PATH": "/bin", "FLET_SERVER_UDS_PATH": "/tmp/old.sock", "flet_platform": "windows", "SERIOUS_PYTHON_SITE_PACKAGES": "x", "PYTHONHOME": "C:/Temp/serious_python_temp", "PYTHONPATH": "y", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"}) assert env == {"PATH": "/bin", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"} def test_source_restart_keeps_a_hidden_console_on_windows(monkeypatch): """DETACHED_PROCESS gave every console child (ssh) of a restarted dev install its own visible terminal.""" from frameport.ui import updater calls = [] monkeypatch.setattr(updater.sys, "platform", "win32") monkeypatch.setattr(updater.subprocess, "Popen", lambda cmd, **k: calls.append(k)) u = updater.Updater.__new__(updater.Updater) monkeypatch.setattr(u, "_quit", lambda: None, raising=False) u._restart_process() flags = calls[0]["creationflags"] assert flags & 0x08000000 and not flags & 0x00000008 # CREATE_NO_WINDOW, not DETACHED_PROCESS