From 9eab5400a44b84f8c96d99b2b43a7b75242caf22 Mon Sep 17 00:00:00 2001 From: Dom <168790843+firestar5683@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:50:22 -0600 Subject: [PATCH] Repair hosted display selection and migrated test fixtures Select the real headless backend for hosted AOL and RELEASE tests. Isolate SpacePilot scripted clocks from unrelated UI readers. Update camera and replay fixtures for existing native APIs, pin the reviewed schema addition, and supply declared CAN batch and throttle inputs. Preserve production guards, acceptance assertions, selected cases, capability skips and receipt gating. Refresh the canonical protected inventory receipt. --- .github/workflows/tests.yaml | 4 ++ .../car/hyundai/tests/test_ioniq6_handoff.py | 6 +-- .../test/longitudinal_maneuvers/plant.py | 1 + .../custom_reviewed_schema_additions.json | 33 +++++++++--- .../tests/test_pedal_schema_evolution.py | 41 +++++++++++++++ .../starpilot/replay/tests/test_render.py | 6 +-- .../ui/tests/test_mici_transitions.py | 4 +- .../ui/tests/test_spacepilot_timing.py | 21 ++++++-- prebuilt.json | 2 +- .../ci/tests/test_headless_ci_environment.py | 52 +++++++++++++++++++ 10 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 tools/ci/tests/test_headless_ci_environment.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fb11f69296..f5e9891207 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -343,6 +343,8 @@ jobs: run: scons - name: Gate every assigned lifecycle module serially timeout-minutes: 10 + env: + RAYLIB_BACKEND: headless run: python3 tools/ci/run_vehicle_tests.py --suite aol-lifecycle --shard-index "${{ matrix.shard }}" --shard-count 16 --output "ci-results/aol-lifecycle-${{ matrix.shard }}" - name: Save every lifecycle outcome and native build if: always() @@ -453,6 +455,8 @@ jobs: run: scons - name: Run every assigned RELEASE target serially timeout-minutes: 20 + env: + RAYLIB_BACKEND: headless run: python3 tools/ci/run_vehicle_tests.py --suite safety-release --shard-index "${{ matrix.shard }}" --shard-count 4 --output "ci-results/safety-release-${{ matrix.shard }}" - name: Save every RELEASE outcome and native build if: always() diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_ioniq6_handoff.py b/opendbc_repo/opendbc/car/hyundai/tests/test_ioniq6_handoff.py index 5b3b17bb0b..cb985f0ca1 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_ioniq6_handoff.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_ioniq6_handoff.py @@ -244,14 +244,14 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase): cp = CarInterface.get_params(model, fp, [], False, False, False) ci = CarInterface(cp) packer = CANPacker(DBC[model][Bus.pt]) - ci.update((1_000_000_000, [])) + ci.update([(1_000_000_000, [])]) sources = [(parser, state) for parser in ci.can_parsers.values() for state in parser.message_states.values() if not state.ignore_alive and state.address != 0x2E0] for frame in range(120): packets = [packer.make_can_msg(state.name, parser.bus, {"COUNTER": frame % 256} if any(sig.name == "COUNTER" for sig in state.signals) else {}) for parser, state in sources] - state = ci.update((1_000_000_000 + frame * 100_000_000, packets)) + state = ci.update([(1_000_000_000 + frame * 100_000_000, packets)]) self.assertTrue(state.canValid) self.assertFalse(cp.openpilotLongitudinalControl) self.assertTrue(cp.pcmCruise) @@ -261,7 +261,7 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase): packets = [packer.make_can_msg(msg.name, parser.bus, {"COUNTER": frame % 256} if any(sig.name == "COUNTER" for sig in msg.signals) else {}) for parser, msg in sources if msg.name != "MDPS"] - state = ci.update((1_000_000_000 + frame * 100_000_000, packets)) + state = ci.update([(1_000_000_000 + frame * 100_000_000, packets)]) self.assertFalse(state.canValid) def test_card_tags_aol_candidate_before_cp_publication_without_disabling_ecu(self): diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py index 043c7da165..239910455d 100755 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py @@ -98,6 +98,7 @@ class Plant: # always start at current speed model.modelV2.velocity.x = [float(self.speed)] + [float(self.speed + 0.5)] * (len(T_IDXS) - 1) model.modelV2.acceleration.x = [0.0] * len(T_IDXS) + model.modelV2.meta.disengagePredictions.gasPressProbs = [float(prob_throttle)] * 6 control.controlsState.longControlState = LongCtrlState.pid if self.enabled else LongCtrlState.off ss.selfdriveState.experimentalMode = self.e2e diff --git a/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json b/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json index 8d67bfb345..697ac487a1 100644 --- a/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json +++ b/openpilot/starpilot/car/tesla/tests/fixtures/custom_reviewed_schema_additions.json @@ -1,6 +1,6 @@ { - "reviewed_schema_commit": "cd0f46b9d65a9c5e47002f57e5ad6625c9757c63", - "reviewed_schema_sha256": "5effa2eb1fbbada3d1bbaae0acd3d31d780d8ec0e6ee1e214fedbc38ae400182", + "reviewed_schema_commit": "63beafc6af294d0759b47dc3bc546f24082a2a5d", + "reviewed_schema_sha256": "ca01ef4f6d2b15c5fac59ce237ec6e49f1167e2408903c9088ee322ca490de31", "incumbent_fixture_sha256": "a36ab54c00b5bbe61d520873d24aadfb8f33babd70c31a07046516d5545838d9", "types": { "0xf35cc4560bbf6ec2": { @@ -18,7 +18,7 @@ }, "new_storage": { "dataWordCount": 3, - "pointerCount": 6, + "pointerCount": 7, "preferredListEncoding": "inlineComposite", "isGroup": false, "discriminantCount": 0, @@ -59,11 +59,31 @@ "hadExplicitDefault": false }, "name": "toyotaCruise", - "codeOrder": 38, + "codeOrder": 39, "discriminantValue": 65535, "ordinal": { "explicit": 38 } + }, + { + "slot": { + "offset": 6, + "type": { + "struct": { + "typeId": 15172299076723568810 + } + }, + "defaultValue": { + "schemaValueHex": "00000000040000000000000002000100100000000000000000000000000000000000000000000000" + }, + "hadExplicitDefault": false + }, + "name": "steeringLimitInfo", + "codeOrder": 38, + "discriminantValue": 65535, + "ordinal": { + "explicit": 39 + } } ] }, @@ -656,7 +676,8 @@ }, "published_addition_commits": { "slc": "ab8368f287b78b42f8590722878c7d5834609e19", - "flm": "b151ca11cb4d88f32d7adfdbcff50baf29f4fbbc" + "flm": "b151ca11cb4d88f32d7adfdbcff50baf29f4fbbc", + "tesla_cooperative_steering": "63beafc6af294d0759b47dc3bc546f24082a2a5d" }, - "review_basis": "Published SLC and FLM append-only changes. Every incumbent compiled field descriptor and enum prefix was independently checked unchanged; only the exact seven typed deltas below are permitted." + "review_basis": "Published SLC, FLM, and Tesla cooperative-steering additions. Every incumbent wire field descriptor and enum prefix was independently checked unchanged; only the exact typed deltas listed here are permitted. Tesla adds steeringLimitInfo @39 at pointer slot 6 using the existing SteeringLimitInfo type. ToyotaCruise @38 retains pointer slot 5, type, and default; only its declaration codeOrder changes from 38 to 39." } diff --git a/openpilot/starpilot/car/tesla/tests/test_pedal_schema_evolution.py b/openpilot/starpilot/car/tesla/tests/test_pedal_schema_evolution.py index abcecbd945..a2a7875b0d 100644 --- a/openpilot/starpilot/car/tesla/tests/test_pedal_schema_evolution.py +++ b/openpilot/starpilot/car/tesla/tests/test_pedal_schema_evolution.py @@ -115,6 +115,47 @@ def test_additive_feedback_survives_incumbent_reader_relay(historical): assert recovered.feedbackPublication.to_dict() == expected_feedback +def test_additive_steering_limits_preserve_defaults_and_survive_incumbent_relay(historical): + original = historical.StarPilotCarState.new_message( + pedalMaxRegen=True, pedalLongActive=True, sourceCarStateMonoTime=123456, + gps={'latitude': 41.25, 'longitude': -87.5, 'hasFix': True}) + incumbent = original.to_dict() + with custom.StarPilotCarState.from_bytes(original.to_bytes()) as decoded: + assert not decoded.steeringLimitInfo.valid + assert decoded.steeringLimitInfo.monoTime == 0 + current = decoded.as_builder() + current.feedbackPublication = {'version': 1, 'publicationSequence': 27, 'reportedTorque': 0.25} + current.toyotaCruise = {'version': 1, 'status': 'active', 'targetKph': 72., 'sessionId': 'incumbent'} + current.steeringLimitInfo = { + 'valid': True, 'modelLimitErrorDeg': 1.25, 'resumeLimitErrorDeg': 2.5, + 'cooperativeLimitErrorDeg': 3.75, 'cooperativeOffsetDeg': -0.5, + 'monoTime': 123456789, 'combinedLimitErrorDeg': 4.0} + expected = current.to_dict() + with historical.StarPilotCarState.from_bytes(current.to_bytes()) as old: + assert old.to_dict() == incumbent + relayed = old.as_builder().to_bytes() + with custom.StarPilotCarState.from_bytes(relayed) as recovered: + assert recovered.to_dict() == expected + + +@pytest.mark.parametrize(('before', 'after'), [ + ('dashboardSpeedLimit @3 :Float32;', 'dashboardSpeedLimit @3 :Float64;'), + ('toyotaCruise @38 :ToyotaCruise;', 'toyotaCruise @38 :StarPilotCarControl.SteeringLimitInfo;'), + ('steeringLimitInfo @39 :StarPilotCarControl.SteeringLimitInfo;', 'steeringLimitInfo @39 :StarPilotCarState.Gps;'), + ('steeringLimitInfo @39 :StarPilotCarControl.SteeringLimitInfo;', + 'steeringLimitInfo @39 :StarPilotCarControl.SteeringLimitInfo; unreviewed @40 :Bool;'), +]) +def test_reviewed_steering_addition_does_not_allow_other_schema_changes(historical, tmp_path, before, after): + source = Path(CEREAL_PATH, 'custom.capnp').read_text() + assert source.count(before) == 1 + path = tmp_path / 'unreviewed.capnp' + path.write_text(source.replace(before, after)) + parser = capnp.SchemaParser() + changed = parser.load(str(path), imports=[CEREAL_PATH, str(files('opendbc') / 'car')]) + with pytest.raises(AssertionError): + compare_node(historical.StarPilotCarState, changed.StarPilotCarState) + + @pytest.mark.parametrize(('fields', 'compatible'), [ ('value @0 :UInt32; label @1 :Text;', True), ('value @0 :UInt32; label @1 :Text; extra @2 :Bool;', False), diff --git a/openpilot/starpilot/replay/tests/test_render.py b/openpilot/starpilot/replay/tests/test_render.py index f04376e1a7..6c816ee55c 100644 --- a/openpilot/starpilot/replay/tests/test_render.py +++ b/openpilot/starpilot/replay/tests/test_render.py @@ -574,11 +574,11 @@ def test_published_readiness_survives_buffering_but_rejects_seeks_and_old_epochs @pytest.mark.parametrize('matching_frame', (False, True)) -def test_epoch_reset_preserves_native_camera_stream_and_eligible_paused_frame(monkeypatch, matching_frame): +@pytest.mark.parametrize('streams,expected_streams', (({0, 2}, [0, 2]), ([2, 0], [2, 0]))) +def test_epoch_reset_preserves_native_camera_stream_and_eligible_paused_frame(monkeypatch, matching_frame, streams, expected_streams): monkeypatch.setattr('openpilot.starpilot.ui.runtime_snapshot.RuntimeSnapshotAdapter', Mock()) monkeypatch.setattr('openpilot.starpilot.ui.pip_render.PiPRenderer', Mock()) core: Any = object.__new__(Compositor) - streams = {0, 2} client = Mock(frame_id=5 if matching_frame else 4, timestamp_sof=9_000_000_000, timestamp_eof=9_010_000_000) client.is_connected.return_value = True frame = SimpleNamespace(frame_id=5) @@ -610,7 +610,7 @@ def test_epoch_reset_preserves_native_camera_stream_and_eligible_paused_frame(mo callback() assert core.render_receipts == {} assert core.camera.client is client - assert core.camera.available_streams == streams + assert core.camera.available_streams == expected_streams assert core.camera.available_streams is not streams core.camera._initialize_textures.assert_called_once() assert core.camera.frame is (frame if matching_frame else None) diff --git a/openpilot/starpilot/ui/tests/test_mici_transitions.py b/openpilot/starpilot/ui/tests/test_mici_transitions.py index ad8a509957..b239a2fbd3 100644 --- a/openpilot/starpilot/ui/tests/test_mici_transitions.py +++ b/openpilot/starpilot/ui/tests/test_mici_transitions.py @@ -9,6 +9,7 @@ import pytest from openpilot.selfdrive.ui.mici.layouts import main from openpilot.selfdrive.ui.mici.onroad import cameraview +from openpilot.selfdrive.ui.onroad import cameraview as base_cameraview @pytest.fixture @@ -152,7 +153,8 @@ def camera(monkeypatch): monkeypatch.setattr(cameraview, 'VisionIpcClient', new_client) monkeypatch.setattr(cameraview, 'COMMA_HARDWARE', True) - monkeypatch.setattr(cameraview, 'destroy_egl_image', lambda image: events.append(('egl', image))) + monkeypatch.setattr(base_cameraview, 'COMMA_HARDWARE', True) + monkeypatch.setattr(base_cameraview, 'destroy_egl_image', lambda image: events.append(('egl', image))) monkeypatch.setattr(cameraview.rl, 'unload_texture', lambda texture: events.append(('texture', texture.id))) monkeypatch.setattr(cameraview.rl, 'unload_shader', lambda shader: events.append(('shader', shader.id))) monkeypatch.setattr(cameraview.rl, 'get_time', lambda: 1.0) diff --git a/openpilot/starpilot/ui/tests/test_spacepilot_timing.py b/openpilot/starpilot/ui/tests/test_spacepilot_timing.py index dcbc6f612d..421a1cd966 100644 --- a/openpilot/starpilot/ui/tests/test_spacepilot_timing.py +++ b/openpilot/starpilot/ui/tests/test_spacepilot_timing.py @@ -1,11 +1,12 @@ """Cold native entry and preempted clock reads from the recorded d5 exit paths.""" +import time from types import SimpleNamespace as NS from unittest.mock import Mock import pytest -from openpilot.starpilot.ui.spacepilot import ignition, native, session +from openpilot.starpilot.ui.spacepilot import guard, ignition, native, session from openpilot.starpilot.ui.tests.test_spacepilot_session import Clock, Vehicle, game_ui, offroad, setup as setup from openpilot.starpilot.ui.tests.test_spacepilot_state import mature_gate, read from openpilot.system.ui.lib.application import gui_app @@ -20,11 +21,25 @@ def clock_reads(monkeypatch, *, delayed=1, backwards=False, offset=2_000_000_000 boots.append(now + offset) now += max(delta, 0) mono = Mock(side_effect=monos) - monkeypatch.setattr(session.time, 'monotonic_ns', mono) - monkeypatch.setattr(session.time, 'clock_gettime_ns', Mock(side_effect=boots)) + # Keep background UI readers from consuming the scripted clock pairs. + monkeypatch.setattr(guard, 'time', NS(monotonic_ns=mono, clock_gettime_ns=Mock(side_effect=boots), CLOCK_BOOTTIME=time.CLOCK_BOOTTIME)) return mono +def test_clock_reads_leave_unrelated_process_clocks_untouched(monkeypatch): + process_mono, process_boot = time.monotonic_ns, time.clock_gettime_ns + mono = clock_reads(monkeypatch, delayed=3) + assert time.monotonic_ns is process_mono + assert time.clock_gettime_ns is process_boot + time.monotonic_ns() + time.clock_gettime_ns(time.CLOCK_BOOTTIME) + assert mono.call_count == guard.time.clock_gettime_ns.call_count == 0 + with pytest.raises(ValueError, match='stable device clock'): + guard.sample_clocks() + assert mono.call_count == 6 + assert guard.time.clock_gettime_ns.call_count == 3 + + def witness_ui(monkeypatch): ui = game_ui() Clock().refresh(ui, 0) diff --git a/prebuilt.json b/prebuilt.json index c48a544041..828ab5a291 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "b2b3de9483ae6ab96b75644afda03011e55dba114cb05ec6646ff9d85bee14bb", "schema_version": 1} +{"protected_inventory_sha256": "e0d27b06417496a48ff1a04a246384c056d93f34d2f33f31dc026b9010612af2", "schema_version": 1} diff --git a/tools/ci/tests/test_headless_ci_environment.py b/tools/ci/tests/test_headless_ci_environment.py new file mode 100644 index 0000000000..19a16d5c60 --- /dev/null +++ b/tools/ci/tests/test_headless_ci_environment.py @@ -0,0 +1,52 @@ +"""Hosted collection uses the real display-free Raylib backend, like unit tests.""" + +import os +from pathlib import Path +import re +import unittest +from unittest.mock import patch + +from tools.ci.vehicle_pytest import serial_environment + + +ROOT = Path(__file__).resolve().parents[3] +RUNNERS = ( + ('unit_tests', 'Run unit tests'), + ('custom_pytest', 'Test every assigned custom pytest node serially'), + ('aol_lifecycles', 'Gate every assigned lifecycle module serially'), + ('safety_release_contracts', 'Run every assigned RELEASE target serially'), +) + + +class TestHeadlessCiEnvironment(unittest.TestCase): + def runner_step(self, workflow, job_name, step_name): + jobs = dict(re.findall(r'^ ([a-z_]+):\n(.*?)(?=^ [a-z_]+:|\Z)', workflow, re.M | re.S)) + steps = dict(re.findall(r'^ - name: ([^\n]+)\n(.*?)(?=^ - |\Z)', jobs[job_name], re.M | re.S)) + return steps[step_name] + + def assert_headless_step(self, step): + self.assertRegex(step, r'(?m)^ env:\n(?: .*\n)*? RAYLIB_BACKEND: headless\n') + + def test_hosted_collection_selects_native_headless_backend(self): + workflow = (ROOT / '.github/workflows/tests.yaml').read_text() + for job_name, step_name in RUNNERS: + with self.subTest(job=job_name): + self.assert_headless_step(self.runner_step(workflow, job_name, step_name)) + + def test_missing_or_desktop_backend_is_rejected(self): + workflow = (ROOT / '.github/workflows/tests.yaml').read_text() + for job_name, step_name in RUNNERS: + step = self.runner_step(workflow, job_name, step_name) + for replacement in ('', ' RAYLIB_BACKEND: desktop\n'): + with self.subTest(job=job_name, replacement=replacement), self.assertRaises(AssertionError): + self.assert_headless_step(step.replace(' RAYLIB_BACKEND: headless\n', replacement)) + + def test_serial_vehicle_pytest_preserves_backend(self): + with patch.dict(os.environ, {'RAYLIB_BACKEND': 'headless'}): + with serial_environment(): + self.assertEqual(os.environ['RAYLIB_BACKEND'], 'headless') + self.assertEqual(os.environ['RAYLIB_BACKEND'], 'headless') + + +if __name__ == '__main__': + unittest.main()