diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index 3ddebda58..21c0899cb 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -10,8 +10,8 @@ on: marks: description: "pytest marks expression (e.g. 'build_docker', 'liveliness', 'takeoff_hover_land'). \ Use 'or' to combine marks: 'liveliness or takeoff_hover_land'. Leave blank to run all marks. \ - Note: 'build_packages' is automatically prepended whenever any marks are specified, \ - to ensure code is built before launch tests run." + Note: 'build_packages' is automatically prepended for launch tests. \ + The self-contained 'ws2_smoke' mark is the exception." default: "liveliness or takeoff_hover_land" required: false sim: @@ -166,11 +166,17 @@ jobs: marks = args[i + 1] marks_idx = i + 1 + # WS2 owns its Isaac launch and is not parametrized over simulators. + if 'ws2_smoke' in marks: + sim = 'isaacsim' + # When the user specified any marks, prepend build_packages so code # is built before launch tests try to use it. Skipped when no marks # are given (pytest runs everything including build_packages) and - # when build_packages is already in the expression. - if marks and 'build_packages' not in marks: + # when build_packages is already in the expression. WS2 prepares the + # one package it needs below and must leave its stopped containers in + # place for episode.py. + if marks and 'build_packages' not in marks and 'ws2_smoke' not in marks: marks = f'build_packages or {marks}' args[marks_idx] = marks @@ -179,6 +185,7 @@ jobs: with open(os.environ['GITHUB_OUTPUT'], 'a') as f: f.write(f'pytest_args={quoted}\n') + f.write(f'marks={marks}\n') f.write(f'sim={sim}\n') f.write(f'skip_image_prep={"true" if skip_prep else "false"}\n') @@ -200,7 +207,7 @@ jobs: const args = ${{ toJSON(steps.parse.outputs.pytest_args) }}; const cmd = `pytest tests/ ${args}`.trim(); const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const note = `Note: \`build_packages\` is automatically prepended whenever any marks are specified, to ensure code is built before launch tests run.`; + const note = `Note: \`build_packages\` is automatically prepended for launch tests; the self-contained \`ws2_smoke\` mark is the exception.`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, @@ -327,10 +334,34 @@ jobs: echo "All required images present after pull — skipping build." fi + - name: Prepare WS2 Kim smoke + if: contains(steps.parse.outputs.marks, 'ws2_smoke') + env: + AIRSTACK_ROOT: ${{ github.workspace }} + AUTOLAUNCH: "false" + COMPOSE_PROFILES: desktop,isaac-sim + DISPLAY: "" + run: | + workspace_root="$(dirname "$GITHUB_WORKSPACE")" + git clone --recursive https://github.com/engcang/Collision-avoidance.git "$workspace_root/Collision-avoidance" + git -C "$workspace_root/Collision-avoidance" checkout 395485b + git -C "$workspace_root/Collision-avoidance" submodule update --init --recursive + ( + cd "$workspace_root/Collision-avoidance" + bash docker/build_image.sh + bash docker/download_models.sh + ) + ./airstack.sh up isaac-sim robot-desktop + docker exec airstack-robot-desktop-1 bash -ic 'bws --packages-select mononav_bridge' + docker stop --timeout 3 isaac-sim airstack-robot-desktop-1 + echo "WS2_SMOKE_READY=1" >> "$GITHUB_ENV" + - name: Run tests env: AIRSTACK_ROOT: ${{ github.workspace }} DISPLAY: "" + WS2_HEADLESS: "1" + WS2_OFFICE_USD: ${{ vars.WS2_OFFICE_USD || 'omniverse://airlab-nucleus.andrew.cmu.edu/NVIDIA/Assets/Isaac/4.5/Isaac/Environments/Office/office.usd' }} PYTEST_ARGS: ${{ steps.parse.outputs.pytest_args }} run: | # Re-split the shell-quoted args from the parse step so we forward diff --git a/tests/pytest.ini b/tests/pytest.ini index 78a916a0c..26555970c 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -5,6 +5,7 @@ markers = liveliness: Container and process health (Docker, tmux, sentinel ROS 2 nodes) sensors: Sim and robot sensor topic rates, LiDAR validation, sim RTF takeoff_hover_land: End-to-end takeoff / hover / land action tests + ws2_smoke: Short Office/Kim planner smoke test testpaths = . addopts = -v --durations=0 cache_dir = /tmp/.pytest_cache diff --git a/tests/requirements.txt b/tests/requirements.txt index bc2a16d4d..d46d04ce5 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,3 +4,4 @@ pytest-dependency tabulate psutil pandas +PyYAML diff --git a/tests/test_ws2_smoke.py b/tests/test_ws2_smoke.py new file mode 100644 index 000000000..54cd33c18 --- /dev/null +++ b/tests/test_ws2_smoke.py @@ -0,0 +1,76 @@ +"""WS2 adapter for the existing AirStack system-test harness.""" + +import json +import os +import shutil +import subprocess +import uuid +from pathlib import Path + +import pytest + +import conftest + + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME = ROOT / "robot/ros_ws/ws2_runtime" +SCENARIO = ROOT / "tools/ws2_bench/scenarios/validate_timeout.yaml" + + +@pytest.mark.ws2_smoke +@pytest.mark.timeout(1800) +@pytest.mark.skipif( + os.environ.get("WS2_SMOKE_READY") != "1", + reason="run through system-tests.yml with -m ws2_smoke", +) +def test_ws2_office_kim_smoke(): + output = RUNTIME / f"ci-smoke-{uuid.uuid4().hex[:12]}" + stdout = "" + returncode = -1 + try: + completed = subprocess.run( + [ + "python3", + str(ROOT / "tools/ws2_bench/episode.py"), + str(SCENARIO), + "--output", + str(output), + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=1700, + check=False, + ) + stdout = completed.stdout + returncode = completed.returncode + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout or "WS2 episode timed out" + if isinstance(stdout, bytes): + stdout = stdout.decode(errors="replace") + finally: + subprocess.run( + [ + "docker", "stop", "--timeout", "3", "ws2-episode-worker", + "collision-avoidance-airstack", "isaac-sim", + "airstack-robot-desktop-1", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + artifact = conftest.RUN_DIR / "ws2-smoke" + artifact.mkdir(parents=True, exist_ok=True) + (artifact / "episode-stdout.log").write_text(stdout, encoding="utf-8") + if output.is_dir(): + shutil.copytree(output, artifact / "episode", dirs_exist_ok=True) + + assert returncode == 0, stdout[-4000:] + result_path = output / "result.json" + assert result_path.is_file(), "WS2 did not produce result.json" + result = json.loads(result_path.read_text(encoding="utf-8")) + assert result.get("outcome") != "infrastructure_error", result + assert result.get("termination", {}).get("reason") == "simulation_time_budget", result + assert result.get("planner_command_count", 0) >= 1, result + assert not result.get("cleanup_errors"), result