#!/usr/bin/env bash # tests/run-godot: Run Godot client test suite via gdUnit4 (D-030) # # Exit: 0 = all pass, non-zero = failure. # Stdout: JSON summary on ONE line, plus a pointer to the full log. # {"suite":"godot","total":N,"passed":N,"failed":N,"duration_ms":N,"log":"/tmp/sr-run-godot.log"} # (on timeout: same JSON + "timeout":true, "timeout_sec":N; exit code 124) # Stderr: a short hint line pointing at the log. The Godot/gdUnit4 output # does NOT stream to stdout or stderr — it is captured to the log file. # This is intentional: streaming 20k+ lines of test log into an LLM # caller's context is unworkable. Inspect the log with the commands the # hint line suggests. # # --filter : narrow the test run to a single file. set -euo pipefail # Hard wall-clock cap. Sprint 36 lost an hour to a hung test suite that # silently consumed CPU forever. 300s is generous for the full suite # (which currently runs in ~60s) and well above the slowest single suite # (~40s for the compositor build). If you need longer for an unusual # workload (fixture regen, etc.), prefer adding a dedicated script over # extending this cap — the cap is the point. TIMEOUT_SEC=300 # Per-process log path. PID suffix prevents concurrent runs across worktrees # from clobbering each other's logs and producing summary JSON that mixes # counts from different suites (R2-Hoshe-2). The actual path is echoed back # via the JSON "log" field and the stderr hint line, so callers don't need # to predict it. LOG_FILE="/tmp/sr-run-godot.$$.log" FILTER="" while [[ $# -gt 0 ]]; do case "$1" in --filter) FILTER="${2:-}"; shift 2 ;; --filter=*) FILTER="${1#--filter=}"; shift ;; *) echo "Unknown argument: $1" >&2; exit 2 ;; esac done REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" GODOT=$(command -v godot4 2>/dev/null || command -v godot 2>/dev/null || echo "") if [[ -z "$GODOT" ]]; then printf '{"suite":"godot","total":0,"passed":0,"failed":0,"duration_ms":0,"error":"godot not found in PATH"}\n' exit 1 fi # Resolve the test target: directory or specific file if [[ -n "$FILTER" ]]; then # Support bare name (test_protocol) or full path (test_protocol.gd) if [[ "$FILTER" == res://* ]]; then TEST_TARGET="$FILTER" elif [[ "$FILTER" == *.gd ]]; then TEST_TARGET="res://tests/$FILTER" else TEST_TARGET="res://tests/${FILTER}.gd" fi else TEST_TARGET="res://tests/" fi START_MS=$(date +%s%3N) # Redirect Godot+gdUnit4 output to the log file. Nothing streams to the # caller — the summary JSON (stdout) and the hint line (stderr) are the # only things the caller ever sees. See header comment for rationale. set +e timeout --foreground --kill-after=10 "$TIMEOUT_SEC" \ "$GODOT" --headless --path "$REPO_ROOT/client" \ -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ --ignoreHeadlessMode \ -c \ -a "$TEST_TARGET" \ > "$LOG_FILE" 2>&1 EXIT_CODE=$? set -e END_MS=$(date +%s%3N) DURATION_MS=$((END_MS - START_MS)) # timeout(1) exit code 124 = wall clock exceeded; 137 = needed SIGKILL. TIMED_OUT=false if [[ "$EXIT_CODE" -eq 124 || "$EXIT_CODE" -eq 137 ]]; then TIMED_OUT=true fi # gdUnit4 outputs per-suite statistics: "N test cases | X errors | Y failures | ..." TOTAL=0; PASSED=0; FAILED=0 STATS_LINES=$(grep -oE "[0-9]+ test cases \| [0-9]+ errors \| [0-9]+ failures" "$LOG_FILE" || true) if [[ -n "$STATS_LINES" ]]; then TOTAL=$(echo "$STATS_LINES" | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') ERRORS=$(echo "$STATS_LINES" | grep -oE '[0-9]+ errors' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') FAILURES=$(echo "$STATS_LINES" | grep -oE '[0-9]+ failures' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') FAILED=$(( ${ERRORS:-0} + ${FAILURES:-0} )) PASSED=$(( TOTAL - FAILED )) fi # Fallback: parse "Executed test cases : (X/N)" for total if stats parse failed if [[ "$TOTAL" -eq 0 ]]; then EXEC_LINE=$(grep -oE "Executed test cases : \([0-9]+/[0-9]+\)" "$LOG_FILE" | tail -1 || true) if [[ -n "$EXEC_LINE" ]]; then TOTAL=$(echo "$EXEC_LINE" | grep -oE '/[0-9]+\)' | grep -oE '[0-9]+') PASSED=$(echo "$EXEC_LINE" | grep -oE '\([0-9]+/' | grep -oE '[0-9]+') FAILED=$(( TOTAL - PASSED )) fi fi LOG_LINES=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0) # Single-line JSON summary on stdout — machine-parseable, small. if [[ "$TIMED_OUT" == "true" ]]; then printf '{"suite":"godot","total":%d,"passed":%d,"failed":%d,"duration_ms":%d,"timeout":true,"timeout_sec":%d,"log":"%s"}\n' \ "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" "$TIMEOUT_SEC" "$LOG_FILE" else printf '{"suite":"godot","total":%d,"passed":%d,"failed":%d,"duration_ms":%d,"log":"%s"}\n' \ "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" "$LOG_FILE" fi # Hint line on stderr. Stays short. LLM callers: read this literally. if [[ "$TIMED_OUT" == "true" ]]; then cat >&2 <} target=${TEST_TARGET} log=${LOG_FILE} (${LOG_LINES} lines) This is NOT a regular test failure — the process was force-terminated. A hung test almost always means a cleanup hook froze (e.g. after_test freeing GdUnit4 infrastructure) or an assertion waits on a signal that never fires. Bisect: grep -E 'STARTED|PASSED|FAILED' ${LOG_FILE} | tail -20 The last STARTED without a matching PASSED/FAILED is the hang site. EOF elif [[ "${FAILED:-0}" -gt 0 ]]; then cat >&2 <' '{print \$1}' | sort | uniq -c | sort -rn First failure block with context: sed 's/\x1b\[[0-9;]*m//g' ${LOG_FILE} | grep -n 'FAILED\|Expecting\|Godot Runtime Error' | head -40 EOF else echo "Tests passed. log=${LOG_FILE} (${LOG_LINES} lines)" >&2 fi exit $EXIT_CODE