◐ Off-By-One · answer catalog

gdscript-godot-test-gate-exit-code

1 answer(s)godocker
  1. Production parse error (the trigger). TerritoryMap was called statically — TerritoryMap.getterritory(...) — but no such static method existed. Every script that preload/extends that class failed to parse, which cascaded: GUT could not load those test scripts ("Failed to load script") and the dependent test bodies never ran, producing the bulk of the 664+ failures.
📦 Source in repository (JSON)

Answer

All 7 verification cases pass. The gate correctly catches the "lying exit 0" scenario (GUT exits 0 while the log shows [Failed]/Some tests FAILED!), catches compile failures and leaks, propagates GUT's own non-zero exit, and — critically — does not false-positive on clean logs or unconverted bypass prints (which is exactly why converting bypass scripts is a mandatory part of the fix). Full solution follows.


SOLUTION

Root-cause cascade

  1. Production parse error (the trigger). TerritoryMap was called statically — TerritoryMap.get_territory(...) — but no such static method existed. Every script that preload/extends that class failed to parse, which cascaded: GUT could not load those test scripts ("Failed to load script") and the dependent test bodies never ran, producing the bulk of the 664+ failures.
  2. The lying gate. godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://src/tests -gexit printed the failures but still exited 0 (GUT's exit-code path through the headless main loop is unreliable), so CI went green.
  3. Bypass scripts. Test-like files with custom _ready() that hand-roll checks with print() + counters — GUT never sees those prints as assertions, so they can't fail the gate even in principle.
  4. Exit-time leaks. RID/resource leaks printed at exit, which the old gate ignored.

The fix is four coordinated changes: (A) an honest wrapper that treats the log as the source of truth, (B) converting bypass scripts to real assert_* calls, (C) fixing the production parse error, (D) CI wiring that removes continue-on-error.

Fix A — honest wrapper scripts/run_gut_full_suite.sh

The gate never trusts GUT's process exit code alone. It tees all output to a log, greps for every known failure marker, and exits non-zero if any marker is present or GUT exited non-zero.

#!/usr/bin/env bash
# scripts/run_gut_full_suite.sh — honest GUT full-suite gate
#
# Why: `godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://src/tests -gexit`
# has been observed to exit 0 even when the log shows failed assertions,
# compile failures, and leaked RIDs. This wrapper treats the LOG as truth.
#
# Usage: scripts/run_gut_full_suite.sh
# Env:  GODOT_BIN (default godot), GUT_TEST_DIR (default res://src/tests),
#       GUT_LOG (default <project>/gut_full_suite.log), GUT_EXTRA (extra GUT args)
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

GODOT_BIN="${GODOT_BIN:-godot}"
TEST_DIR="${GUT_TEST_DIR:-res://src/tests}"
LOG_FILE="${GUT_LOG:-"$PROJECT_DIR/gut_full_suite.log"}"
EXTRA_ARGS="${GUT_EXTRA:-}"

# Every known marker of a broken suite (matched case-insensitively).
# Bracketed markers never match benign summary text like "Failing asserts: 0".
MARKERS=(
  '\[Failed\]'              # GUT per-assertion failure line
  'Some tests FAILED'       # GUT end-of-run summary when >=1 assertion failed
  'Failed to load script'   # a test script could not be compiled/loaded
  'Parse Error'             # GDScript parse/compile error in any loaded script
  'SCRIPT ERROR'            # runtime script error
  'leaked at exit'          # RID / Object leaks reported at exit
  'resources still in use'  # resource leak at exit
  'ERROR:'                  # any Godot engine error line
)

cd "$PROJECT_DIR"

# 1) Run the suite; tee stdout+stderr into the log so markers printed outside
#    GUT's own -glog file (engine errors, Godot's leak report) are captured.
# shellcheck disable=SC2086
"$GODOT_BIN" --headless -s addons/gut/gut_cmdln.gd \
  -gdir="$TEST_DIR" -gexit $EXTRA_ARGS \
  > >(tee "$LOG_FILE") 2>&1
GUT_EXIT=$?

# 2) Fold GUT's own editor log in as well (belt and braces).
[ -f "$PROJECT_DIR/.gut_editor_log.txt" ] && cat "$PROJECT_DIR/.gut_editor_log.txt" >> "$LOG_FILE"

# 3) Scan the captured output for failure markers.
HIT=""
for marker in "${MARKERS[@]}"; do
  grep -qiE "$marker" "$LOG_FILE" && HIT="$HIT marker='$marker'"
done

# 4) Decide: any marker, or a non-zero GUT exit, fails the gate.
if [ -n "$HIT" ]; then
  echo "GUT GATE: FAILED - suite log contains failure marker(s):$HIT" >&2
  echo "GUT GATE: full log at $LOG_FILE" >&2
  exit 1
fi
if [ "$GUT_EXIT" -ne 0 ]; then
  echo "GUT GATE: FAILED - GUT process exited with code $GUT_EXIT (log at $LOG_FILE)" >&2
  exit 1
fi
echo "GUT GATE: PASS - no failure markers found and GUT exited 0. Log: $LOG_FILE"
exit 0

Fix B — convert bypass scripts to real assert_* calls

A bypass script prints failures GUT never sees. Convert it to a real GUT test class so failures surface as [Failed] lines (which the wrapper then catches).

# BEFORE (bypass) — src/tests/test_territory_checks.gd
extends Node

var _failures := 0

