Files
Das-OS/srcpkgs/publisher/files/tests/test_core.py
T
dasandClaude Sonnet 5.5 8db767efb9
build / build (push) Successful in 21m56s
publisher, deployer: report ssh failure instead of BrokenPipeError (0.9.8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:34:29 +02:00

229 lines
10 KiB
Python

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)