import os from pathlib import Path import pytest from publisher import config, detect, packages, pipeline, remote from publisher.packages import BINARY, KINDS, Published APK, APPIMAGE, TARGZ = KINDS[0], KINDS[1], KINDS[-1] SERVER = """ [server] host = "h" apps_dir = "/srv/www/apps" """ def write(tmp_path: Path, body: str) -> Path: p = tmp_path / "publish.toml" p.write_text(SERVER + body) return p def touch(path: Path, data: bytes = b"x", exe: bool = False) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) if exe: path.chmod(0o755) return path def test_load(tmp_path): cfg = config.load(write(tmp_path, """ [[app]] name = "Foo" src = "~/x" packages = ["app/release/*.apk"] keep = 1 """)) a = cfg.app("foo") assert a.src_path == Path.home() / "x" assert cfg.keep(a) == 1 assert a.local("/abs/f") == Path("/abs/f") @pytest.mark.parametrize("body, msg", [ ('name = "Foo_v1"\nsrc = "x"\npackages = ["a"]', "bad name"), ('name = "Foo 2"\nsrc = "x"\npackages = ["a"]', "bad name"), ('name = "Foo"\nsrc = "x"', "packages"), ('name = "Foo"\nsrc = "x"\npackages = ["../a"]', "glob"), ('name = "Foo"\nsrc = "x"\npackages = ["a"]\nicon = "i.jpg"', "icon"), ('name = "Foo"\nsrc = "x"\npackages = ["a"]\nversion_regex = "v.*"', "group"), ('name = "Foo"\nsrc = "x"\npackages = ["a"]\ntypo = 1', "unknown key"), ('name = "Foo"\nsrc = "x"\npackages = "a"', "wrong type"), ]) def test_validation(tmp_path, body, msg): with pytest.raises(config.ConfigError, match=msg): config.load(write(tmp_path, "[[app]]\n" + body)) def test_unsafe_apps_dir(tmp_path): p = tmp_path / "publish.toml" p.write_text('[server]\napps_dir = "/srv"\n') with pytest.raises(config.ConfigError, match="apps_dir"): config.load(p) def test_append_round_trip(tmp_path): p = write(tmp_path, "# keep me\n") app = config.App(name="n", src="~/p", packages=["a/*.apk"], build_env={"JAVA_HOME": "~/jdk"}, build='echo "hi"') config.append_apps(p, [app]) assert "# keep me" in p.read_text() loaded = config.load(p).app("n") assert loaded.build == 'echo "hi"' and loaded.build_env == {"JAVA_HOME": "~/jdk"} @pytest.mark.parametrize("name, version", [ ("Foo_v1.2.0.apk", "1.2.0"), ("Foo_v1.2.0-release.apk", "1.2.0"), ("Foo-1.5-release.apk", "1.5"), ("ExtMess_v0.7.0_linux_x86_64.AppImage", "0.7.0"), ("ExtMess_v0.7.0_linux_amd64.deb", "0.7.0"), ("Stylometer_v0.9.0_linux.tar.gz", "0.9.0"), ("app-release.apk", ""), ("multitext", ""), ]) def test_file_version(name, version): assert packages.file_version(Path(name)) == version @pytest.mark.parametrize("file, exe, app, version, kind", [ ("DasWeather_v1.4.0.apk", False, "DasWeather", "1.4.0", APK), ("WhereAreYou_v1.1.0.AppImage", False, "WhereAreYou", "1.1.0", APPIMAGE), ("multitext_v0.6", True, "multitext", "0.6", BINARY), ("Foo_v1.0-release.apk", False, "Foo", "1.0-release", APK), ("Stilometer_Linux_v0.9.0.tar.gz", False, "Stilometer_Linux", "0.9.0", TARGZ), ]) def test_parse_published(file, exe, app, version, kind): p = packages.parse_published(file, exe) assert (p.app, p.version, p.kind) == (app, version, kind) @pytest.mark.parametrize("file, exe", [("README.txt", True), ("Foo.png", False), ("multitext_v0.6", False), (".Foo_v1.apk.Xy12", False), ("run.sh", True)]) def test_not_packages(file, exe): assert packages.parse_published(file, exe) is None def test_version_key(): assert packages.version_key("1.10.0") > packages.version_key("1.9.3") assert packages.version_key("1.0") == packages.version_key("1.0.0") assert packages.version_key("1.2.0-release") == packages.version_key("1.2.0") def test_resolve(tmp_path): touch(tmp_path / "app/release/Foo_v1.9.0.apk") touch(tmp_path / "app/release/Foo_v1.10.0-release.apk") touch(tmp_path / "build/foo", exe=True) touch(tmp_path / "app/build.gradle.kts", b'android {\n versionName = "1.11.0"\n}\n') app = config.App(name="Foo", src=str(tmp_path), packages=["app/release/*.apk", "build/foo", "dist/*.deb"], version_file="app/build.gradle.kts") builds, notes = packages.resolve(app) assert [(b.site_name("Foo"), b.version) for b in builds] == [("Foo_v1.10.0.apk", "1.10.0"), ("Foo_v1.11.0", "1.11.0")] assert any("rebuild?" in n for n in notes) and any("dist/*.deb" in n for n in notes) def test_resolve_best_of_several_places(tmp_path): touch(tmp_path / "app/release/Foo_v1.0.apk", b"old") touch(tmp_path / "app/build/outputs/apk/release/Foo_v1.1.apk", b"new") app = config.App(name="Foo", src=str(tmp_path), packages=["app/release/*.apk", "app/build/outputs/apk/release/*.apk"]) builds, notes = packages.resolve(app) assert [b.path.name for b in builds] == ["Foo_v1.1.apk"] and notes == [] def test_resolve_same_version_newest_wins(tmp_path): a = touch(tmp_path / "app/release/Foo_v1.0.apk", b"studio") b = touch(tmp_path / "app/build/outputs/apk/release/Foo_v1.0.apk", b"gradle!") os.utime(a, (1000, 1000)) app = config.App(name="Foo", src=str(tmp_path), packages=["app/release/*.apk", "app/build/outputs/apk/release/*.apk"]) builds, notes = packages.resolve(app) assert builds[0].path == b assert any("2 different builds" in n and "app/build/outputs" in n for n in notes) def test_resolve_needs_a_version(tmp_path): touch(tmp_path / "build/foo", exe=True) with pytest.raises(packages.PackageError, match="no version"): packages.resolve(config.App(name="Foo", src=str(tmp_path), packages=["build/foo"])) def test_prune(): pub = [Published(f"F_v{v}.apk", "F", v, APK) for v in ("1.0", "1.10", "1.9", "1.2")] pub.append(Published("F_v1.0.AppImage", "F", "1.0", APPIMAGE)) assert sorted(p.file for p in pipeline.prune(pub, 2)) == ["F_v1.0.apk", "F_v1.2.apk"] assert pipeline.prune(pub, 0) == [] def fake_state(monkeypatch, files: dict[str, tuple[int, bool]], hashes: dict[str, str]): def state(cfg, hash_names=()): st = pipeline.RemoteState({n: (s, 1.0, ex) for n, (s, ex) in files.items()}) st.hashes = {n: h for n, h in hashes.items() if n in hash_names} return st monkeypatch.setattr(pipeline, "remote_state", state) monkeypatch.setattr(pipeline, "git_rev", lambda src: "") def test_preview(tmp_path, monkeypatch): apk = touch(tmp_path / "app/release/Foo_v1.3.0.apk", b"new") img = touch(tmp_path / "dist/Foo_v1.3.0_linux_x86_64.AppImage", b"img") info = touch(tmp_path / "site.txt", b"Foo\nabout") fake_state(monkeypatch, { "Foo_v1.0.0.apk": (1, False), "Foo_v1.1.0.apk": (1, False), "Foo_v1.2.0.apk": (1, False), "Foo_v1.0.0.txt": (1, False), "Foo_v1.3.0.AppImage": (1, True), "Foo.txt": (1, False), "Other_v9.apk": (1, False), }, {"Foo_v1.3.0.AppImage": pipeline.sha256(img), "Foo.txt": pipeline.sha256(info)}) cfg = config.load(write(tmp_path, f'[[app]]\nname = "Foo"\nsrc = "{tmp_path}"\n' 'packages = ["app/release/*.apk", "dist/*.AppImage"]\ninfo = "site.txt"\n')) plan = pipeline.preview(cfg, cfg.app("Foo"), pipeline.Options(notes="fixes"), log=lambda s: None) got = {(i.action, i.target) for i in plan.items} assert got == {("new", "Foo_v1.3.0.apk"), ("same", "Foo_v1.3.0.AppImage"), ("notes", "Foo_v1.3.0.txt"), ("delete", "Foo_v1.0.0.apk"), ("delete", "Foo_v1.0.0.txt")} assert next(i for i in plan.items if i.target == "Foo_v1.3.0.apk").local == apk def test_preview_older_and_changed(tmp_path, monkeypatch): touch(tmp_path / "Foo_v1.0.apk", b"old") touch(tmp_path / "Foo_v2.0.deb", b"changed") fake_state(monkeypatch, {"Foo_v1.1.apk": (1, False), "Foo_v2.0.deb": (1, False)}, {"Foo_v2.0.deb": "0" * 64}) cfg = config.load(write(tmp_path, f'[[app]]\nname = "Foo"\nsrc = "{tmp_path}"\npackages = ["*.apk", "*.deb"]\n')) plan = pipeline.preview(cfg, cfg.app("Foo"), pipeline.Options(), log=lambda s: None) assert [i.action for i in plan.items] == ["skip", "skip"] and not plan.changes plan = pipeline.preview(cfg, cfg.app("Foo"), pipeline.Options(force=True, prune=False), log=lambda s: None) assert [i.action for i in plan.items] == ["new", "replace"] def test_detect_android(tmp_path): touch(tmp_path / "settings.gradle.kts", b'rootProject.name = "DasPressureAndMedicines"\n') touch(tmp_path / "app/build.gradle.kts", b'versionName = "3.13.0"\n') touch(tmp_path / "app/release/DasPressureMedicines_v3.13.0.apk") touch(tmp_path / "desktop/dist/DasPressureMedicines_v3.13.0_linux_x86_64.AppImage", exe=True) app = detect.new_app(tmp_path) assert app.name == "DasPressureMedicines" assert app.packages == ["app/release/*.apk", "app/build/outputs/apk/release/*.apk", "desktop/dist/*.AppImage"] assert app.version_file == "app/build.gradle.kts" config.validate_app(app) def test_detect_go(tmp_path): touch(tmp_path / "internal/version/version.go", b'package version\n\nconst (\n\tVersion = "0.10.0"\n)\n') touch(tmp_path / "build/multitext", exe=True) app = detect.new_app(tmp_path, "multitext") assert (app.packages, app.version_file) == (["build/multitext"], "internal/version/version.go") builds, _ = packages.resolve(app) assert builds[0].site_name("multitext") == "multitext_v0.10.0" def test_site_name_cleanup(): assert detect._site_name("Das Pressure 2") == "DasPressure2" assert os.path.basename(detect._home(Path.home() / "a")) == "a" def test_ssh_exits_early(tmp_path, monkeypatch): # ssh dies at once (e.g. unresolvable host): the broken pipe must surface as RemoteError with its message fake = tmp_path / "ssh" fake.write_text("#!/bin/sh\necho \"ssh: Could not resolve hostname\" >&2\nexit 255\n") fake.chmod(0o755) monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ['PATH']}") srv = config.Server(host="nohost.invalid", user="u") with pytest.raises(remote.RemoteError, match="Could not resolve"): remote.run(srv, "x" * 5_000_000, check=False)