#!/usr/bin/env python3 import hashlib import json import plistlib import subprocess import tempfile import unittest import zipfile from pathlib import Path from unittest import mock import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) import mobile_bundle as bundle def manifest(commit: str, apk: Path, ipa: Path) -> dict: return { "schema_version": 1, "release": "ci-test", "commit": commit, "artifacts": { "android": {"url": str(apk), "sha256": hashlib.sha256(apk.read_bytes()).hexdigest(), "package": "page.writes"}, "ios": {"url": str(ipa), "sha256": hashlib.sha256(ipa.read_bytes()).hexdigest(), "bundle_id": "page.writes"}, }, } class MobileBundleTests(unittest.TestCase): def test_manifest_contract(self): with tempfile.TemporaryDirectory() as temp: root = Path(temp) apk, ipa = root / "a.apk", root / "a.ipa" apk.write_bytes(b"apk") ipa.write_bytes(b"ipa") bundle.validate_manifest(manifest("a" * 40, apk, ipa), "ci-test") with self.assertRaises(bundle.ManifestFailure): bundle.validate_manifest(manifest("a" * 40, apk, ipa), "wrong") def test_exact_worktree_repairs_stale_commit(self): with tempfile.TemporaryDirectory() as temp: repo, worktrees = Path(temp) / "repo", Path(temp) / "worktrees" repo.mkdir() run = lambda args: subprocess.run(args, check=True, stdout=subprocess.DEVNULL) run(["git", "init", "-q", str(repo)]) run(["git", "-C", str(repo), "config", "user.email", "tester@example.invalid"]) run(["git", "-C", str(repo), "config", "user.name", "Tester"]) (repo / "file").write_text("one") run(["git", "-C", str(repo), "add", "file"]) run(["git", "-C", str(repo), "commit", "-qm", "one"]) first = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip() (repo / "file").write_text("two") run(["git", "-C", str(repo), "commit", "-qam", "two"]) second = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip() path = worktrees / second[:12] run(["git", "-C", str(repo), "worktree", "add", "--detach", str(path), first]) actual, commit = bundle.ensure_worktree(second, bundle.RuntimeContext(repo), worktrees) self.assertEqual(actual, path.resolve()) self.assertEqual(commit, second) def test_ipa_identity_and_install(self): with tempfile.TemporaryDirectory() as temp: root = Path(temp) ipa = root / "app.ipa" with zipfile.ZipFile(ipa, "w") as archive: archive.writestr("Payload/Writes.app/Info.plist", plistlib.dumps({"CFBundleIdentifier": "page.writes"})) self.assertEqual(bundle.ios_identity(ipa), "page.writes") context = bundle.RuntimeContext(root) calls = [] def fake(args, **kwargs): calls.append(args) return bundle.CommandResult(0, "/container" if "get_app_container" in args else "") with mock.patch.object(bundle, "run_command", side_effect=fake): result = bundle.install_ios({"xcrun": "xcrun", "udid": "UDID", "name": "Test"}, ipa, "page.writes", context) self.assertTrue(result["verified"]) self.assertTrue(any("install" in call for call in calls)) def test_android_missing_device_is_environment_failure(self): context = bundle.RuntimeContext(Path(".")) with mock.patch.object(bundle.shutil, "which", return_value="/sdk/adb"), mock.patch.object( bundle, "run_command", side_effect=[bundle.CommandResult(0, ""), bundle.CommandResult(0, "List of devices attached")] ): with self.assertRaises(bundle.EnvironmentFailure): bundle.android_preflight(context) def test_network_failure_is_actionable(self): context = bundle.RuntimeContext(Path(".")) with mock.patch.object(bundle.urllib.request, "urlopen", side_effect=OSError("offline")), mock.patch.object(bundle.time, "sleep"): with mock.patch.dict(bundle.os.environ, {"HOST": "test.invalid"}, clear=False): with self.assertRaisesRegex(bundle.EnvironmentFailure, "VPN"): bundle.check_network(context) def test_retry_recovers(self): context = bundle.RuntimeContext(Path(".")) responses = [bundle.CommandResult(1, "busy"), bundle.CommandResult(0, "ok")] with mock.patch.object(bundle, "run_command", side_effect=responses), mock.patch.object(bundle.time, "sleep"): self.assertEqual(bundle.retry_command(["fake"], context=context, action="install").code, 0) def test_full_gate_blocks_install_when_ios_fails(self): with tempfile.TemporaryDirectory() as temp: root = Path(temp) / "repo" root.mkdir() subprocess.run(["git", "init", "-q", str(root)], check=True) subprocess.run(["git", "-C", str(root), "config", "user.email", "tester@example.invalid"], check=True) subprocess.run(["git", "-C", str(root), "config", "user.name", "Tester"], check=True) (root / "marker").write_text("x") subprocess.run(["git", "-C", str(root), "add", "marker"], check=True) subprocess.run(["git", "-C", str(root), "commit", "-qm", "bundle"], check=True) commit = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip() apk, ipa = Path(temp) / "a.apk", Path(temp) / "a.ipa" apk.write_bytes(b"apk") ipa.write_bytes(b"ipa") manifest_path = Path(temp) / "manifest.json" manifest_path.write_text(json.dumps(manifest(commit, apk, ipa))) args = bundle.parser().parse_args(["run", "--release", str(manifest_path), "--root", str(root), "--worktree-root", str(Path(temp) / "wt")]) with mock.patch.object(bundle, "android_identity", return_value="page.writes"), mock.patch.object(bundle, "ios_identity", return_value="page.writes"), mock.patch.object(bundle, "android_preflight", return_value={"adb": "adb", "serial": "device"}), mock.patch.object(bundle, "ios_preflight", side_effect=bundle.EnvironmentFailure("Mac unavailable")), mock.patch.object(bundle, "check_network"), mock.patch.object(bundle, "install_android") as install: self.assertEqual(bundle.execute(args), 1) install.assert_not_called() if __name__ == "__main__": unittest.main()