Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions .github/workflows/system-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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')

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pytest-dependency
tabulate
psutil
pandas
PyYAML
76 changes: 76 additions & 0 deletions tests/test_ws2_smoke.py
Original file line number Diff line number Diff line change
@@ -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