From 3ebeda82dc0592625aadb4fa765299af4f8d383d Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 13:10:03 -0700 Subject: [PATCH 01/11] test: run every suite in CI, fail on engine errors, and settle audio deterministically [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] CI ran a hand-maintained list of 24 suites and failed only on a nonzero exit or "SCRIPT ERROR". New tests silently never ran, and the 11 runner asset checks were never in CI at all. Meanwhile one of them, a1_check, had been failing since an editor session let Godot's detect-3D import flip the Hoppy runner textures from lossless to VRAM compression. - tools/tests/run_tests.py discovers every tests/**/*.gd, runs them in parallel with a per-suite HOME (so user:// never touches real files), and fails on a nonzero exit, a timeout, engine or script errors, or exit-time leaks. tools/tests/suites.toml holds the six skipped GPU, window and hover suites with reasons, plus runner_logic's one intentional error; stale entries fail the run. - AudioService.settle() stops the shared music and waits until the audio thread has released each playback. The fixed 0.3 s waits raced the mixer under parallel load and leaked at exit; every suite now settles before quitting. - The Hoppy texture imports are lossless again with detect-3D disabled, which matches a1_check and keeps the editor from flipping them back. - CI caches .godot imports, runs four suites at a time, uploads the results summary, and unit-tests the runner in the lint job. --- .github/workflows/ci.yml | 73 ++----- .gitignore | 3 + CONTRIBUTING.md | 18 +- README.md | 5 +- .../hoppy/hoppy_accessory_albedo.png.import | 9 +- .../hoppy/hoppy_accessory_emissive.png.import | 9 +- .../hoppy/hoppy_accessory_normal.png.import | 9 +- .../hoppy/hoppy_accessory_orm.png.import | 9 +- .../runner/hoppy/hoppy_body_albedo.png.import | 9 +- .../hoppy/hoppy_body_emissive.png.import | 9 +- .../runner/hoppy/hoppy_body_normal.png.import | 9 +- .../runner/hoppy/hoppy_body_orm.png.import | 9 +- hoppys-adventure-arcade/app/audio_service.gd | 13 ++ .../tests/assets/a3_check.gd | 1 + .../tests/assets/a6_check.gd | 3 +- .../tests/assets/a9_check.gd | 3 +- .../tests/code_city_assets.gd | 3 +- .../tests/code_city_logic.gd | 3 +- .../tests/code_city_motion.gd | 3 +- .../tests/code_city_smoke.gd | 3 +- .../tests/enchanted_assets.gd | 3 +- .../tests/enchanted_boundaries.gd | 3 +- .../tests/enchanted_repo_smoke.gd | 3 +- .../tests/heroes_villains_assets.gd | 3 +- .../tests/heroes_villains_logic.gd | 3 +- .../tests/heroes_villains_motion.gd | 2 +- .../tests/heroes_villains_smoke.gd | 2 +- .../tests/menu_hoppy_smoke.gd | 3 +- .../tests/menu_return_layout.gd | 3 +- .../tests/orbital_assets.gd | 3 +- .../tests/orbital_ops_smoke.gd | 3 +- hoppys-adventure-arcade/tests/perf_probe.gd | 3 +- .../tests/platformer_boss.gd | 3 +- .../tests/platformer_smoke.gd | 3 +- .../tests/platformer_traversal.gd | 5 +- .../tests/runner_boss_validator.gd | 6 +- .../tests/runner_chunk_validator.gd | 6 +- hoppys-adventure-arcade/tests/runner_logic.gd | 6 +- .../tests/runner_visual_smoke.gd | 3 +- tools/tests/run_tests.py | 182 ++++++++++++++++++ tools/tests/suites.toml | 17 ++ tools/tests/test_run_tests.py | 56 ++++++ 42 files changed, 380 insertions(+), 144 deletions(-) create mode 100644 tools/tests/run_tests.py create mode 100644 tools/tests/suites.toml create mode 100644 tools/tests/test_run_tests.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a155ed5..6aebdc47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,62 +44,29 @@ jobs: unzip -q /tmp/godot.zip -d ~/godot mv ~/godot/Godot_v${GODOT_VERSION}_linux.x86_64 ~/godot/godot + - name: Cache imported resources + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.PROJECT }}/.godot + key: godot-import-${{ env.GODOT_VERSION }}-${{ hashFiles('hoppys-adventure-arcade/project.godot', 'hoppys-adventure-arcade/**/*.import') }} + # A stale cache is safe: Godot reimports any source whose hash changed. + restore-keys: godot-import-${{ env.GODOT_VERSION }}- + - name: Import project run: ~/godot/godot --headless --path "$PROJECT" --import - # Headless suites only. Not listed here: - # - capture_runner_menu_previews, perf_probe, runner_perf_probe, runner_pickup_hitch - # need a real window/GPU and are run locally (see CONTRIBUTING.md). - # - menu_smoke and menu_hoppy_smoke rely on pointer hover, which doesn't happen headless - # (2 failures each, also on main). + # Every tests/**/*.gd runs; tools/tests/suites.toml lists the few skipped suites and why. - name: Run headless suites - run: | - suites=( - code_city_assets - code_city_logic - code_city_motion - code_city_smoke - heroes_villains_assets - heroes_villains_logic - heroes_villains_motion - heroes_villains_smoke - enchanted_assets - enchanted_boundaries - enchanted_repo_smoke - menu_return_layout - orbital_assets - orbital_ops_smoke - platformer_boss - platformer_smoke - platformer_traversal - runner_boss_validator - runner_chunk_validator - runner_logic - runner_scaffold_check - runner_smoke - runner_visual_smoke - runner_warmup_check - ) - failed=() - for suite in "${suites[@]}"; do - echo "::group::$suite" - log="/tmp/$suite.log" - set +e - timeout 600 ~/godot/godot --headless --path "$PROJECT" \ - --script "res://tests/$suite.gd" 2>&1 | tee "$log" - rc=${PIPESTATUS[0]} - set -e - echo "::endgroup::" - # A script that fails to parse can still exit 0, so treat SCRIPT ERROR as a failure too. - if [ "$rc" -ne 0 ] || grep -q "SCRIPT ERROR" "$log"; then - echo "::error title=$suite::failed (exit $rc)" - failed+=("$suite") - fi - done - if [ ${#failed[@]} -gt 0 ]; then - echo "Failed suites: ${failed[*]}" - exit 1 - fi + run: python3 tools/tests/run_tests.py --godot ~/godot/godot --no-import --jobs 4 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-results + path: build/test-results/ + retention-days: 7 + if-no-files-found: ignore lint: name: lint @@ -115,6 +82,8 @@ jobs: - run: pip install ruff gdtoolkit==4.* - name: Ruff (tools) run: ruff check tools hoppys-adventure-arcade/tools + - name: Test runner unit tests + run: python -m unittest discover -s tools/tests -v # Advisory until the existing code is formatted; make it blocking afterwards. - name: gdlint (advisory) continue-on-error: true diff --git a/.gitignore b/.gitignore index 52358caa..184628f8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ __pycache__/ # Local editor settings .vscode/ .DS_Store + +# Exported game builds (tools/build/export_game.py) +/build/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a5da154..dcfc5edc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,14 +24,26 @@ Thanks for helping out! This guide covers setup, workflow, testing and the asset ## Tests -The tests are standalone `SceneTree` scripts. Each one exits nonzero on failure: +The tests are standalone `SceneTree` scripts under `hoppys-adventure-arcade/tests/`. Run them all the way CI does: + +```sh +python3 tools/tests/run_tests.py --godot /path/to/Godot # or set $GODOT +python3 tools/tests/run_tests.py -k runner # suites whose name contains "runner" +python3 tools/tests/run_tests.py --list # what runs and what is skipped +``` + +- **Every new `.gd` file under `tests/` runs automatically.** There's no list to update. To keep a suite out of CI (it needs a GPU, a window or pointer hover), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. +- **A suite fails if it exits nonzero, times out, or prints an engine or script error or an exit-time leak.** That includes `ERROR:`, `SCRIPT ERROR`, parse errors and "leaked at exit". If a test provokes an engine error on purpose, list a pattern for it under `[expected_errors]`; the runner fails if that pattern stops matching. +- **Finish a suite with `await root.get_node("AudioService").settle()` before `quit()`.** It stops the shared music and waits until the audio thread has released it. A fixed delay is not enough under parallel load. +- Each suite runs with its own `HOME`, so `user://` files (settings, leaderboards) never touch your real ones. Results are written to `build/test-results/summary.json`. + +To run a single suite directly: ```sh godot --headless --path hoppys-adventure-arcade --script res://tests/runner_logic.gd -godot --headless --path hoppys-adventure-arcade --script res://tests/platformer_smoke.gd ``` -CI runs every headless suite listed in `.github/workflows/ci.yml`. Add a focused regression test when you change behavior. Some suites capture screenshots when run without `--headless`; include those in the PR for visual changes. On macOS, pass `--always-on-top` for graphical runs so the capture doesn't stall when the window is covered. +Add a focused regression test when you change behavior. Some suites capture screenshots when run without `--headless`; include those in the PR for visual changes. On macOS, pass `--always-on-top` for graphical runs so the capture doesn't stall when the window is covered. Per-mode notes and test details live in [`docs/`](docs/). diff --git a/README.md b/README.md index 7c1974c2..9f7e5e90 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,10 @@ In Orbital Ops' zero-gravity bay, hold Jump or Up to rise, hold Down to descend, ## Tests ```sh -godot --headless --path hoppys-adventure-arcade --import -godot --headless --path hoppys-adventure-arcade --script res://tests/runner_logic.gd +python3 tools/tests/run_tests.py --godot /path/to/Godot ``` -Each file in `hoppys-adventure-arcade/tests/` is a standalone script that exits nonzero on failure. CI runs the headless suites on every PR. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow. +Every script in `hoppys-adventure-arcade/tests/` is a standalone suite, and CI runs all of them on every PR, except the few listed with a reason in [`tools/tests/suites.toml`](tools/tests/suites.toml). A suite fails on a nonzero exit, an engine or script error, or a leak. See [CONTRIBUTING.md](CONTRIBUTING.md#tests) for details. ## Contributing diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_albedo.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_albedo.png.import index 6957660b..97a6a21c 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_albedo.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_albedo.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://0smaw6gfvyj5" -path.s3tc="res://.godot/imported/hoppy_accessory_albedo.png-c122b57ea96ce5ad67ded19ac527ca90.s3tc.ctex" +path="res://.godot/imported/hoppy_accessory_albedo.png-c122b57ea96ce5ad67ded19ac527ca90.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "7fb0292e002cccb61d36e1665abdf500" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_accessory_albedo.png" -dest_files=["res://.godot/imported/hoppy_accessory_albedo.png-c122b57ea96ce5ad67ded19ac527ca90.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_accessory_albedo.png-c122b57ea96ce5ad67ded19ac527ca90.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_emissive.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_emissive.png.import index 0935272c..e09234df 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_emissive.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_emissive.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://dk6fxo7twe0us" -path.s3tc="res://.godot/imported/hoppy_accessory_emissive.png-12050a52c862b1593e1bc121f8f122fa.s3tc.ctex" +path="res://.godot/imported/hoppy_accessory_emissive.png-12050a52c862b1593e1bc121f8f122fa.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "ca9505ca9d1c113cd5b01ff7c6ad73d8" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_accessory_emissive.png" -dest_files=["res://.godot/imported/hoppy_accessory_emissive.png-12050a52c862b1593e1bc121f8f122fa.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_accessory_emissive.png-12050a52c862b1593e1bc121f8f122fa.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_normal.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_normal.png.import index c064d36c..4a1fbf20 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_normal.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_normal.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://b27dm5dn5qhlj" -path.s3tc="res://.godot/imported/hoppy_accessory_normal.png-8498ac34154d1c36039fcb9428c418ee.s3tc.ctex" +path="res://.godot/imported/hoppy_accessory_normal.png-8498ac34154d1c36039fcb9428c418ee.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "cd9b358fd1724c346be81e00ee54eda4" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_accessory_normal.png" -dest_files=["res://.godot/imported/hoppy_accessory_normal.png-8498ac34154d1c36039fcb9428c418ee.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_accessory_normal.png-8498ac34154d1c36039fcb9428c418ee.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_orm.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_orm.png.import index c9dcc76e..3d29cf77 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_orm.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_accessory_orm.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://bq52lv7petqa7" -path.s3tc="res://.godot/imported/hoppy_accessory_orm.png-a24d2bbfa7539917534e8ce2748bee64.s3tc.ctex" +path="res://.godot/imported/hoppy_accessory_orm.png-a24d2bbfa7539917534e8ce2748bee64.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "ea46a544c47f6d289cbc403c15970647" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_accessory_orm.png" -dest_files=["res://.godot/imported/hoppy_accessory_orm.png-a24d2bbfa7539917534e8ce2748bee64.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_accessory_orm.png-a24d2bbfa7539917534e8ce2748bee64.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_albedo.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_albedo.png.import index 69e1a2d2..55d1e037 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_albedo.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_albedo.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://dk1u3h8nede88" -path.s3tc="res://.godot/imported/hoppy_body_albedo.png-a4c37408013e37c8de18072982be504d.s3tc.ctex" +path="res://.godot/imported/hoppy_body_albedo.png-a4c37408013e37c8de18072982be504d.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "57ec9dd0ee809dc39c966af35310a745" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_body_albedo.png" -dest_files=["res://.godot/imported/hoppy_body_albedo.png-a4c37408013e37c8de18072982be504d.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_body_albedo.png-a4c37408013e37c8de18072982be504d.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_emissive.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_emissive.png.import index af6f5bcc..681166a6 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_emissive.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_emissive.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://cnwbddowongvs" -path.s3tc="res://.godot/imported/hoppy_body_emissive.png-15b9193f78dd32e62b3bc8d4f2eb5591.s3tc.ctex" +path="res://.godot/imported/hoppy_body_emissive.png-15b9193f78dd32e62b3bc8d4f2eb5591.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "efeebdda98ec1d7fb2ad83d23f0713bf" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_body_emissive.png" -dest_files=["res://.godot/imported/hoppy_body_emissive.png-15b9193f78dd32e62b3bc8d4f2eb5591.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_body_emissive.png-15b9193f78dd32e62b3bc8d4f2eb5591.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_normal.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_normal.png.import index 74b81d51..38cb2bcf 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_normal.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_normal.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://d2rcbm37d6nu4" -path.s3tc="res://.godot/imported/hoppy_body_normal.png-904e19f0aa8db2d232ac2f9e1ac495c8.s3tc.ctex" +path="res://.godot/imported/hoppy_body_normal.png-904e19f0aa8db2d232ac2f9e1ac495c8.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "bcdebc432934601b9de7eafdc1870f80" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_body_normal.png" -dest_files=["res://.godot/imported/hoppy_body_normal.png-904e19f0aa8db2d232ac2f9e1ac495c8.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_body_normal.png-904e19f0aa8db2d232ac2f9e1ac495c8.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_orm.png.import b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_orm.png.import index 8cd32750..3123dad3 100644 --- a/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_orm.png.import +++ b/hoppys-adventure-arcade/Assets/runner/hoppy/hoppy_body_orm.png.import @@ -3,10 +3,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://bjkoyu6nxsyci" -path.s3tc="res://.godot/imported/hoppy_body_orm.png-a36dae46ff5d3b78351e386b721c189f.s3tc.ctex" +path="res://.godot/imported/hoppy_body_orm.png-a36dae46ff5d3b78351e386b721c189f.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], -"vram_texture": true +"vram_texture": false } generator_parameters={ "md5": "514f0903d21ec07dc723ff555f319203" @@ -15,11 +14,11 @@ generator_parameters={ [deps] source_file="res://Assets/runner/hoppy/hoppy_body_orm.png" -dest_files=["res://.godot/imported/hoppy_body_orm.png-a36dae46ff5d3b78351e386b721c189f.s3tc.ctex"] +dest_files=["res://.godot/imported/hoppy_body_orm.png-a36dae46ff5d3b78351e386b721c189f.ctex"] [params] -compress/mode=2 +compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 compress/uastc_level=0 diff --git a/hoppys-adventure-arcade/app/audio_service.gd b/hoppys-adventure-arcade/app/audio_service.gd index 42b73af6..85ebeb2c 100644 --- a/hoppys-adventure-arcade/app/audio_service.gd +++ b/hoppys-adventure-arcade/app/audio_service.gd @@ -93,5 +93,18 @@ func shutdown() -> void: music.stream = null sfx.stream = null +## Stops all audio, then waits until the audio thread has let go of every playback, so the +## caller can quit without "leaked at exit" reports. A fixed delay races the mixer under load. +func settle(max_seconds := 2.0) -> void: + var held: Array[AudioStreamPlayback] = [] + for player: AudioStreamPlayer in [music, sfx]: + if player.has_stream_playback(): + held.append(player.get_stream_playback()) + shutdown() + var deadline := Time.get_ticks_msec() + int(max_seconds * 1000.0) + while held.any(func(playback: AudioStreamPlayback) -> bool: return playback.get_reference_count() > 1) \ + and Time.get_ticks_msec() < deadline: + await get_tree().create_timer(0.02, true, false, true).timeout + func _exit_tree() -> void: shutdown() diff --git a/hoppys-adventure-arcade/tests/assets/a3_check.gd b/hoppys-adventure-arcade/tests/assets/a3_check.gd index 2fbfb73f..8bcc8b10 100644 --- a/hoppys-adventure-arcade/tests/assets/a3_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a3_check.gd @@ -247,6 +247,7 @@ func _run() -> void: check(runtime_errors.count == 0,"no runtime errors during asset checks") preload("res://scenes/runner/hazards/visuals/hazard_art.gd").release_caches() print("ASSET A3: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL",checks]) + await root.get_node("AudioService").settle() quit(0 if failures == 0 else 1) func _round_two(visual: Base, title: String) -> void: diff --git a/hoppys-adventure-arcade/tests/assets/a6_check.gd b/hoppys-adventure-arcade/tests/assets/a6_check.gd index 359984c5..4752ea5b 100644 --- a/hoppys-adventure-arcade/tests/assets/a6_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a6_check.gd @@ -75,7 +75,6 @@ func run() -> void: print("A6 HUNTER CENSUS: %d draws, %d triangles, no textures" % [draws,triangles]) print("A6 HUNTER ENVELOPE: ",envelope) visual.free() - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() print("ASSET A6: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL",checks]) quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/assets/a9_check.gd b/hoppys-adventure-arcade/tests/assets/a9_check.gd index 6b94e39d..a2d4fed1 100644 --- a/hoppys-adventure-arcade/tests/assets/a9_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a9_check.gd @@ -199,8 +199,7 @@ func run() -> void: check(not fixture.post.settings.pursuit and value(fixture.post, "grain") > 0.0, "preview restores safe integrated defaults") fixture.free() if root.has_node("AudioService"): - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() print("ASSET A9: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL", checks]) quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/code_city_assets.gd b/hoppys-adventure-arcade/tests/code_city_assets.gd index 7216c1df..971c858b 100644 --- a/hoppys-adventure-arcade/tests/code_city_assets.gd +++ b/hoppys-adventure-arcade/tests/code_city_assets.gd @@ -141,6 +141,5 @@ func piece(key: String, clip: String, pos: Vector2, size: Vector2) -> void: stage.add_child(sprite) func finish(code: int = 0) -> void: - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() quit(code) diff --git a/hoppys-adventure-arcade/tests/code_city_logic.gd b/hoppys-adventure-arcade/tests/code_city_logic.gd index 6e6ceeb9..51fbcacd 100644 --- a/hoppys-adventure-arcade/tests/code_city_logic.gd +++ b/hoppys-adventure-arcade/tests/code_city_logic.gd @@ -195,6 +195,5 @@ func run() -> void: check(Catalog.MUSIC[0].resource_path.ends_with("Zodik - Future Travel.ogg") and Catalog.MUSIC[1].resource_path.ends_with("helldrake(extended).ogg") and Catalog.MUSIC[2].resource_path.ends_with("kim_lightyear_-_angel_eyes_vision_ii.mp3") and Catalog.BOSS_MUSIC.resource_path.ends_with("Kim Lightyear - The Final.mp3"),"All four user music mappings match") print("Code City logic failures: ",failures) game.queue_free() - root.get_node("AudioService").shutdown() - await create_timer(.4).timeout + await root.get_node("AudioService").settle() quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/code_city_motion.gd b/hoppys-adventure-arcade/tests/code_city_motion.gd index e6536ff0..3a6164c4 100644 --- a/hoppys-adventure-arcade/tests/code_city_motion.gd +++ b/hoppys-adventure-arcade/tests/code_city_motion.gd @@ -150,6 +150,5 @@ func run() -> void: check(boss.position==transition_pos and not boss.leaping and not boss.core_cue.visible,"Defeat clears movement and the exposed-core cue") print("Code City motion failures: ",failures) game.queue_free() - root.get_node("AudioService").shutdown() - await create_timer(.4).timeout + await root.get_node("AudioService").settle() quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/code_city_smoke.gd b/hoppys-adventure-arcade/tests/code_city_smoke.gd index 8270ef47..ae9a4ac9 100644 --- a/hoppys-adventure-arcade/tests/code_city_smoke.gd +++ b/hoppys-adventure-arcade/tests/code_city_smoke.gd @@ -120,6 +120,5 @@ func run() -> void: await process_frame;await process_frame check(not paused and current_scene.name=="BeatemMenu","Completion returns cleanly to the mode menu") current_scene.queue_free() - root.get_node("AudioService").shutdown() - await create_timer(.4).timeout + await root.get_node("AudioService").settle() quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/enchanted_assets.gd b/hoppys-adventure-arcade/tests/enchanted_assets.gd index cbcccd84..c5035cd7 100644 --- a/hoppys-adventure-arcade/tests/enchanted_assets.gd +++ b/hoppys-adventure-arcade/tests/enchanted_assets.gd @@ -102,8 +102,7 @@ func run() -> void: await finish() func finish(code: int = 0) -> void: - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() quit(code) func plate(key: String, pos: Vector2, size: Vector2, opacity: float = 1.0) -> void: diff --git a/hoppys-adventure-arcade/tests/enchanted_boundaries.gd b/hoppys-adventure-arcade/tests/enchanted_boundaries.gd index 6ef6e73c..30badf1b 100644 --- a/hoppys-adventure-arcade/tests/enchanted_boundaries.gd +++ b/hoppys-adventure-arcade/tests/enchanted_boundaries.gd @@ -80,6 +80,5 @@ func run() -> void: check(current_scene.boss.hp==12 and current_scene.boss.armor_stage==0,"Actor state never mutates shared SpriteFrames") change_scene_to_file("res://scenes/platformer/platformer_menu.tscn") await frames(3) - root.get_node("AudioService").shutdown() - await create_timer(.3).timeout + await root.get_node("AudioService").settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/enchanted_repo_smoke.gd b/hoppys-adventure-arcade/tests/enchanted_repo_smoke.gd index f3d7a85e..d07c26ae 100644 --- a/hoppys-adventure-arcade/tests/enchanted_repo_smoke.gd +++ b/hoppys-adventure-arcade/tests/enchanted_repo_smoke.gd @@ -284,6 +284,5 @@ func shutdown() -> void: paused=false change_scene_to_file("res://scenes/platformer/platformer_menu.tscn") await frames(2) - root.get_node("AudioService").shutdown() - await create_timer(.3).timeout + await root.get_node("AudioService").settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/heroes_villains_assets.gd b/hoppys-adventure-arcade/tests/heroes_villains_assets.gd index d78234eb..b513dc94 100644 --- a/hoppys-adventure-arcade/tests/heroes_villains_assets.gd +++ b/hoppys-adventure-arcade/tests/heroes_villains_assets.gd @@ -95,6 +95,5 @@ func run() -> void: else: printerr(failures, " asset checks failed") resources.clear() - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/heroes_villains_logic.gd b/hoppys-adventure-arcade/tests/heroes_villains_logic.gd index 1856e23e..d54e3d91 100644 --- a/hoppys-adventure-arcade/tests/heroes_villains_logic.gd +++ b/hoppys-adventure-arcade/tests/heroes_villains_logic.gd @@ -244,7 +244,6 @@ func run() -> void: check(looped!=track and looped.get_length()>0,"Mapped music duplicates and loads: "+track.resource_path.get_file()) if looped is AudioStreamWAV: check(looped.loop_end>0 and track.loop_mode==AudioStreamWAV.LOOP_DISABLED,"WAV loop does not mutate shared import") game.queue_free();await process_frame - root.get_node("AudioService").shutdown() - await create_timer(.3).timeout + await root.get_node("AudioService").settle() print("Heroes vs. Villains logic failures: ",failures) quit(0 if failures==0 else 1) diff --git a/hoppys-adventure-arcade/tests/heroes_villains_motion.gd b/hoppys-adventure-arcade/tests/heroes_villains_motion.gd index 9bc26c0b..3980d084 100644 --- a/hoppys-adventure-arcade/tests/heroes_villains_motion.gd +++ b/hoppys-adventure-arcade/tests/heroes_villains_motion.gd @@ -117,7 +117,7 @@ func run() -> void: root.get_texture().get_image().save_png("res://../docs/previews/heroes-villains/movement_cycles.png") layer.queue_free() print("Heroes vs. Villains motion failures: ",failures) - root.get_node("AudioService").shutdown() game.queue_free() await process_frame;await process_frame + await root.get_node("AudioService").settle() quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/heroes_villains_smoke.gd b/hoppys-adventure-arcade/tests/heroes_villains_smoke.gd index 363242b0..eea5f84e 100644 --- a/hoppys-adventure-arcade/tests/heroes_villains_smoke.gd +++ b/hoppys-adventure-arcade/tests/heroes_villains_smoke.gd @@ -148,6 +148,6 @@ func run() -> void: if is_instance_valid(current_scene) and current_scene.scene_file_path.ends_with("beatem_menu.tscn") and not root.get_node("AppRouter").busy: break check(current_scene.scene_file_path.ends_with("beatem_menu.tscn") and not paused,"Victory returns to an unpaused mode menu") current_scene.queue_free();await process_frame - root.get_node("AudioService").shutdown();await create_timer(.3).timeout + await root.get_node("AudioService").settle() print("Heroes vs. Villains smoke failures: ",failures) quit(0 if failures==0 else 1) diff --git a/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd b/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd index 428d60ad..4144fbb7 100644 --- a/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd +++ b/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd @@ -51,6 +51,5 @@ func run() -> void: check(hoppy.runner.process_mode == Node.PROCESS_MODE_DISABLED, "Hidden rig stops processing") check(hoppy.get_child_count() == 3, "Switching reuses portrait nodes") print("MENU HOPPY SMOKE: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") - root.get_node("AudioService").shutdown() - await wait(0.3) + await root.get_node("AudioService").settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/menu_return_layout.gd b/hoppys-adventure-arcade/tests/menu_return_layout.gd index e23e2685..5ba08927 100644 --- a/hoppys-adventure-arcade/tests/menu_return_layout.gd +++ b/hoppys-adventure-arcade/tests/menu_return_layout.gd @@ -104,6 +104,5 @@ func run() -> void: await settle() await check_menu("retry-platformer-hub") print("MENU RETURN: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") - root.get_node("AudioService").shutdown() - await wait(0.3) + await root.get_node("AudioService").settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/orbital_assets.gd b/hoppys-adventure-arcade/tests/orbital_assets.gd index e19672e0..2a24bcdb 100644 --- a/hoppys-adventure-arcade/tests/orbital_assets.gd +++ b/hoppys-adventure-arcade/tests/orbital_assets.gd @@ -98,8 +98,7 @@ func run() -> void: await finish() func finish(code: int = 0) -> void: - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() quit(code) func plate(key: String, pos: Vector2, size: Vector2, opacity: float = 1.0) -> void: diff --git a/hoppys-adventure-arcade/tests/orbital_ops_smoke.gd b/hoppys-adventure-arcade/tests/orbital_ops_smoke.gd index 56e9fefb..99fb1ec3 100644 --- a/hoppys-adventure-arcade/tests/orbital_ops_smoke.gd +++ b/hoppys-adventure-arcade/tests/orbital_ops_smoke.gd @@ -286,6 +286,5 @@ func shutdown() -> void: paused=false change_scene_to_file("res://scenes/platformer/platformer_menu.tscn") await frames(2) - root.get_node("AudioService").shutdown() - await create_timer(.3).timeout + await root.get_node("AudioService").settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/perf_probe.gd b/hoppys-adventure-arcade/tests/perf_probe.gd index 73b2b320..27599dc7 100644 --- a/hoppys-adventure-arcade/tests/perf_probe.gd +++ b/hoppys-adventure-arcade/tests/perf_probe.gd @@ -92,6 +92,5 @@ func run() -> void: router.go("hub") await worst(60) report("swap_to_hub") - root.get_node("AudioService").shutdown() - await create_timer(0.2).timeout + await root.get_node("AudioService").settle() quit(0) diff --git a/hoppys-adventure-arcade/tests/platformer_boss.gd b/hoppys-adventure-arcade/tests/platformer_boss.gd index ddfef26d..38634eb5 100644 --- a/hoppys-adventure-arcade/tests/platformer_boss.gd +++ b/hoppys-adventure-arcade/tests/platformer_boss.gd @@ -135,6 +135,5 @@ func run() -> void: print("BOSS TEST: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") change_scene_to_file("res://scenes/platformer/platformer_menu.tscn") await frames(2) - root.get_node("AudioService").shutdown() - await create_timer(0.4).timeout + await root.get_node("AudioService").settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/platformer_smoke.gd b/hoppys-adventure-arcade/tests/platformer_smoke.gd index 847a100b..8e3eb884 100644 --- a/hoppys-adventure-arcade/tests/platformer_smoke.gd +++ b/hoppys-adventure-arcade/tests/platformer_smoke.gd @@ -182,6 +182,5 @@ func run() -> void: check(not root.get_node("AudioService").music.stream_paused, "Menu music restored") print("PLATFORMER SMOKE: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") await frames(35) - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/platformer_traversal.gd b/hoppys-adventure-arcade/tests/platformer_traversal.gd index 7883067c..6dbdeb66 100644 --- a/hoppys-adventure-arcade/tests/platformer_traversal.gd +++ b/hoppys-adventure-arcade/tests/platformer_traversal.gd @@ -87,8 +87,7 @@ func run() -> void: child.stream = null level.music.stop() level.music.stream = null - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout - # Audio cleanup uses wall time even in accelerated --fixed-fps test runs. + await root.get_node("AudioService").settle() + # The level's own player still releases on wall time in accelerated --fixed-fps runs. OS.delay_msec(300) quit(failures) diff --git a/hoppys-adventure-arcade/tests/runner_boss_validator.gd b/hoppys-adventure-arcade/tests/runner_boss_validator.gd index 5dc768c6..0e40de46 100644 --- a/hoppys-adventure-arcade/tests/runner_boss_validator.gd +++ b/hoppys-adventure-arcade/tests/runner_boss_validator.gd @@ -816,4 +816,8 @@ func _report() -> void: player = null tool_node = null print("RUNNER BOSS: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures / ", checks, " checks)") - quit(failures) + _quit_quietly(failures) + +func _quit_quietly(code: int) -> void: + await root.get_node("AudioService").settle() + quit(code) diff --git a/hoppys-adventure-arcade/tests/runner_chunk_validator.gd b/hoppys-adventure-arcade/tests/runner_chunk_validator.gd index 255f6b48..aa7fca64 100644 --- a/hoppys-adventure-arcade/tests/runner_chunk_validator.gd +++ b/hoppys-adventure-arcade/tests/runner_chunk_validator.gd @@ -1132,4 +1132,8 @@ func _report() -> void: junk.clear() wall_scene = null print("RUNNER CHUNKS: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures / ", checks, " checks)") - quit(failures) + _quit_quietly(failures) + +func _quit_quietly(code: int) -> void: + await root.get_node("AudioService").settle() + quit(code) diff --git a/hoppys-adventure-arcade/tests/runner_logic.gd b/hoppys-adventure-arcade/tests/runner_logic.gd index a4f4afef..1c5f340f 100644 --- a/hoppys-adventure-arcade/tests/runner_logic.gd +++ b/hoppys-adventure-arcade/tests/runner_logic.gd @@ -3808,4 +3808,8 @@ func _report() -> void: spawner_script = null player_script = null print("RUNNER LOGIC: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures / ", checks, " checks)") - quit(failures) + _quit_quietly(failures) + +func _quit_quietly(code: int) -> void: + await root.get_node("AudioService").settle() + quit(code) diff --git a/hoppys-adventure-arcade/tests/runner_visual_smoke.gd b/hoppys-adventure-arcade/tests/runner_visual_smoke.gd index f1543de0..d62c86ae 100644 --- a/hoppys-adventure-arcade/tests/runner_visual_smoke.gd +++ b/hoppys-adventure-arcade/tests/runner_visual_smoke.gd @@ -256,8 +256,7 @@ func run() -> void: await check_runner_audio() await check_runner_full_loop() await check_runner_menu_reset() - root.get_node("AudioService").shutdown() - await create_timer(0.3).timeout + await root.get_node("AudioService").settle() failures += _logger.errors() print("RUNNER VISUAL SMOKE: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") OS.remove_logger(_logger) diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py new file mode 100644 index 00000000..07e848b5 --- /dev/null +++ b/tools/tests/run_tests.py @@ -0,0 +1,182 @@ +"""Run every Godot test suite headless and fail on anything the engine reports. + +Usage: + python3 tools/tests/run_tests.py # all suites, in parallel + python3 tools/tests/run_tests.py -k runner -j 2 # suites whose name contains "runner" + python3 tools/tests/run_tests.py --list # what would run, and what is skipped + +Every .gd file under hoppys-adventure-arcade/tests/ (except tests/lib/) is a suite and runs +by default; new tests need no registration. tools/tests/suites.toml is the only way to keep a +suite out, and each skip carries a reason. A suite fails if it exits nonzero, times out, or +prints an engine/script error or an exit-time leak. Errors a suite provokes on purpose are +allowed only through its expected_errors patterns, and every listed pattern must still match. + +Each suite gets its own HOME, so user:// writes (settings, leaderboards) never touch the +developer's real files or another suite running in parallel. Standard library only. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import tomllib +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PROJECT = ROOT / "hoppys-adventure-arcade" +TESTS = PROJECT / "tests" +CONFIG = Path(__file__).with_name("suites.toml") +TIMEOUT = 600 +ANSI = re.compile(r"\x1b\[[0-9;]*m") +# Lines that mean something went wrong even when a suite's own checks all passed. +PROBLEM = re.compile( + r"^(?:ERROR:|SCRIPT ERROR|USER ERROR|Parse Error)" + r"|Failed to load script|leaked at exit|still in use at exit" +) + + +@dataclass +class Result: + name: str + seconds: float + exit_code: int | None + problems: list[str] = field(default_factory=list) + log: str = "" + + @property + def passed(self) -> bool: + return self.exit_code == 0 and not self.problems + + +def discover() -> list[str]: + """Suite names are paths under tests/ without .gd, e.g. "runner_logic" or "assets/a1_check".""" + return sorted( + path.relative_to(TESTS).with_suffix("").as_posix() + for path in TESTS.rglob("*.gd") + if "lib" not in path.relative_to(TESTS).parts[:-1] + ) + + +def load_config(suites: list[str], text: str | None = None + ) -> tuple[dict[str, str], dict[str, list[re.Pattern[str]]], list[str]]: + config = tomllib.loads(CONFIG.read_text(encoding="utf-8") if text is None else text) + skips: dict[str, str] = config.get("skip", {}) + expected = {name: [re.compile(p) for p in patterns] for name, patterns in config.get("expected_errors", {}).items()} + errors = [f"{CONFIG.name}: [{section}] names {name!r}, which is not a suite" + for section, names in (("skip", skips), ("expected_errors", expected)) + for name in names if name not in suites] + errors += [f"{CONFIG.name}: skip for {name!r} needs a reason" + for name, reason in skips.items() if not reason.strip()] + return skips, expected, errors + + +def run_suite(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: + home = tempfile.mkdtemp(prefix="arcade-test-home-") + env = {**os.environ, "HOME": home, "XDG_DATA_HOME": f"{home}/.local/share", + "XDG_CONFIG_HOME": f"{home}/.config", "XDG_CACHE_HOME": f"{home}/.cache"} + command = [godot, "--headless", "--path", str(PROJECT), "--script", f"res://tests/{name}.gd"] + started = time.monotonic() + try: + completed = subprocess.run(command, env=env, capture_output=True, text=True, timeout=TIMEOUT) + exit_code, log = completed.returncode, ANSI.sub("", completed.stdout + completed.stderr) + except subprocess.TimeoutExpired as timeout: + output = timeout.stdout or b"" + exit_code, log = None, ANSI.sub("", output.decode() if isinstance(output, bytes) else output) + finally: + shutil.rmtree(home, ignore_errors=True) + return Result(name, time.monotonic() - started, exit_code, find_problems(log, exit_code, expected), log) + + +def find_problems(log: str, exit_code: int | None, expected: list[re.Pattern[str]]) -> list[str]: + """Everything that fails a suite besides its exit code: engine errors, leaks, stale expectations.""" + problems = [] if exit_code is not None else [f"timed out after {TIMEOUT}s"] + lines = log.splitlines() + problems += [line.strip() for line in lines + if PROBLEM.search(line) and not any(pattern.search(line) for pattern in expected)] + problems += [f"expected error no longer occurs; remove it from {CONFIG.name}: {pattern.pattern}" + for pattern in expected if not any(pattern.search(line) for line in lines)] + return problems + + +def annotate(result: Result) -> None: + if os.environ.get("GITHUB_ACTIONS") != "true": + return + detail = "%0A".join(dict.fromkeys(result.problems))[:4000] or f"exit {result.exit_code}" + print(f"::error title=tests/{result.name}.gd::{detail}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--godot", default=os.environ.get("GODOT", "godot"), + help="Godot 4.7.1 binary (default: $GODOT or godot)") + parser.add_argument("-k", dest="match", action="append", default=[], + help="run suites whose name contains this text") + parser.add_argument("-j", "--jobs", type=int, default=min(4, os.cpu_count() or 1), help="suites to run at once") + parser.add_argument("--no-import", action="store_true", help="skip the resource import step") + parser.add_argument("--list", action="store_true", help="print the plan and exit") + parser.add_argument("--results", type=Path, default=ROOT / "build/test-results/summary.json") + args = parser.parse_args() + + suites = discover() + skips, expected, config_errors = load_config(suites) + if config_errors: + print("\n".join(config_errors), file=sys.stderr) + return 2 + selected = [s for s in suites if not args.match or any(m in s for m in args.match)] + planned = [s for s in selected if s not in skips] + if args.list: + for suite in selected: + print(f"skip {suite}: {skips[suite]}" if suite in skips else f"run {suite}") + return 0 + if not planned: + print("No suites selected.", file=sys.stderr) + return 2 + + if not args.no_import: + imported = subprocess.run([args.godot, "--headless", "--path", str(PROJECT), "--import"], + capture_output=True, text=True, timeout=TIMEOUT) + if imported.returncode != 0: + print(ANSI.sub("", imported.stdout + imported.stderr)[-4000:], file=sys.stderr) + print("Resource import failed.", file=sys.stderr) + return 1 + + print(f"Running {len(planned)} suites ({len(selected) - len(planned)} skipped), {args.jobs} at a time.") + started = time.monotonic() + results: list[Result] = [] + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: + futures = {pool.submit(run_suite, args.godot, s, expected.get(s, [])): s for s in planned} + for future in as_completed(futures): + result = future.result() + results.append(result) + print(f"{'PASS' if result.passed else 'FAIL'} {result.name} ({result.seconds:.0f}s)", flush=True) + + failed = sorted((r for r in results if not r.passed), key=lambda r: r.name) + for result in failed: + print(f"\n--- {result.name}: exit {result.exit_code}") + for problem in dict.fromkeys(result.problems): + print(f" {problem}") + print(" last output:\n" + "\n".join(f" {line}" for line in result.log.splitlines()[-25:])) + annotate(result) + + args.results.parent.mkdir(parents=True, exist_ok=True) + args.results.write_text(json.dumps({ + "seconds": round(time.monotonic() - started, 1), + "skipped": {name: skips[name] for name in selected if name in skips}, + "suites": [{"name": r.name, "passed": r.passed, "exit_code": r.exit_code, "seconds": round(r.seconds, 1), + "problems": list(dict.fromkeys(r.problems))} for r in sorted(results, key=lambda r: r.name)], + }, indent=2) + "\n", encoding="utf-8") + print(f"\n{len(results) - len(failed)} passed, {len(failed)} failed, " + f"{len(selected) - len(planned)} skipped in {time.monotonic() - started:.0f}s") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/suites.toml b/tools/tests/suites.toml new file mode 100644 index 00000000..d9c40e3c --- /dev/null +++ b/tools/tests/suites.toml @@ -0,0 +1,17 @@ +# Configuration for tools/tests/run_tests.py. +# +# Every .gd file under hoppys-adventure-arcade/tests/ runs headless in CI by default. +# A [skip] entry is the only way to keep a suite out, and it must say why. + +[skip] +capture_runner_menu_previews = "Writes menu preview screenshots; needs a window and GPU. Run it locally without --headless." +perf_probe = "Measures GPU frame times, which are meaningless headless. Run it locally." +runner_perf_probe = "Measures runner frame times on a GPU. Run it locally." +runner_pickup_hitch = "Measures GPU hitches during pickups. Run it locally." +menu_smoke = "Drives the menu with pointer hover, which a headless run never produces." +menu_hoppy_smoke = "Drives the menu with pointer hover, which a headless run never produces." + +# Engine errors a suite triggers on purpose. Each pattern must match at least one line of +# that suite's output, so the list can't outlive the behavior it describes. +[expected_errors] +runner_logic = ['^ERROR: ConfigFile parse error at user://test_leaderboard_'] diff --git a/tools/tests/test_run_tests.py b/tools/tests/test_run_tests.py new file mode 100644 index 00000000..b5636759 --- /dev/null +++ b/tools/tests/test_run_tests.py @@ -0,0 +1,56 @@ +"""Unit tests for run_tests.py's discovery, configuration checks and failure detection.""" +import re +import unittest + +import run_tests + + +class FindProblems(unittest.TestCase): + def test_clean_log_has_no_problems(self): + self.assertEqual(run_tests.find_problems("PASS: thing\nAll good\n", 0, []), []) + + def test_engine_and_script_errors_fail(self): + log = "SCRIPT ERROR: Invalid call\nERROR: Condition failed\nParse Error: bad\n" + self.assertEqual(len(run_tests.find_problems(log, 0, [])), 3) + + def test_exit_leaks_fail(self): + log = ("WARNING: 3 ObjectDB instances were leaked at exit (run with `--verbose` for details).\n" + "ERROR: 1 resources still in use at exit (run with --verbose for details).\n") + self.assertEqual(len(run_tests.find_problems(log, 0, [])), 2) + + def test_ordinary_warnings_and_file_names_pass(self): + log = "WARNING: leaderboard schema newer\nStoring File: sfx_sounds_error1.wav\n" + self.assertEqual(run_tests.find_problems(log, 0, []), []) + + def test_expected_errors_are_allowed_but_must_still_occur(self): + expected = [re.compile(r"^ERROR: ConfigFile parse error")] + self.assertEqual(run_tests.find_problems("ERROR: ConfigFile parse error at x\n", 0, expected), []) + stale = run_tests.find_problems("all fine\n", 0, expected) + self.assertEqual(len(stale), 1) + self.assertIn("no longer occurs", stale[0]) + + def test_timeout_is_a_problem(self): + self.assertIn("timed out", run_tests.find_problems("", None, [])[0]) + + +class LoadConfig(unittest.TestCase): + def test_unknown_suites_and_missing_reasons_are_errors(self): + text = '[skip]\nghost = "gone"\nreal = " "\n[expected_errors]\nphantom = ["x"]\n' + _, _, errors = run_tests.load_config(["real"], text) + self.assertEqual(len(errors), 3) + + def test_committed_config_matches_the_suites_on_disk(self): + _, _, errors = run_tests.load_config(run_tests.discover()) + self.assertEqual(errors, []) + + +class Discover(unittest.TestCase): + def test_finds_top_level_and_nested_suites_but_not_helpers(self): + suites = run_tests.discover() + self.assertIn("runner_logic", suites) + self.assertIn("assets/a1_check", suites) + self.assertFalse(any(name.startswith("lib/") for name in suites)) + + +if __name__ == "__main__": + unittest.main() From 6de0374eba5bb0c7e6b07cbd117d30e00a8d200b Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:19:33 -0700 Subject: [PATCH 02/11] fix(tests): isolate user:// on every platform and run timing suites alone [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] - CI on Linux failed assets/prewarm_check: its 1000 ms cold-warm budget can't hold while three other suites share the runner's CPU. A new [serial] table in suites.toml runs timing suites alone after the parallel batch. - assets/starship_check quit without settling the shared audio and leaked at exit; it now awaits AudioService.settle() like the others. - CodeRabbit (#40): Windows keeps user:// under %APPDATA%, which HOME doesn't redirect. The per-suite environment now also sets APPDATA, LOCALAPPDATA and USERPROFILE. Before running, the runner asks Godot where user:// resolves and refuses to start if it escapes the private directory. --- CONTRIBUTING.md | 2 +- .../tests/assets/starship_check.gd | 1 + tools/tests/run_tests.py | 95 +++++++++++++++---- tools/tests/suites.toml | 5 + tools/tests/test_run_tests.py | 9 +- 5 files changed, 86 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcfc5edc..261463ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ python3 tools/tests/run_tests.py --list # what runs and wha - **Every new `.gd` file under `tests/` runs automatically.** There's no list to update. To keep a suite out of CI (it needs a GPU, a window or pointer hover), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. - **A suite fails if it exits nonzero, times out, or prints an engine or script error or an exit-time leak.** That includes `ERROR:`, `SCRIPT ERROR`, parse errors and "leaked at exit". If a test provokes an engine error on purpose, list a pattern for it under `[expected_errors]`; the runner fails if that pattern stops matching. - **Finish a suite with `await root.get_node("AudioService").settle()` before `quit()`.** It stops the shared music and waits until the audio thread has released it. A fixed delay is not enough under parallel load. -- Each suite runs with its own `HOME`, so `user://` files (settings, leaderboards) never touch your real ones. Results are written to `build/test-results/summary.json`. +- Each suite gets a private user-data root (`HOME`, the XDG directories, and `APPDATA` on Windows), so `user://` files (settings, leaderboards) never touch your real ones. The runner checks this with Godot before starting and refuses to run if `user://` would escape. Suites listed under `[serial]` (timing budgets) run alone after the parallel batch. Results are written to `build/test-results/summary.json`. To run a single suite directly: diff --git a/hoppys-adventure-arcade/tests/assets/starship_check.gd b/hoppys-adventure-arcade/tests/assets/starship_check.gd index a7b635b7..e7e859ce 100644 --- a/hoppys-adventure-arcade/tests/assets/starship_check.gd +++ b/hoppys-adventure-arcade/tests/assets/starship_check.gd @@ -78,4 +78,5 @@ func run() -> void: print("STARSHIP TOTAL: %d triangles including deck, far bulkhead and reactor dock" % (triangles + extra_triangles)) print("STARSHIP GEOMETRY: repeated modules=%d triangles; 6 batches; 1 shared 256x256 texture" % triangles) print("STARSHIP CHECK: %s (%d checks, %d failures)" % ["PASS" if failures == 0 else "FAIL",checks,failures]) + await root.get_node("AudioService").settle() quit(0 if failures == 0 else 1) diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py index 07e848b5..6cd3fc10 100644 --- a/tools/tests/run_tests.py +++ b/tools/tests/run_tests.py @@ -10,6 +10,7 @@ suite out, and each skip carries a reason. A suite fails if it exits nonzero, times out, or prints an engine/script error or an exit-time leak. Errors a suite provokes on purpose are allowed only through its expected_errors patterns, and every listed pattern must still match. +Suites listed under [serial] (timing budgets) run one at a time after the parallel batch. Each suite gets its own HOME, so user:// writes (settings, leaderboards) never touch the developer's real files or another suite running in parallel. Standard library only. @@ -65,23 +66,57 @@ def discover() -> list[str]: ) -def load_config(suites: list[str], text: str | None = None - ) -> tuple[dict[str, str], dict[str, list[re.Pattern[str]]], list[str]]: - config = tomllib.loads(CONFIG.read_text(encoding="utf-8") if text is None else text) - skips: dict[str, str] = config.get("skip", {}) - expected = {name: [re.compile(p) for p in patterns] for name, patterns in config.get("expected_errors", {}).items()} +@dataclass +class Config: + skip: dict[str, str] + serial: dict[str, str] + expected: dict[str, list[re.Pattern[str]]] + errors: list[str] + + +def load_config(suites: list[str], text: str | None = None) -> Config: + raw = tomllib.loads(CONFIG.read_text(encoding="utf-8") if text is None else text) + skip: dict[str, str] = raw.get("skip", {}) + serial: dict[str, str] = raw.get("serial", {}) + expected = {name: [re.compile(p) for p in patterns] for name, patterns in raw.get("expected_errors", {}).items()} errors = [f"{CONFIG.name}: [{section}] names {name!r}, which is not a suite" - for section, names in (("skip", skips), ("expected_errors", expected)) + for section, names in (("skip", skip), ("serial", serial), ("expected_errors", expected)) for name in names if name not in suites] - errors += [f"{CONFIG.name}: skip for {name!r} needs a reason" - for name, reason in skips.items() if not reason.strip()] - return skips, expected, errors + errors += [f"{CONFIG.name}: [{section}] entry {name!r} needs a reason" + for section, table in (("skip", skip), ("serial", serial)) + for name, reason in table.items() if not reason.strip()] + return Config(skip, serial, expected, errors) + + +def isolated_env(home: str) -> dict[str, str]: + """Point every platform's user-data root (user://) at a private directory.""" + return {**os.environ, "HOME": home, "USERPROFILE": home, + "XDG_DATA_HOME": f"{home}/.local/share", "XDG_CONFIG_HOME": f"{home}/.config", + "XDG_CACHE_HOME": f"{home}/.cache", + "APPDATA": str(Path(home, "AppData", "Roaming")), "LOCALAPPDATA": str(Path(home, "AppData", "Local"))} + + +def check_isolation(godot: str) -> str | None: + """Ask Godot where user:// resolves under the isolated environment; None when it is private.""" + home = tempfile.mkdtemp(prefix="arcade-test-probe-") + try: + probe = Path(home, "probe.gd") + probe.write_text('extends SceneTree\nfunc _init() -> void:\n\tprint("USER_DIR=", OS.get_user_data_dir())\n' + '\tquit()\n', encoding="utf-8") + output = subprocess.run([godot, "--headless", "--path", str(PROJECT), "--script", str(probe)], + env=isolated_env(home), capture_output=True, text=True, timeout=120).stdout + match = re.search(r"^USER_DIR=(.+)$", ANSI.sub("", output), re.M) + user_dir = Path(match.group(1).strip()).resolve() if match else None + if user_dir is None or Path(home).resolve() not in user_dir.parents: + return f"user:// resolved to {user_dir or 'nothing'}, outside the isolated home {home}" + return None + finally: + shutil.rmtree(home, ignore_errors=True) def run_suite(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: home = tempfile.mkdtemp(prefix="arcade-test-home-") - env = {**os.environ, "HOME": home, "XDG_DATA_HOME": f"{home}/.local/share", - "XDG_CONFIG_HOME": f"{home}/.config", "XDG_CACHE_HOME": f"{home}/.cache"} + env = isolated_env(home) command = [godot, "--headless", "--path", str(PROJECT), "--script", f"res://tests/{name}.gd"] started = time.monotonic() try: @@ -126,15 +161,21 @@ def main() -> int: args = parser.parse_args() suites = discover() - skips, expected, config_errors = load_config(suites) - if config_errors: - print("\n".join(config_errors), file=sys.stderr) + config = load_config(suites) + if config.errors: + print("\n".join(config.errors), file=sys.stderr) return 2 + skips, expected = config.skip, config.expected selected = [s for s in suites if not args.match or any(m in s for m in args.match)] planned = [s for s in selected if s not in skips] if args.list: for suite in selected: - print(f"skip {suite}: {skips[suite]}" if suite in skips else f"run {suite}") + if suite in skips: + print(f"skip {suite}: {skips[suite]}") + elif suite in config.serial: + print(f"serial {suite}: {config.serial[suite]}") + else: + print(f"run {suite}") return 0 if not planned: print("No suites selected.", file=sys.stderr) @@ -148,15 +189,29 @@ def main() -> int: print("Resource import failed.", file=sys.stderr) return 1 - print(f"Running {len(planned)} suites ({len(selected) - len(planned)} skipped), {args.jobs} at a time.") + leak = check_isolation(args.godot) + if leak: + print(f"Refusing to run: {leak}. Suites would touch real settings and leaderboards.", file=sys.stderr) + return 2 + + parallel = [s for s in planned if s not in config.serial] + serial = [s for s in planned if s in config.serial] + print(f"Running {len(planned)} suites ({len(selected) - len(planned)} skipped): " + f"{len(parallel)} at {args.jobs} a time, then {len(serial)} alone.") started = time.monotonic() results: list[Result] = [] + + def record(result: Result) -> None: + results.append(result) + print(f"{'PASS' if result.passed else 'FAIL'} {result.name} ({result.seconds:.0f}s)", flush=True) + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: - futures = {pool.submit(run_suite, args.godot, s, expected.get(s, [])): s for s in planned} + futures = [pool.submit(run_suite, args.godot, s, expected.get(s, [])) for s in parallel] for future in as_completed(futures): - result = future.result() - results.append(result) - print(f"{'PASS' if result.passed else 'FAIL'} {result.name} ({result.seconds:.0f}s)", flush=True) + record(future.result()) + # Suites with timing budgets run on an otherwise idle machine. + for suite in serial: + record(run_suite(args.godot, suite, expected.get(suite, []))) failed = sorted((r for r in results if not r.passed), key=lambda r: r.name) for result in failed: diff --git a/tools/tests/suites.toml b/tools/tests/suites.toml index d9c40e3c..3bc825ba 100644 --- a/tools/tests/suites.toml +++ b/tools/tests/suites.toml @@ -11,6 +11,11 @@ runner_pickup_hitch = "Measures GPU hitches during pickups. Run it locally." menu_smoke = "Drives the menu with pointer hover, which a headless run never produces." menu_hoppy_smoke = "Drives the menu with pointer hover, which a headless run never produces." +# Suites with wall-clock budgets run alone, after the parallel batch, so CPU contention +# from other suites can't fail them. +[serial] +"assets/prewarm_check" = "Asserts the runner's synchronous cold warm finishes under 1000 ms." + # Engine errors a suite triggers on purpose. Each pattern must match at least one line of # that suite's output, so the list can't outlive the behavior it describes. [expected_errors] diff --git a/tools/tests/test_run_tests.py b/tools/tests/test_run_tests.py index b5636759..3256650a 100644 --- a/tools/tests/test_run_tests.py +++ b/tools/tests/test_run_tests.py @@ -35,13 +35,12 @@ def test_timeout_is_a_problem(self): class LoadConfig(unittest.TestCase): def test_unknown_suites_and_missing_reasons_are_errors(self): - text = '[skip]\nghost = "gone"\nreal = " "\n[expected_errors]\nphantom = ["x"]\n' - _, _, errors = run_tests.load_config(["real"], text) - self.assertEqual(len(errors), 3) + text = ('[skip]\nghost = "gone"\nreal = " "\n[serial]\nspecter = "timing"\nreal = ""\n' + '[expected_errors]\nphantom = ["x"]\n') + self.assertEqual(len(run_tests.load_config(["real"], text).errors), 5) def test_committed_config_matches_the_suites_on_disk(self): - _, _, errors = run_tests.load_config(run_tests.discover()) - self.assertEqual(errors, []) + self.assertEqual(run_tests.load_config(run_tests.discover()).errors, []) class Discover(unittest.TestCase): From 0f11406e957f7fb375265e49c3e956644d7f646e Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:28:54 -0700 Subject: [PATCH 03/11] fix(tests): verify isolation before any Godot run and name leaked objects [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] - CodeRabbit (#40): the isolation probe now runs before the resource import, and the import itself uses the isolated environment, so no Godot process touches the caller's editor data or user:// first. - When a suite fails only on an exit-time leak, the runner reruns it once with --verbose and appends the Leaked instance / Resource still in use lines to the failure. The suite still fails; the rerun is diagnosis only. assets/starship_check leaks on Linux CI but not locally, and this will say what is leaking. --- tools/tests/run_tests.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py index 6cd3fc10..08478645 100644 --- a/tools/tests/run_tests.py +++ b/tools/tests/run_tests.py @@ -114,10 +114,25 @@ def check_isolation(godot: str) -> str | None: shutil.rmtree(home, ignore_errors=True) +LEAK = re.compile(r"leaked at exit|still in use at exit") +LEAK_DETAIL = re.compile(r"^(?:Leaked instance|Resource still in use|Orphan StringName)", re.M) + + def run_suite(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: + result = run_once(godot, name, expected) + if result.exit_code is not None and any(LEAK.search(problem) for problem in result.problems): + # Name what leaked: rerun once with --verbose for diagnosis only. The suite still fails. + detail = run_once(godot, name, expected, verbose=True) + result.problems += [f"leak detail (verbose rerun): {line.strip()}" + for line in detail.log.splitlines() if LEAK_DETAIL.search(line)][:20] + return result + + +def run_once(godot: str, name: str, expected: list[re.Pattern[str]], verbose: bool = False) -> Result: home = tempfile.mkdtemp(prefix="arcade-test-home-") env = isolated_env(home) - command = [godot, "--headless", "--path", str(PROJECT), "--script", f"res://tests/{name}.gd"] + command = [godot, "--headless", *(["--verbose"] if verbose else []), "--path", str(PROJECT), + "--script", f"res://tests/{name}.gd"] started = time.monotonic() try: completed = subprocess.run(command, env=env, capture_output=True, text=True, timeout=TIMEOUT) @@ -181,19 +196,23 @@ def main() -> int: print("No suites selected.", file=sys.stderr) return 2 + leak = check_isolation(args.godot) + if leak: + print(f"Refusing to run: {leak}. Suites would touch real settings and leaderboards.", file=sys.stderr) + return 2 + if not args.no_import: - imported = subprocess.run([args.godot, "--headless", "--path", str(PROJECT), "--import"], - capture_output=True, text=True, timeout=TIMEOUT) + home = tempfile.mkdtemp(prefix="arcade-test-import-") + try: + imported = subprocess.run([args.godot, "--headless", "--path", str(PROJECT), "--import"], + env=isolated_env(home), capture_output=True, text=True, timeout=TIMEOUT) + finally: + shutil.rmtree(home, ignore_errors=True) if imported.returncode != 0: print(ANSI.sub("", imported.stdout + imported.stderr)[-4000:], file=sys.stderr) print("Resource import failed.", file=sys.stderr) return 1 - leak = check_isolation(args.godot) - if leak: - print(f"Refusing to run: {leak}. Suites would touch real settings and leaderboards.", file=sys.stderr) - return 2 - parallel = [s for s in planned if s not in config.serial] serial = [s for s in planned if s in config.serial] print(f"Running {len(planned)} suites ({len(selected) - len(planned)} skipped): " From 2fc10f81b0221f7ec054b897ac2b1e1e5f647eaf Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:30:09 -0700 Subject: [PATCH 04/11] fix(tests): fail the import on engine errors and let CI use the runner's import [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit review on #40: a zero-exit import that reports an engine or script error now stops the run, using the same detection as the suites. CI drops its separate import step so that check applies there too. --- .github/workflows/ci.yml | 10 ++++------ tools/tests/run_tests.py | 8 +++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aebdc47..fa7cb121 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,12 +52,10 @@ jobs: # A stale cache is safe: Godot reimports any source whose hash changed. restore-keys: godot-import-${{ env.GODOT_VERSION }}- - - name: Import project - run: ~/godot/godot --headless --path "$PROJECT" --import - - # Every tests/**/*.gd runs; tools/tests/suites.toml lists the few skipped suites and why. - - name: Run headless suites - run: python3 tools/tests/run_tests.py --godot ~/godot/godot --no-import --jobs 4 + # The runner imports first (failing on any engine error), then runs every tests/**/*.gd; + # tools/tests/suites.toml lists the few skipped or serial suites and why. + - name: Import and run headless suites + run: python3 tools/tests/run_tests.py --godot ~/godot/godot --jobs 4 - name: Upload test results if: always() diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py index 08478645..46bd658c 100644 --- a/tools/tests/run_tests.py +++ b/tools/tests/run_tests.py @@ -208,9 +208,11 @@ def main() -> int: env=isolated_env(home), capture_output=True, text=True, timeout=TIMEOUT) finally: shutil.rmtree(home, ignore_errors=True) - if imported.returncode != 0: - print(ANSI.sub("", imported.stdout + imported.stderr)[-4000:], file=sys.stderr) - print("Resource import failed.", file=sys.stderr) + import_log = ANSI.sub("", imported.stdout + imported.stderr) + import_problems = find_problems(import_log, imported.returncode, []) + if imported.returncode != 0 or import_problems: + print(import_log[-4000:], file=sys.stderr) + print("Resource import failed:", *dict.fromkeys(import_problems), sep="\n ", file=sys.stderr) return 1 parallel = [s for s in planned if s not in config.serial] From c4ecb1638167168797b5a707016b03f2f3ec9b1d Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:47:38 -0700 Subject: [PATCH 05/11] fix(tests): classify exit leaks from verbose output and tolerate only the audio-teardown race [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Linux CI still reported the shared music and UI voices as leaked in the 3D suites (a2_check, starship_check, runner_smoke) even after AudioService.settle(): the engine releases audio playbacks on its audio thread, which a loaded runner starves. A diagnostic rerun can't classify a transient leak, so every suite now runs with --verbose and its own log names what leaked. A leak made only of AudioStream* instances and audio files is recorded as a note. Any other leaked instance or resource fails the suite with its class names. Verbose loader noise is dropped from the displayed log. --- CONTRIBUTING.md | 2 +- tools/tests/run_tests.py | 45 ++++++++++++++++++++++++++--------- tools/tests/test_run_tests.py | 21 ++++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f0927f6..33497dac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ python3 tools/tests/run_tests.py --list # what runs and wha ``` - **Every new `.gd` file under `tests/` runs automatically.** There's no list to update. To keep a suite out of CI (it needs a GPU, a window or pointer hover), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. -- **A suite fails if it exits nonzero, times out, or prints an engine or script error or an exit-time leak.** That includes `ERROR:`, `SCRIPT ERROR`, parse errors and "leaked at exit". If a test provokes an engine error on purpose, list a pattern for it under `[expected_errors]`; the runner fails if that pattern stops matching. +- **A suite fails if it exits nonzero, times out, or prints an engine or script error or an exit-time leak.** Suites run with `--verbose`, so a leak names the objects involved. The one tolerated shape is the engine's audio-teardown race: only `AudioStream*` playbacks and audio files, reported as a note. Anything else that leaks fails. That includes `ERROR:`, `SCRIPT ERROR`, parse errors and "leaked at exit". If a test provokes an engine error on purpose, list a pattern for it under `[expected_errors]`; the runner fails if that pattern stops matching. - **Finish a suite with `await root.get_node("AudioService").settle()` before `quit()`.** It stops the shared music and waits until the audio thread has released it. A fixed delay is not enough under parallel load. - Each suite gets a private user-data root (`HOME`, the XDG directories, and `APPDATA` on Windows), so `user://` files (settings, leaderboards) never touch your real ones. The runner checks this with Godot before starting and refuses to run if `user://` would escape. Suites listed under `[serial]` (timing budgets) run alone after the parallel batch. Results are written to `build/test-results/summary.json`. diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py index 46bd658c..93541aa9 100644 --- a/tools/tests/run_tests.py +++ b/tools/tests/run_tests.py @@ -51,6 +51,7 @@ class Result: exit_code: int | None problems: list[str] = field(default_factory=list) log: str = "" + notes: list[str] = field(default_factory=list) @property def passed(self) -> bool: @@ -115,24 +116,44 @@ def check_isolation(godot: str) -> str | None: LEAK = re.compile(r"leaked at exit|still in use at exit") -LEAK_DETAIL = re.compile(r"^(?:Leaked instance|Resource still in use|Orphan StringName)", re.M) +LEAKED_INSTANCE = re.compile(r"^Leaked instance: (\w+):", re.M) +RESOURCE_IN_USE = re.compile(r"^Resource still in use: (.+) \((\w+)\)$", re.M) +# The engine releases audio playbacks on its audio thread. When a suite quits while that thread +# is starved (parallel 3D suites on a small CI runner), the shared music and UI voices are +# reported as leaked even after AudioService.settle(). Only that exact shape is tolerated: +# AudioStream* instances and audio files still in use. Any other leaked object fails the suite. +AUDIO_CLASS = re.compile(r"^AudioStream\w*$") +VERBOSE_NOISE = re.compile(r"^(?:Loading resource:|Completed load for:|Loaded builtin|Loading | ->|ResourceFormat)") + + +def classify_leaks(log: str) -> tuple[bool, list[str]]: + """(is the audio-teardown race, detail lines naming what leaked) from a --verbose log.""" + instances = LEAKED_INSTANCE.findall(log) + resources = RESOURCE_IN_USE.findall(log) + details = [f"leaked: {name}" for name in instances] + [f"still in use: {path} ({kind})" for path, kind in resources] + audio_only = bool(details) and all(AUDIO_CLASS.match(name) for name in instances) \ + and all(AUDIO_CLASS.match(kind) for _, kind in resources) + return audio_only, details def run_suite(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: result = run_once(godot, name, expected) - if result.exit_code is not None and any(LEAK.search(problem) for problem in result.problems): - # Name what leaked: rerun once with --verbose for diagnosis only. The suite still fails. - detail = run_once(godot, name, expected, verbose=True) - result.problems += [f"leak detail (verbose rerun): {line.strip()}" - for line in detail.log.splitlines() if LEAK_DETAIL.search(line)][:20] + if any(LEAK.search(problem) for problem in result.problems): + audio_only, details = classify_leaks(result.log) + if audio_only: + result.problems = [problem for problem in result.problems if not LEAK.search(problem)] + result.notes.append("engine audio-teardown race tolerated: " + ", ".join(details)) + else: + result.problems += details[:20] or ["leak with no --verbose detail"] + result.log = "\n".join(line for line in result.log.splitlines() if not VERBOSE_NOISE.match(line)) return result -def run_once(godot: str, name: str, expected: list[re.Pattern[str]], verbose: bool = False) -> Result: +def run_once(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: home = tempfile.mkdtemp(prefix="arcade-test-home-") env = isolated_env(home) - command = [godot, "--headless", *(["--verbose"] if verbose else []), "--path", str(PROJECT), - "--script", f"res://tests/{name}.gd"] + # --verbose makes Godot name what leaked in the same run, so a leak can be classified. + command = [godot, "--headless", "--verbose", "--path", str(PROJECT), "--script", f"res://tests/{name}.gd"] started = time.monotonic() try: completed = subprocess.run(command, env=env, capture_output=True, text=True, timeout=TIMEOUT) @@ -224,7 +245,8 @@ def main() -> int: def record(result: Result) -> None: results.append(result) - print(f"{'PASS' if result.passed else 'FAIL'} {result.name} ({result.seconds:.0f}s)", flush=True) + note = f" [{result.notes[0][:120]}]" if result.notes else "" + print(f"{'PASS' if result.passed else 'FAIL'} {result.name} ({result.seconds:.0f}s){note}", flush=True) with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: futures = [pool.submit(run_suite, args.godot, s, expected.get(s, [])) for s in parallel] @@ -247,7 +269,8 @@ def record(result: Result) -> None: "seconds": round(time.monotonic() - started, 1), "skipped": {name: skips[name] for name in selected if name in skips}, "suites": [{"name": r.name, "passed": r.passed, "exit_code": r.exit_code, "seconds": round(r.seconds, 1), - "problems": list(dict.fromkeys(r.problems))} for r in sorted(results, key=lambda r: r.name)], + "problems": list(dict.fromkeys(r.problems)), "notes": r.notes} + for r in sorted(results, key=lambda r: r.name)], }, indent=2) + "\n", encoding="utf-8") print(f"\n{len(results) - len(failed)} passed, {len(failed)} failed, " f"{len(selected) - len(planned)} skipped in {time.monotonic() - started:.0f}s") diff --git a/tools/tests/test_run_tests.py b/tools/tests/test_run_tests.py index 3256650a..80c98afd 100644 --- a/tools/tests/test_run_tests.py +++ b/tools/tests/test_run_tests.py @@ -33,6 +33,27 @@ def test_timeout_is_a_problem(self): self.assertIn("timed out", run_tests.find_problems("", None, [])[0]) +class ClassifyLeaks(unittest.TestCase): + AUDIO = ("Leaked instance: AudioStreamPlaybackMP3:1 - Reference count: 1\n" + "Leaked instance: AudioStreamPlaybackPolyphonic:2 - Reference count: 1\n" + "Resource still in use: res://Assets/Audio/Main Menu/track.mp3 (AudioStreamMP3)\n") + + def test_audio_teardown_race_is_recognized(self): + audio_only, details = run_tests.classify_leaks(self.AUDIO) + self.assertTrue(audio_only) + self.assertEqual(len(details), 3) + + def test_any_other_leak_is_not_tolerated(self): + audio_only, details = run_tests.classify_leaks(self.AUDIO + "Leaked instance: Node3D:9 - Node path:\n") + self.assertFalse(audio_only) + self.assertIn("leaked: Node3D", details) + texture = "Resource still in use: res://a.png (CompressedTexture2D)\n" + self.assertFalse(run_tests.classify_leaks(self.AUDIO + texture)[0]) + + def test_a_leak_without_detail_is_not_tolerated(self): + self.assertEqual(run_tests.classify_leaks("WARNING: 3 ObjectDB instances were leaked at exit\n"), (False, [])) + + class LoadConfig(unittest.TestCase): def test_unknown_suites_and_missing_reasons_are_errors(self): text = ('[skip]\nghost = "gone"\nreal = " "\n[serial]\nspecter = "timing"\nreal = ""\n' From 3ab531f0939521154a889989e62c2f88ca5a3a32 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Fri, 9 Oct 2026 22:29:51 -0700 Subject: [PATCH 06/11] fix(tests): count Ogg internals as audio teardown and settle the booth suite [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] - Linux CI reported heroes_villains_logic's music as leaked through Ogg Vorbis's internal OggPacketSequence and OggPacketSequencePlayback objects, the same audio-thread race as the MP3 playbacks. They now count as audio for the tolerated shape; any other leaked class still fails. A unit test covers it. - tests/booth_mode.gd (merged in #43) now ends with AudioService.settle() like every other suite. - Merged main (#43, #67); the vendor-lock check in lint and the pre-commit hook are kept. --- hoppys-adventure-arcade/tests/booth_mode.gd | 3 +-- tools/tests/run_tests.py | 5 +++-- tools/tests/test_run_tests.py | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd b/hoppys-adventure-arcade/tests/booth_mode.gd index 74c0ea34..1f2b1318 100644 --- a/hoppys-adventure-arcade/tests/booth_mode.gd +++ b/hoppys-adventure-arcade/tests/booth_mode.gd @@ -86,7 +86,6 @@ func run() -> void: router.set_booth(false) router.idle_limit = router.IDLE_SECONDS - root.get_node("AudioService").shutdown() - await wait(0.4) + await root.get_node("AudioService").settle() print("Booth mode failures: ", failures) quit(1 if failures else 0) diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py index 98d6fef2..6328de55 100644 --- a/tools/tests/run_tests.py +++ b/tools/tests/run_tests.py @@ -127,8 +127,9 @@ def check_isolation(godot: str) -> str | None: # The engine releases audio playbacks on its audio thread. When a suite quits while that thread # is starved (parallel 3D suites on a small CI runner), the shared music and UI voices are # reported as leaked even after AudioService.settle(). Only that exact shape is tolerated: -# AudioStream* instances and audio files still in use. Any other leaked object fails the suite. -AUDIO_CLASS = re.compile(r"^AudioStream\w*$") +# audio stream classes (AudioStream*, and Ogg Vorbis's internal OggPacketSequence*) and audio +# files still in use. Any other leaked object fails the suite. +AUDIO_CLASS = re.compile(r"^(?:AudioStream\w*|OggPacketSequence\w*)$") VERBOSE_NOISE = re.compile(r"^(?:Loading resource:|Completed load for:|Loaded builtin|Loading | ->|ResourceFormat)") diff --git a/tools/tests/test_run_tests.py b/tools/tests/test_run_tests.py index df813b9e..2b828e0d 100644 --- a/tools/tests/test_run_tests.py +++ b/tools/tests/test_run_tests.py @@ -38,6 +38,12 @@ class ClassifyLeaks(unittest.TestCase): "Leaked instance: AudioStreamPlaybackPolyphonic:2 - Reference count: 1\n" "Resource still in use: res://Assets/Audio/Main Menu/track.mp3 (AudioStreamMP3)\n") + def test_ogg_internals_count_as_audio(self): + log = ("Leaked instance: OggPacketSequence:1 - Reference count: 3\n" + "Leaked instance: OggPacketSequencePlayback:2 - Reference count: 1\n" + "Leaked instance: AudioStreamOggVorbis:3 - Reference count: 1\n") + self.assertTrue(run_tests.classify_leaks(log)[0]) + def test_audio_teardown_race_is_recognized(self): audio_only, details = run_tests.classify_leaks(self.AUDIO) self.assertTrue(audio_only) From df72c883acfc03f486f1398c358d0712a64a7cc5 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Fri, 9 Oct 2026 22:30:42 -0700 Subject: [PATCH 07/11] chore(tests): run A6 and A8 again now that #66 fixed them [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] #66 merged first, so drop the two [skip] entries that pointed at #65. --- tools/tests/suites.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/tests/suites.toml b/tools/tests/suites.toml index 852666cf..3bc825ba 100644 --- a/tools/tests/suites.toml +++ b/tools/tests/suites.toml @@ -10,8 +10,6 @@ runner_perf_probe = "Measures runner frame times on a GPU. Run it locally." runner_pickup_hitch = "Measures GPU hitches during pickups. Run it locally." menu_smoke = "Drives the menu with pointer hover, which a headless run never produces." menu_hoppy_smoke = "Drives the menu with pointer hover, which a headless run never produces." -"assets/a6_check" = "Fails on main since #61's new pursuer catch; fix or update tracked in #65." -"assets/a8_check" = "Fails on main since #61 (game_over SFX no longer exercised); tracked in #65." # Suites with wall-clock budgets run alone, after the parallel batch, so CPU contention # from other suites can't fail them. From cfeebf2920d810a222fe5674fb5b1510dddca472 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 12:08:10 -0700 Subject: [PATCH 08/11] fix(tests): run a2_check alone and classify main's new scripts [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] godot-tests failed on assets/a2_check ("asynchronous prewarm completed before texture census") in three of the last four CI runs. a2_check waits at most 15 s of wall time for the runner's asynchronous prewarm, which runs on Godot's single low-priority worker. The CI runner has 2 cores ("WorkerThreadPool: 2 threads, 1 max low-priority") and the batch runs 4 suites at once, so the prewarm starves. It passed when the batch was lighter. The check itself is unchanged; it now runs in the serial phase with prewarm_check, the way main's one-at-a-time loop ran it. The merge brought in scripts that main's hand list never ran: - runner_synth_threads parks a thread on every core and releases them together. It needs idle cores and starves its neighbours: serial. - capture_code_city_bosses writes docs/previews/ stills in a graphical run: skipped, like capture_runner_menu_previews. - exit_leak needs a --scenario per process and is driven by tools/checks/exit_leak.py, which CI runs as its own step: skipped. --- CONTRIBUTING.md | 2 +- tools/tests/suites.toml | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd1eca52..5dcf2c97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ python3 tools/tests/run_tests.py --list # what runs and wha - **Every new `.gd` file under `tests/` that `extends SceneTree` runs automatically.** Shared helpers that extend anything else, such as `tests/support/`, are never run as suites. There's no list to update. To keep a suite out of CI (it needs a GPU, a window or pointer hover), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. - **A suite fails if it exits nonzero, times out, or prints an engine or script error or an exit-time leak.** Suites run with `--verbose`, so a leak names the objects involved. The one tolerated shape is the engine's audio-teardown race: only `AudioStream*` playbacks and audio files, reported as a note. Anything else that leaks fails. That includes `ERROR:`, `SCRIPT ERROR`, parse errors and "leaked at exit". If a test provokes an engine error on purpose, list a pattern for it under `[expected_errors]`; the runner fails if that pattern stops matching. - **Finish a suite with `await root.get_node("AudioService").settle()` before `quit()`.** It stops the shared music and waits until the audio thread has released it. A fixed delay is not enough under parallel load. -- Each suite gets a private user-data root (`HOME`, the XDG directories, and `APPDATA` on Windows), so `user://` files (settings, leaderboards) never touch your real ones. The runner checks this with Godot before starting and refuses to run if `user://` would escape. Suites listed under `[serial]` (timing budgets) run alone after the parallel batch. Results are written to `build/test-results/summary.json`. +- Each suite gets a private user-data root (`HOME`, the XDG directories, and `APPDATA` on Windows), so `user://` files (settings, leaderboards) never touch your real ones. The runner checks this with Godot before starting and refuses to run if `user://` would escape. Suites listed under `[serial]` (wall-clock budgets, or suites that occupy every core) run alone after the parallel batch. Results are written to `build/test-results/summary.json`. To run a single suite directly: diff --git a/tools/tests/suites.toml b/tools/tests/suites.toml index 3bc825ba..5f3f47e9 100644 --- a/tools/tests/suites.toml +++ b/tools/tests/suites.toml @@ -1,6 +1,7 @@ # Configuration for tools/tests/run_tests.py. # -# Every .gd file under hoppys-adventure-arcade/tests/ runs headless in CI by default. +# Every suite (a script under hoppys-adventure-arcade/tests/ that extends SceneTree or MainLoop) +# runs headless in CI by default. # A [skip] entry is the only way to keep a suite out, and it must say why. [skip] @@ -10,11 +11,15 @@ runner_perf_probe = "Measures runner frame times on a GPU. Run it locally." runner_pickup_hitch = "Measures GPU hitches during pickups. Run it locally." menu_smoke = "Drives the menu with pointer hover, which a headless run never produces." menu_hoppy_smoke = "Drives the menu with pointer hover, which a headless run never produces." +capture_code_city_bosses = "Writes the Code City boss stills into docs/previews/; a graphical run only." +exit_leak = "Needs a --scenario per process; CI runs every scenario through tools/checks/exit_leak.py." -# Suites with wall-clock budgets run alone, after the parallel batch, so CPU contention -# from other suites can't fail them. +# Suites with wall-clock budgets, or that occupy every core, run alone after the parallel +# batch, so CPU contention from other suites can't fail them (or them, the others). [serial] "assets/prewarm_check" = "Asserts the runner's synchronous cold warm finishes under 1000 ms." +"assets/a2_check" = "Waits at most 15 s for the asynchronous prewarm, which the parallel batch starves on a 2-core runner." +runner_synth_threads = "Parks a thread on every core and releases them together; it needs idle cores and starves other suites." # Engine errors a suite triggers on purpose. Each pattern must match at least one line of # that suite's output, so the list can't outlive the behavior it describes. From dbde5de8c277e34ea5b218e56fbe93870aa3dc23 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 12:08:11 -0700 Subject: [PATCH 09/11] fix(tests): keep non-audio exit leaks failing and record setup failures [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses the open CodeRabbit comments on #40: - A suite whose log had the audio-teardown race plus another exit leak, such as "RID allocations ... were leaked at exit", passed: run_suite() dropped every leak line once the verbose details were all audio. Only the two engine summary lines that those details explain are dropped now, and any other leak report fails the suite. run_suite() is tested with a mixed audio and RID log. - An isolation or import failure returned before summary.json was written, so CI uploaded no results. Both now write the summary with a setup_error, covered by two tests. - README says how suites are discovered (scripts that extend SceneTree or MainLoop), not that every script under tests/ is one. - Docstrings on the runner's remaining functions. --- README.md | 2 +- tools/tests/run_tests.py | 45 +++++++++++++++++------ tools/tests/test_run_tests.py | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8fa02cb2..bf0bff7f 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Versioned macOS, Windows, Linux and Web builds come from the [Build workflow](.g python3 tools/tests/run_tests.py --godot /path/to/Godot ``` -Every script in `hoppys-adventure-arcade/tests/` is a standalone suite, and CI runs all of them on every PR, except the few listed with a reason in [`tools/tests/suites.toml`](tools/tests/suites.toml). A suite fails on a nonzero exit, an engine or script error, or a leak. See [CONTRIBUTING.md](CONTRIBUTING.md#tests) for details. +CI discovers the suites in `hoppys-adventure-arcade/tests/`: every script that extends `SceneTree` or `MainLoop`. Helpers such as `tests/support/` are not suites. It runs all of them on every PR, except the few listed with a reason in [`tools/tests/suites.toml`](tools/tests/suites.toml). A suite fails on a nonzero exit, an engine or script error, or a leak. See [CONTRIBUTING.md](CONTRIBUTING.md#tests) for details. ## Contributing diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py index 6328de55..c619832f 100644 --- a/tools/tests/run_tests.py +++ b/tools/tests/run_tests.py @@ -56,6 +56,7 @@ class Result: @property def passed(self) -> bool: + """A clean exit with nothing in the log that fails a suite.""" return self.exit_code == 0 and not self.problems @@ -82,6 +83,7 @@ class Config: def load_config(suites: list[str], text: str | None = None) -> Config: + """Read suites.toml (or `text`); every entry must name a suite, and skips and serials need a reason.""" raw = tomllib.loads(CONFIG.read_text(encoding="utf-8") if text is None else text) skip: dict[str, str] = raw.get("skip", {}) serial: dict[str, str] = raw.get("serial", {}) @@ -122,6 +124,9 @@ def check_isolation(godot: str) -> str | None: LEAK = re.compile(r"leaked at exit|still in use at exit") +# The engine's two summary lines whose --verbose detail lines classify_leaks() reads. Any other exit +# leak report (RID allocations, for one) has no such detail and is never tolerated. +LEAK_SUMMARY = re.compile(r"ObjectDB instances were leaked at exit|resources still in use at exit") LEAKED_INSTANCE = re.compile(r"^Leaked instance: (\w+):", re.M) RESOURCE_IN_USE = re.compile(r"^Resource still in use: (.+) \((\w+)\)$", re.M) # The engine releases audio playbacks on its audio thread. When a suite quits while that thread @@ -137,18 +142,20 @@ def classify_leaks(log: str) -> tuple[bool, list[str]]: """(is the audio-teardown race, detail lines naming what leaked) from a --verbose log.""" instances = LEAKED_INSTANCE.findall(log) resources = RESOURCE_IN_USE.findall(log) + unclassified = [line.strip() for line in log.splitlines() if LEAK.search(line) and not LEAK_SUMMARY.search(line)] details = [f"leaked: {name}" for name in instances] + [f"still in use: {path} ({kind})" for path, kind in resources] - audio_only = bool(details) and all(AUDIO_CLASS.match(name) for name in instances) \ + audio_only = bool(details) and not unclassified and all(AUDIO_CLASS.match(name) for name in instances) \ and all(AUDIO_CLASS.match(kind) for _, kind in resources) - return audio_only, details + return audio_only, details + unclassified def run_suite(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: + """Run one suite and classify any exit leak: only the audio-teardown race becomes a note.""" result = run_once(godot, name, expected) if any(LEAK.search(problem) for problem in result.problems): audio_only, details = classify_leaks(result.log) if audio_only: - result.problems = [problem for problem in result.problems if not LEAK.search(problem)] + result.problems = [problem for problem in result.problems if not LEAK_SUMMARY.search(problem)] result.notes.append("engine audio-teardown race tolerated: " + ", ".join(details)) else: result.problems += details[:20] or ["leak with no --verbose detail"] @@ -157,6 +164,7 @@ def run_suite(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: def run_once(godot: str, name: str, expected: list[re.Pattern[str]]) -> Result: + """One headless, --verbose run of res://tests/.gd with a private user-data root.""" home = tempfile.mkdtemp(prefix="arcade-test-home-") env = isolated_env(home) # --verbose makes Godot name what leaked in the same run, so a leak can be classified. @@ -185,13 +193,31 @@ def find_problems(log: str, exit_code: int | None, expected: list[re.Pattern[str def annotate(result: Result) -> None: + """Report a failed suite as a GitHub Actions error annotation (no-op outside Actions).""" if os.environ.get("GITHUB_ACTIONS") != "true": return detail = "%0A".join(dict.fromkeys(result.problems))[:4000] or f"exit {result.exit_code}" print(f"::error title=tests/{result.name}.gd::{detail}") +def write_summary(path: Path, seconds: float, skipped: dict[str, str], results: list[Result], + setup_error: str | None = None) -> None: + """Write the JSON results summary. A setup failure (isolation, import) is recorded with no suites.""" + summary: dict[str, object] = { + "seconds": round(seconds, 1), + "skipped": skipped, + "suites": [{"name": r.name, "passed": r.passed, "exit_code": r.exit_code, "seconds": round(r.seconds, 1), + "problems": list(dict.fromkeys(r.problems)), "notes": r.notes} + for r in sorted(results, key=lambda r: r.name)], + } + if setup_error is not None: + summary["setup_error"] = setup_error + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + + def main() -> int: + """Discover, filter and run the suites; returns 0 if all pass, 1 on a failure, 2 on a usage or setup error.""" parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--godot", default=os.environ.get("GODOT", "godot"), help="Godot 4.7.1 binary (default: $GODOT or godot)") @@ -223,10 +249,12 @@ def main() -> int: if not planned: print("No suites selected.", file=sys.stderr) return 2 + skipped = {name: skips[name] for name in selected if name in skips} leak = check_isolation(args.godot) if leak: print(f"Refusing to run: {leak}. Suites would touch real settings and leaderboards.", file=sys.stderr) + write_summary(args.results, 0.0, skipped, [], f"user:// isolation: {leak}") return 2 if not args.no_import: @@ -241,6 +269,8 @@ def main() -> int: if imported.returncode != 0 or import_problems: print(import_log[-4000:], file=sys.stderr) print("Resource import failed:", *dict.fromkeys(import_problems), sep="\n ", file=sys.stderr) + reasons = list(dict.fromkeys(import_problems)) or [f"exit {imported.returncode}"] + write_summary(args.results, 0.0, skipped, [], "resource import failed: " + "; ".join(reasons)) return 1 parallel = [s for s in planned if s not in config.serial] @@ -271,14 +301,7 @@ def record(result: Result) -> None: print(" last output:\n" + "\n".join(f" {line}" for line in result.log.splitlines()[-25:])) annotate(result) - args.results.parent.mkdir(parents=True, exist_ok=True) - args.results.write_text(json.dumps({ - "seconds": round(time.monotonic() - started, 1), - "skipped": {name: skips[name] for name in selected if name in skips}, - "suites": [{"name": r.name, "passed": r.passed, "exit_code": r.exit_code, "seconds": round(r.seconds, 1), - "problems": list(dict.fromkeys(r.problems)), "notes": r.notes} - for r in sorted(results, key=lambda r: r.name)], - }, indent=2) + "\n", encoding="utf-8") + write_summary(args.results, time.monotonic() - started, skipped, results) print(f"\n{len(results) - len(failed)} passed, {len(failed)} failed, " f"{len(selected) - len(planned)} skipped in {time.monotonic() - started:.0f}s") return 1 if failed else 0 diff --git a/tools/tests/test_run_tests.py b/tools/tests/test_run_tests.py index 2b828e0d..582af4f5 100644 --- a/tools/tests/test_run_tests.py +++ b/tools/tests/test_run_tests.py @@ -1,6 +1,13 @@ """Unit tests for run_tests.py's discovery, configuration checks and failure detection.""" +import contextlib +import io +import json import re +import subprocess +import tempfile import unittest +from pathlib import Path +from unittest import mock import run_tests @@ -59,6 +66,66 @@ def test_any_other_leak_is_not_tolerated(self): def test_a_leak_without_detail_is_not_tolerated(self): self.assertEqual(run_tests.classify_leaks("WARNING: 3 ObjectDB instances were leaked at exit\n"), (False, [])) + def test_an_rid_leak_beside_the_audio_race_is_not_tolerated(self): + rid = "ERROR: 2 RID allocations of type 'N12MeshStorage4MeshE' were leaked at exit." + audio_only, details = run_tests.classify_leaks(self.AUDIO + rid + "\n") + self.assertFalse(audio_only) + self.assertIn(rid, details) + + +class RunSuite(unittest.TestCase): + """run_suite() turns only the audio-teardown race into a note; every other leak still fails.""" + SUMMARY = ("WARNING: 3 ObjectDB instances were leaked at exit (run with `--verbose` for details).\n" + "ERROR: 1 resources still in use at exit (run with --verbose for details).\n") + RID = "ERROR: 2 RID allocations of type 'N12MeshStorage4MeshE' were leaked at exit." + + def run_with_log(self, log): + result = run_tests.Result("fake", 1.0, 0, run_tests.find_problems(log, 0, []), log) + with mock.patch.object(run_tests, "run_once", return_value=result): + return run_tests.run_suite("godot", "fake", []) + + def test_the_audio_race_alone_passes_with_a_note(self): + result = self.run_with_log(self.SUMMARY + ClassifyLeaks.AUDIO) + self.assertTrue(result.passed, result.problems) + self.assertIn("audio-teardown race", result.notes[0]) + + def test_an_rid_leak_mixed_with_the_audio_race_still_fails(self): + result = self.run_with_log(self.SUMMARY + self.RID + "\n" + ClassifyLeaks.AUDIO) + self.assertFalse(result.passed) + self.assertIn(self.RID, result.problems) + self.assertEqual(result.notes, []) + + +class SetupFailures(unittest.TestCase): + """A run that stops before any suite still writes a results summary saying why.""" + + def run_main(self, results, **patches): + argv = ["run_tests.py", "--godot", "godot", "-k", "runner_logic", "--results", str(results)] + with mock.patch("sys.argv", argv), contextlib.redirect_stderr(io.StringIO()), \ + contextlib.ExitStack() as stack: + for name, value in patches.items(): + stack.enter_context(mock.patch.object(run_tests, name, value)) + return run_tests.main() + + def test_an_isolation_failure_writes_the_summary(self): + with tempfile.TemporaryDirectory() as tmp: + results = Path(tmp, "summary.json") + code = self.run_main(results, check_isolation=mock.Mock(return_value="user:// resolved to /real")) + self.assertEqual(code, 2) + summary = json.loads(results.read_text(encoding="utf-8")) + self.assertIn("/real", summary["setup_error"]) + self.assertEqual(summary["suites"], []) + + def test_an_import_failure_writes_the_summary(self): + imported = subprocess.CompletedProcess([], 0, stdout="ERROR: Cannot import x.png\n", stderr="") + with tempfile.TemporaryDirectory() as tmp, \ + mock.patch.object(run_tests.subprocess, "run", return_value=imported): + results = Path(tmp, "summary.json") + code = self.run_main(results, check_isolation=mock.Mock(return_value=None)) + self.assertEqual(code, 1) + summary = json.loads(results.read_text(encoding="utf-8")) + self.assertIn("Cannot import x.png", summary["setup_error"]) + class LoadConfig(unittest.TestCase): def test_unknown_suites_and_missing_reasons_are_errors(self): From 62b4b315234eb3aca1c0a7ff0fee6346cd299163 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 12:24:08 -0700 Subject: [PATCH 10/11] fix(runner): restore the boss recipe sources the committed models were baked from [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Running every suite showed assets/boss_legacy_monolith_check failing on main: "provenance hash matches source" for build_legacy_monolith.py and boss_kit.py. main's CI never ran the asset checks, so nobody saw it. Each boss bake records the SHA-256 of its recipe sources in its .build.json and in the GLB's asset extras. The four bosses were baked at 4ab289d in #64's branch. #68, a lint autofix merged into that branch afterwards, then removed unused imports from boss_kit.py, build_legacy_monolith.py and build_merge_conflict_hydra.py. That edit was import-only, so it doesn't change any output, but the recorded hashes no longer matched the sources. All four reports were stale. Only the monolith check compares them. This restores those three files byte for byte to 4ab289d. All four reports now match their sources and GLBs again, and no asset changes. ruff.toml exempts only those three files from F401, with the reason, so the next lint fix doesn't drift them again. A rebake can drop the imports and the exemption together. --- hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py | 5 +++-- .../tools/blender/bosses/build_legacy_monolith.py | 2 +- .../tools/blender/bosses/build_merge_conflict_hydra.py | 3 ++- ruff.toml | 6 ++++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py b/hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py index 03abf461..79147346 100644 --- a/hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py +++ b/hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py @@ -7,6 +7,7 @@ """ import hashlib import json +import math import os from pathlib import Path import random @@ -17,14 +18,14 @@ import tempfile import bpy -from mathutils import Matrix, Vector +from mathutils import Euler, Matrix, Vector HERE = Path(__file__).resolve().parent DEFAULT_ASSET_DIR = HERE.parent.parent.parent / 'Assets' / 'runner' / 'bosses' sys.path.insert(0, str(HERE.parent)) sys.path.insert(0, str(HERE.parent / 'props')) import props_kit as P -from props_kit import Part as Part, linear, xform, G2B, B2G +from props_kit import Part, linear, xform, G2B, B2G from glb_atlas import consolidate_atlases SEED = 73019 diff --git a/hoppys-adventure-arcade/tools/blender/bosses/build_legacy_monolith.py b/hoppys-adventure-arcade/tools/blender/bosses/build_legacy_monolith.py index 4b081dc1..ccf299f8 100644 --- a/hoppys-adventure-arcade/tools/blender/bosses/build_legacy_monolith.py +++ b/hoppys-adventure-arcade/tools/blender/bosses/build_legacy_monolith.py @@ -7,7 +7,7 @@ import sys sys.path.insert(0, str(Path(__file__).resolve().parent)) -from boss_kit import DEFAULT_ASSET_DIR, BossBuilder, Part, xform +from boss_kit import DEFAULT_ASSET_DIR, BossBuilder, Part, xform, require RECIPE_REVISION = 3 RACK_Y = (-0.47, 0.34, 1.32, 2.68) diff --git a/hoppys-adventure-arcade/tools/blender/bosses/build_merge_conflict_hydra.py b/hoppys-adventure-arcade/tools/blender/bosses/build_merge_conflict_hydra.py index deb2702e..350d6728 100644 --- a/hoppys-adventure-arcade/tools/blender/bosses/build_merge_conflict_hydra.py +++ b/hoppys-adventure-arcade/tools/blender/bosses/build_merge_conflict_hydra.py @@ -2,10 +2,11 @@ import argparse import bpy import hashlib +import json import math from pathlib import Path import sys -from mathutils import Vector +from mathutils import Matrix, Vector sys.path.insert(0, str(Path(__file__).resolve().parent)) from boss_kit import DEFAULT_ASSET_DIR, BossBuilder, Part, xform, B2G diff --git a/ruff.toml b/ruff.toml index 72b777e8..3b983d38 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,3 +1,9 @@ # Baseline: pyflakes errors only (undefined names, unused imports). Tighten over time. [lint] select = ["F"] + +[lint.per-file-ignores] +# Each boss GLB records the SHA-256 of these recipe sources (boss_kit.py export), and +# tests/assets/boss_legacy_monolith_check.gd fails when a source no longer matches. Leave their +# unused imports until the next rebake, so a lint fix doesn't drift them from the committed models. +"hoppys-adventure-arcade/tools/blender/bosses/{boss_kit,build_legacy_monolith,build_merge_conflict_hydra}.py" = ["F401"] From 0fdd3e0f5b402db0dd96461187177ec858820fee Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 12:24:09 -0700 Subject: [PATCH 11/11] fix(tests): record a probe or import that can't start or times out [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit's full review of #40: - check_isolation() returns a reason when the probe can't launch (a wrong --godot path) or times out, instead of raising. main() then refuses to run and writes the summary with setup_error. - The resource import handles the same two cases: it writes the summary and returns 1 without a traceback. - Tests cover both probe cases and the import timeout. - CONTRIBUTING names MainLoop beside SceneTree in the discovery rule. --- CONTRIBUTING.md | 2 +- tools/tests/run_tests.py | 13 ++++++++++--- tools/tests/test_run_tests.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dcf2c97..101b8f3d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ python3 tools/tests/run_tests.py -k runner # suites whose name python3 tools/tests/run_tests.py --list # what runs and what is skipped ``` -- **Every new `.gd` file under `tests/` that `extends SceneTree` runs automatically.** Shared helpers that extend anything else, such as `tests/support/`, are never run as suites. There's no list to update. To keep a suite out of CI (it needs a GPU, a window or pointer hover), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. +- **Every new `.gd` file under `tests/` that `extends SceneTree` or `MainLoop` runs automatically.** Shared helpers that extend anything else, such as `tests/support/`, are never run as suites. There's no list to update. To keep a suite out of CI (it needs a GPU, a window or pointer hover), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. - **A suite fails if it exits nonzero, times out, or prints an engine or script error or an exit-time leak.** Suites run with `--verbose`, so a leak names the objects involved. The one tolerated shape is the engine's audio-teardown race: only `AudioStream*` playbacks and audio files, reported as a note. Anything else that leaks fails. That includes `ERROR:`, `SCRIPT ERROR`, parse errors and "leaked at exit". If a test provokes an engine error on purpose, list a pattern for it under `[expected_errors]`; the runner fails if that pattern stops matching. - **Finish a suite with `await root.get_node("AudioService").settle()` before `quit()`.** It stops the shared music and waits until the audio thread has released it. A fixed delay is not enough under parallel load. - Each suite gets a private user-data root (`HOME`, the XDG directories, and `APPDATA` on Windows), so `user://` files (settings, leaderboards) never touch your real ones. The runner checks this with Godot before starting and refuses to run if `user://` would escape. Suites listed under `[serial]` (wall-clock budgets, or suites that occupy every core) run alone after the parallel batch. Results are written to `build/test-results/summary.json`. diff --git a/tools/tests/run_tests.py b/tools/tests/run_tests.py index c619832f..a6cd8df8 100644 --- a/tools/tests/run_tests.py +++ b/tools/tests/run_tests.py @@ -112,8 +112,11 @@ def check_isolation(godot: str) -> str | None: probe = Path(home, "probe.gd") probe.write_text('extends SceneTree\nfunc _init() -> void:\n\tprint("USER_DIR=", OS.get_user_data_dir())\n' '\tquit()\n', encoding="utf-8") - output = subprocess.run([godot, "--headless", "--path", str(PROJECT), "--script", str(probe)], - env=isolated_env(home), capture_output=True, text=True, timeout=120).stdout + try: + output = subprocess.run([godot, "--headless", "--path", str(PROJECT), "--script", str(probe)], + env=isolated_env(home), capture_output=True, text=True, timeout=120).stdout + except (OSError, subprocess.TimeoutExpired) as error: + return f"the user:// probe could not run ({error})" match = re.search(r"^USER_DIR=(.+)$", ANSI.sub("", output), re.M) user_dir = Path(match.group(1).strip()).resolve() if match else None if user_dir is None or Path(home).resolve() not in user_dir.parents: @@ -253,7 +256,7 @@ def main() -> int: leak = check_isolation(args.godot) if leak: - print(f"Refusing to run: {leak}. Suites would touch real settings and leaderboards.", file=sys.stderr) + print(f"Refusing to run: {leak}. Suites must not touch real settings or leaderboards.", file=sys.stderr) write_summary(args.results, 0.0, skipped, [], f"user:// isolation: {leak}") return 2 @@ -262,6 +265,10 @@ def main() -> int: try: imported = subprocess.run([args.godot, "--headless", "--path", str(PROJECT), "--import"], env=isolated_env(home), capture_output=True, text=True, timeout=TIMEOUT) + except (OSError, subprocess.TimeoutExpired) as error: + print(f"Resource import failed: {error}", file=sys.stderr) + write_summary(args.results, 0.0, skipped, [], f"resource import failed: {error}") + return 1 finally: shutil.rmtree(home, ignore_errors=True) import_log = ANSI.sub("", imported.stdout + imported.stderr) diff --git a/tools/tests/test_run_tests.py b/tools/tests/test_run_tests.py index 582af4f5..62628f85 100644 --- a/tools/tests/test_run_tests.py +++ b/tools/tests/test_run_tests.py @@ -116,6 +116,23 @@ def test_an_isolation_failure_writes_the_summary(self): self.assertIn("/real", summary["setup_error"]) self.assertEqual(summary["suites"], []) + def test_a_probe_that_cannot_start_is_an_isolation_failure(self): + with mock.patch.object(run_tests.subprocess, "run", side_effect=FileNotFoundError("no godot")): + self.assertIn("no godot", run_tests.check_isolation("missing-godot")) + timeout = subprocess.TimeoutExpired(["godot"], 120) + with mock.patch.object(run_tests.subprocess, "run", side_effect=timeout): + self.assertIn("timed out", run_tests.check_isolation("godot")) + + def test_an_import_timeout_writes_the_summary(self): + timeout = subprocess.TimeoutExpired(["godot", "--import"], run_tests.TIMEOUT) + with tempfile.TemporaryDirectory() as tmp, \ + mock.patch.object(run_tests.subprocess, "run", side_effect=timeout): + results = Path(tmp, "summary.json") + code = self.run_main(results, check_isolation=mock.Mock(return_value=None)) + self.assertEqual(code, 1) + summary = json.loads(results.read_text(encoding="utf-8")) + self.assertIn("timed out", summary["setup_error"]) + def test_an_import_failure_writes_the_summary(self): imported = subprocess.CompletedProcess([], 0, stdout="ERROR: Cannot import x.png\n", stderr="") with tempfile.TemporaryDirectory() as tmp, \