Files
spoopyghosty0--frameport/tests/test_updates.py
T
spoopyghosty0 05f221b216 pop_dialog() closes the pop-up, never a toast (install-link question stayed open)
Flet 1.0 keeps toasts in the same stack as dialogs and pop_dialog() closes the topmost open entry. An install link's
job shows a "waiting for your answer" toast just as its question appears; when the toast came second, "Download
and install" closed the toast, the download ran, and the question stayed on screen. pop_dialog now skips toasts
(reproduced and checked with real clicks in the web build). Also a too-long test docstring line.
2026-10-17 22:25:11 -04:00

438 lines
23 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 # "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