from datetime import datetime from pathlib import Path import tempfile import unittest from types import SimpleNamespace from unittest import mock from openpilot.common.params import Params from openpilot.starpilot.galaxy.software_operations import ( SoftwareOperations, SoftwareOperationError, UpdaterProcess, PROCESS_TITLE, ) from openpilot.starpilot.software.update_control import UpdaterControlError class FakeProcess: def __init__(self): self.running = True self.sent = [] self.fast_requests = [] def available(self): return self.running def send(self, sig, **identity): if not self.running: raise SoftwareOperationError("Updater stopped", 503) self.sent.append(sig) if sig in ("update", "fast"): self.fast_requests.append(identity) def close(self): pass class SoftwareOperationsTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) root = Path(self.temp.name) self.params = Params(str(root / "params")) self.params.put("Version", "test", block=True) self.params.put("GitBranch", "main", block=True) self.params.put("GitCommit", "a" * 40, block=True) self.params.put("UpdaterState", "idle", block=True) self.params.put("UpdaterTargetBranch", "main", block=True) self.params.put("UpdaterAvailableBranches", "main,release", block=True) self.params.put_bool("IsOffroad", True, block=True) self.params.put_bool("UpdateAvailable", False, block=True) self.params.put_bool("DisableUpdates", False, block=True) self.params.put("UpdateFailedCount", 0, block=True) self.process = FakeProcess() self.parked = True self.authorized = True self.finalized = root / "finalized" self.finalized.mkdir() self.installed = root / "installed" self.installed.mkdir() self.git = {self.installed: ("main", "a" * 40), self.finalized: ("release", "b" * 40)} self.owner = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process, finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) def act(self, action, branch=None): body = {"action": action} if branch is not None: body["branch"] = branch return self.owner.action(action, body, authorized=lambda: self.authorized) def denied(self, action, status, branch=None): with self.assertRaises(SoftwareOperationError) as caught: self.act(action, branch) self.assertEqual(caught.exception.status, status) def test_snapshot_refresh_precedes_parked_read_and_relocks_without_signaling(self): order = [] def refresh(): order.append('refresh') self.parked = not self.parked return True def parked(): order.append('parked') return self.parked self.parked = False self.owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) for expected in (True, False): order.clear() view = self.owner.snapshot(include_history=False) self.assertEqual(order, ['refresh', 'parked']) self.assertIs(view['parked'], expected) for key in ('canCheck', 'canDownload', 'canSelect'): self.assertIs(view[key], expected) self.assertIsNone(view['request']) self.assertEqual(self.process.sent, []) def test_failed_refresh_never_uses_cached_parked_authority(self): self.parked = True for outcome in (False, None, 1, OSError('collector failed')): with self.subTest(outcome=outcome): refresh = mock.Mock(side_effect=outcome) if isinstance(outcome, Exception) else mock.Mock(return_value=outcome) parked = mock.Mock(return_value=True) owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(owner.close) view = owner.snapshot(include_history=False) refresh.assert_called_once_with() parked.assert_not_called() self.assertIs(view['parked'], False) for key in ('canCheck', 'canDownload', 'canSelect', 'canInstall', 'canFastUpdate', 'canConfigure'): self.assertIs(view[key], False) self.assertIsNone(view['request']) self.assertEqual(self.process.sent, []) def test_idle_is_not_reported_as_update_progress(self): view = self.owner.snapshot(include_history=False) self.assertIsNone(view["progress"]["stage"]) self.assertEqual(view["progress"]["detail"], "") def test_normal_finalization_completes_only_after_idle_success(self): for failed in (False, True): with self.subTest(failed=failed): self.owner.request = None self.params.put("UpdateFailedCount", 0, block=True) self.params.put("UpdaterState", "idle", block=True) self.params.remove("LastUpdateTime") self.act("download", "main") self.params.put("UpdaterState", "finalizing update...", block=True) self.params.put("UpdaterFastState", {"version": 1, "stage": "complete", "detail": "Old fast update"}, block=True) view = self.owner.snapshot() self.assertEqual(view["request"]["state"], "pending") self.assertEqual(view["progress"], {"stage": "finalizing update...", "detail": "", "percent": None}) self.params.put("UpdaterState", "idle", block=True) if failed: self.params.put("UpdateFailedCount", 1, block=True) self.params.put("LastUpdateException", "Finalization copy failed", block=True) else: self.params.put("LastUpdateTime", datetime(2026, 10, 6), block=True) self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 6), block=True) result = self.owner.snapshot()["request"] self.assertEqual(result["state"], "failed" if failed else "complete") if failed: self.assertEqual(result["error"], "Finalization copy failed") def test_unavailable_finalizing_preserves_request_until_status_recovers(self): self.act("download", "main") self.params.put("UpdaterState", "finalizing update...", block=True) self.process.running = False view = self.owner.snapshot() self.assertEqual(view["progress"]["stage"], "unavailable") self.assertIn("device updater service", view["progress"]["detail"]) self.assertNotIn("last reported state", view["progress"]["detail"]) self.assertEqual(view["request"]["state"], "pending") self.assertEqual(view["reason"], "Updater status is unavailable") self.assertFalse(view["canCheck"]) self.assertEqual(self.params.get("UpdaterState"), "finalizing update...") self.process.running = True self.params.put("UpdaterState", "idle", block=True) self.params.put("LastUpdateTime", datetime(2026, 10, 6), block=True) self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 6), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") def test_running_finalization_timeout_does_not_fabricate_completion(self): self.owner.clock = lambda: 0.0 self.act("download", "main") self.params.put("UpdaterState", "finalizing update...", block=True) self.owner.clock = lambda: 901.0 view = self.owner.snapshot() self.assertEqual(view["progress"], {"stage": "timed_out", "detail": "Request timed out; the updater may still be running", "percent": None}) self.assertTrue(view["updaterAvailable"]) self.assertEqual(view["request"]["state"], "failed") self.assertFalse(view["canCheck"]) self.assertEqual(self.params.get("UpdaterState"), "finalizing update...") def test_recent_version_admission_pins_installed_snapshot_and_listed_target(self): selected = 'c' * 40 recent = {'branch': 'main', 'head': 'b' * 40, 'entries': [{'hash': selected, 'date': '2026-09-29T12:00:00+00:00', 'subject': 'Previous build'}]} payload = {'action': 'version', 'branch': 'main', 'expectedCommit': 'a' * 40, 'selectedCommit': selected} with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent), \ mock.patch.object(self.process, 'send') as send: for field, value in (('selectedCommit', 'f' * 40), ('expectedCommit', 'b' * 40), ('branch', 'release')): with self.subTest(field=field), self.assertRaises(SoftwareOperationError): self.owner.action('version', dict(payload, **{field: value}), authorized=lambda: True) self.parked = False with self.assertRaises(SoftwareOperationError): self.owner.action('version', payload, authorized=lambda: True) self.parked = True self.owner.action('version', payload, authorized=lambda: True) send.assert_called_once_with('version', branch='main', commit='a' * 40, selected_commit=selected) self.assertIsNotNone(self.owner.request) assert self.owner.request is not None self.assertEqual(self.owner.request['selectedCommit'], selected) def test_version_installed_snapshot_cannot_change_at_final_dispatch(self): selected = 'c' * 40 recent = {'branch': 'main', 'head': selected, 'entries': [{'hash': selected, 'date': '2026-09-29T12:00:00+00:00', 'subject': 'Build'}]} calls = 0 def authorized(): nonlocal calls calls += 1 if calls == 3: self.params.put('GitCommit', 'd' * 40, block=True) self.git[self.installed] = ('main', 'd' * 40) return True with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent), \ mock.patch.object(self.process, 'send') as send, self.assertRaises(SoftwareOperationError): self.owner.action('version', {'action': 'version', 'branch': 'main', 'expectedCommit': 'a' * 40, 'selectedCommit': selected}, authorized=authorized) send.assert_not_called() def test_listing_request_completes_without_install_timestamp_or_reboot(self): self.params.put('LastUpdateTime', datetime(2026, 9, 1), block=True) self.params.put('UpdaterLastFetchTime', datetime(2026, 9, 1), block=True) with mock.patch.object(self.process, 'send') as send: self.act('versions', 'main') send.assert_called_once_with('versions', branch='main', commit='a' * 40) self.params.put('UpdaterLastFetchTime', datetime(2026, 9, 2), block=True) state = self.owner.snapshot()['request'] self.assertEqual(state['state'], 'complete') self.assertNotIn('outcome', state) self.assertEqual(self.params.get('LastUpdateTime'), datetime(2026, 9, 1)) self.assertFalse(self.params.get_bool('DoReboot')) def test_select_then_check_then_download_signals_exact_updater(self): self.assertTrue(self.owner.snapshot()["canSelect"]) self.assertEqual(self.owner.snapshot()["selectedTarget"], "main") selected = self.act("select", "release") self.assertEqual(selected["selectedTarget"], "release") self.assertEqual(self.params.get("UpdaterTargetBranch"), "release") self.assertEqual(self.process.sent, []) checked = self.act("check") self.assertEqual(checked["request"]["state"], "pending") self.assertEqual(self.process.sent, ["check"]) self.denied("download", 409, "release") self.params.put("UpdaterState", "checking...", block=True) self.owner.snapshot() self.params.put("UpdaterState", "idle", block=True) self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 0), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") downloaded = self.act("download", "release") self.assertEqual(downloaded["request"]["target"], "release") self.assertEqual(self.process.sent, ["check", "download"]) self.denied("download", 409, "release") def test_fresh_manager_without_target_allows_check_and_select(self): self.params.remove("UpdaterTargetBranch") initial = self.owner.snapshot() self.assertEqual(initial["selectedTarget"], "main") self.assertTrue(initial["canCheck"]) self.assertTrue(initial["canSelect"]) check = self.act("check") self.assertIsNone(check["request"]["target"]) self.assertEqual(check["request"]["state"], "pending") self.params.put("UpdaterTargetBranch", "main", block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "pending") self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 4), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") self.act("select", "release") self.assertEqual(self.params.get("UpdaterTargetBranch"), "release") def test_bad_or_stale_branch_and_missing_updater_never_mutate(self): self.denied("select", 400, "--help") self.denied("select", 409, "unknown") self.assertEqual(self.params.get("UpdaterTargetBranch"), "main") self.process.running = False self.denied("check", 503) self.assertEqual(self.process.sent, []) def test_parked_authority_and_session_rechecked_before_effect(self): for offroad, parked, authorized in ((False, True, True), (True, False, True), (True, True, False)): with self.subTest(offroad=offroad, parked=parked, authorized=authorized): self.params.put_bool("IsOffroad", offroad, block=True) self.parked = parked self.authorized = authorized self.denied("check", 403 if not authorized else 409) self.assertEqual(self.process.sent, []) self.params.put_bool("IsOffroad", True, block=True) self.parked = self.authorized = True checks = iter((True, False)) with self.assertRaises(SoftwareOperationError): self.owner.action("check", {"action": "check"}, authorized=lambda: next(checks)) self.assertEqual(self.process.sent, []) def test_install_requires_exact_finalized_target_and_reboot_once(self): self.act("select", "release") self.params.put_bool("UpdateAvailable", True, block=True) self.params.put("UpdaterNewDescription", "test / release / bbbbbbb / Sep 27", block=True) with mock.patch("openpilot.starpilot.galaxy.software_operations.validate_revision", return_value={}): self.assertFalse(self.owner.snapshot()["canInstall"]) (self.finalized / ".overlay_consistent").touch() self.assertTrue(self.owner.snapshot()["canInstall"]) self.denied("install", 409, "main") self.git[self.finalized] = ("release", "c" * 40) self.assertFalse(self.owner.snapshot()["canInstall"]) self.denied("install", 409, "release") self.git[self.finalized] = ("release", "b" * 40) result = self.act("install", "release") self.assertEqual(result["request"]["state"], "pending") self.assertEqual(result["request"]["action"], "install") self.assertTrue(self.params.get_bool("DoReboot")) self.denied("install", 409, "release") self.assertEqual(self.process.sent, []) def test_target_change_or_failure_is_reported(self): self.act("select", "release") self.act("download", "release") self.params.put("UpdaterTargetBranch", "main", block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") self.act("select", "release") self.act("check") self.params.put("UpdateFailedCount", 1, block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") def test_counter_reset_does_not_claim_a_completed_check(self): self.params.put("UpdateFailedCount", 2, block=True) self.act("check") self.params.put("UpdateFailedCount", 0, block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "pending") self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 1), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "complete") def test_download_without_fetch_cannot_claim_unpublished_target(self): self.act("select", "release") self.act("download", "release") self.params.put_bool("UpdaterFetchAvailable", False, block=True) self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 2), block=True) request = self.owner.snapshot()["request"] self.assertEqual(request["state"], "failed") self.assertIn("No new download", request["error"]) def test_download_does_not_call_missing_remote_branch_up_to_date(self): self.act("download", "main") self.params.put_bool("UpdaterFetchAvailable", False, block=True) self.params.put("LastUpdateTime", datetime(2026, 9, 27, 12, 3), block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") def test_catalog_keeps_other_branches_when_one_entry_is_invalid(self): self.params.put("UpdaterAvailableBranches", "main,feature+lab,bad branch,feature@lab", block=True) branches = self.owner.snapshot()["availableBranches"] self.assertEqual(branches, ["main", "feature+lab", "feature@lab"]) self.act("select", "feature+lab") self.assertEqual(self.params.get("UpdaterTargetBranch"), "feature+lab") def test_published_catalog_with_more_than_64_branches_remains_usable(self): branches = [f"release/{number:03d}" for number in range(76)] self.params.put("UpdaterAvailableBranches", ",".join(branches), block=True) snapshot = self.owner.snapshot() self.assertEqual(snapshot["availableBranches"], branches) self.assertTrue(snapshot["canSelect"]) self.act("select", branches[-1]) self.assertEqual(self.params.get("UpdaterTargetBranch"), branches[-1]) def test_slash_branch_description_can_match_finalized_commit(self): self.params.put("UpdaterAvailableBranches", "main,release/2026", block=True) self.act("select", "release/2026") self.git[self.finalized] = ("release/2026", "b" * 40) self.params.put_bool("UpdateAvailable", True, block=True) self.params.put("UpdaterNewDescription", "test / release/2026 / bbbbbbb / Sep 27", block=True) (self.finalized / ".overlay_consistent").touch() with mock.patch("openpilot.starpilot.galaxy.software_operations.validate_revision", return_value={}): self.assertTrue(self.owner.snapshot()["canInstall"]) def save_downloads(self, enabled, expected): return self.owner.action('preferences', {'action': 'preferences', 'automaticDownloads': enabled, 'expectedAutomaticDownloads': expected}, authorized=lambda: self.authorized) def test_download_preference_is_independent_of_manual_updater_commands(self): self.assertIs(self.owner.snapshot()['automaticDownloads'], True) result = self.save_downloads(False, True) self.assertIs(result['automaticDownloads'], False) self.assertIsNone(result['request']) self.assertEqual(self.process.sent, []) self.assertTrue(result['canDownload']) self.act('download', 'main') self.assertEqual(self.process.sent, ['download']) def test_preference_can_be_saved_without_running_updater_but_not_after_authority_changes(self): self.process.running = False self.assertTrue(self.owner.snapshot()['canConfigure']) self.assertFalse(self.save_downloads(False, True)['automaticDownloads']) for case in ('stale', 'onroad', 'unauthorized', 'reboot'): self.parked = case != 'onroad' self.authorized = case != 'unauthorized' self.params.put_bool('DoReboot', case == 'reboot', block=True) with self.assertRaises(SoftwareOperationError): self.save_downloads(True, True if case == 'stale' else False) self.assertFalse(self.params.get_bool('UpdaterAutomaticDownloads')) self.assertEqual(self.process.sent, []) def test_invalid_preference_can_be_repaired_without_implicit_enabling(self): Path(self.params.get_param_path('UpdaterAutomaticDownloads')).write_bytes(b'bad') self.assertIsNone(self.owner.snapshot()['automaticDownloads']) self.assertFalse(self.save_downloads(False, None)['automaticDownloads']) with self.assertRaises(SoftwareOperationError): self.save_downloads(1, False) def test_repository_link_uses_only_normalized_github_origins(self): for remote in ('https://github.com/owner/repo.git', 'git@github.com:owner/repo.git', 'ssh://git@github.com/owner/repo'): self.params.put('GitRemote', remote, block=True) self.assertEqual(self.owner.snapshot(include_history=False)['repository'], 'owner/repo') self.params.put('GitRemote', 'https://foreign.test/owner/repo', block=True) self.assertIsNone(self.owner.snapshot(include_history=False)['repository']) def test_lightweight_status_preserves_controls_without_reading_history(self): with mock.patch.object(self.owner, '_history_snapshot') as history: light = self.owner.snapshot(include_history=False) self.assertNotIn('history', light) history.assert_not_called() full = self.owner.snapshot() history.assert_called_once() self.assertEqual(light, {key: value for key, value in full.items() if key != 'history'}) def test_history_caches_local_reads_and_refreshes_when_download_identity_changes(self): reader = mock.Mock(side_effect=lambda path, sha: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}]) self.owner.history_reader = reader self.params.put('UpdaterCurrentReleaseNotes', b'