Skip to content

Commit 4e4a05d

Browse files
committed
test(e2e): add dashboard reachability coverage
Adds test/e2e/test-dashboard-reachability.sh validating that the OpenClaw dashboard is reachable from the host on the forwarded port after onboard: port bound (polled), HTTP 200 (polled), HTML body signature (soft marker check). Wires it into nightly-e2e.yaml as a new top-level job with a 30-minute timeout and adds it to notify-on-failure. Closes #2100 Signed-off-by: Evan Takahashi <evan10takahashi@gmail.com>
1 parent a7dca9c commit 4e4a05d

2 files changed

Lines changed: 373 additions & 2 deletions

File tree

.github/workflows/nightly-e2e.yaml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,34 @@ jobs:
356356
path: test-sandbox-operations-*.log
357357
if-no-files-found: ignore
358358

359+
# ── Dashboard reachability ───────────────────────────────────
360+
# Validates the OpenClaw dashboard is reachable from the host on the
361+
# forwarded port after onboard: port bound, HTTP 200, HTML body signature.
362+
dashboard-reachability-e2e:
363+
if: github.repository == 'NVIDIA/NemoClaw'
364+
runs-on: ubuntu-latest
365+
timeout-minutes: 30
366+
steps:
367+
- name: Checkout
368+
uses: actions/checkout@v6
369+
370+
- name: Run dashboard reachability E2E test
371+
env:
372+
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
373+
NEMOCLAW_NON_INTERACTIVE: "1"
374+
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
375+
NEMOCLAW_POLICY_TIER: "open"
376+
GITHUB_TOKEN: ${{ github.token }}
377+
run: bash test/e2e/test-dashboard-reachability.sh
378+
379+
- name: Upload test log on failure
380+
if: failure()
381+
uses: actions/upload-artifact@v4
382+
with:
383+
name: dashboard-reachability-test-log
384+
path: test-dashboard-reachability-*.log
385+
if-no-files-found: ignore
386+
359387
# ── Inference routing (credential isolation + error classification) ──
360388
# TC-INF-05: real API key absent from sandbox env/process/filesystem
361389
# TC-INF-06: invalid API key → classified credential error (PR-safe)
@@ -610,8 +638,8 @@ jobs:
610638

611639
notify-on-failure:
612640
runs-on: ubuntu-latest
613-
needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, inference-routing-e2e, network-policy-e2e, snapshot-commands-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e]
614-
if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.token-rotation-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.hermes-e2e.result == 'failure' || needs.skip-permissions-e2e.result == 'failure' || needs.sandbox-operations-e2e.result == 'failure' || needs.inference-routing-e2e.result == 'failure' || needs.network-policy-e2e.result == 'failure' || needs.snapshot-commands-e2e.result == 'failure' || needs.shields-config-e2e.result == 'failure' || needs.rebuild-openclaw-e2e.result == 'failure' || needs.upgrade-stale-sandbox-e2e.result == 'failure' || needs.rebuild-hermes-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }}
641+
needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, token-rotation-e2e, sandbox-survival-e2e, hermes-e2e, skip-permissions-e2e, sandbox-operations-e2e, dashboard-reachability-e2e, inference-routing-e2e, network-policy-e2e, snapshot-commands-e2e, shields-config-e2e, rebuild-openclaw-e2e, upgrade-stale-sandbox-e2e, rebuild-hermes-e2e, gpu-e2e]
642+
if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.token-rotation-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.hermes-e2e.result == 'failure' || needs.skip-permissions-e2e.result == 'failure' || needs.sandbox-operations-e2e.result == 'failure' || needs.dashboard-reachability-e2e.result == 'failure' || needs.inference-routing-e2e.result == 'failure' || needs.network-policy-e2e.result == 'failure' || needs.snapshot-commands-e2e.result == 'failure' || needs.shields-config-e2e.result == 'failure' || needs.rebuild-openclaw-e2e.result == 'failure' || needs.upgrade-stale-sandbox-e2e.result == 'failure' || needs.rebuild-hermes-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }}
615643
permissions:
616644
issues: write
617645
steps:
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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

Comments
 (0)