diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index f318ddbd3..89f5ba3fa 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -515,6 +515,52 @@ jobs: # Run tests with CloudXR runtime ./scripts/run_tests_with_cloudxr.sh --python-version ${{ matrix.python_version }} + test-quick-start-e2e: + # Runs the documented Quick Start workflow against the candidate wheel on a + # GPU runner: install package, launch CloudXR, source the generated env, run + # the documented gripper retargeting example, and upload the logs/summary. + runs-on: [self-hosted, linux, gpu, x64] + needs: build-ubuntu + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Download isaacteleop wheel (x64, py3.11) + uses: actions/download-artifact@v7 + with: + name: isaacteleop-wheels-x64-py3.11 + path: quick-start-wheels + + - name: Run Quick Start E2E workflow + env: + CI: true + QUICK_START_E2E_REQUIRE_WHEEL: "1" + QUICK_START_E2E_WHEEL_DIR: quick-start-wheels + QUICK_START_E2E_ARTIFACT_DIR: ${{ runner.temp }}/isaacteleop-quick-start-e2e + run: ./scripts/run_quick_start_e2e.sh + + - name: Upload Quick Start E2E artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v6 + with: + name: quick-start-e2e-x64-py3.11 + path: | + ${{ runner.temp }}/isaacteleop-quick-start-e2e/*.log + ${{ runner.temp }}/isaacteleop-quick-start-e2e/*.json + ${{ runner.temp }}/isaacteleop-quick-start-e2e/cloudxr/run/cloudxr.env + ${{ runner.temp }}/isaacteleop-quick-start-e2e/cloudxr/logs/**/*.log + if-no-files-found: warn + retention-days: 7 + test-teleop-ros2: runs-on: [self-hosted, linux, gpu, "${{ matrix.arch }}"] needs: build-ubuntu @@ -820,6 +866,7 @@ jobs: - test-viz-gpu - test-viz-sanitizers - test-cloudxr + - test-quick-start-e2e - test-teleop-ros2 if: ${{ always() }} diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh new file mode 100755 index 000000000..600406306 --- /dev/null +++ b/scripts/run_quick_start_e2e.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Exercise the runnable workflow from docs/source/getting_started/quick_start.rst +# on a clean runner environment. CI uses the wheel produced by the current run; +# local developers can omit QUICK_START_E2E_WHEEL_DIR to install from PyPI as the +# guide describes. + +set -euo pipefail + +ROOT_DIR=$(git rev-parse --show-toplevel) +GUIDE_PATH="docs/source/getting_started/quick_start.rst" +PYTHON_BIN="${QUICK_START_E2E_PYTHON:-python3}" +ARTIFACT_DIR="${QUICK_START_E2E_ARTIFACT_DIR:-${RUNNER_TEMP:-/tmp}/isaacteleop-quick-start-e2e}" +VENV_DIR="${QUICK_START_E2E_VENV_DIR:-${ARTIFACT_DIR}/venv}" +CLOUDXR_INSTALL_DIR="${QUICK_START_E2E_CLOUDXR_INSTALL_DIR:-${ARTIFACT_DIR}/cloudxr}" +WHEEL_DIR="${QUICK_START_E2E_WHEEL_DIR:-${ROOT_DIR}/install/wheels}" +PIP_SPEC="${QUICK_START_E2E_PIP_SPEC:-isaacteleop[cloudxr,retargeters]~=1.0.0}" +PIP_EXTRA_INDEX_URL="${QUICK_START_E2E_PIP_EXTRA_INDEX_URL:-https://pypi.nvidia.com}" +CLOUDXR_READY_TIMEOUT_SEC="${QUICK_START_E2E_CLOUDXR_READY_TIMEOUT_SEC:-180}" +EXAMPLE_TIMEOUT_SEC="${QUICK_START_E2E_EXAMPLE_TIMEOUT_SEC:-75}" +DEFAULT_CLOUDXR_SERVER_PORT=49100 +DEFAULT_WSS_PROXY_PORT=48322 +RUNTIME_PORT="${QUICK_START_E2E_RUNTIME_PORT:-}" +PROXY_PORT="${QUICK_START_E2E_WSS_PROXY_PORT:-}" + +CLOUDXR_LOG="${ARTIFACT_DIR}/cloudxr-server.log" +EXAMPLE_LOG="${ARTIFACT_DIR}/gripper-retargeting-example.log" +SUMMARY_JSON="${ARTIFACT_DIR}/quick-start-e2e-summary.json" + +cloudxr_pid="" + +log() { + printf '[quick-start-e2e] %s\n' "$*" +} + +fail() { + log "ERROR: $*" + write_summary "failed" "$*" + exit 1 +} + +cleanup() { + if [[ -n "${cloudxr_pid}" ]] && kill -0 "${cloudxr_pid}" 2>/dev/null; then + log "Stopping CloudXR server (pid=${cloudxr_pid})" + kill "${cloudxr_pid}" 2>/dev/null || true + wait "${cloudxr_pid}" 2>/dev/null || true + fi +} +trap cleanup EXIT + +json_escape() { + python3 - "$1" <<'PY' +import json +import sys + +print(json.dumps(sys.argv[1])) +PY +} + +write_summary() { + local status="$1" + local message="$2" + local gripper_lines="0" + if [[ -f "${EXAMPLE_LOG}" ]]; then + gripper_lines=$(grep -Ec 'Right:[[:space:]]*-?[0-9]+([.][0-9]+)?' "${EXAMPLE_LOG}" || true) + fi + + mkdir -p "${ARTIFACT_DIR}" + cat > "${SUMMARY_JSON}" </dev/null; then + return 1 + fi + sleep 1 + done + + return 1 +} + +wait_for_log_line() { + local pattern="$1" + local timeout="$2" + local deadline=$((SECONDS + timeout)) + + while [[ "${SECONDS}" -lt "${deadline}" ]]; do + if [[ -f "${CLOUDXR_LOG}" ]] && grep -qE "${pattern}" "${CLOUDXR_LOG}"; then + return 0 + fi + if [[ -n "${cloudxr_pid}" ]] && ! kill -0 "${cloudxr_pid}" 2>/dev/null; then + return 1 + fi + sleep 1 + done + + return 1 +} + +assert_clean_log() { + local label="$1" + local file="$2" + if grep -Eiq 'Traceback|RuntimeError|Segmentation fault|Aborted|failed to start|exited unexpectedly' "${file}"; then + log "===== ${label} =====" + cat "${file}" || true + fail "${label} contains a fatal error" + fi +} + +resolve_wheel() { + shopt -s nullglob + local wheels=("${WHEEL_DIR}"/isaacteleop-*.whl) + shopt -u nullglob + + if (( ${#wheels[@]} == 0 )); then + return 1 + fi + if (( ${#wheels[@]} > 1 )); then + printf 'Expected one wheel in %s, found %d\n' "${WHEEL_DIR}" "${#wheels[@]}" >&2 + return 2 + fi + + printf '%s\n' "${wheels[0]}" +} + +port_is_available() { + "${PYTHON_BIN}" - "$1" <<'PY' +import socket +import sys + +try: + port = int(sys.argv[1]) +except ValueError: + raise SystemExit(2) from None + +if not 1 <= port <= 65535: + raise SystemExit(2) + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("", port)) + except OSError: + raise SystemExit(1) from None +PY +} + +pick_free_port() { + "${PYTHON_BIN}" - <<'PY' +import socket + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + print(sock.getsockname()[1]) +PY +} + +resolve_port() { + local requested="$1" + local default_port="$2" + + if [[ -n "${requested}" ]]; then + port_is_available "${requested}" || return $? + printf '%s\n' "${requested}" + return 0 + fi + + if port_is_available "${default_port}"; then + printf '%s\n' "${default_port}" + return 0 + fi + + pick_free_port +} + +install_package() { + local venv_python="${VENV_DIR}/bin/python" + local wheel="" + local resolve_rc=0 + + "${PYTHON_BIN}" -m venv "${VENV_DIR}" + "${venv_python}" -m pip install --upgrade pip + + wheel=$(resolve_wheel) || resolve_rc=$? + if (( resolve_rc == 0 )); then + local wheel_name + local wheel_version + wheel_name=$(basename "${wheel}") + wheel_version=$(sed -E 's/^isaacteleop-([^-]+)-.*/\1/' <<< "${wheel_name}" | tr '_' '-') + log "Step 2: installing candidate wheel ${wheel_name} with guide extras" + "${venv_python}" -m pip install --no-cache-dir "${wheel}" + "${venv_python}" -m pip install \ + --no-cache-dir \ + --extra-index-url "${PIP_EXTRA_INDEX_URL}" \ + "isaacteleop[cloudxr,retargeters]==${wheel_version}" + elif (( resolve_rc == 1 )); then + if [[ "${QUICK_START_E2E_REQUIRE_WHEEL:-0}" == "1" ]]; then + fail "no isaacteleop wheel found in ${WHEEL_DIR}" + fi + log "Step 2: installing from PyPI spec: ${PIP_SPEC}" + "${venv_python}" -m pip install \ + --no-cache-dir \ + --extra-index-url "${PIP_EXTRA_INDEX_URL}" \ + "${PIP_SPEC}" + else + fail "could not resolve a single isaacteleop wheel from ${WHEEL_DIR}" + fi +} + +check_wss_proxy_port() { + "${VENV_DIR}/bin/python" - "${PROXY_PORT}" <<'PY' +import socket +import sys + +port = int(sys.argv[1]) +with socket.create_connection(("127.0.0.1", port), timeout=5.0): + pass +PY +} + +run_example() { + local env_file="$1" + + log "Step 6: sourcing ${env_file}" + # shellcheck disable=SC1090 + source "${env_file}" + + log "Step 7: running examples/teleop/python/gripper_retargeting_example_simple.py" + local example_rc=0 + if command -v timeout >/dev/null 2>&1; then + timeout "${EXAMPLE_TIMEOUT_SEC}" \ + "${VENV_DIR}/bin/python" "${ROOT_DIR}/examples/teleop/python/gripper_retargeting_example_simple.py" \ + > "${EXAMPLE_LOG}" 2>&1 || example_rc=$? + else + "${VENV_DIR}/bin/python" "${ROOT_DIR}/examples/teleop/python/gripper_retargeting_example_simple.py" \ + > "${EXAMPLE_LOG}" 2>&1 || example_rc=$? + fi + + if (( example_rc != 0 )); then + log "===== gripper retargeting example log =====" + cat "${EXAMPLE_LOG}" || true + fail "gripper example exited with status ${example_rc}" + fi + + assert_clean_log "gripper retargeting example log" "${EXAMPLE_LOG}" + grep -q 'Gripper Retargeting' "${EXAMPLE_LOG}" || fail "gripper example did not print its startup banner" + + local gripper_lines + gripper_lines=$(grep -Ec 'Right:[[:space:]]*-?[0-9]+([.][0-9]+)?' "${EXAMPLE_LOG}" || true) + if (( gripper_lines < 2 )); then + log "===== gripper retargeting example log =====" + cat "${EXAMPLE_LOG}" || true + fail "expected at least 2 gripper output lines, found ${gripper_lines}" + fi +} + +main() { + rm -rf "${ARTIFACT_DIR}" + mkdir -p "${ARTIFACT_DIR}" + + log "Step 1: using checked-out repository at ${ROOT_DIR}" + test -f "${ROOT_DIR}/${GUIDE_PATH}" || fail "Quick Start guide not found at ${GUIDE_PATH}" + test -f "${ROOT_DIR}/examples/teleop/python/gripper_retargeting_example_simple.py" || \ + fail "documented gripper example is missing" + + install_package + + RUNTIME_PORT=$(resolve_port "${RUNTIME_PORT}" "${DEFAULT_CLOUDXR_SERVER_PORT}") || \ + fail "requested CloudXR runtime port ${RUNTIME_PORT} is not available" + PROXY_PORT=$(resolve_port "${PROXY_PORT}" "${DEFAULT_WSS_PROXY_PORT}") || \ + fail "requested WSS proxy port ${PROXY_PORT} is not available" + while [[ "${PROXY_PORT}" == "${RUNTIME_PORT}" ]]; do + PROXY_PORT=$(pick_free_port) + done + export NV_CXR_SERVER_PORT="${RUNTIME_PORT}" + export PROXY_PORT + log "Using CloudXR runtime port ${NV_CXR_SERVER_PORT} and WSS proxy port ${PROXY_PORT}" + + log "Step 3: starting CloudXR server with --accept-eula" + PYTHONUNBUFFERED=1 "${VENV_DIR}/bin/python" -u -m isaacteleop.cloudxr \ + --cloudxr-install-dir "${CLOUDXR_INSTALL_DIR}" \ + --accept-eula \ + > "${CLOUDXR_LOG}" 2>&1 & + cloudxr_pid=$! + + local env_file="${CLOUDXR_INSTALL_DIR}/run/cloudxr.env" + wait_for_file "${env_file}" "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR did not write ${env_file}" + } + wait_for_log_line 'CloudXR runtime:.*running' "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR runtime did not report running" + } + wait_for_log_line 'CloudXR WSS proxy:.*running' "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR WSS proxy did not report running" + } + assert_clean_log "CloudXR server log" "${CLOUDXR_LOG}" + + log "Step 4: firewall allow-list is a machine configuration step; CI validates the local WSS proxy port instead" + check_wss_proxy_port || fail "WSS proxy port ${PROXY_PORT} is not reachable on localhost" + + log "Step 5: desktop browser/IWER is the documented headset-free client path; this CI baseline does not inspect the website" + + run_example "${env_file}" + + log "Quick Start E2E workflow completed" + write_summary "passed" "Quick Start workflow reached the documented gripper example and emitted output" +} + +main "$@" diff --git a/src/core/cloudxr/python/env_config.py b/src/core/cloudxr/python/env_config.py index 4408f6809..1b0848e1f 100644 --- a/src/core/cloudxr/python/env_config.py +++ b/src/core/cloudxr/python/env_config.py @@ -50,6 +50,7 @@ class EnvConfig: "NV_CXR_ENABLE_PUSH_DEVICES": "true", "NV_CXR_ENABLE_TENSOR_DATA": "true", "NV_CXR_FILE_LOGGING": "true", + "NV_CXR_SERVER_PORT": None, # optional; persisted when set in process env "NV_DEVICE_PROFILE": "auto-webrtc", } diff --git a/src/core/cloudxr/python/launcher.py b/src/core/cloudxr/python/launcher.py index 41ef22d93..d681fa837 100644 --- a/src/core/cloudxr/python/launcher.py +++ b/src/core/cloudxr/python/launcher.py @@ -240,11 +240,12 @@ def add_launch_cloudxr_runtime_argument(parser: argparse.ArgumentParser) -> None parser.add_argument( "--launch-cloudxr-runtime", action=argparse.BooleanOptionalAction, - default=True, + default=None, help=( - "Launch the CloudXR runtime and WSS proxy in-process before running " - "(default: true). Pass --no-launch-cloudxr-runtime when the runtime is " - "already running (e.g. after sourcing ~/.cloudxr/run/cloudxr.env)." + "Launch the CloudXR runtime and WSS proxy in-process before running. " + "By default, reuse a sourced CloudXR runtime environment when present " + "and launch otherwise. Pass --no-launch-cloudxr-runtime when an " + "external runtime is already running." ), ) @@ -382,6 +383,21 @@ def _resolve_start_wss_proxy( return start_wss_proxy return bool(getattr(args, "launch_wss_proxy", True)) + @staticmethod + def _has_sourced_cloudxr_runtime_env() -> bool: + """Return true when the current shell looks sourced from ``cloudxr.env``.""" + return bool( + os.environ.get("XR_RUNTIME_JSON") and os.environ.get("NV_CXR_RUNTIME_DIR") + ) + + @staticmethod + def _resolve_launch_cloudxr_runtime(args: argparse.Namespace) -> bool: + """Resolve auto-launch behavior for documented two-terminal workflows.""" + launch_cloudxr_runtime = getattr(args, "launch_cloudxr_runtime", None) + if launch_cloudxr_runtime is not None: + return bool(launch_cloudxr_runtime) + return not CloudXRLauncher._has_sourced_cloudxr_runtime_env() + @staticmethod def launch_context( args: argparse.Namespace, @@ -395,10 +411,10 @@ def launch_context( host_client: bool = False, start_wss_proxy: bool | None = None, ) -> contextlib.AbstractContextManager[CloudXRLauncher | None]: - """Start :class:`CloudXRLauncher` when ``args.launch_cloudxr_runtime`` is true. + """Start :class:`CloudXRLauncher` when runtime launch resolves true. - Returns :func:`contextlib.nullcontext` when ``args.launch_cloudxr_runtime`` is - false so callers can always use ``with CloudXRLauncher.launch_context(args):``. + Returns :func:`contextlib.nullcontext` when runtime launch resolves false so + callers can always use ``with CloudXRLauncher.launch_context(args):``. ``install_dir``, ``env_config``, ``device_profile``, ``accept_eula``, and ``start_wss_proxy`` default to the values registered by @@ -407,7 +423,7 @@ def launch_context( ``accept_eula``, pass ``False`` to force-disable even when the CLI flag is set. """ - if not args.launch_cloudxr_runtime: + if not CloudXRLauncher._resolve_launch_cloudxr_runtime(args): return contextlib.nullcontext(None) return CloudXRLauncher( install_dir=CloudXRLauncher._resolve_install_dir(args, install_dir), diff --git a/src/core/cloudxr/python/oob_teleop_env.py b/src/core/cloudxr/python/oob_teleop_env.py index c8eae2d62..25e2b14a8 100644 --- a/src/core/cloudxr/python/oob_teleop_env.py +++ b/src/core/cloudxr/python/oob_teleop_env.py @@ -104,7 +104,8 @@ def default_web_client_origin() -> str: USB_HOST = "127.0.0.1" # serverIP seen by the headset (its own localhost) USB_UI_DEFAULT_PORT = 8080 # HTTPS static WebXR UI (loopback) -USB_BACKEND_DEFAULT_PORT = 49100 # CloudXR backend (webrtc client direct connection) +CLOUDXR_SERVER_DEFAULT_PORT = 49100 # CloudXR runtime backend +USB_BACKEND_DEFAULT_PORT = CLOUDXR_SERVER_DEFAULT_PORT # adb reverse'd backend USB_TURN_DEFAULT_PORT = 3478 # coturn TURN server port (adb reverse'd to headset) USB_TURN_USER = "cloudxr" # TURN username USB_TURN_CREDENTIAL = "cloudxrpass" # TURN credential @@ -362,6 +363,18 @@ def wss_proxy_port() -> int: return WSS_PROXY_DEFAULT_PORT +def cloudxr_server_port() -> int: + """TCP port for the CloudXR runtime backend. + + Reads the ``NV_CXR_SERVER_PORT`` environment variable if set, else falls + back to :data:`CLOUDXR_SERVER_DEFAULT_PORT` (49100). + """ + raw = os.environ.get("NV_CXR_SERVER_PORT", "").strip() + if raw: + return parse_env_port("NV_CXR_SERVER_PORT", raw) + return CLOUDXR_SERVER_DEFAULT_PORT + + def usb_ui_port() -> int: """TCP port for the USB-local WebXR static HTTPS server. @@ -378,10 +391,10 @@ def usb_ui_port() -> int: def usb_backend_port() -> int: """TCP port for the USB-local CloudXR backend (native client direct connection). - Reads the ``USB_BACKEND_PORT`` environment variable if set, else falls - back to :data:`USB_BACKEND_DEFAULT_PORT` (49100). This port is exposed - to the headset via ``adb reverse``; override only when a host process - already owns 49100. + Reads the ``USB_BACKEND_PORT`` environment variable if set, else falls back + to :data:`USB_BACKEND_DEFAULT_PORT` (49100). This port is exposed to the + headset via ``adb reverse``; override only when a host process already owns + the CloudXR runtime port. """ raw = os.environ.get("USB_BACKEND_PORT", "").strip() if raw: diff --git a/src/core/cloudxr/python/wss.py b/src/core/cloudxr/python/wss.py index 0931a0424..2ec939bd0 100755 --- a/src/core/cloudxr/python/wss.py +++ b/src/core/cloudxr/python/wss.py @@ -23,6 +23,7 @@ run_oob_connect, ) from .oob_teleop_env import ( + cloudxr_server_port, client_ui_fields_from_env, default_initial_stream_config, oob_progress, @@ -508,7 +509,7 @@ async def run( log_file_path: str | Path | None, stop_future: asyncio.Future, backend_host: str = "localhost", - backend_port: int = 49100, + backend_port: int | None = None, proxy_port: int | None = None, setup_oob: bool = False, usb_local: bool = False, @@ -535,6 +536,9 @@ async def run( _extra_log.addHandler(_handler) try: + resolved_backend_port = ( + cloudxr_server_port() if backend_port is None else backend_port + ) resolved_port = wss_proxy_port() if proxy_port is None else proxy_port logging.getLogger("websockets").setLevel(logging.WARNING) @@ -566,7 +570,7 @@ def handler(ws): path = _normalize_request_path(ws.request.path or "/") if path == OOB_WS_PATH: return hub.handle_connection(ws) - return proxy_handler(ws, backend_host, backend_port) + return proxy_handler(ws, backend_host, resolved_backend_port) _host_client_static_dir = None if host_client: @@ -575,7 +579,10 @@ def handler(ws): _host_client_static_dir = require_web_client_static_dir() http_handler = _make_http_handler( - backend_host, backend_port, hub=hub, static_dir=_host_client_static_dir + backend_host, + resolved_backend_port, + hub=hub, + static_dir=_host_client_static_dir, ) async with ws_serve( diff --git a/src/core/cloudxr_tests/python/test_launcher.py b/src/core/cloudxr_tests/python/test_launcher.py index 711cb389a..9507cbd29 100644 --- a/src/core/cloudxr_tests/python/test_launcher.py +++ b/src/core/cloudxr_tests/python/test_launcher.py @@ -399,6 +399,7 @@ def test_add_launcher_arguments_defaults(self) -> None: args = parser.parse_args([]) assert args.cloudxr_env_config is None assert args.accept_eula is False + assert args.launch_cloudxr_runtime is None assert args.launch_wss_proxy is True def test_add_cloudxr_device_profile_argument_default(self) -> None: @@ -413,10 +414,16 @@ def test_add_cloudxr_device_profile_argument_custom(self) -> None: args = parser.parse_args(["--cloudxr-device-profile", "AppleVisionPro"]) assert args.cloudxr_device_profile == "AppleVisionPro" - def test_add_launch_cloudxr_runtime_argument_defaults_true(self) -> None: + def test_add_launch_cloudxr_runtime_argument_defaults_auto(self) -> None: parser = argparse.ArgumentParser() CloudXRLauncher.add_launch_cloudxr_runtime_argument(parser) args = parser.parse_args([]) + assert args.launch_cloudxr_runtime is None + + def test_add_launch_cloudxr_runtime_argument_launch(self) -> None: + parser = argparse.ArgumentParser() + CloudXRLauncher.add_launch_cloudxr_runtime_argument(parser) + args = parser.parse_args(["--launch-cloudxr-runtime"]) assert args.launch_cloudxr_runtime is True def test_add_launch_cloudxr_runtime_argument_no_launch(self) -> None: @@ -442,6 +449,49 @@ def test_launch_context_skips_when_disabled(self) -> None: with CloudXRLauncher.launch_context(args) as launcher: assert launcher is None + def test_resolve_launch_cloudxr_runtime_auto_launches_without_env( + self, monkeypatch + ) -> None: + monkeypatch.delenv("XR_RUNTIME_JSON", raising=False) + monkeypatch.delenv("NV_CXR_RUNTIME_DIR", raising=False) + + args = argparse.Namespace(launch_cloudxr_runtime=None) + + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is True + + def test_resolve_launch_cloudxr_runtime_auto_reuses_sourced_env( + self, monkeypatch + ) -> None: + monkeypatch.setenv("XR_RUNTIME_JSON", "/tmp/cloudxr/openxr.json") + monkeypatch.setenv("NV_CXR_RUNTIME_DIR", "/tmp/cloudxr/run") + + args = argparse.Namespace(launch_cloudxr_runtime=None) + + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is False + + def test_resolve_launch_cloudxr_runtime_explicit_overrides_env( + self, monkeypatch + ) -> None: + monkeypatch.setenv("XR_RUNTIME_JSON", "/tmp/cloudxr/openxr.json") + monkeypatch.setenv("NV_CXR_RUNTIME_DIR", "/tmp/cloudxr/run") + + args = argparse.Namespace(launch_cloudxr_runtime=True) + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is True + + args.launch_cloudxr_runtime = False + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is False + + def test_launch_context_reuses_sourced_runtime_in_auto_mode( + self, monkeypatch + ) -> None: + monkeypatch.setenv("XR_RUNTIME_JSON", "/tmp/cloudxr/openxr.json") + monkeypatch.setenv("NV_CXR_RUNTIME_DIR", "/tmp/cloudxr/run") + + args = argparse.Namespace(launch_cloudxr_runtime=None) + + with CloudXRLauncher.launch_context(args) as launcher: + assert launcher is None + @_windows_skip def test_launch_context_starts_when_enabled(self, tmp_path) -> None: args = argparse.Namespace( diff --git a/src/core/cloudxr_tests/python/test_oob_teleop_env.py b/src/core/cloudxr_tests/python/test_oob_teleop_env.py index 5514620d4..07a6db01c 100644 --- a/src/core/cloudxr_tests/python/test_oob_teleop_env.py +++ b/src/core/cloudxr_tests/python/test_oob_teleop_env.py @@ -11,6 +11,7 @@ import pytest from cloudxr_py_test_ns.oob_teleop_env import ( + CLOUDXR_SERVER_DEFAULT_PORT, FALLBACK_WEB_CLIENT_ORIGIN, TELEOP_WEB_CLIENT_BASE_ENV, TELEOP_WEB_CLIENT_STATIC_DIR_ENV, @@ -21,6 +22,7 @@ WSS_PROXY_DEFAULT_PORT, build_headset_bookmark_url, client_ui_fields_from_env, + cloudxr_server_port, default_initial_stream_config, default_web_client_origin, guess_lan_ipv4, @@ -42,6 +44,7 @@ def clear_teleop_env(monkeypatch: pytest.MonkeyPatch) -> None: """Remove all teleop environment variables so tests start from a clean slate.""" keys = ( "PROXY_PORT", + "NV_CXR_SERVER_PORT", "USB_UI_PORT", "USB_BACKEND_PORT", "USB_TURN_PORT", @@ -72,6 +75,28 @@ def test_wss_proxy_port_from_env( assert wss_proxy_port() == 50000 +def test_cloudxr_server_port_default(clear_teleop_env: None) -> None: + """CloudXR server port returns the runtime default when NV_CXR_SERVER_PORT is unset.""" + assert cloudxr_server_port() == CLOUDXR_SERVER_DEFAULT_PORT + + +def test_cloudxr_server_port_from_env( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """NV_CXR_SERVER_PORT env var overrides the default runtime backend port.""" + monkeypatch.setenv("NV_CXR_SERVER_PORT", "49210") + assert cloudxr_server_port() == 49210 + + +def test_cloudxr_server_port_invalid_env_raises( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Non-integer NV_CXR_SERVER_PORT raises ValueError mentioning the variable name.""" + monkeypatch.setenv("NV_CXR_SERVER_PORT", "not-a-port") + with pytest.raises(ValueError, match="NV_CXR_SERVER_PORT"): + cloudxr_server_port() + + def test_usb_ui_port_default(clear_teleop_env: None) -> None: """USB UI port returns the compile-time default when USB_UI_PORT is unset.""" assert usb_ui_port() == USB_UI_DEFAULT_PORT