feat: add update-all command
This commit is contained in:
@@ -844,6 +844,33 @@ class TestApkValidator:
|
||||
assert err is not None
|
||||
assert "failed" in err
|
||||
|
||||
def test_version_code_aapt2(self, apk_file: Path):
|
||||
validator = sideload.ApkValidator(aapt2_bin="aapt2", aapt_bin="aapt")
|
||||
with mock.patch(
|
||||
"sideload._run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=["aapt2"], returncode=0,
|
||||
stdout="package: name='org.example.test' versionCode='42' versionName='x'\n",
|
||||
stderr="",
|
||||
),
|
||||
):
|
||||
assert validator.version_code(apk_file) == 42
|
||||
|
||||
def test_version_code_falls_back_to_aapt(self, apk_file: Path):
|
||||
validator = sideload.ApkValidator(aapt2_bin="aapt2", aapt_bin="aapt")
|
||||
with mock.patch(
|
||||
"sideload._run",
|
||||
side_effect=[
|
||||
subprocess.CompletedProcess(args=["aapt2"], returncode=1, stdout="", stderr="bad"),
|
||||
subprocess.CompletedProcess(
|
||||
args=["aapt"], returncode=0,
|
||||
stdout="package: name='org.example.test' versionCode='7' versionName='x'\n",
|
||||
stderr="",
|
||||
),
|
||||
],
|
||||
):
|
||||
assert validator.version_code(apk_file) == 7
|
||||
|
||||
|
||||
# ── AdbManager tests ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -944,6 +971,32 @@ class TestAdbManager:
|
||||
assert err is not None
|
||||
assert "INSTALL_FAILED" in err
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("output", "expected"),
|
||||
[
|
||||
("Packages:\n versionCode=123 minSdk=26\n", 123),
|
||||
("Unable to find package org.missing\n", None),
|
||||
],
|
||||
)
|
||||
def test_installed_version_code(self, output: str, expected: int | None):
|
||||
adb = sideload.AdbManager(binary="fake-adb")
|
||||
with mock.patch(
|
||||
"sideload._run",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout=output, stderr=""
|
||||
),
|
||||
):
|
||||
assert adb.installed_version_code("org.example.test", "SERIAL") == expected
|
||||
|
||||
def test_installed_version_code_failure(self):
|
||||
adb = sideload.AdbManager(binary="fake-adb")
|
||||
with mock.patch(
|
||||
"sideload._run",
|
||||
return_value=subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="offline"),
|
||||
):
|
||||
with pytest.raises(sideload.InstalledPackageError):
|
||||
adb.installed_version_code("org.example.test", "SERIAL")
|
||||
|
||||
|
||||
# ── _make_source tests ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -982,6 +1035,58 @@ class TestMakeSource:
|
||||
|
||||
|
||||
class TestSideload:
|
||||
def test_update_all_mixed_batch_continues(self, tmp_path: Path, apk_file: Path, capsys):
|
||||
apps = [
|
||||
sideload.AppEntry(f"App{i}", "fdroid", f"org.example.{i}", {})
|
||||
for i in range(4)
|
||||
]
|
||||
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||
device = sideload.AdbDevice("SERIAL", "Pixel", "usb")
|
||||
|
||||
class FakeAdb:
|
||||
def __init__(self):
|
||||
self.installed = iter([10, 5, None])
|
||||
self.installs: list[str] = []
|
||||
|
||||
def list_devices(self):
|
||||
return [device]
|
||||
|
||||
def installed_version_code(self, package, serial):
|
||||
if package.endswith("3"):
|
||||
raise RuntimeError("dumpsys unavailable")
|
||||
return next(self.installed)
|
||||
|
||||
def install(self, path, serial):
|
||||
self.installs.append(serial)
|
||||
return None
|
||||
|
||||
class FakeValidator:
|
||||
def validate(self, path, package):
|
||||
return None
|
||||
|
||||
def version_code(self, path):
|
||||
return 11 if path.name == "remote-1.apk" else 5
|
||||
|
||||
adb = FakeAdb()
|
||||
release = sideload.ReleaseInfo(apps[0], "x", "https://example.test/x.apk", None)
|
||||
downloaded: list[str] = []
|
||||
|
||||
def fake_download(app, cfg, validator, *, progress=None):
|
||||
downloaded.append(app.name)
|
||||
path = tmp_path / ("remote-1.apk" if app.name == "App0" else "remote-2.apk")
|
||||
path.write_bytes(b"apk")
|
||||
return release, path
|
||||
|
||||
with mock.patch("sideload._download_and_validate", side_effect=fake_download):
|
||||
result = sideload.update_all(config, adb_manager=adb, validator=FakeValidator())
|
||||
assert result == 1
|
||||
assert downloaded == ["App0", "App1"]
|
||||
assert adb.installs == ["SERIAL"]
|
||||
captured = capsys.readouterr()
|
||||
assert "1 updated" in captured.out
|
||||
# The complete four-counter summary is printed on a single line.
|
||||
assert "Summary: 1 updated, 1 unchanged, 1 not installed, 1 failed" in captured.out
|
||||
|
||||
def test_dry_run_success(
|
||||
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
|
||||
):
|
||||
@@ -1199,6 +1304,169 @@ class TestSideload:
|
||||
assert result == 0
|
||||
|
||||
|
||||
class TestUpdateAll:
|
||||
"""Focused coverage for update_all decision paths and failure handling."""
|
||||
|
||||
def _device(self) -> sideload.AdbDevice:
|
||||
return sideload.AdbDevice("SERIAL", "Pixel", "usb")
|
||||
|
||||
def _run_updates(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
capsys,
|
||||
*,
|
||||
installed: int | None = 10,
|
||||
remote_code: int = 11,
|
||||
install_error: str | None = None,
|
||||
version_code_error: Exception | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> tuple[int, list[tuple[Path, str]], pytest.CaptureFixture]:
|
||||
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||
device = self._device()
|
||||
|
||||
class FakeAdb:
|
||||
def __init__(self):
|
||||
self.install_calls: list[tuple[Path, str]] = []
|
||||
|
||||
def list_devices(self):
|
||||
return [device]
|
||||
|
||||
def installed_version_code(self, package, serial):
|
||||
return installed
|
||||
|
||||
def install(self, path, serial):
|
||||
self.install_calls.append((path, serial))
|
||||
return install_error
|
||||
|
||||
class FakeValidator:
|
||||
def validate(self, path, package):
|
||||
return None
|
||||
|
||||
def version_code(self, path):
|
||||
if version_code_error is not None:
|
||||
raise version_code_error
|
||||
return remote_code
|
||||
|
||||
adb = FakeAdb()
|
||||
apk_path = tmp_path / "remote.apk"
|
||||
apk_path.write_bytes(b"apk")
|
||||
release = sideload.ReleaseInfo(apps[0], "x", "https://example.test/x.apk", None)
|
||||
with mock.patch("sideload._download_and_validate", return_value=(release, apk_path)):
|
||||
result = sideload.update_all(
|
||||
config,
|
||||
adb_manager=adb,
|
||||
validator=FakeValidator(),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return result, adb.install_calls, capsys.readouterr()
|
||||
|
||||
def test_dry_run_no_install_but_update_status(self, tmp_path, capsys):
|
||||
result, install_calls, captured = self._run_updates(
|
||||
tmp_path, capsys, installed=10, remote_code=11, dry_run=True
|
||||
)
|
||||
assert result == 0
|
||||
assert install_calls == []
|
||||
assert " remote versionCode: 11" in captured.out
|
||||
assert "[dry-run] would install" in captured.out
|
||||
assert "Summary: 1 updated, 0 unchanged, 0 not installed, 0 failed" in captured.out
|
||||
|
||||
def test_version_code_read_error_counts_failed(self, tmp_path, capsys):
|
||||
error = ValueError("unable to read APK versionCode: no tools available")
|
||||
result, install_calls, captured = self._run_updates(
|
||||
tmp_path, capsys, installed=10, remote_code=11, version_code_error=error
|
||||
)
|
||||
assert result == 1
|
||||
assert install_calls == []
|
||||
assert "unable to read APK versionCode" in captured.err
|
||||
assert "Summary: 0 updated, 0 unchanged, 0 not installed, 1 failed" in captured.out
|
||||
|
||||
def test_remote_version_equal_is_current(self, tmp_path, capsys):
|
||||
result, install_calls, captured = self._run_updates(
|
||||
tmp_path, capsys, installed=10, remote_code=10
|
||||
)
|
||||
assert result == 0
|
||||
assert install_calls == []
|
||||
assert " already up to date; skipped" in captured.out
|
||||
assert "Summary: 0 updated, 1 unchanged, 0 not installed, 0 failed" in captured.out
|
||||
|
||||
def test_remote_version_lower_is_current(self, tmp_path, capsys):
|
||||
result, install_calls, captured = self._run_updates(
|
||||
tmp_path, capsys, installed=10, remote_code=5
|
||||
)
|
||||
assert result == 0
|
||||
assert install_calls == []
|
||||
assert " local version is newer; skipped" in captured.out
|
||||
assert "Summary: 0 updated, 1 unchanged, 0 not installed, 0 failed" in captured.out
|
||||
|
||||
def test_install_error_returned_counts_failed(self, tmp_path, capsys):
|
||||
result, install_calls, captured = self._run_updates(
|
||||
tmp_path,
|
||||
capsys,
|
||||
installed=10,
|
||||
remote_code=11,
|
||||
install_error="install failed: INSTALL_FAILED_TEST",
|
||||
)
|
||||
assert result == 1
|
||||
assert len(install_calls) == 1
|
||||
assert "FAILED: install failed: INSTALL_FAILED_TEST" in captured.err
|
||||
assert "Summary: 0 updated, 0 unchanged, 0 not installed, 1 failed" in captured.out
|
||||
|
||||
def test_absent_app_skips_download(self, tmp_path, capsys):
|
||||
result, install_calls, captured = self._run_updates(tmp_path, capsys, installed=None)
|
||||
assert result == 0
|
||||
assert install_calls == []
|
||||
assert " not installed (skipped; no download)" in captured.out
|
||||
assert "Summary: 0 updated, 0 unchanged, 1 not installed, 0 failed" in captured.out
|
||||
|
||||
def test_device_prep_failure_no_devices(self, tmp_path, capsys):
|
||||
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||
|
||||
class FakeAdb:
|
||||
def list_devices(self):
|
||||
return []
|
||||
|
||||
result = sideload.update_all(config, adb_manager=FakeAdb())
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "Error detecting devices: No devices found" in captured.err
|
||||
assert "Summary:" not in captured.out
|
||||
|
||||
def test_device_prep_failure_serial_not_found(self, tmp_path, capsys):
|
||||
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||
config = sideload.Config(apps, tmp_path / "cache", "fake-adb")
|
||||
device = self._device()
|
||||
|
||||
class FakeAdb:
|
||||
def list_devices(self):
|
||||
return [device]
|
||||
|
||||
result = sideload.update_all(config, adb_manager=FakeAdb(), device_serial="GONE")
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "Error detecting devices: Device 'GONE' not found. Available: SERIAL" in captured.err
|
||||
|
||||
def test_device_prep_failure_wifi_connect(self, tmp_path, capsys):
|
||||
apps = [sideload.AppEntry("App0", "fdroid", "org.example.0", {})]
|
||||
config = sideload.Config(
|
||||
apps,
|
||||
tmp_path / "cache",
|
||||
"fake-adb",
|
||||
adb_mode="wifi",
|
||||
adb_address="192.168.1.10:5555",
|
||||
)
|
||||
|
||||
class FakeAdb:
|
||||
def connect_wifi(self, address):
|
||||
return "failed to connect: timeout"
|
||||
|
||||
result = sideload.update_all(config, adb_manager=FakeAdb())
|
||||
assert result == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "Error detecting devices: failed to connect: timeout" in captured.err
|
||||
|
||||
|
||||
# ── CLI tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1254,6 +1522,54 @@ class TestCLI:
|
||||
assert args.app == "MyApp"
|
||||
assert args.dry_run is True
|
||||
|
||||
def test_update_all_parser_and_conflicts(self):
|
||||
parser = sideload.build_parser()
|
||||
assert parser.parse_args(["--update-all", "--dry-run"]).update_all is True
|
||||
# The positional argument is optional, so these semantic conflicts are
|
||||
# rejected by main() after argparse has parsed the options.
|
||||
with pytest.raises(SystemExit):
|
||||
sideload.main(["--update-all", "MyApp"])
|
||||
with pytest.raises(SystemExit):
|
||||
sideload.main(["--update-all", "--list"])
|
||||
|
||||
def test_main_update_all_dry_run_skips_install(self, tmp_config: Path, tmp_path: Path, capsys):
|
||||
device = sideload.AdbDevice("SERIAL", "Pixel", "usb")
|
||||
apk_path = tmp_path / "remote.apk"
|
||||
apk_path.write_bytes(b"apk")
|
||||
config = sideload.Config.from_file(tmp_config)
|
||||
app = config.apps[0]
|
||||
release = sideload.ReleaseInfo(app, "x", "https://example.test/x.apk", None)
|
||||
|
||||
class FakeAdb:
|
||||
def __init__(self, binary):
|
||||
self.binary = binary
|
||||
|
||||
def list_devices(self):
|
||||
return [device]
|
||||
|
||||
def installed_version_code(self, package, serial):
|
||||
return 10
|
||||
|
||||
def install(self, path, serial):
|
||||
raise AssertionError("install must not run in dry-run")
|
||||
|
||||
class FakeValidator:
|
||||
def validate(self, path, package):
|
||||
return None
|
||||
|
||||
def version_code(self, path):
|
||||
return 11
|
||||
|
||||
with mock.patch("sideload.AdbManager", return_value=FakeAdb("adb")), \
|
||||
mock.patch("sideload.ApkValidator", return_value=FakeValidator()), \
|
||||
mock.patch("sideload._download_and_validate", return_value=(release, apk_path)):
|
||||
result = sideload.main(["--config", str(tmp_config), "--update-all", "--dry-run"])
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "[dry-run] would install" in captured.out
|
||||
assert "Summary: 1 updated, 0 unchanged, 0 not installed, 0 failed" in captured.out
|
||||
|
||||
|
||||
# ── utility tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user