diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index bdafd061..63720601 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -53,5 +53,10 @@ jobs: NEMOCLAW_CLUSTER_IMAGE: ghcr.io/nvidia/nemoclaw/cluster:${{ inputs.image-tag }} run: mise run --no-prepare --skip-deps cluster + - name: Install SSH client for Rust CLI e2e tests + run: apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/* + - name: Run E2E tests - run: mise run --no-prepare --skip-deps test:e2e:sandbox + run: | + mise run --no-prepare --skip-deps e2e:python + mise run --no-prepare --skip-deps e2e:rust diff --git a/.gitignore b/.gitignore index 0e825fa8..3af0edc6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Build output /target/ +e2e/rust/target/ debug/ release/ diff --git a/TESTING.md b/TESTING.md index bdde8105..f7404b91 100644 --- a/TESTING.md +++ b/TESTING.md @@ -15,7 +15,7 @@ crates/*/src/ # Inline #[cfg(test)] modules crates/*/tests/ # Rust integration tests python/navigator/ # Python unit tests (*_test.py suffix) e2e/python/ # Python E2E tests (test_*.py prefix) -e2e/bash/ # Bash E2E scripts +e2e/rust/ # Rust CLI E2E tests ``` ## Rust Tests @@ -130,20 +130,41 @@ def test_multiply(sandbox): | `inference_client` | session | Client for managing inference routes | | `mock_inference_route` | session | Creates a mock OpenAI-protocol route for tests | -### Bash E2E (`e2e/bash/`) +### Rust CLI E2E (`e2e/rust/`) -Self-contained shell scripts that exercise the CLI directly: +Rust-based e2e tests that exercise the `nemoclaw` CLI binary as a subprocess. +They live in the `nemoclaw-e2e` crate and use a shared harness for sandbox +lifecycle management, output parsing, and cleanup. -- `test_sandbox_sync.sh` — file sync round-trip -- `test_sandbox_custom_image.sh` — custom Docker image build and run -- `test_port_forward.sh` — TCP port forwarding through a sandbox +Tests: -Pattern: `set -euo pipefail`, cleanup via `trap`, poll-based readiness checks -parsing CLI output. +- `tests/custom_image.rs` — custom Docker image build and sandbox run +- `tests/sync.rs` — bidirectional file sync round-trip (including large files) +- `tests/port_forward.rs` — TCP port forwarding through a sandbox + +Run all CLI e2e tests: + +```bash +mise run e2e:rust +``` + +Run a single test directly with cargo: + +```bash +cargo test --manifest-path e2e/rust/Cargo.toml --features e2e --test sync +``` + +The harness (`e2e/rust/src/harness/`) provides: + +| Module | Purpose | +|---|---| +| `binary` | Builds and resolves the `nemoclaw` binary from the workspace | +| `sandbox` | `SandboxGuard` RAII type — creates sandboxes and deletes them on drop | +| `output` | ANSI stripping and field extraction from CLI output | +| `port` | `wait_for_port()` and `find_free_port()` for TCP testing | ## Environment Variables | Variable | Purpose | |---|---| | `NEMOCLAW_CLUSTER` | Override active cluster name for E2E tests | -| `NAV_BIN` | Override `nemoclaw` binary path in bash E2E tests | diff --git a/deploy/docker/Dockerfile.ci b/deploy/docker/Dockerfile.ci index 2ce0e2bc..935573cd 100644 --- a/deploy/docker/Dockerfile.ci +++ b/deploy/docker/Dockerfile.ci @@ -25,6 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ pkg-config \ libssl-dev \ + openssh-client \ python3 \ python3-venv \ cmake \ diff --git a/e2e/bash/test_port_forward.sh b/e2e/bash/test_port_forward.sh deleted file mode 100755 index 67b5214a..00000000 --- a/e2e/bash/test_port_forward.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bash - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Integration test for port forwarding through a sandbox. -# -# Prerequisites: -# - A running nemoclaw cluster (nemoclaw cluster admin deploy) -# - The `nemoclaw` binary on PATH (or set NAV_BIN) -# -# Usage: -# ./e2e/bash/test_port_forward.sh - -set -euo pipefail - -############################################################################### -# Configuration -############################################################################### - -# Resolve the nemoclaw binary: prefer NAV_BIN, then target/debug, then PATH. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" - -if [[ -n "${NAV_BIN:-}" ]]; then - NAV="${NAV_BIN}" -elif [[ -x "${PROJECT_ROOT}/target/debug/nemoclaw" ]]; then - NAV="${PROJECT_ROOT}/target/debug/nemoclaw" -else - NAV="nemoclaw" -fi - -FORWARD_PORT="${FORWARD_PORT:-19876}" -TIMEOUT_FORWARD="${TIMEOUT_FORWARD:-30}" -SANDBOX_NAME="" -FORWARD_PID="" -CREATE_PID="" - -############################################################################### -# Helpers -############################################################################### - -info() { printf '==> %s\n' "$*" >&2; } -error() { printf 'ERROR: %s\n' "$*" >&2; } - -# Strip ANSI escape codes from stdin. -strip_ansi() { - sed $'s/\x1b\\[[0-9;]*m//g' -} - -# Wait for a TCP port to accept connections. -wait_for_port() { - local host=$1 port=$2 timeout=$3 - local i - for i in $(seq 1 "${timeout}"); do - if (echo >/dev/tcp/"${host}"/"${port}") 2>/dev/null; then - return 0 - fi - sleep 1 - done - return 1 -} - -# Kill a process and all of its children. -kill_tree() { - local pid=$1 - # Kill children first (best-effort). - pkill -P "${pid}" 2>/dev/null || true - kill "${pid}" 2>/dev/null || true - wait "${pid}" 2>/dev/null || true -} - -cleanup() { - local exit_code=$? - - if [[ -n "${FORWARD_PID}" ]]; then - info "Stopping port-forward (pid ${FORWARD_PID})" - kill_tree "${FORWARD_PID}" - fi - - if [[ -n "${CREATE_PID}" ]]; then - info "Stopping sandbox create (pid ${CREATE_PID})" - kill_tree "${CREATE_PID}" - fi - - if [[ -n "${SANDBOX_NAME}" ]]; then - info "Deleting sandbox ${SANDBOX_NAME}" - "${NAV}" sandbox delete "${SANDBOX_NAME}" 2>/dev/null || true - fi - - if [[ ${exit_code} -eq 0 ]]; then - info "PASS" - else - error "FAIL (exit ${exit_code})" - fi - exit "${exit_code}" -} - -trap cleanup EXIT - -# Verify the test port is not already in use. -if (echo >/dev/tcp/127.0.0.1/"${FORWARD_PORT}") 2>/dev/null; then - error "Port ${FORWARD_PORT} is already in use; choose a different FORWARD_PORT" - exit 1 -fi - -############################################################################### -# Step 1 — Create a sandbox with a long-running TCP echo server. -# -# The echo server runs as the foreground process of `sandbox create --keep`. -# This ensures it stays alive for the duration of the test. We run the -# create command in the background and parse its output for the sandbox name. -############################################################################### - -info "Creating sandbox with TCP echo server on port ${FORWARD_PORT}" - -CREATE_LOG=$(mktemp) - -"${NAV}" sandbox create --keep -- \ - python3 -c " -import socket, sys, signal, os -signal.signal(signal.SIGHUP, signal.SIG_IGN) -signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) -port = ${FORWARD_PORT} -sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind(('127.0.0.1', port)) -sock.listen(1) -sock.settimeout(300) -print('echo-server-ready', flush=True) -try: - while True: - conn, _ = sock.accept() - data = conn.recv(4096) - if data: - conn.sendall(b'echo:' + data) - conn.close() -except (socket.timeout, OSError): - pass -finally: - sock.close() -" > "${CREATE_LOG}" 2>&1 & - -CREATE_PID=$! - -# Wait for the sandbox to be created and the echo server to start. -# We poll the log file for the sandbox name and the ready marker. -info "Waiting for sandbox to be ready" -for i in $(seq 1 120); do - if [[ -f "${CREATE_LOG}" ]] && grep -q 'echo-server-ready' "${CREATE_LOG}" 2>/dev/null; then - break - fi - if ! kill -0 "${CREATE_PID}" 2>/dev/null; then - error "Sandbox create exited prematurely" - cat "${CREATE_LOG}" >&2 - exit 1 - fi - sleep 1 -done - -if ! grep -q 'echo-server-ready' "${CREATE_LOG}" 2>/dev/null; then - error "Echo server did not become ready within 120s" - cat "${CREATE_LOG}" >&2 - exit 1 -fi - -# Parse sandbox name from the create log. -SANDBOX_NAME=$( - strip_ansi < "${CREATE_LOG}" | awk '/Name:/ { print $NF }' -) - -if [[ -z "${SANDBOX_NAME}" ]]; then - error "Could not parse sandbox name from create output" - cat "${CREATE_LOG}" >&2 - exit 1 -fi - -info "Sandbox created: ${SANDBOX_NAME}" - -############################################################################### -# Step 2 — Start port forwarding in the background. -############################################################################### - -info "Starting port forward ${FORWARD_PORT} -> ${SANDBOX_NAME}" - -"${NAV}" sandbox forward start "${FORWARD_PORT}" "${SANDBOX_NAME}" & -FORWARD_PID=$! - -# Wait for the local port to become available. -info "Waiting for local port ${FORWARD_PORT} to open" -if ! wait_for_port 127.0.0.1 "${FORWARD_PORT}" "${TIMEOUT_FORWARD}"; then - if ! kill -0 "${FORWARD_PID}" 2>/dev/null; then - error "Port-forward process exited prematurely" - else - error "Local port ${FORWARD_PORT} did not open within ${TIMEOUT_FORWARD}s" - fi - exit 1 -fi - -info "Port ${FORWARD_PORT} is open" - -# Give the SSH tunnel a moment to fully establish the direct-tcpip channel. -sleep 2 - -############################################################################### -# Step 3 — Send data through the forwarded port and verify the response. -# -# We retry a few times to handle transient tunnel setup delays. -############################################################################### - -info "Sending test payload through forwarded port" - -EXPECTED="echo:hello-nav" -RESPONSE_TRIMMED="" - -for attempt in $(seq 1 5); do - RESPONSE=$( - python3 -c " -import socket, sys -s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -s.settimeout(10) -try: - s.connect(('127.0.0.1', ${FORWARD_PORT})) - s.sendall(b'hello-nav\n') - data = s.recv(4096) - sys.stdout.write(data.decode()) -except Exception: - pass -finally: - s.close() -" 2>/dev/null - ) || true - - RESPONSE_TRIMMED=$(printf '%s' "${RESPONSE}" | tr -d '\r\n') - - if [[ "${RESPONSE_TRIMMED}" == "${EXPECTED}"* ]]; then - break - fi - - info "Attempt ${attempt}: no valid response yet, retrying in 2s..." - sleep 2 -done - -if [[ "${RESPONSE_TRIMMED}" != "${EXPECTED}"* ]]; then - error "Unexpected response: '${RESPONSE_TRIMMED}' (expected '${EXPECTED}')" - exit 1 -fi - -info "Received expected response: '${RESPONSE_TRIMMED}'" - -############################################################################### -# Cleanup is handled by the EXIT trap. -############################################################################### diff --git a/e2e/bash/test_sandbox_custom_image.sh b/e2e/bash/test_sandbox_custom_image.sh deleted file mode 100755 index b7d6973e..00000000 --- a/e2e/bash/test_sandbox_custom_image.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env bash - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Integration test for building a custom container image and running a sandbox -# with it. -# -# Verifies the full flow: -# nemoclaw sandbox create --from -- -# -# Prerequisites: -# - A running nemoclaw cluster (nemoclaw cluster admin deploy) -# - Docker daemon running (for image build) -# - The `nemoclaw` binary on PATH (or set NAV_BIN) -# -# Usage: -# ./e2e/bash/test_sandbox_custom_image.sh - -set -euo pipefail - -############################################################################### -# Configuration -############################################################################### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" - -if [[ -n "${NAV_BIN:-}" ]]; then - NAV="${NAV_BIN}" -elif [[ -x "${PROJECT_ROOT}/target/debug/nemoclaw" ]]; then - NAV="${PROJECT_ROOT}/target/debug/nemoclaw" -else - NAV="nemoclaw" -fi - -SANDBOX_NAME="" -TMPDIR_ROOT="" - -############################################################################### -# Helpers -############################################################################### - -info() { printf '==> %s\n' "$*" >&2; } -error() { printf 'ERROR: %s\n' "$*" >&2; } - -strip_ansi() { - sed $'s/\x1b\\[[0-9;]*m//g' -} - -cleanup() { - local exit_code=$? - - if [[ -n "${SANDBOX_NAME}" ]]; then - info "Deleting sandbox ${SANDBOX_NAME}" - "${NAV}" sandbox delete "${SANDBOX_NAME}" 2>/dev/null || true - fi - - if [[ -n "${TMPDIR_ROOT}" && -d "${TMPDIR_ROOT}" ]]; then - rm -rf "${TMPDIR_ROOT}" - fi - - if [[ ${exit_code} -eq 0 ]]; then - info "PASS" - else - error "FAIL (exit ${exit_code})" - fi - exit "${exit_code}" -} - -trap cleanup EXIT - -############################################################################### -# Step 1 — Create a minimal Dockerfile for testing -############################################################################### - -info "Creating temporary Dockerfile" - -TMPDIR_ROOT=$(mktemp -d) -DOCKERFILE="${TMPDIR_ROOT}/Dockerfile" - -cat > "${DOCKERFILE}" <<'DOCKERFILE_CONTENT' -FROM python:3.12-slim - -# Create the sandbox user/group so the supervisor can switch to it. -RUN groupadd -g 1000 sandbox && \ - useradd -m -u 1000 -g sandbox sandbox - -# Write a marker file so we can verify this is our custom image. -RUN echo "custom-image-e2e-marker" > /opt/marker.txt - -CMD ["sleep", "infinity"] -DOCKERFILE_CONTENT - -############################################################################### -# Step 2 — Create a sandbox from the Dockerfile and verify it works -############################################################################### - -info "Creating sandbox from Dockerfile" - -CREATE_LOG=$(mktemp) -if ! "${NAV}" sandbox create \ - --from "${DOCKERFILE}" \ - -- cat /opt/marker.txt \ - > "${CREATE_LOG}" 2>&1; then - error "Sandbox create failed" - cat "${CREATE_LOG}" >&2 - exit 1 -fi - -# Parse sandbox name from the create output for cleanup. -SANDBOX_NAME=$( - strip_ansi < "${CREATE_LOG}" | awk '/Name:/ { print $NF }' -) || true - -info "Verifying marker file from custom image" - -# The sandbox ran `cat /opt/marker.txt` — check that the expected marker -# appears in the output. -if ! strip_ansi < "${CREATE_LOG}" | grep -q "custom-image-e2e-marker"; then - error "Marker file content not found in sandbox output" - cat "${CREATE_LOG}" >&2 - exit 1 -fi - -info "Custom image marker verified" - -############################################################################### -# Cleanup is handled by the EXIT trap. -############################################################################### diff --git a/e2e/bash/test_sandbox_sync.sh b/e2e/bash/test_sandbox_sync.sh deleted file mode 100755 index f8cc0645..00000000 --- a/e2e/bash/test_sandbox_sync.sh +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env bash - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Integration test for bidirectional file sync with a sandbox. -# -# Verifies the full flow: -# 1. nemoclaw sandbox create --keep (long-running sandbox for sync tests) -# 2. nemoclaw sandbox sync --up (push) -# 3. nemoclaw sandbox sync --down (pull) -# 4. Single-file round-trip -# -# Prerequisites: -# - A running nemoclaw cluster (nemoclaw cluster admin deploy) -# - The `nemoclaw` binary on PATH (or set NAV_BIN) -# -# Usage: -# ./e2e/bash/test_sandbox_sync.sh - -set -euo pipefail - -############################################################################### -# Configuration -############################################################################### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" - -if [[ -n "${NAV_BIN:-}" ]]; then - NAV="${NAV_BIN}" -elif [[ -x "${PROJECT_ROOT}/target/debug/nemoclaw" ]]; then - NAV="${PROJECT_ROOT}/target/debug/nemoclaw" -else - NAV="nemoclaw" -fi - -SANDBOX_NAME="" -CREATE_PID="" -TMPDIR_ROOT="" - -############################################################################### -# Helpers -############################################################################### - -info() { printf '==> %s\n' "$*" >&2; } -error() { printf 'ERROR: %s\n' "$*" >&2; } - -strip_ansi() { - sed $'s/\x1b\\[[0-9;]*m//g' -} - -# Kill a process and all of its children. -kill_tree() { - local pid=$1 - pkill -P "${pid}" 2>/dev/null || true - kill "${pid}" 2>/dev/null || true - wait "${pid}" 2>/dev/null || true -} - -cleanup() { - local exit_code=$? - - if [[ -n "${CREATE_PID}" ]]; then - info "Stopping sandbox create (pid ${CREATE_PID})" - kill_tree "${CREATE_PID}" - fi - - if [[ -n "${SANDBOX_NAME}" ]]; then - info "Deleting sandbox ${SANDBOX_NAME}" - "${NAV}" sandbox delete "${SANDBOX_NAME}" 2>/dev/null || true - fi - - if [[ -n "${TMPDIR_ROOT}" && -d "${TMPDIR_ROOT}" ]]; then - rm -rf "${TMPDIR_ROOT}" - fi - - if [[ ${exit_code} -eq 0 ]]; then - info "PASS" - else - error "FAIL (exit ${exit_code})" - fi - exit "${exit_code}" -} - -trap cleanup EXIT - -############################################################################### -# Step 1 — Create a sandbox with --keep so it stays alive for sync tests. -# -# `sandbox create --keep -- sleep infinity` blocks forever, so we run it in -# the background and poll the log for the sandbox name and ready marker. -############################################################################### - -info "Creating sandbox with sleep infinity" - -CREATE_LOG=$(mktemp) - -"${NAV}" sandbox create --keep -- sleep infinity \ - > "${CREATE_LOG}" 2>&1 & -CREATE_PID=$! - -# Wait for the sandbox to become ready. The CLI prints the phase label -# "Ready" once the sandbox reaches that state. We also check for "Name:" -# in the header to know the sandbox was created. -info "Waiting for sandbox to be ready" -for i in $(seq 1 120); do - if [[ -f "${CREATE_LOG}" ]] && strip_ansi < "${CREATE_LOG}" | grep -q 'Name:'; then - # Name is printed in the header; now wait for Ready phase. - if strip_ansi < "${CREATE_LOG}" | grep -qw 'Ready'; then - break - fi - fi - if ! kill -0 "${CREATE_PID}" 2>/dev/null; then - error "Sandbox create exited prematurely" - cat "${CREATE_LOG}" >&2 - exit 1 - fi - sleep 1 -done - -if ! strip_ansi < "${CREATE_LOG}" | grep -qw 'Ready'; then - error "Sandbox did not become ready within 120s" - cat "${CREATE_LOG}" >&2 - exit 1 -fi - -# Parse sandbox name from the create output. -SANDBOX_NAME=$( - strip_ansi < "${CREATE_LOG}" | awk '/Name:/ { print $NF }' -) || true - -if [[ -z "${SANDBOX_NAME}" ]]; then - error "Could not parse sandbox name from create output" - cat "${CREATE_LOG}" >&2 - exit 1 -fi - -info "Sandbox created: ${SANDBOX_NAME}" - -############################################################################### -# Step 2 — Sync up: push a local directory into the sandbox. -############################################################################### - -info "Preparing local test files" - -TMPDIR_ROOT=$(mktemp -d) -LOCAL_UP="${TMPDIR_ROOT}/upload" -mkdir -p "${LOCAL_UP}/subdir" -echo "hello-from-local" > "${LOCAL_UP}/greeting.txt" -echo "nested-content" > "${LOCAL_UP}/subdir/nested.txt" - -info "Syncing local directory up to sandbox" - -SYNC_UP_LOG=$(mktemp) -if ! "${NAV}" sandbox sync "${SANDBOX_NAME}" --up "${LOCAL_UP}" /sandbox/uploaded \ - > "${SYNC_UP_LOG}" 2>&1; then - error "sync --up failed" - cat "${SYNC_UP_LOG}" >&2 - exit 1 -fi - -############################################################################### -# Step 3 — Sync down: pull the uploaded files back and verify contents. -############################################################################### - -info "Syncing files back down from sandbox" - -LOCAL_DOWN="${TMPDIR_ROOT}/download" -mkdir -p "${LOCAL_DOWN}" - -SYNC_DOWN_LOG=$(mktemp) -if ! "${NAV}" sandbox sync "${SANDBOX_NAME}" --down /sandbox/uploaded "${LOCAL_DOWN}" \ - > "${SYNC_DOWN_LOG}" 2>&1; then - error "sync --down failed" - cat "${SYNC_DOWN_LOG}" >&2 - exit 1 -fi - -info "Verifying downloaded files" - -# Check top-level file. -if [[ ! -f "${LOCAL_DOWN}/greeting.txt" ]]; then - error "greeting.txt not found after sync --down" - ls -lR "${LOCAL_DOWN}" >&2 - exit 1 -fi - -GREETING_CONTENT=$(cat "${LOCAL_DOWN}/greeting.txt") -if [[ "${GREETING_CONTENT}" != "hello-from-local" ]]; then - error "greeting.txt content mismatch: got '${GREETING_CONTENT}'" - exit 1 -fi - -info "greeting.txt verified" - -# Check nested file. -if [[ ! -f "${LOCAL_DOWN}/subdir/nested.txt" ]]; then - error "subdir/nested.txt not found after sync --down" - ls -lR "${LOCAL_DOWN}" >&2 - exit 1 -fi - -NESTED_CONTENT=$(cat "${LOCAL_DOWN}/subdir/nested.txt") -if [[ "${NESTED_CONTENT}" != "nested-content" ]]; then - error "subdir/nested.txt content mismatch: got '${NESTED_CONTENT}'" - exit 1 -fi - -info "subdir/nested.txt verified" - -############################################################################### -# Step 4 — Large-file round-trip to exercise multi-chunk SSH transport. -# -# The tar archive is streamed through the SSH channel in 4096-byte chunks. -# Historically, a fire-and-forget tokio::spawn per chunk caused out-of-order -# delivery that corrupted the tar stream. A ~512 KiB file spans many chunks -# and makes such ordering bugs much more likely to surface. -############################################################################### - -info "Generating large test file (~512 KiB)" - -LARGE_DIR="${TMPDIR_ROOT}/large_upload" -mkdir -p "${LARGE_DIR}" - -# Deterministic pseudo-random content so we can verify with a checksum. -dd if=/dev/urandom bs=1024 count=512 2>/dev/null > "${LARGE_DIR}/large.bin" -EXPECTED_HASH=$(shasum -a 256 "${LARGE_DIR}/large.bin" | awk '{print $1}') - -info "Syncing large file up to sandbox" -if ! "${NAV}" sandbox sync "${SANDBOX_NAME}" --up "${LARGE_DIR}" /sandbox/large_test \ - > /dev/null 2>&1; then - error "sync --up large file failed" - exit 1 -fi - -info "Syncing large file back down" -LARGE_DOWN="${TMPDIR_ROOT}/large_download" -mkdir -p "${LARGE_DOWN}" - -if ! "${NAV}" sandbox sync "${SANDBOX_NAME}" --down /sandbox/large_test "${LARGE_DOWN}" \ - > /dev/null 2>&1; then - error "sync --down large file failed" - exit 1 -fi - -ACTUAL_HASH=$(shasum -a 256 "${LARGE_DOWN}/large.bin" | awk '{print $1}') -if [[ "${EXPECTED_HASH}" != "${ACTUAL_HASH}" ]]; then - error "large.bin checksum mismatch after round-trip" - error " expected: ${EXPECTED_HASH}" - error " actual: ${ACTUAL_HASH}" - exit 1 -fi - -ACTUAL_SIZE=$(wc -c < "${LARGE_DOWN}/large.bin" | tr -d ' ') -if [[ "${ACTUAL_SIZE}" -ne 524288 ]]; then - error "large.bin size mismatch: expected 524288, got ${ACTUAL_SIZE}" - exit 1 -fi - -info "Large file round-trip verified (SHA-256 match, ${ACTUAL_SIZE} bytes)" - -############################################################################### -# Step 5 — Sync up a single file and round-trip it. -############################################################################### - -info "Testing single-file sync" - -SINGLE_FILE="${TMPDIR_ROOT}/single.txt" -echo "single-file-payload" > "${SINGLE_FILE}" - -if ! "${NAV}" sandbox sync "${SANDBOX_NAME}" --up "${SINGLE_FILE}" /sandbox \ - > /dev/null 2>&1; then - error "sync --up single file failed" - exit 1 -fi - -LOCAL_SINGLE_DOWN="${TMPDIR_ROOT}/single_down" -mkdir -p "${LOCAL_SINGLE_DOWN}" - -if ! "${NAV}" sandbox sync "${SANDBOX_NAME}" --down /sandbox/single.txt "${LOCAL_SINGLE_DOWN}" \ - > /dev/null 2>&1; then - error "sync --down single file failed" - exit 1 -fi - -SINGLE_CONTENT=$(cat "${LOCAL_SINGLE_DOWN}/single.txt") -if [[ "${SINGLE_CONTENT}" != "single-file-payload" ]]; then - error "single.txt content mismatch: got '${SINGLE_CONTENT}'" - exit 1 -fi - -info "Single-file round-trip verified" - -############################################################################### -# Cleanup is handled by the EXIT trap. -############################################################################### diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock new file mode 100644 index 00000000..6dc48037 --- /dev/null +++ b/e2e/rust/Cargo.lock @@ -0,0 +1,803 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nemoclaw-e2e" +version = "0.1.0" +dependencies = [ + "hex", + "rand", + "sha2", + "tempfile", + "tokio", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml new file mode 100644 index 00000000..fa94eaed --- /dev/null +++ b/e2e/rust/Cargo.toml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Standalone crate — the empty [workspace] table prevents Cargo from +# treating this as part of the root workspace, avoiding Dockerfile +# and Cargo.lock coupling. +[workspace] + +[package] +name = "nemoclaw-e2e" +description = "End-to-end tests for the NemoClaw CLI" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" +publish = false + +[features] +e2e = [] + +[dependencies] +tokio = { version = "1.43", features = ["full"] } +tempfile = "3" +sha2 = "0.10" +hex = "0.4" +rand = "0.9" + +[lints.rust] +unsafe_code = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" diff --git a/e2e/rust/src/harness/binary.rs b/e2e/rust/src/harness/binary.rs new file mode 100644 index 00000000..d4160278 --- /dev/null +++ b/e2e/rust/src/harness/binary.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! CLI binary resolution for e2e tests. +//! +//! Resolves the `nemoclaw` binary at `/target/debug/nemoclaw`. +//! The binary must already be built — the `e2e:rust` mise task handles +//! this by running `cargo build -p navigator-cli` before the tests. + +use std::path::{Path, PathBuf}; + +/// Locate the workspace root by walking up from the crate's manifest directory. +fn workspace_root() -> PathBuf { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + // e2e/rust/ is two levels below the workspace root. + manifest_dir + .ancestors() + .nth(2) + .expect("failed to resolve workspace root from CARGO_MANIFEST_DIR") + .to_path_buf() +} + +/// Return the path to the `nemoclaw` CLI binary. +/// +/// Expects the binary at `/target/debug/nemoclaw`. +/// +/// # Panics +/// +/// Panics if the binary is not found. Run `cargo build -p navigator-cli` +/// (or `mise run e2e:rust`) first. +pub fn nemoclaw_bin() -> PathBuf { + let bin = workspace_root().join("target/debug/nemoclaw"); + assert!( + bin.is_file(), + "nemoclaw binary not found at {bin:?} — run `cargo build -p navigator-cli` first" + ); + bin +} + +/// Create a [`tokio::process::Command`] pre-configured to invoke the +/// `nemoclaw` CLI. +/// +/// The command has `kill_on_drop(true)` set so that background child processes +/// are cleaned up when the handle is dropped. +pub fn nemoclaw_cmd() -> tokio::process::Command { + let mut cmd = tokio::process::Command::new(nemoclaw_bin()); + cmd.kill_on_drop(true); + cmd +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_root_resolves() { + let root = workspace_root(); + assert!( + root.join("Cargo.toml").is_file(), + "workspace root should contain Cargo.toml: {root:?}" + ); + } +} diff --git a/e2e/rust/src/harness/mod.rs b/e2e/rust/src/harness/mod.rs new file mode 100644 index 00000000..b3add2c0 --- /dev/null +++ b/e2e/rust/src/harness/mod.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared test harness modules for CLI e2e tests. + +pub mod binary; +pub mod output; +pub mod port; +pub mod sandbox; diff --git a/e2e/rust/src/harness/output.rs b/e2e/rust/src/harness/output.rs new file mode 100644 index 00000000..c1c926e6 --- /dev/null +++ b/e2e/rust/src/harness/output.rs @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! CLI output parsing utilities. + +/// Strip ANSI escape codes (e.g. colors, bold) from a string. +/// +/// Handles the common `ESC[m` SGR sequences produced by the CLI's +/// `owo-colors` output. +pub fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\x1b' { + // Consume the `[` and everything up to the terminating letter. + if chars.peek() == Some(&'[') { + chars.next(); // consume '[' + // Consume parameter bytes (digits, ';') and the final byte. + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } + } else { + out.push(c); + } + } + + out +} + +/// Extract a field value from CLI tabular output. +/// +/// Given output like: +/// ```text +/// Name: fuzzy-panda +/// Status: Running +/// ``` +/// +/// `extract_field(output, "Name")` returns `Some("fuzzy-panda")`. +/// +/// The search is performed on ANSI-stripped text. +pub fn extract_field(output: &str, field: &str) -> Option { + let clean = strip_ansi(output); + let prefix = format!("{field}:"); + + for line in clean.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(&prefix) { + let value = rest.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_ansi_removes_color_codes() { + let colored = "\x1b[1m\x1b[32mName:\x1b[0m fuzzy-panda"; + assert_eq!(strip_ansi(colored), "Name: fuzzy-panda"); + } + + #[test] + fn strip_ansi_passthrough_plain_text() { + let plain = "no colors here"; + assert_eq!(strip_ansi(plain), plain); + } + + #[test] + fn extract_field_finds_value() { + let output = " Name: fuzzy-panda\n Status: Running\n"; + assert_eq!(extract_field(output, "Name"), Some("fuzzy-panda".into())); + assert_eq!(extract_field(output, "Status"), Some("Running".into())); + } + + #[test] + fn extract_field_with_ansi() { + let output = "\x1b[1mName:\x1b[0m fuzzy-panda\n"; + assert_eq!(extract_field(output, "Name"), Some("fuzzy-panda".into())); + } + + #[test] + fn extract_field_missing_returns_none() { + let output = " Name: fuzzy-panda\n"; + assert_eq!(extract_field(output, "Missing"), None); + } +} diff --git a/e2e/rust/src/harness/port.rs b/e2e/rust/src/harness/port.rs new file mode 100644 index 00000000..70f45499 --- /dev/null +++ b/e2e/rust/src/harness/port.rs @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! TCP port utilities for e2e tests. + +use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; +use std::time::Duration; + +use tokio::net::TcpStream; +use tokio::time::{interval, timeout}; + +/// Wait for a TCP port to accept connections. +/// +/// Polls once per second until either a connection succeeds or the timeout +/// elapses. Returns `Ok(())` on success, `Err` on timeout. +/// +/// # Errors +/// +/// Returns an error if the port does not accept a connection within `max_wait`. +pub async fn wait_for_port(host: &str, port: u16, max_wait: Duration) -> Result<(), String> { + let addr = format!("{host}:{port}"); + + let result = timeout(max_wait, async { + let mut tick = interval(Duration::from_secs(1)); + loop { + tick.tick().await; + if TcpStream::connect(&addr).await.is_ok() { + return; + } + } + }) + .await; + + match result { + Ok(()) => Ok(()), + Err(_) => Err(format!( + "port {port} on {host} did not accept connections within {max_wait:?}" + )), + } +} + +/// Find an available TCP port by binding to port 0. +/// +/// The OS assigns an ephemeral port which is returned. The listener is dropped +/// immediately, freeing the port for use by the test. There is a small TOCTOU +/// window, but it is acceptable for test code. +/// +/// # Panics +/// +/// Panics if the OS cannot allocate an ephemeral port. +pub fn find_free_port() -> u16 { + let listener = + TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("bind to port 0"); + listener + .local_addr() + .expect("local_addr after bind") + .port() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_free_port_returns_nonzero() { + let port = find_free_port(); + assert_ne!(port, 0); + } + + #[tokio::test] + async fn wait_for_port_succeeds_when_listening() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + wait_for_port("127.0.0.1", port, Duration::from_secs(5)) + .await + .expect("should connect to listening port"); + } + + #[tokio::test] + async fn wait_for_port_times_out_when_nothing_listens() { + // Port 1 is almost certainly not listening and requires root. + let result = wait_for_port("127.0.0.1", 1, Duration::from_secs(2)).await; + assert!(result.is_err()); + } +} diff --git a/e2e/rust/src/harness/sandbox.rs b/e2e/rust/src/harness/sandbox.rs new file mode 100644 index 00000000..96bcbe23 --- /dev/null +++ b/e2e/rust/src/harness/sandbox.rs @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox lifecycle management with automatic cleanup. +//! +//! [`SandboxGuard`] creates a sandbox and ensures it is deleted when the guard +//! is dropped, replacing the `trap cleanup EXIT` pattern from the bash tests. + +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::time::timeout; + +use super::binary::nemoclaw_cmd; +use super::output::{extract_field, strip_ansi}; + +/// Default timeout for waiting for a sandbox to become ready. +const SANDBOX_READY_TIMEOUT: Duration = Duration::from_secs(120); + +/// RAII guard that deletes a sandbox on drop. +/// +/// For sandboxes created with `--keep` (long-running background command), the +/// guard also holds the child process handle and kills it during cleanup. +pub struct SandboxGuard { + /// The sandbox name, parsed from CLI output. + pub name: String, + + /// The full captured stdout from the create command (for short-lived + /// sandboxes). Empty for `--keep` sandboxes where output is streamed. + pub create_output: String, + + /// Background child process for `--keep` sandboxes. + child: Option, + + /// Whether cleanup has already been performed. + cleaned_up: bool, +} + +impl SandboxGuard { + /// Create a sandbox that runs a command to completion (no `--keep`). + /// + /// Captures the full CLI output and parses the sandbox name from it. + /// The sandbox is created synchronously (the CLI blocks until the command + /// finishes). + /// + /// # Arguments + /// + /// * `args` — Extra arguments to `nemoclaw sandbox create`, including + /// `-- ` if needed. + /// + /// # Errors + /// + /// Returns an error if the CLI exits with a non-zero status or the sandbox + /// name cannot be parsed from the output. + pub async fn create(args: &[&str]) -> Result { + let mut cmd = nemoclaw_cmd(); + cmd.arg("sandbox").arg("create"); + for arg in args { + cmd.arg(arg); + } + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .map_err(|e| format!("failed to spawn nemoclaw: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + if !output.status.success() { + return Err(format!( + "sandbox create failed (exit {:?}):\n{combined}", + output.status.code() + )); + } + + let name = extract_field(&combined, "Name").ok_or_else(|| { + format!("could not parse sandbox name from create output:\n{combined}") + })?; + + Ok(Self { + name, + create_output: combined, + child: None, + cleaned_up: false, + }) + } + + /// Create a sandbox with `--keep` that runs a long-lived background + /// command. + /// + /// The CLI process runs in the background. This method polls its stdout + /// for `ready_marker` (a string the background command prints when it is + /// ready to accept work). Sandbox name is parsed from the output header. + /// + /// # Arguments + /// + /// * `command` — The command and arguments to run inside the sandbox + /// (passed after `--`). + /// * `ready_marker` — A string to wait for in the combined output that + /// signals readiness. + /// + /// # Errors + /// + /// Returns an error if the process exits prematurely, the ready marker is + /// not seen within [`SANDBOX_READY_TIMEOUT`], or the sandbox name cannot + /// be parsed. + pub async fn create_keep( + command: &[&str], + ready_marker: &str, + ) -> Result { + let mut cmd = nemoclaw_cmd(); + cmd.arg("sandbox") + .arg("create") + .arg("--keep") + .arg("--") + .args(command); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to spawn nemoclaw: {e}"))?; + + let stdout = child.stdout.take().expect("stdout must be piped"); + let mut reader = BufReader::new(stdout).lines(); + + let mut accumulated = String::new(); + let mut name: Option = None; + let mut ready = false; + + let poll_result = timeout(SANDBOX_READY_TIMEOUT, async { + while let Ok(Some(line)) = reader.next_line().await { + let clean = strip_ansi(&line); + accumulated.push_str(&clean); + accumulated.push('\n'); + + // Try to extract the sandbox name from the header. + if name.is_none() { + if let Some(n) = extract_field(&accumulated, "Name") { + name = Some(n); + } + } + + // Check for the ready marker. + if clean.contains(ready_marker) { + ready = true; + break; + } + } + }) + .await; + + if poll_result.is_err() { + // Timeout — kill the child and report. + let _ = child.kill().await; + return Err(format!( + "sandbox did not become ready within {SANDBOX_READY_TIMEOUT:?}.\n\ + Output so far:\n{accumulated}" + )); + } + + if !ready { + // The line reader ended before seeing the marker (process exited). + let _ = child.kill().await; + return Err(format!( + "sandbox create exited before ready marker '{ready_marker}' was seen.\n\ + Output:\n{accumulated}" + )); + } + + let sandbox_name = name.ok_or_else(|| { + format!("could not parse sandbox name from create output:\n{accumulated}") + })?; + + Ok(Self { + name: sandbox_name, + create_output: accumulated, + child: Some(child), + cleaned_up: false, + }) + } + + /// Run a `nemoclaw sandbox sync` command on this sandbox. + /// + /// # Arguments + /// + /// * `args` — Arguments after `nemoclaw sandbox sync `, + /// e.g. `["--up", "/local/path", "/sandbox/dest"]`. + /// + /// # Errors + /// + /// Returns an error if the sync command fails. + pub async fn sync(&self, args: &[&str]) -> Result { + let mut cmd = nemoclaw_cmd(); + cmd.arg("sandbox") + .arg("sync") + .arg(&self.name) + .args(args); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .map_err(|e| format!("failed to spawn nemoclaw sync: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + if !output.status.success() { + return Err(format!( + "sandbox sync failed (exit {:?}):\n{combined}", + output.status.code() + )); + } + + Ok(combined) + } + + /// Spawn `nemoclaw sandbox forward start` as a background process. + /// + /// Returns the child process handle. The caller is responsible for killing + /// it (or it will be killed on drop since `kill_on_drop(true)` is set). + /// + /// # Errors + /// + /// Returns an error if the process cannot be spawned. + pub fn spawn_forward(&self, port: u16) -> Result { + let mut cmd = nemoclaw_cmd(); + cmd.arg("sandbox") + .arg("forward") + .arg("start") + .arg(port.to_string()) + .arg(&self.name); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + cmd.spawn() + .map_err(|e| format!("failed to spawn port forward: {e}")) + } + + /// Delete the sandbox explicitly. + /// + /// Also kills the background child process if one exists. This is called + /// automatically by [`Drop`], but can be called manually for clarity. + pub async fn cleanup(&mut self) { + if self.cleaned_up { + return; + } + self.cleaned_up = true; + + // Kill the background child process if present. + if let Some(ref mut child) = self.child { + let _ = child.kill().await; + let _ = child.wait().await; + } + + // Delete the sandbox. + let mut cmd = nemoclaw_cmd(); + cmd.arg("sandbox").arg("delete").arg(&self.name); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + + let _ = cmd.status().await; + } +} + +impl Drop for SandboxGuard { + fn drop(&mut self) { + if self.cleaned_up { + return; + } + + // We need to run async cleanup in a sync Drop. Use block_in_place to + // avoid blocking the tokio runtime. This is acceptable for test code. + let name = self.name.clone(); + let mut child = self.child.take(); + + // Attempt cleanup with a new runtime if we're not inside one, or + // block_in_place if we are. + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("create cleanup runtime"); + rt.block_on(async { + if let Some(ref mut child) = child { + let _: Result<(), _> = child.kill().await; + let _ = child.wait().await; + } + + let mut cmd = nemoclaw_cmd(); + cmd.arg("sandbox").arg("delete").arg(&name); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + let _ = cmd.status().await; + }); + }); + } +} diff --git a/e2e/rust/src/lib.rs b/e2e/rust/src/lib.rs new file mode 100644 index 00000000..61422ebb --- /dev/null +++ b/e2e/rust/src/lib.rs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test harness for `NemoClaw` CLI end-to-end tests. +//! +//! Provides utilities for: +//! - Resolving and invoking the `nemoclaw` CLI binary +//! - Managing sandbox lifecycle with automatic cleanup +//! - Parsing CLI output (ANSI stripping, field extraction) +//! - TCP port utilities (wait for port, find free port) + +pub mod harness; diff --git a/e2e/rust/tests/custom_image.rs b/e2e/rust/tests/custom_image.rs new file mode 100644 index 00000000..b4385469 --- /dev/null +++ b/e2e/rust/tests/custom_image.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E test: build a custom container image and run a sandbox with it. +//! +//! Replaces `e2e/bash/test_sandbox_custom_image.sh`. +//! +//! Prerequisites: +//! - A running nemoclaw cluster (`nemoclaw cluster admin deploy`) +//! - Docker daemon running (for image build) +//! - The `nemoclaw` binary (built automatically from the workspace) + +use std::io::Write; + +use nemoclaw_e2e::harness::output::strip_ansi; +use nemoclaw_e2e::harness::sandbox::SandboxGuard; + +const DOCKERFILE_CONTENT: &str = r#"FROM python:3.12-slim + +# Create the sandbox user/group so the supervisor can switch to it. +RUN groupadd -g 1000 sandbox && \ + useradd -m -u 1000 -g sandbox sandbox + +# Write a marker file so we can verify this is our custom image. +RUN echo "custom-image-e2e-marker" > /opt/marker.txt + +CMD ["sleep", "infinity"] +"#; + +const MARKER: &str = "custom-image-e2e-marker"; + +/// Build a custom Docker image from a Dockerfile and verify that a sandbox +/// created from it contains the expected marker file. +#[tokio::test] +async fn sandbox_from_custom_dockerfile() { + // Step 1 — Write a temporary Dockerfile. + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + let dockerfile_path = tmpdir.path().join("Dockerfile"); + { + let mut f = std::fs::File::create(&dockerfile_path).expect("create Dockerfile"); + f.write_all(DOCKERFILE_CONTENT.as_bytes()) + .expect("write Dockerfile"); + } + + // Step 2 — Create a sandbox from the Dockerfile. + let dockerfile_str = dockerfile_path.to_str().expect("Dockerfile path is UTF-8"); + let mut guard = SandboxGuard::create(&[ + "--from", + dockerfile_str, + "--", + "cat", + "/opt/marker.txt", + ]) + .await + .expect("sandbox create from Dockerfile"); + + // Step 3 — Verify the marker file content appears in the output. + let clean_output = strip_ansi(&guard.create_output); + assert!( + clean_output.contains(MARKER), + "expected marker '{MARKER}' in sandbox output:\n{clean_output}" + ); + + // Explicit cleanup (also happens in Drop, but explicit is clearer in tests). + guard.cleanup().await; +} diff --git a/e2e/rust/tests/port_forward.rs b/e2e/rust/tests/port_forward.rs new file mode 100644 index 00000000..b02f2eca --- /dev/null +++ b/e2e/rust/tests/port_forward.rs @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E test: TCP port forwarding through a sandbox. +//! +//! Replaces `e2e/bash/test_port_forward.sh`. +//! +//! Prerequisites: +//! - A running nemoclaw cluster (`nemoclaw cluster admin deploy`) +//! - The `nemoclaw` binary (built automatically from the workspace) + +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use nemoclaw_e2e::harness::port::{find_free_port, wait_for_port}; +use nemoclaw_e2e::harness::sandbox::SandboxGuard; + +/// Python script that runs a single-threaded TCP echo server inside the +/// sandbox. It prints `echo-server-ready` to stdout once listening, which +/// the harness uses as the readiness marker. +fn echo_server_script(port: u16) -> String { + format!( + r" +import socket, sys, signal +signal.signal(signal.SIGHUP, signal.SIG_IGN) +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +port = {port} +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind(('127.0.0.1', port)) +sock.listen(1) +sock.settimeout(300) +print('echo-server-ready', flush=True) +try: + while True: + conn, _ = sock.accept() + data = conn.recv(4096) + if data: + conn.sendall(b'echo:' + data) + conn.close() +except (socket.timeout, OSError): + pass +finally: + sock.close() +" + ) +} + +/// Create a sandbox with a TCP echo server, forward the port locally, send +/// data through it, and verify the echoed response. +#[tokio::test] +async fn port_forward_echo() { + let port = find_free_port(); + let script = echo_server_script(port); + + // --------------------------------------------------------------- + // Step 1 — Create a sandbox with the echo server running. + // --------------------------------------------------------------- + let mut guard = + SandboxGuard::create_keep(&["python3", "-c", &script], "echo-server-ready") + .await + .expect("sandbox create with echo server"); + + // --------------------------------------------------------------- + // Step 2 — Start port forwarding in the background. + // --------------------------------------------------------------- + let mut forward_child = guard + .spawn_forward(port) + .expect("spawn port forward"); + + // Wait for the local port to accept connections. + wait_for_port("127.0.0.1", port, Duration::from_secs(30)) + .await + .expect("local port should open for forwarding"); + + // Give the SSH tunnel a moment to fully establish the direct-tcpip channel. + tokio::time::sleep(Duration::from_secs(2)).await; + + // --------------------------------------------------------------- + // Step 3 — Send data through the forwarded port and verify response. + // --------------------------------------------------------------- + let expected = "echo:hello-nav"; + let mut last_response = String::new(); + + for attempt in 1..=5 { + match try_echo(port).await { + Ok(resp) if resp.starts_with(expected) => { + last_response = resp; + break; + } + Ok(resp) => { + last_response = resp; + eprintln!("attempt {attempt}: unexpected response '{last_response}', retrying..."); + } + Err(e) => { + eprintln!("attempt {attempt}: connection error: {e}, retrying..."); + } + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + + assert!( + last_response.starts_with(expected), + "expected response starting with '{expected}', got '{last_response}'" + ); + + // --------------------------------------------------------------- + // Cleanup — kill forward process, then sandbox guard handles the rest. + // --------------------------------------------------------------- + let _ = forward_child.kill().await; + let _ = forward_child.wait().await; + guard.cleanup().await; +} + +/// Attempt to send `hello-nav\n` to the echo server and read the response. +async fn try_echo(port: u16) -> Result { + let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")) + .await + .map_err(|e| format!("connect: {e}"))?; + + stream + .write_all(b"hello-nav\n") + .await + .map_err(|e| format!("write: {e}"))?; + + let mut buf = vec![0u8; 4096]; + let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buf)) + .await + .map_err(|_| "read timeout".to_string())? + .map_err(|e| format!("read: {e}"))?; + + let response = String::from_utf8_lossy(&buf[..n]) + .trim_end_matches(['\r', '\n']) + .to_string(); + + Ok(response) +} diff --git a/e2e/rust/tests/sync.rs b/e2e/rust/tests/sync.rs new file mode 100644 index 00000000..7b31025d --- /dev/null +++ b/e2e/rust/tests/sync.rs @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E test: bidirectional file sync with a sandbox. +//! +//! Replaces `e2e/bash/test_sandbox_sync.sh`. +//! +//! Prerequisites: +//! - A running nemoclaw cluster (`nemoclaw cluster admin deploy`) +//! - The `nemoclaw` binary (built automatically from the workspace) + +use std::fs; +use std::io::Write; + +use sha2::{Digest, Sha256}; + +use nemoclaw_e2e::harness::sandbox::SandboxGuard; + +/// Create a long-running sandbox, sync files up and down, and verify contents. +/// +/// Covers: +/// 1. Directory round-trip (nested files) +/// 2. Large file round-trip (~512 KiB) with SHA-256 checksum verification +/// 3. Single-file round-trip +#[tokio::test] +async fn sandbox_file_sync_round_trip() { + // --------------------------------------------------------------- + // Step 1 — Create a sandbox with `--keep` running `sleep infinity`. + // --------------------------------------------------------------- + let mut guard = SandboxGuard::create_keep(&["sleep", "infinity"], "Ready") + .await + .expect("sandbox create --keep"); + + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + + // --------------------------------------------------------------- + // Step 2 — Sync up: push a local directory into the sandbox. + // --------------------------------------------------------------- + let upload_dir = tmpdir.path().join("upload"); + fs::create_dir_all(upload_dir.join("subdir")).expect("create upload dirs"); + fs::write(upload_dir.join("greeting.txt"), "hello-from-local").expect("write greeting.txt"); + fs::write(upload_dir.join("subdir/nested.txt"), "nested-content").expect("write nested.txt"); + + let upload_str = upload_dir.to_str().expect("upload path is UTF-8"); + guard + .sync(&["--up", upload_str, "/sandbox/uploaded"]) + .await + .expect("sync --up directory"); + + // --------------------------------------------------------------- + // Step 3 — Sync down: pull the uploaded files back and verify. + // --------------------------------------------------------------- + let download_dir = tmpdir.path().join("download"); + fs::create_dir_all(&download_dir).expect("create download dir"); + + let download_str = download_dir.to_str().expect("download path is UTF-8"); + guard + .sync(&["--down", "/sandbox/uploaded", download_str]) + .await + .expect("sync --down directory"); + + // Verify top-level file. + let greeting = fs::read_to_string(download_dir.join("greeting.txt")) + .expect("read greeting.txt after sync down"); + assert_eq!( + greeting, "hello-from-local", + "greeting.txt content mismatch" + ); + + // Verify nested file. + let nested = fs::read_to_string(download_dir.join("subdir/nested.txt")) + .expect("read subdir/nested.txt after sync down"); + assert_eq!(nested, "nested-content", "subdir/nested.txt content mismatch"); + + // --------------------------------------------------------------- + // Step 4 — Large-file round-trip (~512 KiB) to exercise multi-chunk + // SSH transport. + // --------------------------------------------------------------- + let large_dir = tmpdir.path().join("large_upload"); + fs::create_dir_all(&large_dir).expect("create large_upload dir"); + + let large_file = large_dir.join("large.bin"); + { + let mut f = fs::File::create(&large_file).expect("create large.bin"); + let mut rng_data = vec![0u8; 512 * 1024]; // 512 KiB + rand::fill(&mut rng_data[..]); + f.write_all(&rng_data).expect("write large.bin"); + } + + let expected_hash = { + let data = fs::read(&large_file).expect("read large.bin for hash"); + let mut hasher = Sha256::new(); + hasher.update(&data); + hex::encode(hasher.finalize()) + }; + + let large_dir_str = large_dir.to_str().expect("large_dir path is UTF-8"); + guard + .sync(&["--up", large_dir_str, "/sandbox/large_test"]) + .await + .expect("sync --up large file"); + + let large_down = tmpdir.path().join("large_download"); + fs::create_dir_all(&large_down).expect("create large_download dir"); + + let large_down_str = large_down.to_str().expect("large_down path is UTF-8"); + guard + .sync(&["--down", "/sandbox/large_test", large_down_str]) + .await + .expect("sync --down large file"); + + let actual_data = fs::read(large_down.join("large.bin")).expect("read large.bin after sync"); + let actual_hash = { + let mut hasher = Sha256::new(); + hasher.update(&actual_data); + hex::encode(hasher.finalize()) + }; + + assert_eq!( + expected_hash, actual_hash, + "large.bin SHA-256 mismatch after round-trip" + ); + assert_eq!( + actual_data.len(), + 512 * 1024, + "large.bin size mismatch: expected {} bytes, got {}", + 512 * 1024, + actual_data.len() + ); + + // --------------------------------------------------------------- + // Step 5 — Single-file round-trip. + // --------------------------------------------------------------- + let single_file = tmpdir.path().join("single.txt"); + fs::write(&single_file, "single-file-payload").expect("write single.txt"); + + let single_str = single_file.to_str().expect("single path is UTF-8"); + guard + .sync(&["--up", single_str, "/sandbox"]) + .await + .expect("sync --up single file"); + + let single_down = tmpdir.path().join("single_down"); + fs::create_dir_all(&single_down).expect("create single_down dir"); + + let single_down_str = single_down.to_str().expect("single_down path is UTF-8"); + guard + .sync(&["--down", "/sandbox/single.txt", single_down_str]) + .await + .expect("sync --down single file"); + + let single_content = fs::read_to_string(single_down.join("single.txt")) + .expect("read single.txt after sync"); + assert_eq!( + single_content, "single-file-payload", + "single.txt content mismatch" + ); + + // --------------------------------------------------------------- + // Cleanup (guard also cleans up on drop). + // --------------------------------------------------------------- + guard.cleanup().await; +} diff --git a/tasks/test.toml b/tasks/test.toml index 96d08737..b34f47d7 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -8,13 +8,8 @@ description = "Run all tests (Rust + Python)" depends = ["test:rust", "test:python"] [e2e] -description = "Run default end-to-end test lane" -depends = ["test:e2e:sandbox"] - -["test:e2e"] -description = "Alias for e2e" -depends = ["e2e"] -hide = true +description = "Run all end-to-end tests (Rust + Python)" +depends = ["e2e:rust", "e2e:python"] ["test:rust"] description = "Run Rust tests" @@ -28,27 +23,13 @@ env = { UV_NO_SYNC = "1" } run = "uv run pytest python/" hide = true -["test:e2e:sandbox"] -description = "Run sandbox end-to-end tests (E2E_PARALLEL=N or 'auto'; default 5)" +["e2e:rust"] +description = "Run Rust CLI e2e tests (requires a running cluster)" +depends = ["cluster"] +run = ["cargo build -p navigator-cli", "cargo test --manifest-path e2e/rust/Cargo.toml --features e2e"] + +["e2e:python"] +description = "Run Python e2e tests (E2E_PARALLEL=N or 'auto'; default 5)" depends = ["python:proto", "cluster"] env = { UV_NO_SYNC = "1", PYTHONPATH = "python" } run = "uv run pytest -o python_files='test_*.py' -n ${E2E_PARALLEL:-5} e2e/python" -hide = true - -["test:e2e:port-forward"] -description = "Run port-forward integration test" -depends = ["cluster"] -run = "bash e2e/bash/test_port_forward.sh" -hide = true - -["test:e2e:custom-image"] -description = "Run custom image build and sandbox e2e test" -depends = ["cluster"] -run = "bash e2e/bash/test_sandbox_custom_image.sh" -hide = true - -["test:e2e:sync"] -description = "Run sandbox file sync e2e test" -depends = ["cluster"] -run = "bash e2e/bash/test_sandbox_sync.sh" -hide = true