Files
StarPilot/tools/ci/run_vehicle_tests.py
T
2026-10-04 23:30:46 -05:00

396 lines
22 KiB
Python

#!/usr/bin/env python3
"""Run explicitly scoped vehicle suites with collection and native-build evidence."""
import argparse
import ast
from collections import Counter
import hashlib
import importlib.machinery
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
import traceback
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from tools import test_runner
from tools.ci import vehicle_pytest
RELEASE_TARGETS = (
"opendbc_repo/opendbc/safety/tests/test_tesla_preap_stock.py",
"opendbc_repo/opendbc/car/tesla/tests/test_preap_startup.py",
"opendbc_repo/opendbc/car/tesla/tests/test_preap_stock.py",
"openpilot/starpilot/car/tesla/tests/test_preap_intent.py",
"openpilot/starpilot/car/tesla/tests/test_stock_events.py",
"opendbc_repo/opendbc/car/ford/tests/test_aol_startup.py",
"openpilot/starpilot/tests/test_ford_aol_intent.py",
"opendbc_repo/opendbc/safety/tests/test_ford_aol.py",
"opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py",
"opendbc_repo/opendbc/car/ford/tests/test_added_firmware_identity.py",
"opendbc_repo/opendbc/car/ford/tests/test_required_source_health.py",
"opendbc_repo/opendbc/car/ford/tests/test_blend_settings.py",
"opendbc_repo/opendbc/car/ford/tests/test_blend_settings_startup.py",
"opendbc_repo/opendbc/car/ford/tests/test_production_extensions.py",
"opendbc_repo/opendbc/car/ford/tests/test_generic_canfd.py",
"opendbc_repo/opendbc/safety/tests/test_ford_generic_canfd_extended.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_blended_stock_aol.py",
"openpilot/starpilot/aol/tests/test_nexo_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_nexo_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_stock_angle_aol.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_stock_angle_aol.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_g90_startup.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_classic_long_aol.py",
"openpilot/starpilot/aol/tests/test_classic_long_profiles.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_shared_canfd_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_hda2_long_aol.py",
"opendbc_repo/opendbc/safety/tests/test_prepared_torque_ev_native.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_shared_canfd_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_classic_scc_aol.py",
"openpilot/starpilot/aol/tests/test_classic_scc_profiles.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_blended.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_palisade_2023.py",
"openpilot/starpilot/longitudinal/tests/test_output_max.py",
"openpilot/starpilot/ui/tests/test_output_max_feature.py",
"openpilot/starpilot/tests/test_output_max_namespace.py",
"opendbc_repo/opendbc/car/gm/tests/test_conventional_pedal_transitions.py",
"opendbc_repo/opendbc/car/gm/tests/test_conventional_pedal_disabled.py",
"opendbc_repo/opendbc/safety/tests/test_gm_cc_pedal_disabled.py",
"opendbc_repo/opendbc/car/gm/tests/test_silverado_cc_pedal.py",
"opendbc_repo/opendbc/safety/tests/test_gm_silverado_pedal.py",
"opendbc_repo/opendbc/safety/tests/test_gm_cc_pedal.py",
"opendbc_repo/opendbc/car/gm/tests/test_conventional_pedal.py",
"openpilot/starpilot/tests/test_gm_conventional_pedal_startup.py",
"tools/ci/test_release_safety.py",
"opendbc_repo/opendbc/safety/tests/test_defaults.py::TestNoOutput",
"opendbc_repo/opendbc/safety/tests/test_defaults.py::TestSilent",
"opendbc_repo/opendbc/safety/tests/test_tesla.py::TestTeslaStockSafety",
"opendbc_repo/opendbc/safety/tests/test_tesla_hw1.py",
"opendbc_repo/opendbc/safety/tests/test_volvo_c1.py",
"opendbc_repo/opendbc/safety/tests/test_mazda.py::TestMazdaSafety",
"opendbc_repo/opendbc/safety/tests/test_toyota.py::TestToyotaStockLongitudinalTorque",
"opendbc_repo/opendbc/safety/tests/test_toyota_auto_hold.py",
"opendbc_repo/opendbc/safety/tests/test_gm.py::TestGmCameraSafety",
"opendbc_repo/opendbc/safety/tests/test_gm.py::TestGmCameraEVSafety",
"opendbc_repo/opendbc/safety/tests/test_gm_bolt_pedal.py",
"opendbc_repo/opendbc/safety/tests/test_gm_bolt_cc.py",
"opendbc_repo/opendbc/car/gm/tests/test_bolt_cc_low.py",
"openpilot/starpilot/tests/test_bolt_cc_controls_low.py",
"openpilot/starpilot/tests/test_bolt_cc_controls_manual.py",
"openpilot/starpilot/car/gm/tests/test_gear_events.py",
"openpilot/starpilot/tests/test_bolt_disable_active_pedal.py::TestBoltDisableActivePedal",
"opendbc_repo/opendbc/car/gm/tests/test_bolt_acc_cc.py",
"opendbc_repo/opendbc/car/gm/tests/test_bolt_factory_acc.py",
"opendbc_repo/opendbc/safety/tests/test_gm_bolt_factory_stock.py",
"opendbc_repo/opendbc/safety/tests/test_gm_bolt_euv.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_cc.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_cc_gas.py",
"opendbc_repo/opendbc/safety/tests/test_gm_aol.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ascm_intercept.py",
"opendbc_repo/opendbc/safety/tests/test_gm_sdgm_stock.py",
"opendbc_repo/opendbc/safety/tests/test_gm_cc_gateway_stock.py",
"opendbc_repo/opendbc/safety/tests/test_gm_stock_cancel_delay.py",
"openpilot/starpilot/tests/test_gm_stock_caller.py",
"opendbc_repo/opendbc/safety/tests/test_gm_camera_stock_four.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_gateway_mapping.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_alternate_brake.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_ascm.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_ascm.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_sdgm.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_cc.py",
"opendbc_repo/opendbc/car/gm/tests/test_ordinary_cc.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_camera.py",
"opendbc_repo/opendbc/car/gm/tests/test_ordinary_camera.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_camera_removed.py",
"opendbc_repo/opendbc/car/gm/tests/test_ordinary_camera_removed.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ascm_aol.py",
"opendbc_repo/opendbc/car/gm/tests/test_ordinary_ascm.py",
"openpilot/starpilot/longitudinal/tests/test_gm_suburban_outer.py",
"opendbc_repo/opendbc/car/gm/tests/test_ordinary_sdgm.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_camera.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_camera_removed.py",
"opendbc_repo/opendbc/car/gm/tests/test_volt_camera_removed.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_sdgm.py",
"opendbc_repo/opendbc/car/gm/tests/test_volt_sdgm_control.py",
"opendbc_repo/opendbc/car/gm/tests/test_volt_camera_control.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_mrr35_angle_four.py",
"opendbc_repo/opendbc/safety/tests/test_kia_ev6_2025_angle.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_angle_hybrids_three.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_ccnc_angle_two.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_ioniq6_long.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_ray_pedal.py",
"opendbc_repo/opendbc/safety/tests/test_honda_mvl_stock.py",
"opendbc_repo/opendbc/safety/tests/test_honda_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_mazda_stock_aol.py",
"opendbc_repo/opendbc/car/mazda/tests/test_stock_aol_pause.py",
"opendbc_repo/opendbc/safety/tests/test_honda_rdx_gas_cap.py",
"opendbc_repo/opendbc/safety/tests/test_honda_aol_source.py",
"opendbc_repo/opendbc/safety/tests/test_honda_aol_family.py",
"opendbc_repo/opendbc/safety/tests/test_aol_defensive_config.py",
"opendbc_repo/opendbc/safety/tests/test_honda_nidec_six.py",
"opendbc_repo/opendbc/safety/tests/test_honda_interceptor.py",
"opendbc_repo/opendbc/safety/tests/test_honda_bosch_radarless_three.py",
"opendbc_repo/opendbc/safety/tests/test_subaru_gen2_angle_pair.py",
"opendbc_repo/opendbc/safety/tests/test_subaru_ascent_angle.py",
"opendbc_repo/opendbc/safety/tests/test_ford_three.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_canfd_carnival.py",
)
# Vehicle tests import both the vendored car code and these host control owners.
# Presentation work can proceed independently; native binaries have separate
# provenance below. Keep result directories outside these source areas.
SOURCE_INPUT_PATHS = (
"opendbc_repo", "openpilot/cereal", "openpilot/common", "openpilot/selfdrive/car",
"openpilot/selfdrive/controls", "openpilot/selfdrive/locationd", "openpilot/selfdrive/selfdrived",
"openpilot/starpilot/aol", "openpilot/starpilot/car", "openpilot/starpilot/lateral", "openpilot/starpilot/longitudinal",
"openpilot/starpilot/speed_limits", "openpilot/starpilot/schema_cache.py", "msgq_repo", "rednose_repo",
"openpilot/starpilot/vehicle_preferences.py",
"openpilot/starpilot/vehicle_startup.py", "openpilot/starpilot/controller_extensions.py",
"tools/ci", "tools/test_runner.py", "pyproject.toml", "uv.lock", "upstream-sync.json",
)
def source_input_snapshot():
def git(*args):
return subprocess.check_output(["git", *args], cwd=ROOT)
untracked = {}
for raw_path in git("ls-files", "--others", "--exclude-standard", "-z", "--", *SOURCE_INPUT_PATHS).split(b"\0"):
if raw_path:
name = os.fsdecode(raw_path)
path = ROOT / name
data = os.fsencode(os.readlink(path)) if path.is_symlink() else path.read_bytes()
untracked[name] = hashlib.sha256(data).hexdigest()
patch_bytes = git("diff", "--no-ext-diff", "--no-textconv", "--binary", "HEAD", "--", *SOURCE_INPUT_PATHS)
return {"revision": git("rev-parse", "HEAD").decode().strip(), "paths": list(SOURCE_INPUT_PATHS),
"tracked_patch_sha256": hashlib.sha256(patch_bytes).hexdigest(), "untracked_sha256": untracked}
def sha256(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def targets_for(suite):
if suite == "interfaces":
car_root = ROOT / "opendbc_repo/opendbc/car"
return [str(path.relative_to(ROOT)) for path in sorted(car_root.rglob("test_*.py"))
if path != car_root / "tests/test_models.py"]
if suite == "safety-debug":
return [str(path.relative_to(ROOT)) for path in sorted((ROOT / "opendbc_repo/opendbc/safety/tests").glob("test_*.py"))]
if suite == "safety-release":
return list(RELEASE_TARGETS)
raise ValueError(f"Unknown vehicle test suite: {suite}")
def audit_interface_modules(targets, test_ids):
"""Expose empty modules and test declarations unsupported by unittest discovery."""
modules, errors = [], []
for target in targets:
path = ROOT / target
module = ".".join(path.relative_to(ROOT).with_suffix("").parts)
selected = [test_id for test_id in test_ids if test_id.startswith(module + ".")]
declarations = ast.parse(path.read_text(), filename=str(path))
functions, missing_methods = [], []
for node in declarations.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
functions.append(node.name)
elif isinstance(node, ast.ClassDef):
for method in node.body:
if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) and method.name.startswith("test_"):
expected = f"{module}.{node.name}.{method.name}"
if not any(test_id == expected or test_id.startswith((expected + "_", expected + "[")) for test_id in selected):
missing_methods.append(f"{node.name}.{method.name}")
if not selected:
errors.append(f"Interface test module collected zero tests: {target}")
for function in functions:
expected = f"{module}.{function}"
if not any(test_id == expected or test_id.startswith(expected + "[") for test_id in selected):
errors.append(f"Declared module-level test was not collected: {target}: {function}")
if missing_methods:
errors.append(f"Declared interface tests were not collected: {target}: {missing_methods}")
modules.append({"path": target, "source_sha256": sha256(path), "collected_count": len(selected),
"module_level_test_functions": functions, "uncollected_test_methods": missing_methods})
return modules, errors
def interface_coverage(test_ids, platforms):
prefix = ".TestCarInterfaces.test_car_interfaces_"
matches = [(test_id.split(prefix, 1)[1], test_id) for test_id in test_ids if prefix in test_id]
counts = Counter(platform for platform, _ in matches)
if set(counts) != set(platforms) or any(count != 1 for count in counts.values()):
missing, extra = sorted(set(platforms) - counts.keys()), sorted(counts.keys() - set(platforms))
duplicates = sorted(platform for platform, count in counts.items() if count != 1)
raise ValueError(f"Concrete interface collection mismatch: missing={missing}, extra={extra}, duplicates={duplicates}")
return dict(matches)
def interface_execution_errors(platform_tests, records):
by_id = {record["id"]: record for record in records}
return [f"Concrete interface test for {platform} must pass: {by_id.get(test_id, {}).get('status', 'missing')} ({test_id})"
for platform, test_id in platform_tests.items() if by_id.get(test_id, {}).get("status") != "passed"]
def vehicle_inventory():
from opendbc.car.values import PLATFORMS
from opendbc.car.tests.routes import non_tested_cars, routes
return {"platform_ids": sorted(PLATFORMS), "platform_count": len(PLATFORMS),
"platforms_without_route": sorted(set(PLATFORMS) - {str(route.car_model) for route in routes}),
"declared_route_exemptions": sorted(str(platform) for platform in non_tested_cars)}
def source_provenance():
def git(*args):
return subprocess.check_output(["git", *args], cwd=ROOT).decode().strip()
manifest = ROOT / "upstream-sync.json"
return {"revision": git("rev-parse", "HEAD"), "status": git("status", "--porcelain"),
"inputs": source_input_snapshot(),
"manifest_sha256": sha256(manifest), "dependencies": json.loads(manifest.read_text())["dependencies"],
"runner_sha256": sha256(ROOT / "tools/test_runner.py"), "suite_runner_sha256": sha256(__file__),
"pytest_runner_sha256": sha256(ROOT / "tools/ci/vehicle_pytest.py")}
def native_provenance():
import capnp.lib.capnp as native_capnp
from opendbc.can import packer, parser
path = Path(native_capnp.__file__).resolve()
if not any(str(path).endswith(suffix) for suffix in importlib.machinery.EXTENSION_SUFFIXES):
raise RuntimeError("Vehicle tests require the native pycapnp extension")
expected = ROOT / "opendbc_repo/opendbc/can"
if Path(packer.__file__).resolve().parent != expected or Path(parser.__file__).resolve().parent != expected:
raise RuntimeError("Vehicle tests imported CAN code from outside the tracked dependency")
return {"pycapnp": {"path": str(path), "sha256": sha256(path)},
"can_implementation": "tracked upstream Python parser/packer", "can_sources": {
str(Path(module.__file__).relative_to(ROOT)): sha256(module.__file__) for module in (packer, parser)}}
def build_safety_library(release, output):
from opendbc.car.structs import CarParams
from opendbc.safety.tests.libsafety import libsafety_py
commands = []
check_call = subprocess.check_call
def record_command(command, *args, **kwargs):
commands.append(list(command))
return check_call(command, *args, **kwargs)
with patch.object(libsafety_py.subprocess, "check_call", side_effect=record_command):
built = Path(libsafety_py._build_libsafety(release=release))
path = output / ("libsafety-release.so" if release else "libsafety-debug.so")
shutil.copyfile(built, path)
built.unlink()
defines_debug = any("-DALLOW_DEBUG" in command for command in commands)
if defines_debug == release or len(commands) != 2:
raise RuntimeError("Safety build did not use the requested compile variant")
libsafety_py.load(path)
status = libsafety_py.libsafety.set_safety_hooks(CarParams.SafetyModel.allOutput, 0)
if (status == 0) != (not release):
raise RuntimeError("Loaded safety library does not match the requested debug/release registry")
return {"variant": "release" if release else "ALLOW_DEBUG", "path": str(path), "sha256": sha256(path),
"commands": commands, "compiler": shutil.which(commands[0][0]),
"compiler_version": subprocess.check_output([commands[0][0], "--version"], text=True).strip(),
"all_output_hook_available": status == 0,
"loader_sha256": sha256(libsafety_py.__file__)}
def run(suite, output, collect_only=False):
output = Path(output).resolve()
output.mkdir(parents=True, exist_ok=True)
started = time.monotonic()
tests, errors = [], []
pytest_nodeids = []
records, platform_tests, gate_errors = [], {}, []
phase = "initialization"
plan = {"schema_version": 1, "suite": suite, "status": "initializing", "workers": 1,
"scope": "host synthetic interfaces" if suite == "interfaces" else "host safety hooks",
"uncovered": ["historical route replay", "hardware and bus integration", "additional downstream vehicles",
"independent lateral/longitudinal feature transitions", "device firmware execution"],
"targets": [], "selected_test_ids": [], "collection_errors": [],
"fuzz_seed": os.environ.get("FUZZ_SEED"), "max_examples_override": os.environ.get("MAX_EXAMPLES")}
if suite == "safety-release":
plan["uncovered"].append("full release-mode behavioral safety suite; only explicit selected classes run")
(output / "plan.json").write_text(json.dumps(plan, sort_keys=True, indent=2) + "\n")
try:
plan["targets"] = targets_for(suite)
plan["source"] = source_provenance()
plan.update(vehicle_inventory())
plan["native"] = native_provenance()
if suite != "interfaces" and not collect_only:
plan["native"]["safety"] = build_safety_library(suite == "safety-release", output)
phase = "collection"
unittest_targets, pytest_targets = vehicle_pytest.split_targets(ROOT, plan["targets"])
plan["runners"] = {"unittest": unittest_targets, "pytest": pytest_targets}
tests, errors = test_runner.collect(unittest_targets, None) if unittest_targets else ([], [])
pytest_nodeids, pytest_errors = vehicle_pytest.collect(pytest_targets)
errors.extend(pytest_errors)
ids = [test.id() for test in tests] + [vehicle_pytest.test_id(nodeid) for nodeid in pytest_nodeids]
if len(set(ids)) != len(ids):
errors.append("Vehicle runners selected duplicate test IDs")
if suite == "interfaces":
plan["module_collection"], module_errors = audit_interface_modules(plan["targets"], ids)
errors.extend(module_errors)
platform_tests = interface_coverage(ids, plan["platform_ids"])
if not ids:
errors.append("Vehicle suite collected no tests")
plan.update(selected_test_ids=ids, interface_test_ids=platform_tests, collection_errors=errors,
status="error" if errors else "not_run" if collect_only else "ready")
(output / "plan.json").write_text(json.dumps(plan, sort_keys=True, indent=2) + "\n")
print(f"{suite}: {len(ids)} tests; {len(platform_tests)} concrete platform interface tests; one process", flush=True)
if collect_only and not errors:
return 0
if not errors:
phase = "execution"
records = test_runner.run_batch([test.id() for test in tests], capture_output=True) if tests else []
pytest_records, pytest_errors = vehicle_pytest.run(pytest_nodeids)
records.extend(pytest_records)
errors.extend(pytest_errors)
phase = "source verification"
plan["source_after"] = source_provenance()
if plan["source"].get("inputs") != plan["source_after"].get("inputs"):
gate_errors.append("Vehicle/control source inputs changed during the suite; rerun against stable source")
except Exception:
errors.append(f"{phase} error:\n{traceback.format_exc()}")
plan["error_phase"] = phase
ids = [test.id() for test in tests] + [vehicle_pytest.test_id(nodeid) for nodeid in pytest_nodeids]
records = test_runner.account_for_tests(ids, records, tests)
if not errors and suite == "interfaces":
gate_errors.extend(interface_execution_errors(platform_tests, records))
elapsed = time.monotonic() - started
exit_code = test_runner.report(records, errors + gate_errors, 10, elapsed)
status = "error" if errors else "failed" if exit_code else "completed"
test_runner.write_json_report(output / "results.json", ids, records, errors, elapsed, exit_code)
result = json.loads((output / "results.json").read_text())
result.update(status=status, gate_errors=gate_errors)
if "error_phase" in plan:
result["error_phase"] = plan["error_phase"]
(output / "results.json").write_text(json.dumps(result, sort_keys=True, indent=2) + "\n")
by_id = {record["id"]: record for record in records}
plan.update(status=status, exit_code=exit_code, collection_errors=errors, gate_errors=gate_errors, duration_seconds=elapsed,
selected_test_ids=ids, interface_test_ids=platform_tests,
result_counts=dict(Counter(record["status"] for record in records)),
interface_results={platform: by_id[test_id] for platform, test_id in platform_tests.items()},
result_sha256=sha256(output / "results.json"))
if errors:
(output / "plan.json").write_text(json.dumps(plan, sort_keys=True, indent=2) + "\n")
(output / "coverage.json").write_text(json.dumps(plan, sort_keys=True, indent=2) + "\n")
return exit_code
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--suite", choices=("interfaces", "safety-debug", "safety-release"), required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--collect-only", action="store_true")
args = parser.parse_args()
os.chdir(ROOT)
# Keep the same generated examples across collection, execution, and reruns.
os.environ.setdefault("FUZZ_SEED", "0")
return run(args.suite, args.output, args.collect_only)
if __name__ == "__main__":
raise SystemExit(main())