Found by walking into it. test_step_canvas_annotation_layer.gd had a parse error from an earlier edit in this session, so gdUnit4 could not load it and ran the other suites instead. The harness printed 3610 passed / 0 failed and exit 0. Fifty tests had not run for hours and nothing said so — the full suite reports 3660 with the file repaired, and that difference was invisible. Two states are now hard harness failures rather than test results: load_error — a suite failed to LOAD. Any pass count excludes it, so a green number is a lie. The hint names the offending file. no_tests — zero tests executed. A run that executes nothing can never be a pass; previously a mistyped --filter printed "Tests passed". Both add a "harness_error" field to the summary JSON and exit 2. The exit code cannot inherit gdUnit4's, which returns 0 in both states — that is precisely why they were invisible. Verified by injecting each failure rather than by reasoning about it. The load_error guard was checked in the case that actually matters: one broken file among many, where total stays large and failed stays zero. That run now reports 3610/0 WITH harness_error and exits 2, where before it was indistinguishable from success. Also repairs the file itself: a missed set_frame() argument (the parse error), and a cell-placement test still asserting pre-inversion spacing. Rewritten to assert the invariant that survives the extent inversion, the viewport aspect ratio and panning — half the SHORT axis is half a rung cell — instead of a literal. Two things it deliberately does not assert, both of which the previous version got wrong: "the corner is half a district away" holds only on a square canvas, and the canvas is one district WIDE without sitting ON a district. It is a free-floating window centred wherever the player panned; zoom is stepped, pan is continuous. A rung names a scale, not a cell you are inside. A second test pins that with a deliberately unaligned world centre, so a future change that snaps the canvas to the rung lattice — making pan step instead of slide — fails here. Pair session with Jeroen, 2026-07-27. Co-Authored-By: Claude <noreply@anthropic.com>
191 lines
8.4 KiB
Bash
Executable File
191 lines
8.4 KiB
Bash
Executable File
#!/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 <stem|filename|res-path>: 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
|
|
|
|
# --- Harness-integrity guards (2026-07-26) -------------------------------
|
|
# A test file that fails to PARSE never runs, and gdUnit4 reports whatever
|
|
# DID run as a clean pass — so a broken suite reads as success. Both halves
|
|
# of that bit on the same day:
|
|
# * a filtered run printed {"total":0,...} followed by "Tests passed";
|
|
# * a full run printed 3610 passed / 0 failed while silently dropping an
|
|
# entire suite whose parse error had gone unnoticed for hours.
|
|
# Neither is a test RESULT, so neither may be reported as one. A run that
|
|
# executed zero tests is never a pass, and a run that could not load a suite
|
|
# is a harness failure regardless of how many other suites went green.
|
|
LOAD_ERROR_COUNT=$(grep -c 'Failed to load script' "$LOG_FILE" 2>/dev/null || true)
|
|
LOAD_ERROR_LIST=$(grep -oE 'Failed to load script "[^"]+"' "$LOG_FILE" 2>/dev/null | sort -u || true)
|
|
HARNESS_ERROR=""
|
|
if [[ "${LOAD_ERROR_COUNT:-0}" -gt 0 ]]; then
|
|
HARNESS_ERROR="load_error"
|
|
elif [[ "$TOTAL" -eq 0 && "$TIMED_OUT" != "true" ]]; then
|
|
HARNESS_ERROR="no_tests"
|
|
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"
|
|
elif [[ -n "$HARNESS_ERROR" ]]; then
|
|
printf '{"suite":"godot","total":%d,"passed":%d,"failed":%d,"duration_ms":%d,"harness_error":"%s","log":"%s"}\n' \
|
|
"${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" "$HARNESS_ERROR" "$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 <<EOF
|
|
TEST_TIMEOUT: tests/run-godot exceeded ${TIMEOUT_SEC}s wall-clock cap (killed=${EXIT_CODE}).
|
|
filter=${FILTER:-<none>} 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 [[ "$HARNESS_ERROR" == "load_error" ]]; then
|
|
cat >&2 <<EOF
|
|
HARNESS_ERROR: a test suite failed to LOAD — this is not a test result.
|
|
${LOAD_ERROR_LIST}
|
|
Those suites did not run at all. Any pass count above EXCLUDES them, so a
|
|
green number here would be a lie. Usually a parse error (arity change, a
|
|
renamed symbol, a stale call signature) in the listed file. Find it with:
|
|
grep -n 'Parse Error' ${LOG_FILE} | head
|
|
log=${LOG_FILE} (${LOG_LINES} lines)
|
|
EOF
|
|
elif [[ "$HARNESS_ERROR" == "no_tests" ]]; then
|
|
cat >&2 <<EOF
|
|
HARNESS_ERROR: zero tests executed — this is not a pass.
|
|
filter=${FILTER:-<none>} target=${TEST_TARGET} log=${LOG_FILE} (${LOG_LINES} lines)
|
|
Either the filter matched no suite (check the name), or the target failed to
|
|
load. A run that executes nothing can never be reported as success.
|
|
EOF
|
|
elif [[ "${FAILED:-0}" -gt 0 ]]; then
|
|
cat >&2 <<EOF
|
|
Tests finished with failures. log=${LOG_FILE} (${LOG_LINES} lines)
|
|
Summarize failures per suite:
|
|
sed 's/\x1b\[[0-9;]*m//g' ${LOG_FILE} | grep -E 'FAILED [0-9]+ms\$' | awk -F'>' '{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
|
|
|
|
# A harness failure must never inherit gdUnit4's exit code — it exits 0 both
|
|
# when a suite fails to load and when the filter matches nothing, which is the
|
|
# whole reason these two states were invisible.
|
|
if [[ -n "$HARNESS_ERROR" && "$EXIT_CODE" -eq 0 ]]; then
|
|
exit 2
|
|
fi
|
|
|
|
exit $EXIT_CODE
|