From 7210ce3aac975bf87845c317392f55afbb978259 Mon Sep 17 00:00:00 2001 From: Charles Madjeri <80175305+charlesmadjeri@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:04:11 +0200 Subject: [PATCH] chore(docker): remove Docker, add Pixi CI and tests Remove Docker-based workflow in favor of native Pixi environment. Add comprehensive CI and unit tests for the new workflow. - Remove docker/Dockerfile.jazzy, ensure_image.sh, gpu_detect.sh, gui_desktop.sh - Update .github/workflows/install-and-launch.yml for Pixi CI - Add pytest.ini and tests/conftest.py - Add tests/test_install_optional_repos.py - Add tests/test_launcher_pixi.py - Add tests/test_repos_config.py - Add scripts/ci_colcon_test.sh and ci_tmux_launcher_smoke.sh --- .github/workflows/install-and-launch.yml | 158 +++++++++++----------- docker/Dockerfile.jazzy | 79 ----------- docker/ensure_image.sh | 117 ---------------- docker/gpu_detect.sh | 156 ---------------------- docker/gui_desktop.sh | 162 ----------------------- pytest.ini | 4 + scripts/ci_colcon_test.sh | 61 +++++++++ scripts/ci_tmux_launcher_smoke.sh | 72 ++++++++++ tests/conftest.py | 10 ++ tests/test_install_optional_repos.py | 106 +++++++++++++++ tests/test_launcher_pixi.py | 66 +++++++++ tests/test_repos_config.py | 153 +++++++++++++++++++++ 12 files changed, 552 insertions(+), 592 deletions(-) delete mode 100644 docker/Dockerfile.jazzy delete mode 100644 docker/ensure_image.sh delete mode 100755 docker/gpu_detect.sh delete mode 100755 docker/gui_desktop.sh create mode 100644 pytest.ini create mode 100755 scripts/ci_colcon_test.sh create mode 100755 scripts/ci_tmux_launcher_smoke.sh create mode 100644 tests/conftest.py create mode 100644 tests/test_install_optional_repos.py create mode 100644 tests/test_launcher_pixi.py create mode 100644 tests/test_repos_config.py diff --git a/.github/workflows/install-and-launch.yml b/.github/workflows/install-and-launch.yml index aaca32c..8396ea7 100644 --- a/.github/workflows/install-and-launch.yml +++ b/.github/workflows/install-and-launch.yml @@ -1,16 +1,8 @@ -# Full workspace install (clone + Docker image + rosdep + colcon + yarn) plus a -# non-interactive launch smoke test, on both x86_64 and ARM64 GitHub-hosted runners. +# Pixi + RoboStack workspace install (clone + colcon + yarn) and a headless launch +# smoke test on Linux, macOS, and Windows GitHub-hosted runners. # -# - DEV=false -> install.sh uses `url_https` from config/repos.json (sub-repos are public). -# - CI=true -> install.sh skips the host `xhost` check (no GUI on runners). -# - variant=arm runs on `ubuntu-24.04-arm` with `./install.sh --arm`; the built image -# is then verified to be linux/arm64 (linux/amd64 for the default variant). -# - Runner OS (22.04 or 24.04) is only the CI host. ROS 2 Jazzy always runs inside -# Noble-based images (`ubuntu:24.04` + `ros-jazzy-*` packages; tagged `lucy_ros2:jazzy`). -# - `ubuntu-24.04-arm` is GitHub's hosted ARM64 runner label; there is no 22.04-arm -# standard runner. The container OS stays Noble (24.04) regardless. -# - The launch smoke test uses `./launch_lucy.sh --headless `, which runs a -# single command inside the container (no control panel, no auto Gazebo/RViz). +# - DEV=false -> install.sh uses url_https from config/repos.json. +# - ./install.sh --skip-build clones sub-repos; colcon build runs via pixi run build. name: Install, Launch & Release @@ -19,7 +11,7 @@ on: push: branches: [master, dev] tags: - - 'v*' # Run on version tags like v1.0, v2.3.4 + - 'v*' pull_request: branches: [master, dev] @@ -27,9 +19,10 @@ permissions: contents: read jobs: - install-and-launch: + pixi-install-build-test: + name: Pixi install & build (${{ matrix.runner }}) concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.variant }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.runner }} cancel-in-progress: true runs-on: ${{ matrix.runner }} timeout-minutes: 120 @@ -37,71 +30,77 @@ jobs: fail-fast: false matrix: include: - - variant: default - runner: ubuntu-latest - use_arm_install: false - expected_arch: linux/amd64 - - variant: arm - runner: ubuntu-24.04-arm - use_arm_install: true - expected_arch: linux/arm64 + - runner: ubuntu-latest + pixi_platform: linux-64 + - runner: ubuntu-24.04-arm + pixi_platform: linux-aarch64 + - runner: macos-latest + pixi_platform: osx-arm64 + - runner: windows-latest + pixi_platform: win-64 env: DEV: "false" CI: "true" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + + - uses: prefix-dev/setup-pixi@v0.10.0 + with: + pixi-version: latest + cache: ${{ matrix.pixi_platform != 'win-64' }} + post-cleanup: false + + - name: Workspace unit tests + shell: bash + run: pixi run workspace-test - # Make sure no committed/local .env can flip DEV back to true at install time. - name: Drop workspace .env if present + shell: bash run: rm -f .env .env.local || true - # Docker Hub metadata/pull flakes (i/o timeout) fail `docker build` before - # any Dockerfile layer runs. Pre-pull with retries so the build reuses cache. - - name: Pre-pull base images (retry on Hub flakes) + - name: Clone sub-repositories (skip colcon) + shell: bash run: | - pull_with_retry() { - image="$1" - for attempt in 1 2 3 4 5; do - if docker pull "$image"; then - return 0 - fi - echo "docker pull ${image} failed (attempt ${attempt}/5), retrying..." - sleep $((attempt * 15)) - done - return 1 - } - pull_with_retry ubuntu:24.04 + chmod +x install.sh launch_lucy.sh + ./install.sh --skip-build + + - name: Build workspace (colcon) + shell: bash + run: pixi run build + + - name: Install control panel (yarn) + shell: bash + run: pixi run panel-install + + - name: Run colcon tests + shell: bash + run: pixi run test-only + + - name: ROS doctor + shell: bash + run: pixi run doctor - - name: Install (clone + Docker image + rosdep + colcon + yarn) + - name: Launch smoke test (headless) + shell: bash + run: ./launch_lucy.sh --headless ros2 doctor --report + + - name: Launcher tmux smoke (Linux) + if: matrix.pixi_platform == 'linux-64' + shell: bash run: | - chmod +x install.sh launch_lucy.sh - if [ "${{ matrix.use_arm_install }}" = true ]; then - ./install.sh --arm - else - ./install.sh - fi + chmod +x scripts/ci_tmux_launcher_smoke.sh + LUCY_WS_ROOT="$PWD" ./scripts/ci_tmux_launcher_smoke.sh - - name: Verify Lucy image architecture + - name: Verify Pixi platform + shell: bash run: | - osarch=$(docker image inspect lucy_ros2:jazzy --format '{{.Os}}/{{.Architecture}}') - echo "lucy_ros2:jazzy -> ${osarch}" - if [ "${osarch}" != "${{ matrix.expected_arch }}" ]; then - echo "::error::Expected ${{ matrix.expected_arch }} after ./install.sh on ${{ matrix.runner }}, got ${osarch}" + got=$(pixi info --json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('platform', ''))") + echo "pixi platform -> ${got} (expected ${{ matrix.pixi_platform }})" + if [ "${got}" != "${{ matrix.pixi_platform }}" ]; then + echo "::error::Pixi platform mismatch on ${{ matrix.runner }}" exit 1 fi - # Single command in the container -> bypasses the control-panel / Gazebo / RViz auto-launch. - - name: Launch smoke test (headless, no TTY) - run: ./launch_lucy.sh --headless ros2 doctor --report - - # Windows-specific CI. NOTE: GitHub-hosted Windows runners cannot run Linux - # containers (no Hyper-V/nested virtualization, no WSL2), so the heavy install - # step (docker build of the ROS image + colcon) CANNOT run here — that is - # covered for both amd64/arm64 by the Linux `install-and-launch` job above. - # This job verifies the Windows-only pieces on real x64 AND arm64 hardware: - # - host CPU -> Docker platform detection (install_ops.host_container_platform) - # - Lucy.exe builds (PyInstaller) and its bundled --cli imports work - # - the NSIS installer compiles windows-build-test: name: Windows build & installer test (${{ matrix.arch }}) strategy: @@ -110,15 +109,21 @@ jobs: include: - arch: x64 runner: windows-latest - expected_platform: linux/amd64 + expected_platform: win-64 - arch: arm64 runner: windows-11-arm - expected_platform: linux/arm64 + expected_platform: win-64 runs-on: ${{ matrix.runner }} timeout-minutes: 30 steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 + + - uses: prefix-dev/setup-pixi@v0.10.0 + with: + pixi-version: latest + cache: false + post-cleanup: false - name: Set up Python uses: actions/setup-python@v5 @@ -128,14 +133,14 @@ jobs: - name: Generate releases manifest run: python windows/generate_releases.py - - name: Verify host architecture detection + - name: Verify host Pixi platform detection shell: pwsh run: | $expected = "${{ matrix.expected_platform }}" - $got = (python -c "import sys; sys.path.insert(0,'windows'); import install_ops; print(install_ops.host_container_platform())").Trim() - Write-Host "host_container_platform() -> $got (expected $expected)" + $got = (python -c "import sys; sys.path.insert(0,'windows'); import install_ops; print(install_ops.host_pixi_platform())").Trim() + Write-Host "host_pixi_platform() -> $got (expected $expected)" if ($got -ne $expected) { - Write-Error "Arch detection mismatch: got '$got', expected '$expected'" + Write-Error "Platform detection mismatch: got '$got', expected '$expected'" exit 1 } @@ -159,8 +164,6 @@ jobs: dist\Lucy.exe --cli check-prereqs $code = $LASTEXITCODE Write-Host "check-prereqs exit code: $code" - # 0 (all present) or 1 (a prereq missing, e.g. no Docker on CI) both mean - # the exe ran fine; >1 = crash. Reset the propagated exit code explicitly. if ($code -gt 1) { Write-Error "Lucy.exe --cli check-prereqs crashed (exit $code)"; exit 1 } exit 0 @@ -175,7 +178,7 @@ jobs: if (-not (Test-Path "dist/Lucy-Setup-0.0.0-ci.exe")) { Write-Error "Installer not produced"; exit 1 } - name: Upload Windows artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: lucy-windows-${{ matrix.arch }} path: | @@ -185,15 +188,14 @@ jobs: build-and-release-windows-exe: name: Build and Release Windows Executable - # Only run this job when a new tag is pushed if: startsWith(github.ref, 'refs/tags/') runs-on: windows-latest - needs: install-and-launch + needs: pixi-install-build-test permissions: - contents: write # Required to create a release and upload assets + contents: write steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 diff --git a/docker/Dockerfile.jazzy b/docker/Dockerfile.jazzy deleted file mode 100644 index 41f24e4..0000000 --- a/docker/Dockerfile.jazzy +++ /dev/null @@ -1,79 +0,0 @@ -ARG LUCY_FROM_PLATFORM=linux/amd64 -ARG LUCY_BASE_IMAGE=ubuntu:24.04 - -FROM --platform=${LUCY_FROM_PLATFORM} ${LUCY_BASE_IMAGE} - -ARG LUCY_INSTALL_VNC=0 -ARG DOCKERFILE_SHA256=unknown -ARG LUCY_DOCKER_BUILD_PLATFORM=unknown - -LABEL org.opencontainers.image.title="Lucy ROS 2 Runtime" -LABEL org.opencontainers.image.vendor="Lucy" -LABEL org.opencontainers.image.licenses="GPLv3" -LABEL org.opencontainers.image.source="https://github.com/sentience-robotics/lucy_ws" -LABEL lucy.dockerfile.sha256="${DOCKERFILE_SHA256}|${LUCY_DOCKER_BUILD_PLATFORM}|vnc=${LUCY_INSTALL_VNC}" - -ENV DEBIAN_FRONTEND=noninteractive -ENV LANG=C.UTF-8 -ENV LC_ALL=C.UTF-8 - -RUN printf '%s\n' \ - 'Acquire::http::Pipeline-Depth "0";' \ - 'Acquire::http::No-Cache "true";' \ - 'Acquire::Retries "5";' \ - > /etc/apt/apt.conf.d/99-lucy-mirror-resilience - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates software-properties-common curl lsb-release gnupg git wget tmux \ - && add-apt-repository -y universe multiverse \ - && apt-get update \ - && rm -rf /var/lib/apt/lists/* - -RUN curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" | tee /etc/apt/sources.list.d/ros2.list > /dev/null - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ros-jazzy-desktop-full \ - ros-jazzy-ros-gz \ - ros-jazzy-rosbridge-suite \ - ros-jazzy-ros2-control ros-jazzy-ros2-controllers \ - ros-jazzy-gz-ros2-control \ - ros-jazzy-rosbridge-server \ - ros-jazzy-realsense2-camera \ - ros-dev-tools \ - python3-pytest-cov \ - python3-pytest-mock \ - gstreamer1.0-tools \ - gstreamer1.0-plugins-good \ - && rm -rf /var/lib/apt/lists/* - -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g yarn \ - && rm -rf /var/lib/apt/lists/* - -RUN if [ "${LUCY_INSTALL_VNC}" = 1 ]; then \ - apt-get update && apt-get install -y --no-install-recommends \ - xvfb \ - x11vnc \ - fluxbox \ - novnc \ - websockify \ - mesa-utils; \ - fi \ - && rm -rf /var/lib/apt/lists/* - -RUN echo "alias launcher='python3 /workspace/launcher.py'" >> ~/.bashrc - -RUN echo '#!/bin/bash' > /ros_entrypoint.sh \ - && echo 'set -e' >> /ros_entrypoint.sh \ - && echo 'source /opt/ros/jazzy/setup.bash' >> /ros_entrypoint.sh \ - && echo 'exec "$@"' >> /ros_entrypoint.sh \ - && chmod +x /ros_entrypoint.sh - -WORKDIR /workspace - -ENTRYPOINT ["/ros_entrypoint.sh"] - -CMD ["/bin/bash"] diff --git a/docker/ensure_image.sh b/docker/ensure_image.sh deleted file mode 100644 index 08ded9b..0000000 --- a/docker/ensure_image.sh +++ /dev/null @@ -1,117 +0,0 @@ -# shellcheck shell=bash -# Helpers shared by install.sh and launch_lucy.sh: -# -# ensure_lucy_docker_image build (or rebuild) the lucy_ros2:jazzy image -# when Dockerfile.jazzy or the target platform changes. -# docker_run_platform_flags populate DOCKER_RUN_PLATFORM_ARGS for `docker run`. -# docker_run_it_flags populate DOCKER_RUN_IT (-it locally, -i in CI / no TTY). -# -# Target platform priority: -# 1. $LUCY_DOCKER_PLATFORM -# 2. first line of /.lucy-docker-platform (written by `./install.sh --arm`) -# 3. host CPU architecture (linux/amd64 or linux/arm64) -# -# Rebuild detection: -# Each built image is stamped with LABEL lucy.dockerfile.sha256="|". -# When that label does not match the current Dockerfile sha + target platform, -# the image is rebuilt; otherwise the existing image is reused. - -lucy_host_container_platform() { - case "$(uname -m)" in - x86_64 | amd64) echo linux/amd64 ;; - aarch64 | arm64) echo linux/arm64 ;; - *) echo "linux/$(uname -m)" ;; - esac -} - -lucy_workspace_target_platform() { - local ws_root="$1" - local v - if [[ -n "${LUCY_DOCKER_PLATFORM:-}" ]]; then - echo "${LUCY_DOCKER_PLATFORM}" - return - fi - if [[ -f "$ws_root/.lucy-docker-platform" ]]; then - v=$(head -1 "$ws_root/.lucy-docker-platform" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - if [[ -n "$v" ]]; then - echo "$v" - return - fi - fi - lucy_host_container_platform -} - -# Always pass --platform on `docker run` so the daemon never guesses -# (avoids the "no specific platform was requested" warning vs. image arch). -docker_run_platform_flags() { - local ws_root="$1" - DOCKER_RUN_PLATFORM_ARGS=(--platform "$(lucy_workspace_target_platform "$ws_root")") -} - -ensure_lucy_docker_image() { - local ws_root="$1" - local image_name="${2:-lucy_ros2:jazzy}" - local dockerfile="${3:-$ws_root/docker/Dockerfile.jazzy}" - local target_platform base_image install_vnc - local hash want want_id - local build_platform_args - - dockerfile_build_hash() { - awk ' - /^[[:space:]]*#/ { next } - /^[[:space:]]*$/ { next } - { sub(/[[:space:]]+$/, "") ; print } - ' "$1" | sha256sum | awk '{print $1}' - } - - if [ ! -f "$dockerfile" ]; then - echo "ensure_lucy_docker_image: missing $dockerfile" >&2 - return 1 - fi - - target_platform=$(lucy_workspace_target_platform "$ws_root") - build_platform_args=(--platform "$target_platform") - - base_image="ubuntu:24.04" - - # VNC virtual-desktop tooling: installed on arm64, or forced on any arch with - # LUCY_FORCE_VNC=1 (lets an amd64 host try the VNC path). Folded into want_id and - # the image label below so toggling it forces a rebuild even though the - # Dockerfile text — the only other rebuild trigger — is unchanged. - install_vnc=0 - [ "$target_platform" = "linux/arm64" ] && install_vnc=1 - case "$(echo "${LUCY_FORCE_VNC:-}" | tr '[:upper:]' '[:lower:]')" in - 1|true|yes) install_vnc=1 ;; - esac - - hash=$(dockerfile_build_hash "$dockerfile") - want_id="${hash}|${target_platform}|vnc=${install_vnc}" - - if docker image inspect "$image_name" &>/dev/null; then - want=$(docker image inspect "$image_name" \ - --format '{{index .Config.Labels "lucy.dockerfile.sha256"}}' 2>/dev/null || true) - if [ "$want" = "$want_id" ]; then - return 0 - fi - echo "Lucy Dockerfile or platform changed; rebuilding $image_name ..." - else - echo "Building Docker image $image_name ..." - fi - - docker build "${build_platform_args[@]}" -f "$dockerfile" \ - --build-arg "LUCY_FROM_PLATFORM=$target_platform" \ - --build-arg "LUCY_BASE_IMAGE=$base_image" \ - --build-arg "LUCY_INSTALL_VNC=$install_vnc" \ - --build-arg "DOCKERFILE_SHA256=$hash" \ - --build-arg "LUCY_DOCKER_BUILD_PLATFORM=$target_platform" \ - -t "$image_name" "$ws_root" -} - -# `docker run -it` fails without a TTY (e.g. GitHub Actions); fall back to `-i`. -docker_run_it_flags() { - if [ -n "${CI:-}" ] || ! [ -t 1 ]; then - DOCKER_RUN_IT=(-i) - else - DOCKER_RUN_IT=(-it) - fi -} diff --git a/docker/gpu_detect.sh b/docker/gpu_detect.sh deleted file mode 100755 index a356c74..0000000 --- a/docker/gpu_detect.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# Detect host GPU capabilities and populate Docker flags for Lucy launches. -# -# Source from launch_lucy.sh or install.sh (do not execute directly): -# source "$SCRIPT_DIR/docker/gpu_detect.sh" -# -# Sets: -# LUCY_GPU_MODE — jetson | nvidia | dri | software -# GPU_DOCKER_ARGS — bash array appended to docker run -# -# Override detection for testing: LUCY_GPU_MODE=software|jetson|nvidia|dri - -GPU_DOCKER_ARGS=() -LUCY_GPU_MODE=software - -_lucy_is_jetson() { - if [[ -f /etc/nv_tegra_release ]]; then - return 0 - fi - if [[ -r /proc/device-tree/model ]]; then - tr -d '\0' /dev/null | grep -qi jetson - return $? - fi - return 1 -} - -_lucy_docker_has_nvidia_runtime() { - docker info 2>/dev/null | grep -qiE 'nvidia|Runtimes.*nvidia' -} - -_lucy_append_dri_devices() { - local node - shopt -s nullglob - for node in /dev/dri/card* /dev/dri/renderD*; do - GPU_DOCKER_ARGS+=(--device "$node") - done - shopt -u nullglob -} - -_lucy_append_render_groups() { - # Docker resolves group names against the *image* /etc/group, not the host. - # Pass numeric GIDs from the host so render/video access works in the container. - local group gid - for group in render video; do - gid="$(getent group "$group" 2>/dev/null | awk -F: '{print $3}')" - [[ -n "$gid" ]] || continue - GPU_DOCKER_ARGS+=(--group-add "$gid") - done -} - -_lucy_apply_jetson_gpu() { - LUCY_GPU_MODE=jetson - if _lucy_docker_has_nvidia_runtime; then - GPU_DOCKER_ARGS+=(--runtime nvidia) - GPU_DOCKER_ARGS+=(-e "NVIDIA_VISIBLE_DEVICES=all") - GPU_DOCKER_ARGS+=(-e "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute,video") - GPU_DOCKER_ARGS+=(-e "__GLX_VENDOR_LIBRARY_NAME=nvidia") - GPU_DOCKER_ARGS+=(-e "__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json") - else - echo "GPU: jetson detected but Docker nvidia runtime missing; using software rendering." >&2 - echo " Install nvidia-container-toolkit for hardware GL in the container." >&2 - fi - if [[ -d /dev/dri ]]; then - _lucy_append_dri_devices - _lucy_append_render_groups - fi -} - -lucy_apply_gpu_detect() { - GPU_DOCKER_ARGS=() - LUCY_GPU_MODE=software - - case "$(echo "${LUCY_GPU_MODE_OVERRIDE:-}" | tr '[:upper:]' '[:lower:]')" in - jetson|nvidia|dri|software) - LUCY_GPU_MODE="${LUCY_GPU_MODE_OVERRIDE,,}" - case "$LUCY_GPU_MODE" in - jetson) _lucy_apply_jetson_gpu ;; - nvidia) - GPU_DOCKER_ARGS+=(--gpus all) - GPU_DOCKER_ARGS+=(-e "NVIDIA_VISIBLE_DEVICES=all") - GPU_DOCKER_ARGS+=(-e "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute,video") - GPU_DOCKER_ARGS+=(-e "__GLX_VENDOR_LIBRARY_NAME=nvidia") - GPU_DOCKER_ARGS+=(-e "__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json") - ;; - dri) - _lucy_append_dri_devices - _lucy_append_render_groups - ;; - esac - return 0 - ;; - esac - - if _lucy_is_jetson; then - _lucy_apply_jetson_gpu - return 0 - fi - - if command -v nvidia-smi >/dev/null 2>&1 && _lucy_docker_has_nvidia_runtime; then - if nvidia-smi >/dev/null 2>&1; then - LUCY_GPU_MODE=nvidia - GPU_DOCKER_ARGS+=(--gpus all) - GPU_DOCKER_ARGS+=(-e "NVIDIA_VISIBLE_DEVICES=all") - GPU_DOCKER_ARGS+=(-e "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute,video") - GPU_DOCKER_ARGS+=(-e "__GLX_VENDOR_LIBRARY_NAME=nvidia") - GPU_DOCKER_ARGS+=(-e "__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json") - return 0 - fi - fi - - if [[ -d /dev/dri ]] && compgen -G '/dev/dri/renderD*' >/dev/null 2>&1; then - LUCY_GPU_MODE=dri - _lucy_append_dri_devices - _lucy_append_render_groups - return 0 - fi - - LUCY_GPU_MODE=software -} - -lucy_gpu_launch_message() { - case "$LUCY_GPU_MODE" in - jetson) - if _lucy_docker_has_nvidia_runtime; then - echo "GPU: jetson (nvidia container runtime + /dev/dri when available)" - else - echo "GPU: jetson (software fallback — nvidia runtime not configured)" - fi - ;; - nvidia) echo "GPU: nvidia (hardware acceleration enabled)" ;; - dri) echo "GPU: dri (Mesa /dev/dri passthrough)" ;; - *) echo "GPU: software (VNC llvmpipe or headless rendering)" ;; - esac -} - -# Pick a host DISPLAY when unset so Jetson can use native X11 + GPU instead of VNC/llvmpipe. -lucy_resolve_host_display() { - [[ -n "${DISPLAY:-}" ]] && return 0 - - local sock n - shopt -s nullglob - for sock in /tmp/.X11-unix/X[0-9]*; do - n="${sock##*/X}" - DISPLAY=":${n}" - export DISPLAY - shopt -u nullglob - echo "GUI: auto-selected DISPLAY=$DISPLAY (local X11 socket)" >&2 - return 0 - done - shopt -u nullglob -} - -# When sourced, apply immediately unless caller sets LUCY_GPU_DETECT_DEFER=1. -if [[ "${BASH_SOURCE[0]}" != "${0}" ]] && [[ "${LUCY_GPU_DETECT_DEFER:-}" != 1 ]]; then - lucy_apply_gpu_detect -fi diff --git a/docker/gui_desktop.sh b/docker/gui_desktop.sh deleted file mode 100755 index 64c0903..0000000 --- a/docker/gui_desktop.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# Self-contained virtual desktop for GUI apps that need OpenGL (RViz, Gazebo). -# -# Runs entirely inside the container: an Xvfb display rendered by Mesa llvmpipe -# (software GL) plus a small window manager (the "display"), and on top of it a -# password-protected VNC server and/or a passwordless noVNC (browser) endpoint. -# Used where the host X server can't give the container an OpenGL/GLX context — -# notably macOS XQuartz, where RViz/Gazebo otherwise fail to create a GLXContext. -# -# Components are controlled independently: -# gui_desktop.sh display start|stop|status # Xvfb + window manager (the monitor) -# gui_desktop.sh vnc start|stop|status # native VNC server (localhost:5901, password) -# gui_desktop.sh novnc start|stop|status # browser viewer (http://localhost:6080) -# gui_desktop.sh start|stop # all of the above (back-compat) -# -# launch_lucy.sh auto-starts the display on macOS before tmux; the VNC and noVNC -# endpoints are toggled as interfaces from the Lucy Control Center launcher. -set -u - -DISPLAY_NUM="${LUCY_GUI_DISPLAY_NUM:-99}" -GEOMETRY="${LUCY_GUI_GEOMETRY:-1600x900}" -DEPTH=24 -VNC_PORT="${LUCY_GUI_VNC_PORT:-5901}" -NOVNC_PORT="${LUCY_GUI_NOVNC_PORT:-6080}" -NOVNC_VNC_PORT="${LUCY_GUI_NOVNC_VNC_PORT:-5902}" -# Native VNC clients (macOS Screen Sharing, RealVNC Viewer) refuse a no-auth -# server, so the native VNC endpoint is password-protected. The VNC auth scheme -# only uses the first 8 characters. Override with LUCY_GUI_VNC_PASSWORD. -VNC_PASSWORD="${LUCY_GUI_VNC_PASSWORD:-lucy}" -VNC_PASSWD_FILE=/tmp/.lucy_vncpasswd -export DISPLAY=":${DISPLAY_NUM}" - -# When the container was started with Jetson/NVIDIA GPU passthrough, prefer the -# NVIDIA GLX vendor so Xvfb-backed RViz/Gazebo use the Tegra GPU instead of llvmpipe. -case "${LUCY_GPU_MODE:-}" in - jetson|nvidia) - export __GLX_VENDOR_LIBRARY_NAME=nvidia - export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json - ;; -esac -export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/runtime-root}" -mkdir -p "$XDG_RUNTIME_DIR" 2>/dev/null || true - -log() { echo "[gui_desktop] $*"; } -# Match by exact process name (comm), not full command line, so these checks can't -# accidentally match the surrounding shell whose argv may mention these binaries. -proc_up() { pgrep -x "$1" >/dev/null 2>&1; } -display_up() { [ -e "/tmp/.X11-unix/X${DISPLAY_NUM}" ]; } -vnc_up() { pgrep -f "x11vnc.*-rfbport ${1}( |$)" >/dev/null 2>&1; } -novnc_up() { pgrep -f "websockify.*${NOVNC_PORT}" >/dev/null 2>&1; } - -# --- display: the virtual monitor (required for any GL rendering) ------------- -start_display() { - if ! display_up; then - Xvfb ":${DISPLAY_NUM}" -screen 0 "${GEOMETRY}x${DEPTH}" \ - +extension GLX +extension RANDR +render -noreset >/tmp/xvfb.log 2>&1 & - # Wait for the display socket before anything connects to it. - for _ in $(seq 1 20); do - display_up && break - sleep 0.25 - done - log "Xvfb on :${DISPLAY_NUM} (${GEOMETRY}x${DEPTH})" - # Update tmux env so new windows (e.g. core relaunch) pick up the virtual display. - tmux set-environment -t lucy_ws DISPLAY ":${DISPLAY_NUM}" 2>/dev/null || true - fi - # Window manager so RViz/Gazebo windows are decorated, movable and resizable. - if ! proc_up fluxbox; then - fluxbox >/tmp/fluxbox.log 2>&1 & - log "fluxbox window manager" - fi -} - -stop_display() { - stop_novnc - stop_vnc - proc_up fluxbox && pkill -x fluxbox 2>/dev/null || true - display_up && pkill -f "Xvfb :${DISPLAY_NUM}" 2>/dev/null || true - # Restore original DISPLAY in tmux env so relaunched apps use native X11 (if any). - orig="${LUCY_ORIGINAL_DISPLAY:-}" - if [ -n "$orig" ]; then - tmux set-environment -t lucy_ws DISPLAY "$orig" 2>/dev/null || true - else - tmux set-environment -t lucy_ws -u DISPLAY 2>/dev/null || true - fi - log "display stopped" -} - -# --- vnc: native, password-protected VNC server (5901) ----------------------- -start_vnc() { - start_display - if ! vnc_up "${VNC_PORT}"; then - x11vnc -storepasswd "${VNC_PASSWORD}" "${VNC_PASSWD_FILE}" >/dev/null 2>&1 - x11vnc -display ":${DISPLAY_NUM}" -forever -shared \ - -rfbauth "${VNC_PASSWD_FILE}" -rfbport "${VNC_PORT}" -bg -quiet >/tmp/x11vnc.log 2>&1 - log "VNC server on :${VNC_PORT} (password: ${VNC_PASSWORD})" - fi -} - -stop_vnc() { - pkill -f "x11vnc.*-rfbport ${VNC_PORT}( |$)" 2>/dev/null || true - # Wait for exit so a status check right after (the launcher rebuilds immediately - # after Apply) reflects the real state instead of a still-dying process. - for _ in $(seq 1 15); do vnc_up "${VNC_PORT}" || break; sleep 0.1; done -} - -# --- novnc: browser viewer (own passwordless backend on 5902 + websockify) ---- -start_novnc() { - start_display - if ! command -v websockify >/dev/null 2>&1 || [ ! -d /usr/share/novnc ]; then - log "noVNC not installed (websockify / /usr/share/novnc missing)" - return 0 - fi - # Dedicated localhost-only, passwordless VNC backend so the browser connects - # without a prompt while the native VNC endpoint stays password-protected. - if ! vnc_up "${NOVNC_VNC_PORT}"; then - x11vnc -display ":${DISPLAY_NUM}" -forever -shared \ - -nopw -localhost -rfbport "${NOVNC_VNC_PORT}" -bg -quiet >/tmp/x11vnc-novnc.log 2>&1 - fi - if ! novnc_up; then - websockify --web=/usr/share/novnc "${NOVNC_PORT}" "localhost:${NOVNC_VNC_PORT}" \ - >/tmp/novnc.log 2>&1 & - log "noVNC on http://localhost:${NOVNC_PORT}/vnc.html (no password)" - fi -} - -stop_novnc() { - pkill -f "websockify.*${NOVNC_PORT}" 2>/dev/null || true - pkill -f "x11vnc.*-rfbport ${NOVNC_VNC_PORT}( |$)" 2>/dev/null || true - # Wait for exit so an immediate status check reflects the real state. - for _ in $(seq 1 15); do novnc_up || break; sleep 0.1; done -} - -usage() { echo "usage: $0 {display|vnc|novnc} {start|stop|status} | {start|stop}" >&2; exit 2; } - -component="${1:-start}" -action="${2:-start}" -case "$component" in - display) - case "$action" in - start) start_display ;; - stop) stop_display ;; - status) display_up ;; - *) usage ;; - esac ;; - vnc) - case "$action" in - start) start_vnc ;; - stop) stop_vnc ;; - status) vnc_up "${VNC_PORT}" ;; - *) usage ;; - esac ;; - novnc) - case "$action" in - start) start_novnc ;; - stop) stop_novnc ;; - status) novnc_up ;; - *) usage ;; - esac ;; - start) start_display; start_vnc; start_novnc ;; - stop) stop_novnc; stop_vnc; stop_display ;; - *) usage ;; -esac diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..f8e77ca --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +# RoboStack pytest plugins conflict with modern pytest hook specs. +addopts = -p no:launch_testing -p no:launch_testing_ros_pytest_entrypoint +testpaths = tests diff --git a/scripts/ci_colcon_test.sh b/scripts/ci_colcon_test.sh new file mode 100755 index 0000000..10e9a31 --- /dev/null +++ b/scripts/ci_colcon_test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# CI-friendly colcon test: stable skips, optional thais_urdf, pytest plugin guard. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +skip=(lucy_cli lucy_config_pipeline lucy_control_supervisor camera_ros) +if [ -d "src/thais_urdf" ]; then + skip+=(thais_urdf) +fi + +export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 + +args=( + test + --return-code-on-test-failure + --packages-skip "${skip[@]}" +) +for opt in "$@"; do + args+=("$opt") +done + +dump_failure_logs() { + mapfile -t tested < <(colcon list --names-only --packages-skip "${skip[@]}") + + echo "::group::colcon test-result" + if [ "${#tested[@]}" -gt 0 ]; then + colcon test-result --verbose --packages-select "${tested[@]}" || true + else + colcon test-result --verbose || true + fi + echo "::endgroup::" + + echo "::group::Failed test logs" + if [ -d log ]; then + find log -type f \( -name 'stdout.log' -o -name 'stderr.log' \) -path '*/test_*/*' | while read -r f; do + if grep -qE 'FAILED|ERROR|Failed|Traceback|NO TESTS RAN' "$f" 2>/dev/null; then + echo "--- ${f} ---" + tail -120 "$f" + fi + done + fi + for ctest_log in build/*/Testing/Temporary/LastTest.log; do + if [ -f "$ctest_log" ] && grep -qE 'Failed|FAILED' "$ctest_log" 2>/dev/null; then + echo "--- ${ctest_log} ---" + tail -80 "$ctest_log" + fi + done + echo "::endgroup::" +} + +set +e +colcon "${args[@]}" +rc=$? +set -e + +if [ "$rc" -ne 0 ]; then + dump_failure_logs + exit "$rc" +fi diff --git a/scripts/ci_tmux_launcher_smoke.sh b/scripts/ci_tmux_launcher_smoke.sh new file mode 100755 index 0000000..ae86b3c --- /dev/null +++ b/scripts/ci_tmux_launcher_smoke.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# CI smoke: tmux + pixi-wrapped core (headless sim) and control panel. +# Exercises the same tmux/pixi paths as launcher.py apply_changes without the TUI. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if ! command -v tmux >/dev/null 2>&1; then + echo "ci_tmux_launcher_smoke: tmux not found" >&2 + exit 1 +fi + +if [[ ! -f "${ROOT}/install/setup.bash" ]]; then + echo "ci_tmux_launcher_smoke: workspace not built" >&2 + exit 1 +fi + +printf 'DEV=true\n' > .env + +export LUCY_WS_ROOT="$ROOT" +TMUX_SESSION="${LUCY_TMUX_SESSION:-lucy_ws}" +export LUCY_TMUX_SESSION="$TMUX_SESSION" + +tmux start-server +tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true +tmux new-session -d -s "$TMUX_SESSION" -n Lucy 'sleep 300' + +pixi run -- python3 <<'PY' +import os + +os.chdir(os.environ["LUCY_WS_ROOT"]) +from launcher import ( + load_workspace_env, + _tmux_new_pixi_window, + run_shell_command, +) + +load_workspace_env() + +core_cmd = ( + "ros2 launch lucy_bringup lucy.launch.py " + "robot_package:=inmoov_urdf gazebo:=true headless:=true" +) +run_shell_command(_tmux_new_pixi_window("core", core_cmd, remain_on_exit=True)) +run_shell_command( + _tmux_new_pixi_window("control_panel", "pixi run panel-dev", remain_on_exit=True) +) +PY + +wait_for() { + local pattern="$1" + local label="$2" + local timeout="${3:-180}" + local elapsed=0 + while ! pgrep -f "$pattern" >/dev/null 2>&1; do + sleep 2 + elapsed=$((elapsed + 2)) + if [ "$elapsed" -ge "$timeout" ]; then + echo "ci_tmux_launcher_smoke: timeout waiting for ${label}" >&2 + tmux list-windows -t "$TMUX_SESSION" 2>/dev/null || true + return 1 + fi + done + echo "ci_tmux_launcher_smoke: ${label} up (${elapsed}s)" +} + +wait_for '[r]osbridge_websocket' 'rosbridge' 180 +wait_for '[v]ite' 'control panel (vite)' 180 + +tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true +echo "ci_tmux_launcher_smoke: OK" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aee241c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Workspace-root unit tests (launcher helpers, install config).""" + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +os.chdir(ROOT) diff --git a/tests/test_install_optional_repos.py b/tests/test_install_optional_repos.py new file mode 100644 index 0000000..d9661cc --- /dev/null +++ b/tests/test_install_optional_repos.py @@ -0,0 +1,106 @@ +"""Tests for install.sh optional-repo colcon skip (COLCON_IGNORE).""" + +import json +import os +import subprocess +from pathlib import Path + +def _run_mark_optional(workspace: Path, build_optional: str | None = None) -> None: + env = os.environ.copy() + if build_optional is not None: + env["LUCY_BUILD_OPTIONAL"] = build_optional + root = str(workspace) + bash_script = f""" +set -euo pipefail +cd {root!r} +CONFIG_FILE="{root}/config/repos.json" + +parse_repos() {{ + python3 -c " +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +for r in data.get('repos', []): + name = str(r.get('name', '')).strip().strip('\\r\\n') + optional = 1 if r.get('optional') else 0 + if name: + print(name, optional, sep='\\t') +" "$CONFIG_FILE" +}} + +mark_optional_colcon_ignore() {{ + case "$(echo "${{LUCY_BUILD_OPTIONAL:-}}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes) return 0 ;; + esac + while IFS=$'\\t' read -r name optional; do + name="${{name//$'\\r'/}}" + optional="${{optional//$'\\r'/}}" + if [ "$optional" = "1" ] && [ -d "src/${{name}}" ]; then + touch "src/${{name}}/COLCON_IGNORE" + fi + done < <(parse_repos) +}} + +mark_optional_colcon_ignore +""" + subprocess.run( + ["bash", "-c", bash_script], + env=env, + check=True, + cwd=workspace, + ) + + +def test_mark_optional_colcon_ignore_skips_optional_repos(tmp_path): + cfg_dir = tmp_path / "config" + cfg_dir.mkdir() + (cfg_dir / "repos.json").write_text( + json.dumps( + { + "repos": [ + { + "name": "required_pkg", + "branch": "main", + "url_https": "https://example.com/required.git", + }, + { + "name": "opt_pkg", + "branch": "main", + "optional": True, + "url_https": "https://example.com/opt.git", + }, + ] + } + ) + ) + (tmp_path / "src" / "required_pkg").mkdir(parents=True) + (tmp_path / "src" / "opt_pkg").mkdir(parents=True) + + _run_mark_optional(tmp_path) + + assert (tmp_path / "src" / "opt_pkg" / "COLCON_IGNORE").is_file() + assert not (tmp_path / "src" / "required_pkg" / "COLCON_IGNORE").exists() + + +def test_mark_optional_colcon_ignore_respects_build_optional_flag(tmp_path): + cfg_dir = tmp_path / "config" + cfg_dir.mkdir() + (cfg_dir / "repos.json").write_text( + json.dumps( + { + "repos": [ + { + "name": "opt_pkg", + "branch": "main", + "optional": True, + "url_https": "https://example.com/opt.git", + } + ] + } + ) + ) + (tmp_path / "src" / "opt_pkg").mkdir(parents=True) + + _run_mark_optional(tmp_path, build_optional="1") + + assert not (tmp_path / "src" / "opt_pkg" / "COLCON_IGNORE").exists() diff --git a/tests/test_launcher_pixi.py b/tests/test_launcher_pixi.py new file mode 100644 index 0000000..4c0e501 --- /dev/null +++ b/tests/test_launcher_pixi.py @@ -0,0 +1,66 @@ +"""Tests for launcher Pixi/tmux command wrapping (no tmux or ROS required).""" + +import os + +import launcher +from launcher import ( + STATE_FILE, + WORKSPACE_ROOT, + _gui_env_exports, + _pixi_workspace_script, + _tmux_new_pixi_window, + load_workspace_env, + needs_tmux_session, +) + + +def test_state_file_is_workspace_scoped(): + assert STATE_FILE == WORKSPACE_ROOT / ".lucy_launcher_modifiers.json" + + +def test_pixi_workspace_script_wraps_ros2(): + body = _pixi_workspace_script("ros2 doctor --report") + assert f"cd {WORKSPACE_ROOT}" in body + assert "pixi run -- bash -lc" in body + assert "ros2 doctor --report" in body + + +def test_pixi_workspace_script_preserves_pixi_command(): + body = _pixi_workspace_script("pixi run panel-dev") + assert "pixi run panel-dev" in body + assert "pixi run -- pixi" not in body + + +def test_pixi_workspace_script_complex_shell_uses_bash_lc(): + body = _pixi_workspace_script("echo hi && ros2 doctor") + assert "pixi run -- bash -lc" in body + + +def test_gui_env_exports_forwards_display(): + os.environ["DISPLAY"] = ":1" + exports = _gui_env_exports() + assert "export DISPLAY=" in exports + assert ":1" in exports + del os.environ["DISPLAY"] + + +def test_tmux_new_pixi_window_wraps_in_bash_lc(): + cmd = _tmux_new_pixi_window("core", "ros2 launch pkg launch.py", remain_on_exit=True) + assert "tmux new-window" in cmd + assert "-n core" in cmd + assert "bash -lc" in cmd + assert "remain-on-exit on" in cmd + + +def test_load_workspace_env_reads_dotenv(tmp_path, monkeypatch): + env_file = tmp_path / ".env" + env_file.write_text("PORT_CONTROL_PANEL=5555\n") + monkeypatch.setattr(launcher, "WORKSPACE_ROOT", tmp_path) + os.environ.pop("PORT_CONTROL_PANEL", None) + load_workspace_env() + assert os.environ.get("PORT_CONTROL_PANEL") == "5555" + + +def test_needs_tmux_session_false_on_windows(): + if os.name == "nt": + assert needs_tmux_session() is False diff --git a/tests/test_repos_config.py b/tests/test_repos_config.py new file mode 100644 index 0000000..caf3a55 --- /dev/null +++ b/tests/test_repos_config.py @@ -0,0 +1,153 @@ +"""Tests for install.sh repos.json parsing (HTTPS vs SSH, optional entries).""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +PARSE_REPOS_SNIPPET = """ +import json, os, sys + +def clean(s): + return str(s).strip().strip('\\r\\n') + +use_ssh = os.environ.get('DEV', '').strip().lower() in ('1', 'true', 'yes') + +with open(sys.argv[1]) as f: + data = json.load(f) +for r in data.get('repos', []): + name = clean(r.get('name', '')) + branch = clean(r.get('branch', 'main')) + url_https = clean(r.get('url_https') or r.get('url') or '') + url_ssh = clean(r.get('url_ssh') or '') + url = (url_ssh or url_https) if use_ssh else (url_https or url_ssh) + optional = 1 if r.get('optional') else 0 + if name and url: + print(name, branch, url, optional, sep='\\t') +""" + + +def _parse_repos(config_path: Path, dev: bool = False) -> list[tuple[str, str, str, str]]: + env = os.environ.copy() + if dev: + env["DEV"] = "true" + else: + env.pop("DEV", None) + proc = subprocess.run( + [sys.executable, "-c", PARSE_REPOS_SNIPPET, str(config_path)], + capture_output=True, + text=True, + env=env, + check=True, + ) + rows = [] + for line in proc.stdout.strip().splitlines(): + name, branch, url, optional = line.split("\t") + rows.append((name, branch, url, optional)) + return rows + + +def test_parse_repos_https_default(tmp_path): + cfg = tmp_path / "repos.json" + cfg.write_text( + json.dumps( + { + "repos": [ + { + "name": "foo_pkg", + "branch": "main", + "url_https": "https://example.com/foo.git", + "url_ssh": "git@example.com:foo.git", + } + ] + } + ) + ) + rows = _parse_repos(cfg) + assert rows == [("foo_pkg", "main", "https://example.com/foo.git", "0")] + + +def test_parse_repos_ssh_when_dev_true(tmp_path): + cfg = tmp_path / "repos.json" + cfg.write_text( + json.dumps( + { + "repos": [ + { + "name": "foo_pkg", + "branch": "dev", + "url_https": "https://example.com/foo.git", + "url_ssh": "git@example.com:foo.git", + } + ] + } + ) + ) + rows = _parse_repos(cfg, dev=True) + assert rows == [("foo_pkg", "dev", "git@example.com:foo.git", "0")] + + +def test_parse_repos_marks_optional_repos(tmp_path): + cfg = tmp_path / "repos.json" + cfg.write_text( + json.dumps( + { + "repos": [ + { + "name": "required_pkg", + "branch": "main", + "url_https": "https://example.com/required.git", + }, + { + "name": "opt_pkg", + "branch": "main", + "optional": True, + "url_https": "https://example.com/opt.git", + }, + ] + } + ) + ) + rows = _parse_repos(cfg) + assert rows == [ + ("required_pkg", "main", "https://example.com/required.git", "0"), + ("opt_pkg", "main", "https://example.com/opt.git", "1"), + ] + + +def test_parse_repos_strips_carriage_returns(tmp_path): + cfg = tmp_path / "repos.json" + cfg.write_text( + json.dumps( + { + "repos": [ + { + "name": "foo_pkg", + "branch": "main", + "url_https": "https://example.com/foo.git\r", + } + ] + } + ) + ) + rows = _parse_repos(cfg) + assert rows == [("foo_pkg", "main", "https://example.com/foo.git", "0")] + + +def test_tracked_repos_json_parses(): + cfg = ROOT / "config" / "repos.json" + rows = _parse_repos(cfg) + names = {r[0] for r in rows} + assert "lucy_ros_packages" in names + assert "micro_ros_agent" in names + + +def test_optional_flag_preserved_in_json(): + cfg = ROOT / "config" / "repos.json" + data = json.loads(cfg.read_text()) + optional = [r["name"] for r in data["repos"] if r.get("optional")] + assert "micro_ros_agent" in optional + assert "audio_common" in optional