Files
adb-sideload-free-apps/tests/test_sideload.py

1033 lines
39 KiB
Python

"""Unit tests for sideload module — no network, ADB, or Android SDK required."""
from __future__ import annotations
import hashlib
import io
import json
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from unittest import mock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
import sideload # noqa: E402
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_apk_bytes(content: bytes = b"fake-apk-content") -> bytes:
return content
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _write_config(path: Path, content: str) -> Path:
path.write_text(content)
return path
def _mock_urlopen_response(data: bytes):
"""Return a MagicMock that acts as a urlopen context manager returning *data*."""
m = mock.MagicMock()
m.__enter__ = mock.MagicMock(return_value=m)
m.__exit__ = mock.MagicMock(return_value=False)
m.read.side_effect = [data, b""]
return m
# ── fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def tmp_config(tmp_path: Path) -> Path:
return _write_config(
tmp_path / "config.toml",
"""\
[cache]
directory = "/tmp/test-cache"
[[apps]]
name = "TestApp"
source = "fdroid"
app_id = "org.example.test"
repo_url = "https://f-droid.org/repo"
""",
)
@pytest.fixture
def tmp_config_multi(tmp_path: Path) -> Path:
return _write_config(
tmp_path / "config.toml",
"""\
[[apps]]
name = "Fennec"
source = "fdroid"
app_id = "org.mozilla.fennec_fdroid"
repo_url = "https://f-droid.org/repo"
[[apps]]
name = "NewPipe"
source = "github"
app_id = "org.schabi.newpipe"
owner = "TeamNewPipe"
repo = "NewPipe"
[[apps]]
name = "Aurora"
source = "gitlab"
app_id = "com.aurora.store"
project_url = "https://gitlab.com/AuroraOSS/AuroraStore"
""",
)
@pytest.fixture
def app_entry() -> sideload.AppEntry:
return sideload.AppEntry(
name="TestApp",
source="fdroid",
app_id="org.example.test",
config={"repo_url": "https://f-droid.org/repo"},
)
@pytest.fixture
def github_entry() -> sideload.AppEntry:
return sideload.AppEntry(
name="NewPipe",
source="github",
app_id="org.schabi.newpipe",
config={"owner": "TeamNewPipe", "repo": "NewPipe"},
)
@pytest.fixture
def gitlab_entry() -> sideload.AppEntry:
return sideload.AppEntry(
name="Aurora",
source="gitlab",
app_id="com.aurora.store",
config={"project_url": "https://gitlab.com/AuroraOSS/AuroraStore"},
)
@pytest.fixture
def config(tmp_config: Path) -> sideload.Config:
return sideload.Config.from_file(tmp_config)
@pytest.fixture
def cache(tmp_path: Path) -> sideload.DownloadCache:
return sideload.DownloadCache(tmp_path / "cache")
@pytest.fixture
def apk_bytes() -> bytes:
return _make_apk_bytes()
@pytest.fixture
def apk_file(tmp_path: Path, apk_bytes: bytes) -> Path:
p = tmp_path / "test.apk"
p.write_bytes(apk_bytes)
return p
# ── Config tests ──────────────────────────────────────────────────────────────
class TestConfig:
def test_from_file_basic(self, tmp_config: Path):
cfg = sideload.Config.from_file(tmp_config)
assert len(cfg.apps) == 1
assert cfg.apps[0].name == "TestApp"
assert cfg.apps[0].source == "fdroid"
assert cfg.apps[0].app_id == "org.example.test"
assert cfg.apps[0].config["repo_url"] == "https://f-droid.org/repo"
assert cfg.cache_dir == Path("/tmp/test-cache")
assert cfg.adb_binary == "adb"
def test_from_file_multi(self, tmp_config_multi: Path):
cfg = sideload.Config.from_file(tmp_config_multi)
assert len(cfg.apps) == 3
sources = {a.name: a.source for a in cfg.apps}
assert sources == {"Fennec": "fdroid", "NewPipe": "github", "Aurora": "gitlab"}
def test_from_file_default_cache(self, tmp_path: Path):
p = _write_config(
tmp_path / "cfg.toml",
'[[apps]]\nname="X"\nsource="fdroid"\napp_id="x"\nrepo_url="https://r"',
)
cfg = sideload.Config.from_file(p)
assert cfg.cache_dir == Path("~/.cache/adb-sideload").expanduser().resolve()
def test_from_file_no_apps_raises(self, tmp_path: Path):
p = _write_config(tmp_path / "cfg.toml", "")
with pytest.raises(ValueError, match="no apps configured"):
sideload.Config.from_file(p)
def test_from_file_missing_required_field(self, tmp_path: Path):
p = _write_config(
tmp_path / "cfg.toml",
'[[apps]]\nname="X"\nsource="fdroid"\napp_id="x"',
)
cfg = sideload.Config.from_file(p)
assert cfg.apps[0].config.get("repo_url") is None
def test_from_file_package_and_url_format(self, tmp_path: Path):
p = _write_config(
tmp_path / "cfg.toml",
"""\
[[apps]]
name = "NewPipe"
source = "github"
package = "org.schabi.newpipe"
url = "https://github.com/TeamNewPipe/NewPipe"
asset_pattern = ".*\\\\.apk$"
""",
)
cfg = sideload.Config.from_file(p)
assert cfg.apps[0].app_id == "org.schabi.newpipe"
assert cfg.apps[0].config["url"].startswith("https://github.com/")
# ── DownloadCache tests ───────────────────────────────────────────────────────
class TestDownloadCache:
def test_cached_path(self, cache: sideload.DownloadCache):
p = cache.cached_path("org.example", "abc123")
assert p == cache.root / "org.example" / "abc123.apk"
def test_find_cached_missing(self, cache: sideload.DownloadCache):
assert cache.find_cached("org.example", "abc123") is None
def test_find_cached_present(self, cache: sideload.DownloadCache, apk_file: Path):
sha = sideload._sha256_file(apk_file)
cache.store("org.example", apk_file, sha)
found = cache.find_cached("org.example", sha)
assert found is not None
assert found.is_file()
def test_find_cached_wrong_sha(self, cache: sideload.DownloadCache, apk_file: Path):
cache.store("org.example", apk_file, "abc123")
assert cache.find_cached("org.example", "abc123") is None
def test_store_computes_sha(self, cache: sideload.DownloadCache, apk_file: Path):
dest = cache.store("org.example", apk_file)
assert dest.is_file()
assert dest.read_bytes() == apk_file.read_bytes()
def test_store_idempotent(self, cache: sideload.DownloadCache, apk_file: Path):
sha = sideload._sha256_file(apk_file)
d1 = cache.store("org.example", apk_file, sha)
d2 = cache.store("org.example", apk_file, sha)
assert d1 == d2
# ── FdroidSource tests ────────────────────────────────────────────────────────
class TestFdroidSource:
FDROID_INDEX = {
"packages": {
"org.example.test": {
"12345": {
"file": {
"name": "/org.example.test_12345.apk",
"sha256": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"size": 1234567,
}
},
"12344": {
"file": {
"name": "/org.example.test_12344.apk",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
}
},
}
}
}
def test_get_latest_release(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(self.FDROID_INDEX).encode()):
release = source.get_latest_release()
assert release.version == "12345"
assert release.download_url == "https://f-droid.org/repo/org.example.test_12345.apk"
assert release.expected_sha256 == self.FDROID_INDEX["packages"]["org.example.test"]["12345"]["file"]["sha256"]
assert release.file_size == 1234567
def test_get_latest_release_app_not_found(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
index = {"packages": {}}
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
with pytest.raises(ValueError, match="not found in F-Droid index"):
source.get_latest_release()
def test_get_latest_release_no_versions(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
index = {"packages": {"org.example.test": {}}}
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
with pytest.raises(ValueError, match="no versions"):
source.get_latest_release()
def test_get_latest_release_no_file_name(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
index = {"packages": {"org.example.test": {"1": {"file": {}}}}}
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
with pytest.raises(ValueError, match="no file name"):
source.get_latest_release()
def test_get_latest_release_index_v2_versions_key(self, app_entry: sideload.AppEntry):
index = {
"packages": {
"org.example.test": {
"metadata": {"name": "Test"},
"versions": {
"12345": {
"file": {
"name": "org.example.test_12345.apk",
"sha256": "a" * 64,
}
}
},
}
}
}
source = sideload.FdroidSource(app_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
release = source.get_latest_release()
assert release.version == "12345"
assert release.download_url.endswith("/repo/org.example.test_12345.apk")
def test_get_latest_release_uses_manifest_version_code(self, app_entry: sideload.AppEntry):
index = {
"packages": {
"org.example.test": {
"versions": {
"sha256-of-version": {
"file": {"name": "/org.example.test_36.apk", "sha256": "a" * 64},
"manifest": {"versionCode": 36, "versionName": "5.1.1"},
},
"older-version": {
"file": {"name": "/org.example.test_34.apk", "sha256": "b" * 64},
"manifest": {"versionCode": 34, "versionName": "5.0.0"},
},
}
}
}
}
source = sideload.FdroidSource(app_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
release = source.get_latest_release()
assert release.version == "5.1.1"
assert release.download_url.endswith("/org.example.test_36.apk")
def test_missing_repo_url_raises(self):
entry = sideload.AppEntry(name="X", source="fdroid", app_id="x", config={})
with pytest.raises(ValueError, match="repo_url is required"):
sideload.FdroidSource(entry)
def test_download_apk_cached(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache, apk_file: Path):
sha = sideload._sha256_file(apk_file)
cache.store(app_entry.app_id, apk_file, sha)
source = sideload.FdroidSource(app_entry)
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="1",
download_url="https://example.com/x.apk",
expected_sha256=sha,
)
result = source.download_apk(release, cache)
assert result == cache.cached_path(app_entry.app_id, sha)
def test_download_apk_sha256_mismatch(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache):
apk_data = b"real-content"
wrong_sha = "0" * 64
source = sideload.FdroidSource(app_entry)
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="1",
download_url="https://example.com/x.apk",
expected_sha256=wrong_sha,
)
mock_resp = _mock_urlopen_response(apk_data)
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
with pytest.raises(ValueError, match="SHA-256 mismatch"):
source.download_apk(release, cache)
# ── GitHubSource tests ────────────────────────────────────────────────────────
class TestGitHubSource:
GITHUB_RELEASE = {
"tag_name": "v0.27.0",
"assets": [
{
"name": "NewPipe_v0.27.0.apk",
"browser_download_url": "https://github.com/TeamNewPipe/NewPipe/releases/download/v0.27.0/NewPipe_v0.27.0.apk",
"size": 12345678,
},
{
"name": "Source code (zip)",
"browser_download_url": "https://github.com/TeamNewPipe/NewPipe/archive/v0.27.0.zip",
},
],
}
def test_get_latest_release(self, github_entry: sideload.AppEntry):
source = sideload.GitHubSource(github_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(self.GITHUB_RELEASE).encode()):
release = source.get_latest_release()
assert release.version == "0.27.0"
assert "NewPipe_v0.27.0.apk" in release.download_url
assert release.expected_sha256 is None
assert release.file_size == 12345678
def test_get_latest_release_no_apk(self, github_entry: sideload.AppEntry):
source = sideload.GitHubSource(github_entry)
data = {"tag_name": "v1.0", "assets": [{"name": "source.zip"}]}
with mock.patch("sideload._http_get", return_value=json.dumps(data).encode()):
with pytest.raises(ValueError, match="no APK asset"):
source.get_latest_release()
def test_missing_owner_raises(self):
entry = sideload.AppEntry(name="X", source="github", app_id="x", config={"repo": "r"})
with pytest.raises(ValueError, match="owner and repo are required"):
sideload.GitHubSource(entry)
def test_missing_repo_raises(self):
entry = sideload.AppEntry(name="X", source="github", app_id="x", config={"owner": "o"})
with pytest.raises(ValueError, match="owner and repo are required"):
sideload.GitHubSource(entry)
def test_download_apk(self, github_entry: sideload.AppEntry, cache: sideload.DownloadCache):
source = sideload.GitHubSource(github_entry)
release = sideload.ReleaseInfo(
app_entry=github_entry,
version="0.27.0",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
apk_data = b"github-apk-content"
mock_resp = _mock_urlopen_response(apk_data)
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
result = source.download_apk(release, cache)
assert result.is_file()
assert result.read_bytes() == apk_data
# ── GitLabSource tests ────────────────────────────────────────────────────────
class TestGitLabSource:
GITLAB_RELEASE = [
{
"tag_name": "v4.4.1",
"name": "Aurora Store 4.4.1",
"assets": {
"links": [
{
"name": "AuroraStore-4.4.1.apk",
"url": "https://gitlab.com/AuroraOSS/AuroraStore/-/releases/v4.4.1/downloads/AuroraStore-4.4.1.apk",
}
]
},
}
]
def test_get_latest_release(self, gitlab_entry: sideload.AppEntry):
source = sideload.GitLabSource(gitlab_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(self.GITLAB_RELEASE).encode()):
release = source.get_latest_release()
assert release.version == "4.4.1"
assert "AuroraStore-4.4.1.apk" in release.download_url
assert release.expected_sha256 is None
def test_get_latest_release_no_releases(self, gitlab_entry: sideload.AppEntry):
source = sideload.GitLabSource(gitlab_entry)
with mock.patch("sideload._http_get", return_value=b"[]"):
with pytest.raises(ValueError, match="no releases found"):
source.get_latest_release()
def test_get_latest_release_no_apk(self, gitlab_entry: sideload.AppEntry):
source = sideload.GitLabSource(gitlab_entry)
data = [{"tag_name": "v1.0", "assets": {"links": [{"name": "source.zip"}]}}]
with mock.patch("sideload._http_get", return_value=json.dumps(data).encode()):
with pytest.raises(ValueError, match="no APK link"):
source.get_latest_release()
def test_missing_project_url_raises(self):
entry = sideload.AppEntry(name="X", source="gitlab", app_id="x", config={})
with pytest.raises(ValueError, match="project_url is required"):
sideload.GitLabSource(entry)
def test_download_apk(self, gitlab_entry: sideload.AppEntry, cache: sideload.DownloadCache):
source = sideload.GitLabSource(gitlab_entry)
release = sideload.ReleaseInfo(
app_entry=gitlab_entry,
version="4.4.1",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
apk_data = b"gitlab-apk-content"
mock_resp = _mock_urlopen_response(apk_data)
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
result = source.download_apk(release, cache)
assert result.is_file()
assert result.read_bytes() == apk_data
# ── ApkValidator tests ────────────────────────────────────────────────────────
class TestApkValidator:
def test_validate_success(self, apk_file: Path):
validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="org.example.test\n", stderr=""
),
):
err = validator.validate(apk_file, "org.example.test")
assert err is None
def test_validate_mismatch(self, apk_file: Path):
validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="com.other.app\n", stderr=""
),
):
err = validator.validate(apk_file, "org.example.test")
assert err is not None
assert "mismatch" in err
def test_validate_aapt_fails(self, apk_file: Path):
validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="invalid APK"
),
):
err = validator.validate(apk_file, "org.example.test")
assert err is not None
assert "failed" in err
# ── AdbManager tests ──────────────────────────────────────────────────────────
class TestAdbManager:
ADB_DEVICES_OUTPUT = (
"List of devices attached\n"
"R5CT1234ABCD device usb:1-1 product:blueline model:Pixel_3 device:blueline transport_id:1\n"
"192.168.1.100:5555 device product:redfin model:Pixel_5 device:redfin transport_id:2\n"
)
def test_list_devices(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout=self.ADB_DEVICES_OUTPUT, stderr=""
),
):
devices = adb.list_devices()
assert len(devices) == 2
assert devices[0].serial == "R5CT1234ABCD"
assert devices[0].model == "Pixel_3"
assert devices[0].transport == "usb"
assert devices[1].serial == "192.168.1.100:5555"
assert devices[1].model == "Pixel_5"
assert devices[1].transport == "wifi"
def test_list_devices_empty(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="List of devices attached\n", stderr=""
),
):
devices = adb.list_devices()
assert devices == []
def test_list_devices_skips_offline(self):
adb = sideload.AdbManager(binary="fake-adb")
output = "List of devices attached\nABCD1234 offline\n"
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout=output, stderr=""
),
):
devices = adb.list_devices()
assert devices == []
def test_connect_wifi_success(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="connected to 192.168.1.100:5555\n", stderr=""
),
):
err = adb.connect_wifi("192.168.1.100:5555")
assert err is None
def test_connect_wifi_failure(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=1, stdout="cannot connect\n", stderr=""
),
):
err = adb.connect_wifi("192.168.1.100:5555")
assert err is not None
assert "failed to connect" in err
def test_install_success(self, apk_file: Path):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="Performing Streamed Install\nSuccess\n", stderr=""
),
):
err = adb.install(apk_file, serial="R5CT1234ABCD")
assert err is None
def test_install_failure(self, apk_file: Path):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="",
stderr="INSTALL_FAILED_INSUFFICIENT_STORAGE",
),
):
err = adb.install(apk_file)
assert err is not None
assert "INSTALL_FAILED" in err
# ── _make_source tests ────────────────────────────────────────────────────────
class TestMakeSource:
def test_fdroid(self, app_entry: sideload.AppEntry):
s = sideload._make_source(app_entry)
assert isinstance(s, sideload.FdroidSource)
def test_github(self, github_entry: sideload.AppEntry):
s = sideload._make_source(github_entry)
assert isinstance(s, sideload.GitHubSource)
def test_gitlab(self, gitlab_entry: sideload.AppEntry):
s = sideload._make_source(gitlab_entry)
assert isinstance(s, sideload.GitLabSource)
def test_unknown_source_raises(self):
entry = sideload.AppEntry(name="X", source="unknown", app_id="x", config={})
with pytest.raises(ValueError, match="unknown source type"):
sideload._make_source(entry)
# ── sideload orchestration tests ──────────────────────────────────────────────
class TestSideload:
def test_dry_run_success(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
"""Full dry-run flow: resolve, download, validate, skip install."""
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
def mock_run(*args, **kwargs):
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config, dry_run=True,
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
)
assert result == 0
def test_resolve_error(self, app_entry: sideload.AppEntry, config: sideload.Config):
with mock.patch("sideload._http_get", side_effect=RuntimeError("network down")):
result = sideload.sideload(app_entry, config, dry_run=True)
assert result == 1
def test_validation_fails(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
def mock_run(*args, **kwargs):
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="com.wrong.id\n", stderr=""
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config, dry_run=True,
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
)
assert result == 1
def test_no_devices(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
call_count = 0
def mock_run(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="List of devices attached\n",
stderr="",
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config,
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
adb_manager=sideload.AdbManager(binary="fake-adb"),
)
assert result == 1
def test_device_not_found_by_serial(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
call_count = 0
def mock_run(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="List of devices attached\nABCD1234 device model:X\n",
stderr="",
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config,
device_serial="NONEXISTENT",
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
adb_manager=sideload.AdbManager(binary="fake-adb"),
)
assert result == 1
def test_full_install_success(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
call_count = 0
def mock_run(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
if call_count == 2:
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="List of devices attached\nABCD1234 device model:Pixel\n",
stderr="",
)
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="Performing Streamed Install\nSuccess\n",
stderr="",
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config,
device_serial="ABCD1234",
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
adb_manager=sideload.AdbManager(binary="fake-adb"),
)
assert result == 0
# ── CLI tests ─────────────────────────────────────────────────────────────────
class TestCLI:
def test_list(self, tmp_config: Path, capsys):
result = sideload.main(["--config", str(tmp_config), "--list"])
assert result == 0
captured = capsys.readouterr()
assert "TestApp" in captured.out
def test_config_not_found(self, capsys):
result = sideload.main(["--config", "/nonexistent/config.toml"])
assert result == 1
captured = capsys.readouterr()
assert "Config not found" in captured.err
def test_app_not_found(self, tmp_config: Path, capsys):
result = sideload.main(["--config", str(tmp_config), "NonExistent"])
assert result == 1
captured = capsys.readouterr()
assert "not found" in captured.err
def test_ambiguous_name(self, tmp_config_multi: Path, capsys):
apk_data = b"fake"
real_sha = _sha256(apk_data)
fdroid_index = {
"packages": {
"org.mozilla.fennec_fdroid": {
"1": {"file": {"name": "/repo/fennec.apk", "sha256": real_sha}}
}
}
}
mock_resp = _mock_urlopen_response(apk_data)
with mock.patch("sideload._http_get", return_value=json.dumps(fdroid_index).encode()):
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
with mock.patch("sideload._run", return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="org.mozilla.fennec_fdroid\n", stderr=""
)):
result = sideload.main(
["--config", str(tmp_config_multi), "fennec", "--dry-run"]
)
assert result == 0
def test_version(self, capsys):
with pytest.raises(SystemExit):
sideload.main(["--version"])
def test_build_parser(self):
parser = sideload.build_parser()
args = parser.parse_args(["--list"])
assert args.list is True
args = parser.parse_args(["MyApp", "--dry-run"])
assert args.app == "MyApp"
assert args.dry_run is True
# ── utility tests ─────────────────────────────────────────────────────────────
class TestUtilities:
def test_sha256_file(self, tmp_path: Path):
p = tmp_path / "test.bin"
p.write_bytes(b"hello world")
expected = hashlib.sha256(b"hello world").hexdigest()
assert sideload._sha256_file(p) == expected
def test_make_apk_stem(self, app_entry: sideload.AppEntry):
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="1.2.3",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
stem = sideload._make_apk_stem(release)
assert stem == "org.example.test_1.2.3"
def test_make_apk_stem_sanitizes_slashes(self, app_entry: sideload.AppEntry):
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="feature/branch",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
stem = sideload._make_apk_stem(release)
assert "/" not in stem
def test_atomic_download(self, tmp_path: Path):
dest = tmp_path / "sub" / "file.apk"
data = b"downloaded-content"
mock_resp = _mock_urlopen_response(data)
with mock.patch("urllib.request.urlopen", return_value=mock_resp):
result = sideload._atomic_download("https://example.com/x.apk", dest)
assert result == dest
assert dest.read_bytes() == data
assert not dest.with_suffix(".apk.tmp").exists()
def test_atomic_download_cleans_up_on_error(self, tmp_path: Path):
dest = tmp_path / "file.apk"
with mock.patch("urllib.request.urlopen", side_effect=RuntimeError("fail")):
with pytest.raises(RuntimeError):
sideload._atomic_download("https://example.com/x.apk", dest)
assert not dest.exists()
assert not dest.with_suffix(".apk.tmp").exists()
def test_http_get_error(self):
with mock.patch(
"urllib.request.urlopen",
side_effect=urllib.error.HTTPError(
"https://example.com", 404, "Not Found", {}, None
),
):
with pytest.raises(RuntimeError, match="HTTP 404"):
sideload._http_get("https://example.com")
def test_http_get_network_error(self):
with mock.patch(
"urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused"),
):
with pytest.raises(RuntimeError, match="network error"):
sideload._http_get("https://example.com")
def test_run_passes_args_correctly(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=["echo", "hello"], returncode=0, stdout="hello\n", stderr=""
)
sideload._run("echo", "hello")
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert call_args == ["echo", "hello"]
# ── AdbDevice dataclass ───────────────────────────────────────────────────────
class TestAdbDevice:
def test_creation(self):
d = sideload.AdbDevice(serial="ABC123", model="Pixel", transport="usb")
assert d.serial == "ABC123"
assert d.model == "Pixel"
assert d.transport == "usb"
# ── ReleaseInfo dataclass ─────────────────────────────────────────────────────
class TestReleaseInfo:
def test_creation(self, app_entry: sideload.AppEntry):
r = sideload.ReleaseInfo(
app_entry=app_entry,
version="1.0",
download_url="https://example.com/x.apk",
expected_sha256="abc",
file_size=1000,
)
assert r.version == "1.0"
assert r.file_size == 1000