fix(cli): launch single Rust files in mixed runs (#221) #917
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # HORUS Integration Tests | |
| # Cross-process IPC, CLI behavioral tests, runtime tests, and example tests | |
| # Modeled after ROS2 CLI integration tests: assert exact output, exit codes, and side effects | |
| name: Integration Tests | |
| on: | |
| pull_request: | |
| branches: [main, dev] | |
| push: | |
| branches: [main] # dev mirrors main; PRs still gate dev work | |
| workflow_dispatch: | |
| concurrency: | |
| # Cancel superseded runs: a rapid series of pushes used to leave every | |
| # earlier run grinding to completion long after it was obsolete. | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Least privilege for the default GITHUB_TOKEN: these jobs check out code, build | |
| # it, and run it. None write back to the repository. Jobs that need even less | |
| # than this narrow it further themselves (see the `permissions: {}` below). | |
| permissions: | |
| contents: read | |
| env: | |
| CARGO_TERM_COLOR: always | |
| HORUS: ./target/release/horus | |
| NO_COLOR: 1 | |
| # horus_manager's cargo_gen/cmake_gen/new/pkg tests generate .horus build | |
| # files, which needs the HORUS source tree. find_horus_source_dir() only | |
| # probes ~/softmata/horus, ~/horus, /opt/horus etc. — none of which exist | |
| # on a runner — so point it at the checkout. | |
| HORUS_SOURCE: ${{ github.workspace }} | |
| jobs: | |
| # Cross-process IPC integration tests | |
| ipc-integration: | |
| name: Cross-Process IPC Tests | |
| runs-on: ubuntu-latest | |
| env: | |
| HORUS_NAMESPACE: ci_${{ github.run_id }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libudev-dev pkg-config | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | |
| - name: Cache Cargo | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| ~/.cargo/bin/ | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| target/ | |
| key: ${{ runner.os }}-cargo-integration-${{ hashFiles('**/Cargo.lock') }} | |
| restore-keys: ${{ runner.os }}-cargo-integration- | |
| # Setup shared memory with proper permissions | |
| - name: Setup Shared Memory | |
| run: | | |
| rm -rf /dev/shm/horus_* | |
| # Create namespaced SHM directory structure | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| # No sudo: sudo mkdir leaves these root-owned, which is what made | |
| # SchedulerRegistry::open and the presence writer fail with | |
| # PermissionDenied. Create them as the runner and keep 0700 — | |
| # regression_shm_topics_dir_permissions_restricted asserts the topics | |
| # dir is owner-only, so chmod 777 here would break that guarantee. | |
| mkdir -p "$SHM_DIR"/{topics,nodes,control,network,scheduler} | |
| chmod -R 700 "$SHM_DIR" | |
| # Increase shared memory limits | |
| sudo sysctl -w kernel.shmmax=2147483648 | |
| sudo sysctl -w kernel.shmall=2147483648 | |
| - name: Build Workspace | |
| # --tests so the integration-test binaries are compiled here rather than | |
| # inside the timed step below; the previous run spent its whole 10-minute | |
| # budget in rustc and was killed before a single test ran. | |
| # horus_py is excluded because its pyo3 extension-module feature leaves | |
| # the Python symbols undefined, so its lib test cannot link — the same | |
| # reason ci.yml and safety.yml exclude it. | |
| run: cargo build --workspace --exclude horus_py --release --tests | |
| - name: Build and install Python extension | |
| run: | | |
| python3 -m venv /tmp/horus-ci-venv | |
| /tmp/horus-ci-venv/bin/pip install maturin | |
| VIRTUAL_ENV=/tmp/horus-ci-venv \ | |
| /tmp/horus-ci-venv/bin/maturin develop --release --manifest-path horus_py/Cargo.toml | |
| echo "/tmp/horus-ci-venv/bin" >> "$GITHUB_PATH" | |
| # Run integration tests with extended timeout | |
| - name: Run IPC Integration Tests | |
| run: | | |
| # Latency percentile assertions are performance/stress gates, not IPC | |
| # correctness checks. They are sensitive to shared-runner scheduling | |
| # (the 1P4S case intermittently leaves one subscriber unscheduled). | |
| # | |
| # The stress_rt_* family is the same class and coverage.yml has always | |
| # skipped all four; this job just never matched it. They assert RT | |
| # timing consistency while eight compute nodes saturate the box. On a | |
| # dedicated machine that is a real property. On a shared two-vCPU | |
| # runner it measures the runner: this job saw 34.24% spread, and what | |
| # such a number is evidence of is neighbours, not a regression in | |
| # HORUS. | |
| # | |
| # These five tests therefore run NOWHERE in CI. An earlier version of | |
| # this comment said they were "exercised by the dedicated | |
| # benchmark/stress workflows"; they are not. benchmarks.yml runs the | |
| # binaries in benchmarks/ (cross_process_benchmark, | |
| # robotics_messages_benchmark) and gates on the real-time suitability | |
| # lines those print -- it never invokes these cargo tests, and | |
| # `grep -rn stress_rt .github/workflows/` matches only the two files | |
| # that skip them. They are developer-run gates. Anyone changing the | |
| # tick path should run them locally: | |
| # cargo test -p horus_core --test stress_rt_contention -- --nocapture | |
| # The gate-logic tests in that file (spread_gate_*, stall_rate_*, | |
| # clean_timing_*) are pure and fast, and DO run here -- they carry no | |
| # timing dependency, so the reasoning above does not apply to them. | |
| cargo test --workspace --exclude horus_py --release --no-fail-fast --test '*' -- \ | |
| --test-threads=1 --nocapture \ | |
| --skip shm_fanout_latency_percentiles_cross_thread \ | |
| --skip stress_rt_isolation_from_compute_nodes \ | |
| --skip stress_rt_1khz_under_cpu_contention \ | |
| --skip stress_rt_1khz_baseline_no_contention \ | |
| --skip stress_rt_multi_rate_under_contention | |
| timeout-minutes: 45 | |
| env: | |
| RUST_BACKTRACE: 1 | |
| RUST_LOG: debug | |
| PYTHONPATH: ${{ github.workspace }}/horus_py | |
| # `--test '*'` above does NOT reach #[ignore]d tests, and nothing else | |
| # passed --ignored for horus_core: every `--ignored`/`--include-ignored` | |
| # in this repo targets horus_manager (ci.yml's source_resolver step and | |
| # docs-contract.yml). 85 horus_core tests were therefore dead -- 76 of | |
| # them carrying a bare #[ignore] with no reason string -- including the | |
| # entire cross-process chaos suite, ipc_torture, and | |
| # crash_recovery_kill9_and_reconnect. | |
| # | |
| # Counted as `grep -cE '^\s*#\[ignore'` over horus_core/tests. An | |
| # unanchored `grep -c '#\[ignore'` returns 93 because it also matches | |
| # prose inside module doc comments; an earlier draft of this comment | |
| # quoted that number. | |
| # | |
| # The #[ignore] is the mechanism these suites are built on, not neglect: | |
| # they re-exec their own test binary as child processes (see | |
| # chaos_cross_process.rs, which spawns `<test> --exact --ignored` per | |
| # role) so that publisher and subscriber are genuinely separate | |
| # processes. chaos_cross_process.rs:8 documents the intended invocation. | |
| # It just never had a runner. | |
| # | |
| # Suites are named explicitly rather than globbed. Three others | |
| # (cli_runtime_integration, launch_and_workflow, real_project_ipc) | |
| # resolve `target/debug/examples/...` and the `horus` binary by hardcoded | |
| # path, so they need a debug build of `-p horus --examples` and | |
| # horus_manager on top of the release build above. They are left out | |
| # until that prerequisite is wired, rather than added here to fail on a | |
| # missing file. | |
| # | |
| # rust_python_matrix is included, but only after fixing it: turning it | |
| # on unchanged would have made a required gate red. It was failing -- | |
| # | |
| # rust_python_matrix.rs:241 rp_all_message_types_rust_to_python | |
| # panicked: Python should receive messages across types, got 0 | |
| # | |
| # -- because all five of its publisher nodes answered to one hardcoded | |
| # name, and a scheduler holding five nodes under a single name ran none | |
| # of them. `Rust sent` was 0 for every type, so the message blamed the | |
| # Python binding for a Rust scheduler that never ticked. The child's | |
| # stderr was muted, which is what left that unexplained. Both fixed in | |
| # this change; the suite passes 3/3. | |
| # | |
| # This runs 55 of those 85. The rest stay dead and are not claimed | |
| # otherwise: cli_runtime_integration (18), launch_and_workflow (5) and | |
| # real_project_ipc (3) need the debug build described above; and | |
| # participant_count_leak, | |
| # crash_recovery's panic_hook_subprocess_helper (a subprocess entry | |
| # point, not a test), loom_pod_broadcast and acceptance_topic's flaky | |
| # high-frequency scenario are each ignored deliberately. | |
| # | |
| # Measured locally at ~5 min for the 55 tests, all passing, with no | |
| # /dev/shm growth across the run. | |
| # Split by how much CPU the suite actually needs, which #126 got wrong. | |
| # | |
| # #126 enabled these as one blocking step on the strength of them passing | |
| # in ~5 min on a 12-core box. That was the wrong measurement: a hosted | |
| # runner has 2 vCPUs, and several of these spawn REAL child processes -- | |
| # chaos_cross_process spawns five, and its own xp_10_processes_chaos test | |
| # spawns ten. At 2 vCPUs | |
| # that is heavy oversubscription and their timing assumptions stop holding. | |
| # Pinned to two cores locally, chaos_cross_process failed 1 of 4 once and | |
| # production_validation exceeded 900s once, while both passed cleanly on a | |
| # calmer pass. | |
| # | |
| # The CI failure that prompted this was NOT that, and is worse: | |
| # multi_scheduler_matrix::deterministic_alongside_normal_scheduler dies | |
| # with SIGBUS. Reproduced locally 2 runs in 5 on a 12-core box on current | |
| # main, so it is not a runner artifact. gdb puts it in | |
| # `dispatch::send_shm_mp_pod::<CmdVel>` -> `Scheduler::tick_once`: the MPSC | |
| # POD send dereferences `local.cached_header_ptr`, and SIGBUS means that | |
| # page is mapped but no longer backed -- the region was recreated or | |
| # truncated under an in-flight send. `epoch_guard_send!` only compares | |
| # `process_epoch`, so it does not cover a region swapped by another party. | |
| # That suite is advisory below until the fault is fixed; the fix belongs | |
| # in the topic layer, not in CI config. | |
| # | |
| # An intermittently-red REQUIRED check is worse than no check: it blocks | |
| # unrelated PRs at random. That is the same reasoning this file already | |
| # applies to stress_rt_* above -- "what such a number is evidence of is | |
| # neighbours, not a regression in HORUS". | |
| - name: "Single-process #[ignore]d suites (gating)" | |
| timeout-minutes: 25 | |
| run: | | |
| for suite in ipc_torture chaos_monkey message_type_matrix \ | |
| hardware_emulation \ | |
| params_intent log_system_tests \ | |
| rust_python_full_matrix rust_python_matrix \ | |
| humanoid_stress matrix_sweep; do | |
| echo "::group::$suite" | |
| cargo test -p horus_core --release --test "$suite" -- \ | |
| --ignored --test-threads=1 --nocapture | |
| echo "::endgroup::" | |
| done | |
| env: | |
| RUST_BACKTRACE: 1 | |
| PYTHONPATH: ${{ github.workspace }}/horus_py | |
| # The heavy-process suites still RUN -- dropping them would put them back | |
| # in the dark #126 found them in -- but they do not gate, because on this | |
| # hardware a failure does not distinguish a HORUS regression from a busy | |
| # runner. A real regression shows up as a persistent red across runs. | |
| # | |
| # To promote one back to blocking: show it green across ~20 consecutive | |
| # 2-vCPU runs, or move it to dedicated hardware where its process count is | |
| # not oversubscribed. | |
| - name: Oversubscribed multi-process suites (advisory, not a gate) | |
| continue-on-error: true | |
| timeout-minutes: 25 | |
| run: | | |
| # `|| status=1` per suite, not a bare invocation. Actions runs this | |
| # with `bash -e`, so the FIRST failing suite would otherwise end the | |
| # step and the rest would never run -- an advisory step that reports | |
| # one failure and hides four is worse than no advisory step, because | |
| # the silence reads as "the others passed". | |
| status=0 | |
| for suite in chaos_cross_process production_validation \ | |
| chaos_xp_schedulers production_fanout_battle; do | |
| echo "::group::$suite" | |
| cargo test -p horus_core --release --test "$suite" -- \ | |
| --ignored --test-threads=1 --nocapture || { | |
| status=1 | |
| echo "::warning::$suite failed (advisory)" | |
| } | |
| echo "::endgroup::" | |
| done | |
| exit $status | |
| env: | |
| RUST_BACKTRACE: 1 | |
| PYTHONPATH: ${{ github.workspace }}/horus_py | |
| # multi_scheduler_matrix is advisory for a DIFFERENT reason than the step | |
| # above, and merging the two would bury it. The suites above are advisory | |
| # because 2 vCPUs cannot schedule five or ten real child processes to | |
| # their timing assumptions -- an environment limit. This one is advisory | |
| # because it exposes a correctness fault: | |
| # | |
| # deterministic_alongside_normal_scheduler | |
| # (signal: 7, SIGBUS: access to undefined memory) | |
| # | |
| # reproduced 2 runs in 5 on a 12-core box on main, so it is not a runner | |
| # artifact. gdb puts it in `dispatch::send_shm_mp_pod::<CmdVel>` -> | |
| # `Scheduler::tick_once`: the MPSC POD send dereferences | |
| # `local.cached_header_ptr` and the page is mapped but no longer backed -- | |
| # the region was recreated or truncated under an in-flight send. | |
| # `epoch_guard_send!` compares only `process_epoch`, so it does not cover | |
| # a region swapped by another party. | |
| # | |
| # Kept running, and kept SEPARATE, so the day it stops dying is visible. | |
| # It goes back to gating when the topic-layer fix lands -- not when a | |
| # runner happens to be quiet. | |
| - name: "multi_scheduler_matrix (advisory — known SIGBUS, see #144)" | |
| continue-on-error: true | |
| timeout-minutes: 15 | |
| run: | | |
| cargo test -p horus_core --release --test multi_scheduler_matrix -- \ | |
| --ignored --test-threads=1 --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| PYTHONPATH: ${{ github.workspace }}/horus_py | |
| # Verify shared memory cleanup | |
| - name: Verify SHM Cleanup | |
| run: | | |
| echo "Checking shared memory state after tests..." | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| ls -la "$SHM_DIR/" || echo "SHM directory clean" | |
| # ═══════════════════════════════════════════════════════════════ | |
| # CLI Integration Tests — assert exact behavior like ROS2 | |
| # ═══════════════════════════════════════════════════════════════ | |
| cli-integration: | |
| name: CLI Integration Tests | |
| runs-on: ubuntu-latest | |
| env: | |
| HORUS_NAMESPACE: ci_${{ github.run_id }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| cmake g++ libudev-dev pkg-config python3 \ | |
| libeigen3-dev libfmt-dev libgtest-dev | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | |
| - name: Cache Cargo | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| ~/.cargo/bin/ | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| target/ | |
| key: ${{ runner.os }}-cargo-cli-${{ hashFiles('**/Cargo.lock') }} | |
| restore-keys: ${{ runner.os }}-cargo-cli- | |
| - name: Setup Shared Memory | |
| run: | | |
| # Create namespaced SHM directory structure | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| # No sudo: sudo mkdir leaves these root-owned, which is what made | |
| # SchedulerRegistry::open and the presence writer fail with | |
| # PermissionDenied. Create them as the runner and keep 0700 — | |
| # regression_shm_topics_dir_permissions_restricted asserts the topics | |
| # dir is owner-only, so chmod 777 here would break that guarantee. | |
| mkdir -p "$SHM_DIR"/{topics,nodes,control,network,scheduler} | |
| chmod -R 700 "$SHM_DIR" | |
| - name: Build horus_manager | |
| run: cargo build -p horus_manager --release | |
| # ───────────────────────────────────────────────── | |
| # 1. Version & Help | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus --version outputs correct version" | |
| run: | | |
| output=$($HORUS --version) | |
| echo "Got: $output" | |
| echo "$output" | grep -qE '^horus [0-9]+\.[0-9]+\.[0-9]+' | |
| echo "PASS: version format is correct" | |
| - name: "Assert: horus --help lists all core subcommands" | |
| run: | | |
| output=$($HORUS --help) | |
| echo "$output" | |
| # Assert all documented subcommands appear in help. | |
| # NOTE: there is no `pkg` command — package operations are top-level | |
| # (install/uninstall/search/list/update/info). `hf` was renamed `frame`. | |
| for cmd in new run build test check clean monitor topic node param msg log \ | |
| install uninstall search frame service action \ | |
| deploy add remove plugin env cache record launch; do | |
| echo "$output" | grep -qw "$cmd" || { echo "FAIL: subcommand '$cmd' missing from --help"; exit 1; } | |
| done | |
| echo "PASS: all core subcommands present in --help" | |
| # ───────────────────────────────────────────────── | |
| # 2. Subcommand --help smoke tests | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: every subcommand --help exits 0 and shows Usage" | |
| run: | | |
| PASS=0 | |
| FAIL=0 | |
| for cmd in new run build test check clean monitor topic node param frame msg \ | |
| log install uninstall search deploy add remove plugin env \ | |
| cache record launch; do | |
| if $HORUS $cmd --help > /tmp/help_out 2>&1; then | |
| if grep -qiE "usage|Usage" /tmp/help_out; then | |
| PASS=$((PASS + 1)) | |
| else | |
| echo "WARN: '$cmd --help' exited 0 but no Usage line" | |
| PASS=$((PASS + 1)) | |
| fi | |
| else | |
| echo "FAIL: 'horus $cmd --help' exited non-zero" | |
| FAIL=$((FAIL + 1)) | |
| fi | |
| done | |
| echo "Subcommand --help: $PASS passed, $FAIL failed" | |
| [ "$FAIL" -eq 0 ] || exit 1 | |
| - name: "Assert: nested subcommand --help works" | |
| run: | | |
| # `msg show` is `msg info`; `plugin list` is `plugin trusted`; `env` has | |
| # no subcommands (flags only); `hf *` was renamed `frame *`; and the | |
| # `pkg *` group never existed — those are top-level commands. | |
| for subcmd in "topic list" "topic echo" "topic pub" \ | |
| "node list" "node info" "node kill" \ | |
| "param get" "param set" "param list" \ | |
| "msg list" "msg info" "msg hash" \ | |
| "plugin trusted" "plugin verify" \ | |
| "plugin enable" "plugin disable" \ | |
| "cache info" "cache clean" \ | |
| "record list" "record info" \ | |
| "service list" "action list" \ | |
| "frame list" "frame echo" "frame tree"; do | |
| $HORUS $subcmd --help > /dev/null 2>&1 || { echo "FAIL: 'horus $subcmd --help' failed"; exit 1; } | |
| done | |
| echo "PASS: all nested subcommand --help exit 0" | |
| # ───────────────────────────────────────────────── | |
| # 3. horus new — project creation | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus new creates Rust project with correct structure" | |
| run: | | |
| cd /tmp | |
| rm -rf test_robot_rs | |
| $GITHUB_WORKSPACE/$HORUS new test_robot_rs -r 2>&1 | tee /tmp/new_rs_output | |
| # Assert success message | |
| grep -q "Project created successfully" /tmp/new_rs_output || { echo "FAIL: no success message"; exit 1; } | |
| # Assert files exist | |
| [ -f test_robot_rs/horus.toml ] || { echo "FAIL: horus.toml not created"; exit 1; } | |
| # `horus new -r` puts the entrypoint at src/main.rs (the Python | |
| # template does use a root main.py). cargo_gen accepts either. | |
| [ -f test_robot_rs/src/main.rs ] || { echo "FAIL: src/main.rs not created"; exit 1; } | |
| [ -f test_robot_rs/.gitignore ] || { echo "FAIL: .gitignore not created"; exit 1; } | |
| [ -d test_robot_rs/.horus ] || { echo "FAIL: .horus/ not created"; exit 1; } | |
| # Assert horus.toml content is valid | |
| grep -q 'name = "test_robot_rs"' test_robot_rs/horus.toml || { echo "FAIL: wrong name in horus.toml"; exit 1; } | |
| # No `language` key: horus.toml does not carry one for either Rust or | |
| # Python projects — language is inferred from the source files present | |
| # (detect_context). Assert the generated layout instead. | |
| grep -q 'version = ' test_robot_rs/horus.toml || { echo "FAIL: no version in horus.toml"; exit 1; } | |
| # Assert main.rs has Scheduler usage | |
| grep -q "Scheduler::new" test_robot_rs/src/main.rs || { echo "FAIL: src/main.rs missing Scheduler"; exit 1; } | |
| grep -q "Topic" test_robot_rs/src/main.rs || grep -q "node!" test_robot_rs/src/main.rs || { echo "FAIL: src/main.rs missing Topic or node! macro"; exit 1; } | |
| echo "PASS: Rust project created with correct structure" | |
| rm -rf test_robot_rs | |
| - name: "Assert: horus new creates Python project with correct structure" | |
| run: | | |
| cd /tmp | |
| rm -rf test_robot_py | |
| $GITHUB_WORKSPACE/$HORUS new test_robot_py -p 2>&1 | tee /tmp/new_py_output | |
| # Assert success message | |
| grep -q "Project created successfully" /tmp/new_py_output | |
| # Assert files exist | |
| [ -f test_robot_py/horus.toml ] || { echo "FAIL: horus.toml not created"; exit 1; } | |
| [ -f test_robot_py/main.py ] || { echo "FAIL: main.py not created"; exit 1; } | |
| # Assert horus.toml content | |
| grep -q 'name = "test_robot_py"' test_robot_py/horus.toml || { echo "FAIL: wrong name"; exit 1; } | |
| # See the Rust project step: horus.toml has no `language` key. | |
| # Assert main.py has horus API usage | |
| grep -q "import horus" test_robot_py/main.py || { echo "FAIL: main.py missing 'import horus'"; exit 1; } | |
| grep -q "horus.Node" test_robot_py/main.py || { echo "FAIL: main.py missing horus.Node"; exit 1; } | |
| grep -q "horus.run" test_robot_py/main.py || { echo "FAIL: main.py missing horus.run"; exit 1; } | |
| echo "PASS: Python project created with correct structure" | |
| rm -rf test_robot_py | |
| - name: "Assert: horus new creates macro project with node! macro" | |
| run: | | |
| cd /tmp | |
| rm -rf test_robot_macro | |
| $GITHUB_WORKSPACE/$HORUS new test_robot_macro -r -m 2>&1 | |
| # Assert main.rs uses node! macro | |
| grep -q "node!" test_robot_macro/src/main.rs || { echo "FAIL: macro project missing node! macro"; exit 1; } | |
| # horus_macros is not written into horus.toml — it is one of the | |
| # implicit path deps cargo_gen injects into the generated | |
| # .horus/Cargo.toml (same mechanism as the implicit serde entry), so | |
| # it only exists after a build generates the build files. | |
| (cd test_robot_macro && $GITHUB_WORKSPACE/$HORUS build >/dev/null 2>&1) || true | |
| grep -q "horus_macros" test_robot_macro/.horus/Cargo.toml || { echo "FAIL: generated Cargo.toml missing horus_macros dep"; exit 1; } | |
| echo "PASS: macro project created correctly" | |
| rm -rf test_robot_macro | |
| - name: "Assert: horus new rejects duplicate project" | |
| run: | | |
| cd /tmp | |
| rm -rf test_robot_dup | |
| $GITHUB_WORKSPACE/$HORUS new test_robot_dup -r 2>&1 | |
| # Second create should fail because directory already exists | |
| if $GITHUB_WORKSPACE/$HORUS new test_robot_dup -r 2>&1; then | |
| # It may succeed by overwriting — just check the dir still has valid structure | |
| [ -f test_robot_dup/horus.toml ] || { echo "FAIL: second create broke the project"; exit 1; } | |
| fi | |
| echo "PASS: duplicate project handled" | |
| rm -rf test_robot_dup | |
| # ───────────────────────────────────────────────── | |
| # 4. horus check — workspace validation | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus check validates a valid Rust project" | |
| run: | | |
| cd /tmp | |
| rm -rf test_check_project | |
| $GITHUB_WORKSPACE/$HORUS new test_check_project -r 2>&1 | |
| cd test_check_project | |
| output=$($GITHUB_WORKSPACE/$HORUS check horus.toml 2>&1) || true | |
| echo "$output" | |
| # Assert it found and validated the manifest | |
| echo "$output" | grep -q "manifest valid\|Validating TOML syntax\|All checks passed" || { | |
| echo "FAIL: horus check didn't validate manifest" | |
| exit 1 | |
| } | |
| echo "PASS: horus check validates valid project" | |
| cd /tmp && rm -rf test_check_project | |
| - name: "Assert: horus check detects invalid TOML" | |
| run: | | |
| mkdir -p /tmp/test_bad_toml | |
| echo "invalid = [broken" > /tmp/test_bad_toml/horus.toml | |
| if $HORUS check /tmp/test_bad_toml/horus.toml 2>&1 | tee /tmp/check_bad_output; then | |
| # Should have failed but might exit 0 with error messages | |
| true | |
| fi | |
| # Assert some error was reported | |
| cat /tmp/check_bad_output | grep -qi "error\|fail\|invalid\|parse" || { | |
| echo "FAIL: no error reported for invalid TOML" | |
| exit 1 | |
| } | |
| echo "PASS: horus check detects invalid TOML" | |
| rm -rf /tmp/test_bad_toml | |
| # ───────────────────────────────────────────────── | |
| # 5. horus topic — topic introspection | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus topic list shows 'No active topics' when idle" | |
| run: | | |
| output=$($HORUS topic list 2>&1) || true | |
| echo "$output" | |
| echo "$output" | grep -q "No active topics found" || { | |
| echo "FAIL: expected 'No active topics found' message" | |
| exit 1 | |
| } | |
| echo "PASS: topic list reports no topics when idle" | |
| - name: "Assert: horus topic list --json returns valid empty JSON array" | |
| run: | | |
| output=$($HORUS topic list --json 2>&1) || true | |
| echo "$output" | |
| # Should be a valid JSON array (empty []) | |
| # The list commands emit {"count": N, "items": [...]}, not a bare | |
| # array — same shape for topic/node/msg list. | |
| echo "$output" | python3 -c "import sys,json; d=json.load(sys.stdin); assert isinstance(d, dict) and isinstance(d.get('items'), list), f'Expected {{count,items}}, got {d!r}'" || { | |
| echo "FAIL: --json output is not a {count,items} object" | |
| exit 1 | |
| } | |
| echo "PASS: topic list --json returns valid JSON" | |
| - name: "Assert: horus topic echo for non-existent topic reports error" | |
| run: | | |
| if $HORUS topic echo nonexistent_topic 2>&1 | tee /tmp/echo_err; then | |
| echo "WARN: echo exited 0 for missing topic" | |
| fi | |
| grep -qi "not found\|error" /tmp/echo_err || { | |
| echo "FAIL: no error for non-existent topic" | |
| exit 1 | |
| } | |
| echo "PASS: topic echo reports error for missing topic" | |
| - name: "Assert: horus topic pub writes to shared memory" | |
| run: | | |
| # Publish a test message | |
| $HORUS topic pub test_integration_topic '"hello_from_ci"' 2>&1 | tee /tmp/pub_output || true | |
| grep -qi "Published\|publish" /tmp/pub_output || { | |
| echo "FAIL: no publish confirmation" | |
| exit 1 | |
| } | |
| # Verify the topic file was created in shared memory | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| if [ -f "$SHM_DIR/topics/test_integration_topic" ]; then | |
| content=$(cat "$SHM_DIR/topics/test_integration_topic") | |
| echo "Topic content: $content" | |
| [ "$content" = "hello_from_ci" ] || echo "WARN: content mismatch (may be binary)" | |
| else | |
| echo "WARN: topic file not at expected path (SHM layout may differ)" | |
| fi | |
| echo "PASS: topic pub sends messages" | |
| # ───────────────────────────────────────────────── | |
| # 6. horus node — node introspection | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus node list shows 'No running nodes' when idle" | |
| run: | | |
| output=$($HORUS node list 2>&1) || true | |
| echo "$output" | |
| echo "$output" | grep -q "No running nodes found" || { | |
| echo "FAIL: expected 'No running nodes found' message" | |
| exit 1 | |
| } | |
| echo "PASS: node list reports no nodes when idle" | |
| # ───────────────────────────────────────────────── | |
| # 7. horus msg — message type introspection | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus msg list discovers message types" | |
| run: | | |
| output=$($HORUS msg list 2>&1) || true | |
| echo "$output" | |
| # Should find message types from horus_library/messages/ | |
| echo "$output" | grep -q "HORUS Message Types\|MESSAGE TYPE\|message type" || { | |
| # May fail if not run from repo root where horus_library exists | |
| echo "$output" | grep -q "No message types found\|Could not find" && { | |
| echo "WARN: msg list can't find horus_library/messages (expected in CI)" | |
| exit 0 | |
| } | |
| echo "FAIL: unexpected output from msg list" | |
| exit 1 | |
| } | |
| echo "PASS: msg list discovers message types" | |
| - name: "Assert: horus msg info reports error for unknown type" | |
| run: | | |
| # `msg show` is not a subcommand (the real ones are `list` and `info`), | |
| # so this only ever matched clap's own "error: unrecognized subcommand" | |
| # text and never exercised the unknown-type path. Use `msg info` and | |
| # assert on "not found" so a clap usage error can no longer satisfy it. | |
| if $HORUS msg info FakeNonexistentMsg 2>&1 | tee /tmp/msg_info_err; then | |
| true | |
| fi | |
| grep -qi "not found" /tmp/msg_info_err || { | |
| echo "FAIL: no 'not found' error for unknown message type" | |
| cat /tmp/msg_info_err | |
| exit 1 | |
| } | |
| echo "PASS: msg info reports error for unknown type" | |
| # ───────────────────────────────────────────────── | |
| # 8. horus cache — cache management | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus cache info shows cache information" | |
| run: | | |
| output=$($HORUS cache info 2>&1) | |
| echo "$output" | |
| echo "$output" | grep -q "HORUS Cache Information" || { | |
| echo "FAIL: missing cache info header" | |
| exit 1 | |
| } | |
| # Should show directory, size, and package count | |
| echo "$output" | grep -q "Cache directory:" || { | |
| echo "FAIL: missing 'Cache directory:' in output" | |
| exit 1 | |
| } | |
| echo "$output" | grep -q "Packages:" || { | |
| echo "FAIL: missing 'Packages:' in output" | |
| exit 1 | |
| } | |
| echo "PASS: cache info shows expected fields" | |
| - name: "Assert: horus cache list outputs without error" | |
| run: | | |
| output=$($HORUS cache list 2>&1) | |
| echo "$output" | |
| echo "$output" | grep -q "Cached Packages" || { | |
| echo "FAIL: missing 'Cached Packages' header" | |
| exit 1 | |
| } | |
| echo "PASS: cache list works" | |
| # ───────────────────────────────────────────────── | |
| # 9. horus clean — artifact cleanup | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus clean --dry-run does not delete files" | |
| run: | | |
| # Create a fake target dir to clean | |
| mkdir -p /tmp/test_clean_project/target | |
| echo "test" > /tmp/test_clean_project/target/dummy | |
| cd /tmp/test_clean_project | |
| output=$($GITHUB_WORKSPACE/$HORUS clean --dry-run 2>&1) || true | |
| echo "$output" | |
| # Assert dry-run message | |
| echo "$output" | grep -qi "would clean\|dry.run\|Would remove\|Cleaning" || { | |
| echo "FAIL: no dry-run indication" | |
| exit 1 | |
| } | |
| # Assert files still exist | |
| [ -f /tmp/test_clean_project/target/dummy ] || { | |
| echo "FAIL: --dry-run deleted files!" | |
| exit 1 | |
| } | |
| echo "PASS: clean --dry-run preserves files" | |
| rm -rf /tmp/test_clean_project | |
| - name: "Assert: horus clean --shm cleans shared memory" | |
| run: | | |
| # Create test SHM data | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| # Unprivileged: `horus clean --shm` runs as the runner, so a | |
| # root-owned fixture is one it could never remove. | |
| mkdir -p "$SHM_DIR/topics/test_clean_topic" | |
| echo "test" > "$SHM_DIR/topics/test_clean_topic/data" | |
| output=$($HORUS clean --shm 2>&1) || true | |
| echo "$output" | |
| echo "$output" | grep -qi "Cleaning HORUS artifacts\|Removing\|Clean complete\|Nothing to clean" || { | |
| echo "FAIL: unexpected clean output" | |
| exit 1 | |
| } | |
| echo "PASS: clean --shm runs successfully" | |
| # Recreate SHM dirs for subsequent tests | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| # No sudo: sudo mkdir leaves these root-owned, which is what made | |
| # SchedulerRegistry::open and the presence writer fail with | |
| # PermissionDenied. Create them as the runner and keep 0700 — | |
| # regression_shm_topics_dir_permissions_restricted asserts the topics | |
| # dir is owner-only, so chmod 777 here would break that guarantee. | |
| mkdir -p "$SHM_DIR"/{topics,nodes,control,network,scheduler} | |
| chmod -R 700 "$SHM_DIR" | |
| # ───────────────────────────────────────────────── | |
| # 10. horus record — recording management | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus record list shows 'No recording sessions' when empty" | |
| run: | | |
| output=$($HORUS record list 2>&1) || true | |
| echo "$output" | |
| echo "$output" | grep -q "No recording sessions found" || { | |
| echo "FAIL: expected 'No recording sessions found'" | |
| exit 1 | |
| } | |
| echo "PASS: record list handles empty state" | |
| # ───────────────────────────────────────────────── | |
| # 11. horus completion — shell completions | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus completion bash generates valid completions" | |
| run: | | |
| output=$($HORUS completion bash 2>&1) | |
| # Bash completions should contain the binary name and completion functions | |
| echo "$output" | grep -q "horus" || { | |
| echo "FAIL: completion output doesn't reference 'horus'" | |
| exit 1 | |
| } | |
| # Should be substantial (at least 100 chars for a real completion script) | |
| len=${#output} | |
| [ "$len" -gt 100 ] || { | |
| echo "FAIL: completion output too short ($len chars)" | |
| exit 1 | |
| } | |
| echo "PASS: bash completion generates valid output ($len chars)" | |
| # ───────────────────────────────────────────────── | |
| # 12. horus param — parameter management | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus param list handles no-params state" | |
| run: | | |
| output=$($HORUS param list 2>&1) || true | |
| echo "$output" | |
| # Should either list params or say none exist | |
| echo "$output" | grep -qi "param\|parameter\|no.*found\|no.*param\|empty" || { | |
| echo "WARN: param list output doesn't match expected patterns" | |
| } | |
| echo "PASS: param list runs without crash" | |
| # ───────────────────────────────────────────────── | |
| # 13. horus_manager unit tests | |
| # ───────────────────────────────────────────────── | |
| - name: Run horus_manager Tests | |
| # Several discovery tests share the namespaced SHM registry, so keep | |
| # this package serial instead of allowing libtest to race its fixtures. | |
| run: cargo test -p horus_manager --release -- --test-threads=1 | |
| env: | |
| RUST_BACKTRACE: 1 | |
| # ───────────────────────────────────────────────── | |
| # 14. End-to-end: new → check → clean | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: full lifecycle — new → check → clean" | |
| run: | | |
| cd /tmp | |
| rm -rf lifecycle_test | |
| echo "=== Step 1: Create project ===" | |
| $GITHUB_WORKSPACE/$HORUS new lifecycle_test -r 2>&1 | |
| [ -f lifecycle_test/horus.toml ] || { echo "FAIL: project not created"; exit 1; } | |
| echo "=== Step 2: Validate project ===" | |
| cd lifecycle_test | |
| $GITHUB_WORKSPACE/$HORUS check horus.toml 2>&1 | tee /tmp/lifecycle_check | |
| # Check should not report errors for a freshly-created project | |
| if grep -qi "error(s) found" /tmp/lifecycle_check; then | |
| echo "FAIL: fresh project has check errors" | |
| exit 1 | |
| fi | |
| echo "=== Step 3: Clean ===" | |
| # Create a fake target dir | |
| mkdir -p target | |
| echo "build_artifact" > target/dummy | |
| $GITHUB_WORKSPACE/$HORUS clean 2>&1 | |
| # target/ should be gone | |
| [ ! -d target ] || { | |
| echo "FAIL: clean didn't remove target/" | |
| exit 1 | |
| } | |
| echo "PASS: full lifecycle (new → check → clean) works" | |
| cd /tmp && rm -rf lifecycle_test | |
| # ───────────────────────────────────────────────── | |
| # 15. Parameter set/get/delete roundtrip | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: param set/get/delete roundtrip" | |
| run: | | |
| $HORUS param set ci.key 42 2>&1 | |
| output=$($HORUS param get ci.key --json 2>&1) | |
| echo "$output" | |
| echo "$output" | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| assert data['value'] == 42 or data['value'] == '42', f'Expected 42, got {data[\"value\"]}' | |
| print('PASS: param get returns correct value') | |
| " | |
| $HORUS param delete ci.key 2>&1 | |
| # Verify get fails after delete | |
| if $HORUS param get ci.key --json 2>&1 | grep -q '"value"'; then | |
| echo "WARN: param still exists after delete (may be expected)" | |
| fi | |
| echo "PASS: param set/get/delete roundtrip" | |
| # ───────────────────────────────────────────────── | |
| # 16. Parameter type inference | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: param type inference works for int, float, bool, JSON" | |
| run: | | |
| $HORUS param set ci.int_val 42 2>&1 | |
| $HORUS param set ci.float_val 3.14 2>&1 | |
| $HORUS param set ci.bool_val true 2>&1 | |
| $HORUS param set ci.json_val '{"nested":true}' 2>&1 | |
| for key in ci.int_val ci.float_val ci.bool_val ci.json_val; do | |
| output=$($HORUS param get "$key" --json 2>&1) | |
| echo "$key: $output" | |
| echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" || { | |
| echo "FAIL: param get --json for $key is not valid JSON" | |
| exit 1 | |
| } | |
| done | |
| # Verify specific types | |
| $HORUS param get ci.int_val --json 2>&1 | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| val = data.get('value', data) | |
| assert val == 42 or val == '42', f'int: expected 42, got {val}' | |
| " | |
| # Cleanup | |
| for key in ci.int_val ci.float_val ci.bool_val ci.json_val; do | |
| $HORUS param delete "$key" 2>&1 || true | |
| done | |
| echo "PASS: param type inference" | |
| # ───────────────────────────────────────────────── | |
| # 17. Parameter save/load file roundtrip | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: param save/load file roundtrip" | |
| run: | | |
| $HORUS param set ci.save_a 100 2>&1 | |
| $HORUS param set ci.save_b hello 2>&1 | |
| $HORUS param set ci.save_c 3.14 2>&1 | |
| $HORUS param save /tmp/ci_params.yaml 2>&1 | |
| [ -f /tmp/ci_params.yaml ] || { echo "FAIL: param save didn't create file"; exit 1; } | |
| $HORUS param reset --force 2>&1 | |
| $HORUS param load /tmp/ci_params.yaml 2>&1 | |
| # Verify restored | |
| output=$($HORUS param get ci.save_a --json 2>&1) | |
| echo "Restored ci.save_a: $output" | |
| echo "$output" | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| val = data.get('value', data) | |
| assert str(val) == '100', f'Expected 100, got {val}' | |
| " | |
| # Cleanup | |
| for key in ci.save_a ci.save_b ci.save_c; do | |
| $HORUS param delete "$key" 2>&1 || true | |
| done | |
| rm -f /tmp/ci_params.yaml | |
| echo "PASS: param save/load roundtrip" | |
| # ───────────────────────────────────────────────── | |
| # 18. Launch --dry-run with valid YAML | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus launch --dry-run shows plan without launching" | |
| run: | | |
| output=$($HORUS launch horus_core/tests/fixtures/test_launch.yaml --dry-run 2>&1) | |
| echo "$output" | |
| echo "$output" | grep -q "DRY RUN" || { echo "FAIL: missing DRY RUN indicator"; exit 1; } | |
| echo "$output" | grep -q "sensor" || { echo "FAIL: missing node 'sensor'"; exit 1; } | |
| echo "$output" | grep -q "controller" || { echo "FAIL: missing node 'controller'"; exit 1; } | |
| echo "$output" | grep -q "logger" || { echo "FAIL: missing node 'logger'"; exit 1; } | |
| echo "PASS: launch --dry-run shows all nodes" | |
| # ───────────────────────────────────────────────── | |
| # 19. Launch --list | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus launch --list shows nodes from file" | |
| run: | | |
| output=$($HORUS launch horus_core/tests/fixtures/test_launch.yaml --list 2>&1) | |
| echo "$output" | |
| echo "$output" | grep -q "sensor" || { echo "FAIL: missing node 'sensor'"; exit 1; } | |
| echo "$output" | grep -q "controller" || { echo "FAIL: missing node 'controller'"; exit 1; } | |
| echo "$output" | grep -q "logger" || { echo "FAIL: missing node 'logger'"; exit 1; } | |
| echo "PASS: launch --list shows all nodes" | |
| # ───────────────────────────────────────────────── | |
| # 20. Launch circular dependency detection | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus launch detects circular dependencies" | |
| run: | | |
| # Note: circular dependency is detected during topological sort, which | |
| # only runs without --dry-run. The command should fail with exit code != 0. | |
| output=$($HORUS launch horus_core/tests/fixtures/test_launch_circular.yaml 2>&1) || true | |
| echo "$output" | |
| echo "$output" | grep -qi "circular\|cycle" || { | |
| echo "FAIL: no circular dependency error detected" | |
| exit 1 | |
| } | |
| echo "PASS: circular dependency detected" | |
| # ═══════════════════════════════════════════════════════════════ | |
| # Generated Project Tests — `horus run` against the real horus crate | |
| # ═══════════════════════════════════════════════════════════════ | |
| # | |
| # Every other CLI assertion here stops at "the command parsed and dispatched". | |
| # The UAT that came closest (uat_build_and_run_quick_exit) calls | |
| # strip_horus_dep() and swaps in a println!-only main, so it compiles a | |
| # hello-world with no horus dependency at all. | |
| # | |
| # That blind spot shipped a real break: `.horus/Cargo.toml` declares its own | |
| # [workspace], so it never inherited the root workspace's | |
| # [patch."…horus-robotics.git"] tables, and every generated project died at | |
| # dependency resolution with "no matching package named `horus_core` found". | |
| # Nothing in CI compiled a generated project against the real horus crate, so | |
| # nothing caught it. | |
| # | |
| # This job keeps the horus dependency and builds for real. | |
| generated-project: | |
| name: Generated Project Builds | |
| runs-on: ubuntu-latest | |
| env: | |
| HORUS_NAMESPACE: ci_${{ github.run_id }} | |
| # Do NOT set CARGO_TARGET_DIR here. `horus run` execs a hardcoded | |
| # `.horus/target/<profile>/<name>` (run_rust.rs), so redirecting Cargo's | |
| # output dir builds the binary somewhere horus will not look for it. The | |
| # generated project therefore compiles cold — that is the cost of actually | |
| # building against the real horus crate, which is this job's whole point. | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libudev-dev pkg-config | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | |
| - name: Cache Cargo | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| ~/.cargo/bin/ | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| target/ | |
| key: ${{ runner.os }}-cargo-genproj-${{ hashFiles('**/Cargo.lock') }} | |
| restore-keys: ${{ runner.os }}-cargo-genproj- | |
| - name: Setup Shared Memory | |
| run: | | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| mkdir -p "$SHM_DIR"/{topics,nodes,control,network,scheduler} | |
| chmod -R 700 "$SHM_DIR" | |
| - name: Build horus_manager | |
| run: cargo build -p horus_manager --release | |
| - name: "Assert: horus new scaffolds a Rust project" | |
| run: | | |
| set -euo pipefail | |
| rm -rf /tmp/genproj && mkdir -p /tmp/genproj | |
| $HORUS new gen-robot -r -o /tmp/genproj | |
| test -f /tmp/genproj/gen-robot/horus.toml || { | |
| echo "FAIL: horus.toml not scaffolded" | |
| exit 1 | |
| } | |
| echo "PASS: project scaffolded" | |
| # A program that exercises the real horus API, so the horus crate — and | |
| # with it the horus-robotics/horus-tf git deps — must actually resolve and | |
| # compile. run_for() bounds the run so the scheduler exits on its own | |
| # instead of needing a signal. | |
| - name: "Write a horus-dependent entrypoint" | |
| run: | | |
| set -euo pipefail | |
| cat > /tmp/genproj/gen-robot/src/main.rs <<'RUST' | |
| use horus::prelude::*; | |
| use std::time::Duration; | |
| struct Ping { | |
| ticks: u32, | |
| } | |
| impl Node for Ping { | |
| fn name(&self) -> &str { | |
| "ping" | |
| } | |
| fn init(&mut self) -> Result<()> { | |
| Ok(()) | |
| } | |
| fn tick(&mut self) { | |
| self.ticks += 1; | |
| } | |
| } | |
| fn main() -> Result<()> { | |
| let mut scheduler = Scheduler::new(); | |
| scheduler.add(Ping { ticks: 0 }).order(0).build()?; | |
| scheduler.run_for(Duration::from_millis(200))?; | |
| println!("HORUS_RUN_OK"); | |
| Ok(()) | |
| } | |
| RUST | |
| echo "PASS: entrypoint written" | |
| # This is the step that would have caught the horus-robotics break: it | |
| # fails at dependency resolution, before a single crate compiles. | |
| - name: "Assert: horus build compiles against the real horus crate" | |
| run: | | |
| set -euo pipefail | |
| cd /tmp/genproj/gen-robot | |
| if ! timeout 1800 $GITHUB_WORKSPACE/target/release/horus build 2>&1 | tee build.log; then | |
| echo "FAIL: horus build exited non-zero" | |
| if grep -q "no matching package named" build.log; then | |
| echo | |
| echo "The generated .horus/Cargo.toml is missing its [patch] tables." | |
| echo "See write_patch_sections in horus_manager/src/cargo_gen.rs —" | |
| echo "a generated manifest declares its own [workspace], so the root" | |
| echo "workspace's [patch] tables do not carry over and must be re-emitted." | |
| fi | |
| exit 1 | |
| fi | |
| echo "PASS: generated project compiles" | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: generated manifest carries the git [patch] tables" | |
| run: | | |
| set -euo pipefail | |
| manifest=/tmp/genproj/gen-robot/.horus/Cargo.toml | |
| test -f "$manifest" || { | |
| echo "FAIL: horus did not generate $manifest" | |
| exit 1 | |
| } | |
| echo "--- generated manifest ---" | |
| cat "$manifest" | |
| for url in \ | |
| "https://github.com/softmata/horus-robotics.git" \ | |
| "https://github.com/softmata/horus-tf.git" | |
| do | |
| grep -qF "[patch.\"$url\"]" "$manifest" || { | |
| echo "FAIL: missing [patch.\"$url\"] in generated manifest" | |
| exit 1 | |
| } | |
| done | |
| echo "PASS: patch tables present" | |
| - name: "Assert: horus run executes the built project to completion" | |
| run: | | |
| set -euo pipefail | |
| cd /tmp/genproj/gen-robot | |
| output=$(timeout 600 $GITHUB_WORKSPACE/target/release/horus run 2>&1) || { | |
| echo "$output" | |
| echo "FAIL: horus run exited non-zero" | |
| exit 1 | |
| } | |
| echo "$output" | |
| echo "$output" | grep -q "HORUS_RUN_OK" || { | |
| echo "FAIL: program did not reach completion" | |
| exit 1 | |
| } | |
| echo "PASS: horus run built and executed a real horus project" | |
| env: | |
| RUST_BACKTRACE: 1 | |
| # ═══════════════════════════════════════════════════════════════ | |
| # Runtime Integration Tests — scheduler, topics, multi-node | |
| # ═══════════════════════════════════════════════════════════════ | |
| runtime-integration: | |
| name: Runtime Integration Tests | |
| runs-on: ubuntu-latest | |
| env: | |
| HORUS_NAMESPACE: ci_${{ github.run_id }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libudev-dev pkg-config | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | |
| - name: Cache Cargo | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| ~/.cargo/bin/ | |
| ~/.cargo/registry/index/ | |
| ~/.cargo/registry/cache/ | |
| ~/.cargo/git/db/ | |
| target/ | |
| key: ${{ runner.os }}-cargo-runtime-${{ hashFiles('**/Cargo.lock') }} | |
| restore-keys: ${{ runner.os }}-cargo-runtime- | |
| - name: Setup Shared Memory | |
| run: | | |
| # Create namespaced SHM directory structure | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| # No sudo: sudo mkdir leaves these root-owned, which is what made | |
| # SchedulerRegistry::open and the presence writer fail with | |
| # PermissionDenied. Create them as the runner and keep 0700 — | |
| # regression_shm_topics_dir_permissions_restricted asserts the topics | |
| # dir is owner-only, so chmod 777 here would break that guarantee. | |
| mkdir -p "$SHM_DIR"/{topics,nodes,control,network,scheduler} | |
| chmod -R 700 "$SHM_DIR" | |
| - name: Build Workspace | |
| run: cargo build --workspace --release | |
| - name: "Assert: Scheduler runs and stops cleanly with run_for()" | |
| run: | | |
| cargo test -p horus_core --release --lib -- scheduling::scheduler::tests::test_scheduler_run_for_short_duration --exact --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: Node lifecycle (init → tick → shutdown) executes in order" | |
| run: | | |
| cargo test -p horus_core --release --test acceptance_scheduler -- test_scenario_1_complete_node_lifecycle --exact --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: Init failure prevents tick execution" | |
| run: | | |
| cargo test -p horus_core --release --test acceptance_scheduler -- test_scenario_2_init_failure_prevents_execution --exact --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: Multiple nodes execute independently" | |
| run: | | |
| cargo test -p horus_core --release --test acceptance_scheduler -- test_multiple_nodes_independent --exact --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: RT nodes with budget/deadline monitoring work" | |
| run: | | |
| cargo test -p horus_core --release --test rt_scheduler_integration -- test_scheduler_with_rt_nodes --exact --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: budget violation is detected" | |
| run: | | |
| # test_budget_violation_detection never existed. libtest exits 0 when a | |
| # filter matches nothing, so this gate silently asserted nothing. The | |
| # real budget-violation test lives in rt_deadline_enforcement. | |
| cargo test -p horus_core --release --test rt_deadline_enforcement -- test_budget_violation_node_still_runs --exact --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: Mixed RT and normal nodes coexist" | |
| run: | | |
| cargo test -p horus_core --release --test rt_scheduler_integration -- test_mixed_rt_and_normal_nodes --exact --nocapture | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: Topic send/recv roundtrip works" | |
| run: | | |
| # One `--` only: a second one makes libtest treat `--test-threads=1` | |
| # as another name filter instead of the thread setting, so these tests | |
| # silently ran in parallel and blew the stack. | |
| cargo test -p horus_core --release --lib -- communication::topic::tests \ | |
| --nocapture --test-threads=1 \ | |
| --skip topic_cross_thread_1p_multi_c_spmc \ | |
| --skip topic_cross_thread_mpmc_pre_initialized_99_percent \ | |
| --skip topic_cross_thread_multi_p_multi_c_mpmc | |
| timeout-minutes: 3 | |
| env: | |
| RUST_BACKTRACE: 1 | |
| - name: "Assert: Cross-process IPC roundtrip" | |
| run: | | |
| cargo test -p horus_core --release --test cross_process_ipc -- --nocapture --test-threads=1 | |
| timeout-minutes: 5 | |
| env: | |
| RUST_BACKTRACE: 1 | |
| # ───────────────────────────────────────────────── | |
| # Topic type safety (Rust integration test) | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: Topic type mismatch is detected" | |
| run: cargo test -p horus_core --release --test topic_type_safety -- --test-threads=1 --nocapture | |
| timeout-minutes: 3 | |
| env: | |
| RUST_BACKTRACE: 1 | |
| # ───────────────────────────────────────────────── | |
| # Live node discovery | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus node list discovers running nodes" | |
| run: | | |
| export HORUS_CI_READY_FILE="/tmp/ci_node_ready" | |
| export HORUS_CI_DURATION_MS=10000 | |
| rm -f "$HORUS_CI_READY_FILE" | |
| ./target/release/ci_test_node & | |
| CI_PID=$! | |
| # Wait for ready file (up to 5s) | |
| for i in $(seq 1 50); do [ -f "$HORUS_CI_READY_FILE" ] && break; sleep 0.1; done | |
| sleep 1 | |
| output=$($HORUS node list --json 2>&1) | |
| echo "$output" | |
| echo "$output" | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| items = data['items'] if isinstance(data, dict) else data | |
| assert any('ci_test_node' in n.get('name','') for n in items), f'ci_test_node not found in: {data}' | |
| print('PASS: ci_test_node discovered') | |
| " | |
| kill $CI_PID 2>/dev/null; wait $CI_PID 2>/dev/null || true | |
| timeout-minutes: 2 | |
| env: | |
| RUST_BACKTRACE: 1 | |
| # ───────────────────────────────────────────────── | |
| # Live topic listing | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: horus topic list discovers active topics" | |
| run: | | |
| export HORUS_CI_READY_FILE="/tmp/ci_topic_ready" | |
| export HORUS_CI_TOPIC_NAME="ci_live_topic" | |
| export HORUS_CI_DURATION_MS=10000 | |
| rm -f "$HORUS_CI_READY_FILE" | |
| ./target/release/ci_test_node & | |
| CI_PID=$! | |
| # Wait for ready file (up to 5s) | |
| for i in $(seq 1 50); do [ -f "$HORUS_CI_READY_FILE" ] && break; sleep 0.1; done | |
| sleep 1 | |
| output=$($HORUS topic list --json 2>&1) | |
| echo "$output" | |
| echo "$output" | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| items = data['items'] if isinstance(data, dict) else data | |
| names = [t.get('name','') for t in items] | |
| assert any('ci_live_topic' in n for n in names), f'ci_live_topic not found in: {names}' | |
| print('PASS: ci_live_topic discovered') | |
| " | |
| kill $CI_PID 2>/dev/null; wait $CI_PID 2>/dev/null || true | |
| timeout-minutes: 2 | |
| env: | |
| RUST_BACKTRACE: 1 | |
| # ───────────────────────────────────────────────── | |
| # Monitor API | |
| # ───────────────────────────────────────────────── | |
| - name: "Assert: Monitor API returns valid JSON" | |
| run: | | |
| # `horus monitor` is a pure passthrough to an external `horus-monitor` | |
| # plugin binary (see which_monitor_binary() in horus_manager/src/main.rs: | |
| # ~/.horus/bin, ./.horus/bin, then PATH). That plugin does not live in | |
| # this repo and no step installs it, so the command used to exit 1 | |
| # immediately, nothing ever listened on :3199, and all three curl | |
| # assertions failed — silently, because of continue-on-error. | |
| # Skip explicitly when the plugin is absent; assert for real when it is | |
| # present. The monitor code that DOES live here is covered by | |
| # .github/workflows/monitor-tests.yml. | |
| if ! command -v horus-monitor >/dev/null 2>&1 \ | |
| && [ ! -x "$HOME/.horus/bin/horus-monitor" ] \ | |
| && [ ! -x ".horus/bin/horus-monitor" ]; then | |
| echo "SKIP: horus-monitor plugin not installed — monitor API not exercised here." | |
| exit 0 | |
| fi | |
| # Bypass auth by creating empty password hash | |
| mkdir -p ~/.horus && echo -n "" > ~/.horus/dashboard_password.hash | |
| $HORUS monitor 3199 & | |
| MON_PID=$! | |
| # Wait for monitor to start (up to 5s) | |
| for i in $(seq 1 50); do curl -s http://localhost:3199/api/status >/dev/null 2>&1 && break; sleep 0.1; done | |
| # Test status endpoint | |
| curl -sf http://localhost:3199/api/status | python3 -c "import sys,json; assert isinstance(json.load(sys.stdin), dict); print('PASS: /api/status')" | |
| # Test nodes endpoint | |
| curl -sf http://localhost:3199/api/nodes | python3 -c "import sys,json; json.load(sys.stdin); print('PASS: /api/nodes')" | |
| # Test topics endpoint | |
| curl -sf http://localhost:3199/api/topics | python3 -c "import sys,json; json.load(sys.stdin); print('PASS: /api/topics')" | |
| kill $MON_PID 2>/dev/null; wait $MON_PID 2>/dev/null || true | |
| timeout-minutes: 2 | |
| - name: "Assert: Shared memory is cleaned up after tests" | |
| run: | | |
| # Count topic files remaining | |
| SHM_DIR="/dev/shm/horus_ci_${GITHUB_RUN_ID:-$$}" | |
| count=$(find "$SHM_DIR/topics" -type f 2>/dev/null | wc -l) | |
| echo "Remaining SHM topic files: $count" | |
| # Warn but don't fail — some tests may leave stale files | |
| [ "$count" -lt 50 ] || { | |
| echo "FAIL: excessive SHM files remaining ($count)" | |
| exit 1 | |
| } | |
| echo "PASS: SHM cleanup looks healthy" | |
| # Summary job | |
| integration-success: | |
| name: Integration Tests Success | |
| permissions: {} | |
| needs: [ipc-integration, cli-integration, generated-project, runtime-integration] | |
| runs-on: ubuntu-latest | |
| if: always() | |
| steps: | |
| - name: Check Results | |
| run: | | |
| echo "IPC Integration: ${{ needs.ipc-integration.result }}" | |
| echo "CLI Integration: ${{ needs.cli-integration.result }}" | |
| echo "Generated Project: ${{ needs.generated-project.result }}" | |
| echo "Runtime Integration: ${{ needs.runtime-integration.result }}" | |
| FAILURES="" | |
| # Generated project must build against the real horus crate | |
| if [[ "${{ needs.generated-project.result }}" != "success" ]]; then | |
| FAILURES="$FAILURES GeneratedProject" | |
| fi | |
| # IPC integration must pass | |
| if [[ "${{ needs.ipc-integration.result }}" != "success" ]]; then | |
| FAILURES="$FAILURES IPC" | |
| fi | |
| # CLI integration must pass | |
| if [[ "${{ needs.cli-integration.result }}" != "success" ]]; then | |
| FAILURES="$FAILURES CLI" | |
| fi | |
| # Runtime integration must pass | |
| if [[ "${{ needs.runtime-integration.result }}" != "success" ]]; then | |
| FAILURES="$FAILURES Runtime" | |
| fi | |
| if [[ -n "$FAILURES" ]]; then | |
| echo "Integration test failures:$FAILURES" | |
| exit 1 | |
| fi | |
| echo "All integration tests passed!" |