mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 21:43:44 +08:00
a82935f326
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services. Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com> Co-authored-by: Daniel Koepping <elkoled@gmail.com> Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com> Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: Shane Smiskol <shane@smiskol.com> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: stef <19478336+stefpi@users.noreply.github.com> Co-authored-by: whoisdomi <dcantu83@gmail.com> Co-authored-by: Intelli <contact@intelli.software> Co-authored-by: pharmacomaniac <blittle65@gmail.com> Co-authored-by: prabhaavp <prabhaav@msu.edu>
571 lines
28 KiB
Python
571 lines
28 KiB
Python
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'<h1>Current</h1>', block=True)
|
|
self.params.put('UpdaterNewReleaseNotes', b'<h1>Next</h1>', block=True)
|
|
with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions',
|
|
return_value={'branch': 'main', 'head': 'c' * 40, 'entries': []}) as recent:
|
|
first = self.owner.snapshot()['history']
|
|
self.assertEqual(first['installed'][0]['hash'], 'a' * 40)
|
|
self.assertEqual(first['downloaded'][0]['hash'], 'b' * 40)
|
|
self.assertEqual(first['currentReleaseNotes'], 'Current')
|
|
self.assertEqual(first['downloadedReleaseNotes'], 'Next')
|
|
self.assertEqual(reader.call_count, 2)
|
|
self.assertEqual(self.owner.snapshot()['history'], first)
|
|
self.assertEqual(reader.call_count, 2)
|
|
self.assertEqual(recent.call_count, 1)
|
|
self.params.put('UpdaterNewDescription', 'new description', block=True)
|
|
self.owner.snapshot()
|
|
self.assertEqual(reader.call_count, 4)
|
|
self.assertEqual(recent.call_count, 2)
|
|
self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 4, 12), block=True)
|
|
self.owner.snapshot()
|
|
self.assertEqual(recent.call_count, 3)
|
|
self.assertEqual(reader.call_count, 6)
|
|
|
|
def test_unified_update_pins_source_and_tracks_completion(self):
|
|
result = self.act("update", "release")
|
|
self.assertEqual(result["request"]["action"], "update")
|
|
self.assertEqual(result["request"]["target"], "release")
|
|
self.assertEqual(self.process.sent, ["update"])
|
|
self.assertEqual(self.process.fast_requests, [{"branch": "release", "commit": "a" * 40}])
|
|
self.params.put("LastUpdateTime", datetime(2026, 10, 3, 12), block=True)
|
|
self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 3, 12), block=True)
|
|
self.params.put_bool("DoReboot", True, block=True)
|
|
result = self.owner.snapshot()["request"]
|
|
self.assertEqual(result["state"], "complete")
|
|
self.assertEqual(result["outcome"], "restarting")
|
|
|
|
def test_fast_current_branch_without_branch_list_or_selected_target(self):
|
|
self.params.remove("UpdaterAvailableBranches")
|
|
self.params.put("UpdaterTargetBranch", "release", block=True)
|
|
self.assertTrue(self.owner.snapshot()["canFastUpdate"])
|
|
result = self.act("fast", "main")
|
|
self.assertEqual(result["request"]["target"], "main")
|
|
self.assertEqual(self.process.sent, ["fast"])
|
|
self.assertEqual(self.process.fast_requests, [{"branch": "main", "commit": "a" * 40}])
|
|
self.assertEqual(self.params.get("UpdaterTargetBranch"), "release")
|
|
self.assertFalse(self.params.get_bool("DoReboot"))
|
|
self.params.put("LastUpdateTime", datetime(2026, 10, 3, 12), block=True)
|
|
self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 3, 12), block=True)
|
|
result = self.owner.snapshot()["request"]
|
|
self.assertEqual(result["state"], "complete")
|
|
self.assertEqual(result["outcome"], "up_to_date")
|
|
|
|
def test_fast_uses_registered_offroad_and_live_parked_authority(self):
|
|
Path(self.params.get_param_path("IsOnroad")).write_bytes(b"1")
|
|
self.assertTrue(self.owner.snapshot()["canFastUpdate"])
|
|
self.parked = False
|
|
self.assertFalse(self.owner.snapshot()["canFastUpdate"])
|
|
self.denied("fast", 409, "main")
|
|
self.parked = True
|
|
self.act("fast", "main")
|
|
self.assertEqual(self.process.sent, ["fast"])
|
|
|
|
def test_fast_rejects_stale_installed_identity(self):
|
|
self.git[self.installed] = ("main", "b" * 40)
|
|
self.denied("fast", 409, "main")
|
|
self.git[self.installed] = ("release", "a" * 40)
|
|
self.denied("fast", 409, "main")
|
|
self.assertEqual(self.process.sent, [])
|
|
self.assertFalse(self.params.get_bool("DoReboot"))
|
|
|
|
def test_fast_keeps_existing_admission_and_observes_failure(self):
|
|
for key in ("DisableUpdates", "DoReboot"):
|
|
self.params.put_bool(key, True, block=True)
|
|
self.denied("fast", 409, "main")
|
|
self.params.put_bool(key, False, block=True)
|
|
self.authorized = False
|
|
self.denied("fast", 403, "main")
|
|
self.authorized = True
|
|
self.act("fast", "main")
|
|
self.denied("fast", 409, "main")
|
|
self.params.put("UpdateFailedCount", 1, block=True)
|
|
self.assertEqual(self.owner.snapshot()["request"]["state"], "failed")
|
|
self.assertEqual(self.process.sent, ["fast"])
|
|
|
|
|
|
class UpdaterIdentityTest(unittest.TestCase):
|
|
def test_fresh_manager_pid_and_same_proc_start_before_signal(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
proc = root / "123"
|
|
proc.mkdir()
|
|
(proc / "stat").write_text("123 (updater) " + " ".join(["S", *(["0"] * 18), "3456"]))
|
|
(proc / "cmdline").write_bytes(PROCESS_TITLE + b"\0")
|
|
state = SimpleNamespace(name="updated", running=True, shouldBeRunning=True, pid=123)
|
|
class ManagerMessages:
|
|
seen = {"managerState": True}
|
|
alive = {"managerState": True}
|
|
valid = {"managerState": True}
|
|
recv_time = {"managerState": 1.25}
|
|
logMonoTime = {"managerState": 1_250_000_000}
|
|
|
|
def update(self, timeout):
|
|
pass
|
|
|
|
def __getitem__(self, key):
|
|
return SimpleNamespace(processes=[state])
|
|
|
|
sm = ManagerMessages()
|
|
clock = [1_000_000_000]
|
|
class FakeControl:
|
|
def __init__(self):
|
|
self.available_calls = []
|
|
self.sent = []
|
|
|
|
def available(self, pid, start):
|
|
self.available_calls.append((pid, start))
|
|
return True
|
|
|
|
def send(self, pid, start, action):
|
|
self.sent.append((pid, start, action))
|
|
|
|
control = FakeControl()
|
|
process = UpdaterProcess(sm, control=control, proc_root=root, mono_ns=lambda: clock[0])
|
|
clock[0] = 1_500_000_000
|
|
with mock.patch("os.kill") as kill:
|
|
self.assertTrue(process.available())
|
|
process.send("check")
|
|
self.assertEqual(control.available_calls, [(123, 3456)])
|
|
self.assertEqual(control.sent, [(123, 3456, "check")])
|
|
with mock.patch.object(control, "send", side_effect=UpdaterControlError("declined")):
|
|
with self.assertRaises(SoftwareOperationError) as denied:
|
|
process.send("download")
|
|
self.assertEqual(denied.exception.status, 503)
|
|
with mock.patch.object(process, "_manager_pid", side_effect=[123, 124]):
|
|
with self.assertRaises(SoftwareOperationError):
|
|
process.send("download")
|
|
self.assertEqual(control.sent, [(123, 3456, "check")])
|
|
kill.assert_not_called()
|
|
sm.recv_time["managerState"] = 0.9
|
|
self.assertFalse(process.available())
|
|
sm.recv_time["managerState"] = 1.25
|
|
(proc / "cmdline").write_bytes(b"other\0")
|
|
self.assertFalse(process.available())
|
|
with self.assertRaises(SoftwareOperationError):
|
|
process.send("download")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|