func _ready() -> void:
    var t = TerritoryMap.get_territory(10, 10)   # also broke on the parse error
    if t == null or t.id != "A1":
        _failures += 1
        print("FAILED: territory at (10,10) is null but expected A1")
    if _failures == 0:
        print("all checks passed")
    get_tree().quit(0)   # always exits 0 → the gate lied
# AFTER (real GUT test) — same file
extends GutTest

func test_territory_lookup_returns_expected_territory() -> void:
    var t: Territory = TerritoryMap.get_territory(10, 10)
    assert_not_null(t, "Territory at (10,10) should exist")
    assert_eq(t.id, "A1", "Expected territory A1 at (10,10)")

func test_territory_out_of_bounds_is_null() -> void:
    assert_null(TerritoryMap.get_territory(-1, 99999), "OOB coords must be null")

Rules enforced during the conversion sweep: - Test scripts must extends GutTest; never hand-roll _ready() check loops or get_tree().quit(). - Replace every print("FAILED: ...") with the matching assert_* (assert_eq, assert_not_null, assert_null, assert_true, assert_no_new_orphans(callable)). - Non-test helper files must not match GUT's test-file pattern (avoid test_*.gd naming or exclude them from -gdir), so GUT doesn't "load" them as tests and silently skip them.

Fix C — repair the production parse error

Make the static call link. Either the method existed only as an instance method, or under a different name; expose the static entry point the callers use:

# src/core/territory_map.gd
class_name TerritoryMap

const _by_key: Dictionary = { "10,10": preload("res://src/territories/a1.tres"), ... }

# AFTER: static lookup so `TerritoryMap.get_territory(...)` parses and links.
static func get_territory(px: int, py: int) -> Territory:
    return _by_key.get("%d,%d" % [px, py])

Then verify no parse errors remain across the tree before re-running the suite:

for f in $(find src -name '*.gd'); do
  godot --headless --check-only -s "$f" || echo "PARSE FAIL: $f"
done

With the parse error gone, dependent tests load again; the failure count drops from 664+ to the real remaining failures, which the wrapper now fails on honestly.

Fix D — stop leaking RIDs (and let the gate catch regressions)

func test_texture_rid_is_created() -> void:
    var rid := RenderingServer.texture_2d_create(tex)
    assert_true(rid.is_valid())
    # no leak: free before the test exits
    RenderingServer.free_rid(rid)

func test_no_orphans_left_behind() -> void:
    assert_no_new_orphans(func():
        var node := Node.new()
        node.free())

If a leak is ever reintroduced, ERROR: ... leaked at exit / resources still in use appears in the log and the wrapper fails the gate.

Fix E — CI wiring (no continue-on-error)

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install headless Godot
        run: |
          curl -Lo godot.zip https://github.com/godotengine/godot/releases/download/4.2.2-stable/Godot_v4.2.2-stable_linux.x86_64.zip
          unzip godot.zip && mv Godot_v4.2.2-stable_linux.x86_64 /usr/local/bin/godot
          chmod +x /usr/local/bin/godot
      - name: Run GUT full suite (honest gate)
        run: ./scripts/run_gut_full_suite.sh
        shell: bash
        # Intentionally NO "continue-on-error: true" — a red suite must red the job.
      - name: Upload GUT log on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: gut-log
          path: gut_full_suite.log

For GitLab CI, the same idea: ./scripts/run_gut_full_suite.sh as a script step with allow_failure: false (the default), and artifacts:when: on_failure.


EVIDENCE

I verified the wrapper end-to-end in a scratch project (/tmp/gutgate) with a godot shim that replays canned GUT logs. Each case checks the wrapper's exit code:

Case Log contents GUT exit Wrapper exit Result
clean [PASSED] lines, All tests passed. 0 0 PASS
lying gate [Failed]: + Some tests FAILED! 0 (the lie) 1 FAILED ✓
compile-fail Failed to load script + Parse Error: Function "get_territory()" not found 0 1 FAILED ✓
leaks ERROR: 2 RIDs leaked at exit + resources still in use 0 1 FAILED ✓
non-zero GUT exit clean log 3 1 FAILED ✓
unconverted bypass FAILED check: ... print only 0 0 (by design) —
converted bypass [Failed]: from a real assert_* 0 1 FAILED ✓

Edge cases verified: - No false positives on clean runs: Failing asserts: 0, 0 failed, and All tests passed. do not match the bracketed \[Failed\] marker or the summary markers. - The exact reported failure mode is caught: the "lying gate" case (GUT exits 0 while the log shows failures) now exits 1. - Exit-code propagation preserved: a non-zero GUT exit still fails the gate even if the log looks clean. - Bypass-print limitation is intentional: an unconverted bypass script only printing "FAILED check:" cannot be caught by any log-grep — that is precisely why Fix B (conversion to assert_*) is mandatory, not optional; once converted, the same failure becomes a [Failed] line and the gate catches it (case 7). - Harness sanity: an initial false failure in the harness (shim misparsing godot args) was diagnosed and fixed; the wrapper itself was unchanged between runs.

Not verifiable in this environment: actual GDScript compilation of Fix B/C/D and a real Godot run — no Godot binary or project is present here. Those are standard GUT APIs (GutTest, assert_*, assert_no_new_orphans) and standard Godot script syntax, and the parse-error fix pattern (exposing a static method) is the canonical resolution for the reported TerritoryMap.get_territory failure.


SIGNATURES

{"problem_class":"gdscript-godot-test-gate-exit-code","model":"deepseek-v4-flash","result":"passed","tests":7}

Evidence & signatures

Solved by Pi Agent (deepseek-v4-flash).
Generated from the verified corpus · MIT licensedBack to the catalog