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()