#!/usr/bin/env bash # Runs the headless GDScript gameplay tests. # # bash tests/run_tests.sh # everything # bash tests/run_tests.sh formation # only tests matching a substring # JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang # VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too # IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself # GODOT_BIN=/path/to/godot bash tests/run_tests.sh # pick the binary # # There are two ways to get a Godot to run them with, picked automatically: # # Termux the Godot 4.7 Linux arm64 build installed inside the # proot-distro Ubuntu container, which is the only way to run # a desktop Godot on the phone at all. # WSL the Windows Godot build off /mnt/c. A Linux `godot` on PATH # is used first if there is one. # # The platform decides which Godot runs and where the project is copied to. # Everything else - the copy itself, the worker count, and # tests/_run_inside.sh, which does the actual running - is shared. # # On Termux, three things make this fast, in descending order of how much # they matter (measured, not guessed - see tests/_profile_startup.gd): # # 1. It runs from a COPY on Termux's own filesystem, not from the project # directory. The project lives under ~/storage/shared, which is Android's # FUSE bridge; read through proot on top of that, `load("res://world/Main.tscn")` # alone took 8.5s per test, against ~2s from a native path. Nearly every # test loads Main.tscn, and none of that time is gameplay. The copy is # ~1.4s for the whole suite and its .godot import cache is kept between # runs. WSL copies too, but to a native NTFS path rather than to ext4: # a Windows Godot reads C: at full speed and would read \\wsl$ slowly, so # the copy is for ISOLATION rather than for speed there. Six Godot # processes churning the project directory is six processes writing # .godot, creating and deleting files under PlanetSetups/, and doing it # in a working tree that git, an open editor and the player are also # using. Under that, single tests failed at random - a delete that did # not take, a scene that would not instantiate - never the same one # twice. # 2. Tests run JOBS at a time. Each is its own Godot process - they call # quit() and stamp on the GameState autoload, so they cannot share one - # which makes the suite a pile of independent, single-core-bound jobs on # an 8-core phone. # 3. The whole run happens in ONE `proot-distro login`. Entering the # container costs ~0.5s, which used to be paid once per test. # # Output is buffered per test and printed only for failures; six interleaved # logs are unreadable. set -uo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PLATFORM="termux" if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then PLATFORM="wsl" fi # Finding a Godot on WSL. A Linux build on PATH wins - no interop, no drive # letters - but there usually is not one, and the Windows build the editor # already runs from is right there on /mnt/c. Two things about picking that # one up: # - it must be the *_console.exe. The plain .exe is built as a Windows GUI # subsystem binary: it detaches from the terminal, so every test would # produce an empty log and the runner would call all of them failures. # - sort -V so 4.7.2 beats 4.6.1 when several versions are downloaded. _find_godot_wsl() { local found found="$(command -v godot 2>/dev/null)" if [[ -n "$found" ]]; then echo "$found" return fi found="$(find /mnt/c/Users/*/Downloads /mnt/c/Users/*/AppData/Local/Programs \ "/mnt/c/Program Files" -maxdepth 2 -name 'Godot_v*_console.exe' \ 2>/dev/null | sort -V | tail -1)" echo "$found" } # Worker count comes from FREE MEMORY, not core count. Each Godot instance is # ~400MB and this phone shares its RAM with everything else running on it. # Getting it wrong is bad in both directions: # - too many and the kernel SIGKILLs the runner mid-suite (exit 137), which # looks exactly like a hang # - too many under swap pressure and every worker thrashes: the same suite # measured 100-190s with memory free, and 888s while 9GB sat in swap # One worker per ~900MB available, capped at 6, and forced to 1 once swap is # more than half consumed - past that, parallelism costs more than it buys. # Override with JOBS= when you know better. _pick_jobs() { local avail swap_total swap_free n avail=$(awk '/MemAvailable/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null) swap_total=$(awk '/SwapTotal/ {print $2}' /proc/meminfo 2>/dev/null) swap_free=$(awk '/SwapFree/ {print $2}' /proc/meminfo 2>/dev/null) if [[ -z "$avail" ]]; then echo 2 return fi if [[ -n "$swap_total" ]] && (( swap_total > 0 )) \ && (( (swap_total - swap_free) * 2 > swap_total )); then echo 1 return fi n=$(( avail / 900 )) (( n < 1 )) && n=1 (( n > 6 )) && n=6 echo "$n" } JOBS="${JOBS:-$(_pick_jobs)}" VERBOSE="${VERBOSE:-0}" IN_PLACE="${IN_PLACE:-0}" # Where the copy lives. Termux wants its own native filesystem; WSL wants a # WINDOWS one, because the thing reading it is a Windows binary and $HOME is # ext4 behind the \\wsl$ share. %LOCALAPPDATA% is the natural home for it; # if that cannot be read, fall back to a sibling of the project - still on # the same native drive, still outside the repo. _default_work_dir() { if [[ "$PLATFORM" != "wsl" ]]; then echo "$HOME/.cache/spaceship-tests" return fi local win_local win_local="$(cmd.exe /c 'echo %LOCALAPPDATA%' 2>/dev/null | tr -d '\r')" if [[ -n "$win_local" && "$win_local" != *'%'* ]]; then local unix_local unix_local="$(wslpath -u "$win_local" 2>/dev/null)" if [[ -n "$unix_local" ]]; then echo "$unix_local/spaceship-tests" return fi fi echo "$(dirname "$PROJECT_DIR")/.spaceship-tests" } WORK_DIR="${WORK_DIR:-$(_default_work_dir)}" if [[ -z "${GODOT_BIN:-}" ]]; then if [[ "$PLATFORM" == "wsl" ]]; then GODOT_BIN="$(_find_godot_wsl)" if [[ -z "$GODOT_BIN" ]]; then echo "no Godot found. Install one on PATH, or download a Windows" >&2 echo "build and point GODOT_BIN at its *_console.exe." >&2 exit 2 fi else GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" fi fi filter="${1:-}" tests=() for path in "$PROJECT_DIR"/tests/test_*.gd; do name="$(basename "$path")" if [[ -z "$filter" || "$name" == *"$filter"* ]]; then tests+=("$name") fi done if (( ${#tests[@]} == 0 )); then echo "no tests match '$filter'" exit 2 fi start=$(date +%s) run_dir="$PROJECT_DIR" if [[ "$IN_PLACE" != "1" ]]; then run_dir="$WORK_DIR" mkdir -p "$run_dir" # Clear last run's sources but keep .godot, so imports stay warm. Without # the clear, a test deleted from the project would live on in the copy and # keep being run. find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} + # android/ and builds/ are export artefacts no test touches, and the .apk # is 30MB of nothing useful here. tar -C "$PROJECT_DIR" \ --exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \ -cf - . 2>/dev/null | tar -C "$run_dir" -xf - fi if [[ "$PLATFORM" == "wsl" ]]; then bash "$run_dir/tests/_run_inside.sh" \ "$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}" else proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \ "$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}" fi status=$? echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS" exit $status