|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 3 | +# SPDX-License-Identifier: Apache-2.0 |
| 4 | +# |
| 5 | +# ============================================================================= |
| 6 | +# test-dashboard-reachability.sh |
| 7 | +# NemoClaw OpenClaw Dashboard Reachability E2E Test |
| 8 | +# |
| 9 | +# Covers: TC-DASH-01 through TC-DASH-03 |
| 10 | +# Verifies the host → pod serving chain for the OpenClaw dashboard (default |
| 11 | +# port 18789): port bound on host, HTTP 200 response, and a body-signature |
| 12 | +# check so an unrelated process binding the port cannot silently pass. |
| 13 | +# ============================================================================= |
| 14 | + |
| 15 | +set -euo pipefail |
| 16 | + |
| 17 | +# ── Overall timeout (prevents hung CI jobs) ────────────────────────────────── |
| 18 | +if [ -z "${NEMOCLAW_E2E_NO_TIMEOUT:-}" ]; then |
| 19 | + export NEMOCLAW_E2E_NO_TIMEOUT=1 |
| 20 | + TIMEOUT_SECONDS="${NEMOCLAW_E2E_TIMEOUT_SECONDS:-1800}" |
| 21 | + if command -v timeout >/dev/null 2>&1; then |
| 22 | + exec timeout -s TERM "$TIMEOUT_SECONDS" bash "$0" "$@" |
| 23 | + elif command -v gtimeout >/dev/null 2>&1; then |
| 24 | + exec gtimeout -s TERM "$TIMEOUT_SECONDS" bash "$0" "$@" |
| 25 | + fi |
| 26 | +fi |
| 27 | + |
| 28 | +# ── Config ─────────────────────────────────────────────────────────────────── |
| 29 | +SANDBOX="test-dash" |
| 30 | +DASHBOARD_PORT="${NEMOCLAW_DASHBOARD_PORT:-18789}" |
| 31 | +DASHBOARD_URL="http://127.0.0.1:${DASHBOARD_PORT}/" |
| 32 | +POLL_ATTEMPTS=30 |
| 33 | +POLL_INTERVAL=1 |
| 34 | +LOG_FILE="test-dashboard-reachability-$(date +%Y%m%d-%H%M%S).log" |
| 35 | + |
| 36 | +# macOS uses gtimeout (from coreutils); Linux uses timeout |
| 37 | +if command -v gtimeout &>/dev/null; then |
| 38 | + TIMEOUT_CMD="gtimeout" |
| 39 | +elif command -v timeout &>/dev/null; then |
| 40 | + TIMEOUT_CMD="timeout" |
| 41 | +else |
| 42 | + echo "ERROR: Neither timeout nor gtimeout found. Install coreutils: brew install coreutils" |
| 43 | + exit 1 |
| 44 | +fi |
| 45 | + |
| 46 | +RED='\033[0;31m' |
| 47 | +GREEN='\033[0;32m' |
| 48 | +YELLOW='\033[1;33m' |
| 49 | +CYAN='\033[0;36m' |
| 50 | +NC='\033[0m' |
| 51 | + |
| 52 | +# ── Counters ───────────────────────────────────────────────────────────────── |
| 53 | +PASS=0 |
| 54 | +FAIL=0 |
| 55 | +TOTAL=0 |
| 56 | + |
| 57 | +# ── Helpers ────────────────────────────────────────────────────────────────── |
| 58 | +log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $*" | tee -a "$LOG_FILE"; } |
| 59 | +pass() { |
| 60 | + ((PASS += 1)) |
| 61 | + ((TOTAL += 1)) |
| 62 | + echo -e "${GREEN} PASS${NC} $1" | tee -a "$LOG_FILE" |
| 63 | +} |
| 64 | +fail() { |
| 65 | + ((FAIL += 1)) |
| 66 | + ((TOTAL += 1)) |
| 67 | + echo -e "${RED} FAIL${NC} $1 — $2" | tee -a "$LOG_FILE" |
| 68 | +} |
| 69 | + |
| 70 | +# Onboard the test sandbox in non-interactive mode. Returns 0 if the sandbox |
| 71 | +# appears in nemoclaw list. |
| 72 | +onboard_sandbox() { |
| 73 | + local name="$1" |
| 74 | + log " Onboarding sandbox '$name'..." |
| 75 | + |
| 76 | + rm -f "$HOME/.nemoclaw/onboard.lock" 2>/dev/null || true |
| 77 | + |
| 78 | + local onboard_exit=0 |
| 79 | + NEMOCLAW_SANDBOX_NAME="$name" \ |
| 80 | + NEMOCLAW_NON_INTERACTIVE=1 \ |
| 81 | + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ |
| 82 | + NEMOCLAW_RECREATE_SANDBOX=1 \ |
| 83 | + nemoclaw onboard --non-interactive --yes-i-accept-third-party-software \ |
| 84 | + 2>&1 | tee -a "$LOG_FILE" || onboard_exit=$? |
| 85 | + |
| 86 | + if [[ $onboard_exit -ne 0 ]]; then |
| 87 | + log " [onboard_sandbox] nemoclaw onboard exited with code $onboard_exit" |
| 88 | + return 1 |
| 89 | + fi |
| 90 | + |
| 91 | + if ! nemoclaw list 2>/dev/null | grep -q "$name"; then |
| 92 | + log " [onboard_sandbox] Sandbox '$name' not found in nemoclaw list after onboard" |
| 93 | + return 1 |
| 94 | + fi |
| 95 | + return 0 |
| 96 | +} |
| 97 | + |
| 98 | +# ── Resolve repo root ──────────────────────────────────────────────────────── |
| 99 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" |
| 100 | +if [ -f "$SCRIPT_DIR/../../install.sh" ]; then |
| 101 | + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" |
| 102 | +elif [ -f "./install.sh" ]; then |
| 103 | + REPO_ROOT="$(pwd)" |
| 104 | +else |
| 105 | + echo "ERROR: Cannot find install.sh — run from the repo root or test/e2e/" |
| 106 | + exit 1 |
| 107 | +fi |
| 108 | + |
| 109 | +# ── Install NemoClaw if not present ────────────────────────────────────────── |
| 110 | +install_nemoclaw() { |
| 111 | + if command -v nemoclaw &>/dev/null; then |
| 112 | + log "nemoclaw already installed: $(nemoclaw --version 2>/dev/null || echo 'unknown')" |
| 113 | + return 0 |
| 114 | + fi |
| 115 | + |
| 116 | + log "=== Installing NemoClaw via install.sh ===" |
| 117 | + |
| 118 | + local install_exit=0 |
| 119 | + bash "$REPO_ROOT/install.sh" --non-interactive --yes-i-accept-third-party-software \ |
| 120 | + 2>&1 | tee -a "$LOG_FILE" || install_exit=$? |
| 121 | + |
| 122 | + if [ -f "$HOME/.bashrc" ]; then |
| 123 | + # shellcheck source=/dev/null |
| 124 | + source "$HOME/.bashrc" 2>/dev/null || true |
| 125 | + fi |
| 126 | + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" |
| 127 | + if [ -s "$NVM_DIR/nvm.sh" ]; then |
| 128 | + # shellcheck source=/dev/null |
| 129 | + . "$NVM_DIR/nvm.sh" |
| 130 | + fi |
| 131 | + if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then |
| 132 | + export PATH="$HOME/.local/bin:$PATH" |
| 133 | + fi |
| 134 | + |
| 135 | + if [[ $install_exit -ne 0 ]]; then |
| 136 | + echo -e "${RED}FATAL: install.sh failed (exit $install_exit)${NC}" |
| 137 | + exit 1 |
| 138 | + fi |
| 139 | + |
| 140 | + if ! command -v nemoclaw &>/dev/null; then |
| 141 | + echo -e "${RED}FATAL: nemoclaw not found on PATH after install${NC}" |
| 142 | + exit 1 |
| 143 | + fi |
| 144 | + |
| 145 | + log "nemoclaw installed: $(nemoclaw --version 2>/dev/null || echo 'unknown')" |
| 146 | + |
| 147 | + local install_sandbox |
| 148 | + install_sandbox="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" |
| 149 | + if nemoclaw list 2>/dev/null | grep -q "$install_sandbox"; then |
| 150 | + log "Destroying install sandbox '$install_sandbox'..." |
| 151 | + nemoclaw "$install_sandbox" destroy --yes 2>/dev/null || true |
| 152 | + fi |
| 153 | +} |
| 154 | + |
| 155 | +# ── Pre-flight ─────────────────────────────────────────────────────────────── |
| 156 | +preflight() { |
| 157 | + log "=== Pre-flight checks ===" |
| 158 | + |
| 159 | + if ! docker info &>/dev/null; then |
| 160 | + echo -e "${RED}ERROR: Docker is not running.${NC}" |
| 161 | + exit 1 |
| 162 | + fi |
| 163 | + log "Docker is running" |
| 164 | + |
| 165 | + if [[ -z "${NVIDIA_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then |
| 166 | + echo -e "${YELLOW}WARNING: No API key detected.${NC}" |
| 167 | + fi |
| 168 | + |
| 169 | + install_nemoclaw |
| 170 | + |
| 171 | + log "nemoclaw: $(nemoclaw --version 2>/dev/null || echo 'unknown')" |
| 172 | + log "openshell: $(openshell --version 2>&1 | head -1 || echo 'unknown')" |
| 173 | + log "dashboard port: $DASHBOARD_PORT" |
| 174 | + log "timeout: $TIMEOUT_CMD" |
| 175 | + |
| 176 | + if [[ -f "$HOME/.nemoclaw/onboard.lock" ]]; then |
| 177 | + log "Removing stale onboard lock" |
| 178 | + rm -f "$HOME/.nemoclaw/onboard.lock" |
| 179 | + fi |
| 180 | + |
| 181 | + if nemoclaw list 2>/dev/null | grep -q "$SANDBOX"; then |
| 182 | + log "Cleaning up leftover sandbox: $SANDBOX" |
| 183 | + nemoclaw "$SANDBOX" destroy --yes 2>/dev/null || true |
| 184 | + fi |
| 185 | + |
| 186 | + log "Pre-flight complete" |
| 187 | + echo "" |
| 188 | +} |
| 189 | + |
| 190 | +# ── Setup: Onboard the test sandbox ───────────────────────────────────────── |
| 191 | +setup_sandbox() { |
| 192 | + log "=== Setup: Onboarding sandbox '$SANDBOX' ===" |
| 193 | + log "This may take a few minutes..." |
| 194 | + |
| 195 | + if ! onboard_sandbox "$SANDBOX"; then |
| 196 | + echo -e "${RED}FATAL: Onboard failed — sandbox '$SANDBOX' not found.${NC}" |
| 197 | + exit 1 |
| 198 | + fi |
| 199 | + |
| 200 | + # Defensively re-establish the port-forward. nemoclaw onboard already |
| 201 | + # starts it, but an earlier crashed run can leave a stale entry and the |
| 202 | + # dashboard test is meaningless without a live forward. |
| 203 | + log "Ensuring port-forward on $DASHBOARD_PORT..." |
| 204 | + openshell forward start --background "$DASHBOARD_PORT" "$SANDBOX" \ |
| 205 | + >>"$LOG_FILE" 2>&1 || log " forward start returned non-zero (may already be running)" |
| 206 | + |
| 207 | + log "Sandbox '$SANDBOX' onboarded successfully" |
| 208 | + echo "" |
| 209 | +} |
| 210 | + |
| 211 | +# ============================================================================= |
| 212 | +# Test cases |
| 213 | +# ============================================================================= |
| 214 | + |
| 215 | +# ── TC-DASH-01: Dashboard port bound on host ───────────────────────────────── |
| 216 | +# Confirms the port-forward exists before we try HTTP. Separating this from |
| 217 | +# the HTTP check gives a clearer failure signal: if the port is not bound at |
| 218 | +# all, it's a forward-layer problem, not a gateway-process problem. |
| 219 | +test_dash_01_port_bound() { |
| 220 | + log "=== TC-DASH-01: Dashboard port bound on host ===" |
| 221 | + |
| 222 | + if lsof -iTCP:"$DASHBOARD_PORT" -sTCP:LISTEN >/dev/null 2>&1; then |
| 223 | + pass "TC-DASH-01: Port $DASHBOARD_PORT is bound" |
| 224 | + else |
| 225 | + fail "TC-DASH-01: Dashboard port bound" \ |
| 226 | + "Nothing listening on $DASHBOARD_PORT — port-forward not established" |
| 227 | + fi |
| 228 | +} |
| 229 | + |
| 230 | +# ── TC-DASH-02: Dashboard returns HTTP 200 ────────────────────────────────── |
| 231 | +# Polls the dashboard up to POLL_ATTEMPTS × POLL_INTERVAL seconds. The |
| 232 | +# gateway can take several seconds after onboard to start accepting |
| 233 | +# connections, so a single-shot check would be flaky. |
| 234 | +test_dash_02_http_200() { |
| 235 | + log "=== TC-DASH-02: Dashboard returns HTTP 200 ===" |
| 236 | + |
| 237 | + local status="" |
| 238 | + local i |
| 239 | + for i in $(seq 1 "$POLL_ATTEMPTS"); do |
| 240 | + status=$(curl -s -o /dev/null -w '%{http_code}' \ |
| 241 | + --max-time 5 "$DASHBOARD_URL" 2>/dev/null || echo "000") |
| 242 | + if [[ "$status" == "200" ]]; then |
| 243 | + pass "TC-DASH-02: HTTP 200 after ${i}s" |
| 244 | + return |
| 245 | + fi |
| 246 | + sleep "$POLL_INTERVAL" |
| 247 | + done |
| 248 | + |
| 249 | + fail "TC-DASH-02: Dashboard HTTP 200" \ |
| 250 | + "Last status after ${POLL_ATTEMPTS}s: $status (expected 200)" |
| 251 | +} |
| 252 | + |
| 253 | +# ── TC-DASH-03: Response body signature ───────────────────────────────────── |
| 254 | +# Guards against an unrelated process binding the dashboard port. The real |
| 255 | +# OpenClaw dashboard is an HTML page identifying itself in the body; any |
| 256 | +# other service returning 200 would not match. |
| 257 | +test_dash_03_body_signature() { |
| 258 | + log "=== TC-DASH-03: Response body signature ===" |
| 259 | + |
| 260 | + local body |
| 261 | + body=$(curl -s --max-time 10 "$DASHBOARD_URL" 2>/dev/null || true) |
| 262 | + |
| 263 | + if [[ -z "$body" ]]; then |
| 264 | + fail "TC-DASH-03: Body signature" "Empty response body" |
| 265 | + return |
| 266 | + fi |
| 267 | + |
| 268 | + # Primary: looks like HTML. |
| 269 | + if ! echo "$body" | grep -qiE '<html|<!doctype'; then |
| 270 | + fail "TC-DASH-03: Body signature" \ |
| 271 | + "Response is not HTML — something else is bound to $DASHBOARD_PORT" |
| 272 | + return |
| 273 | + fi |
| 274 | + |
| 275 | + # Secondary: body or <title> contains an OpenClaw / Control UI marker. |
| 276 | + if echo "$body" | grep -qiE 'openclaw|control[- ]?ui|nemoclaw'; then |
| 277 | + pass "TC-DASH-03: Response body identifies as OpenClaw dashboard" |
| 278 | + else |
| 279 | + fail "TC-DASH-03: Body signature" \ |
| 280 | + "HTML served but no OpenClaw/Control-UI marker in body" |
| 281 | + fi |
| 282 | +} |
| 283 | + |
| 284 | +# ── Teardown ───────────────────────────────────────────────────────────────── |
| 285 | +teardown() { |
| 286 | + # Disable errexit during teardown — cleanup must be best-effort |
| 287 | + set +e |
| 288 | + log "" |
| 289 | + log "=== Teardown ===" |
| 290 | + openshell forward stop "$DASHBOARD_PORT" 2>/dev/null || true |
| 291 | + if nemoclaw list 2>/dev/null | grep -q "$SANDBOX"; then |
| 292 | + log "Destroying sandbox '$SANDBOX'..." |
| 293 | + nemoclaw "$SANDBOX" destroy --yes 2>/dev/null || true |
| 294 | + fi |
| 295 | + openshell gateway destroy -g nemoclaw 2>/dev/null || true |
| 296 | + rm -f "$HOME/.nemoclaw/onboard.lock" 2>/dev/null || true |
| 297 | + log "Teardown complete" |
| 298 | + set -e |
| 299 | +} |
| 300 | + |
| 301 | +# ── Summary ────────────────────────────────────────────────────────────────── |
| 302 | +summary() { |
| 303 | + echo "" |
| 304 | + echo "============================================================" |
| 305 | + echo " TEST SUMMARY" |
| 306 | + echo "============================================================" |
| 307 | + echo -e " ${GREEN}PASS: $PASS${NC}" |
| 308 | + echo -e " ${RED}FAIL: $FAIL${NC}" |
| 309 | + echo " TOTAL: $TOTAL" |
| 310 | + echo "============================================================" |
| 311 | + echo " Log: $LOG_FILE" |
| 312 | + echo "============================================================" |
| 313 | + echo "" |
| 314 | + |
| 315 | + if [[ $FAIL -gt 0 ]]; then |
| 316 | + exit 1 |
| 317 | + fi |
| 318 | + exit 0 |
| 319 | +} |
| 320 | + |
| 321 | +# ── Main ───────────────────────────────────────────────────────────────────── |
| 322 | +main() { |
| 323 | + echo "" |
| 324 | + echo "============================================================" |
| 325 | + echo " NemoClaw Dashboard Reachability E2E Test" |
| 326 | + echo " $(date)" |
| 327 | + echo "============================================================" |
| 328 | + echo "" |
| 329 | + |
| 330 | + preflight |
| 331 | + setup_sandbox |
| 332 | + |
| 333 | + test_dash_01_port_bound |
| 334 | + test_dash_02_http_200 |
| 335 | + test_dash_03_body_signature |
| 336 | + |
| 337 | + trap - EXIT |
| 338 | + teardown |
| 339 | + summary |
| 340 | +} |
| 341 | + |
| 342 | +trap teardown EXIT |
| 343 | +main "$@" |
0 commit comments