godot --headless --check-only tells an agent its script parses. It doesn't tell it that the enemy never dies, that a signal fires twice, or that a node was null halfway through a test. This loadout gives the agent real tests that run in Godot's headless mode, with no editor and no window, in a second or two.
The runner is one GDScript file that extends SceneTree. It finds tests/test_*.gd, runs every test_*() method with optional before_each and after_each, prints PASS or FAIL per test and quits with exit code 1 on any failure. It hooks Godot's Logger class (4.5 and later), so a script error inside a test, such as calling a method on null, fails that test. Without the hook Godot only prints the error and the test counts as a pass. The rules cover scene tests with real frames, queue_free timing, autoload state that leaks between tests, seeded randomness and the --import step a fresh clone needs before class_name types resolve. A standard-library Python linter flags tests with no check, get_tree() in a suite, OS.delay_msec, an un-awaited frame wait and quit() inside a test.
What's in it
- One-file SceneTree runner, exit code 1 on any failure, for CI and agents
- Script errors inside a test fail it, via Godot's Logger hook
- Scene tests with awaited frames, queue_free timing and autoload resets
- Linter flags tests that can only pass, blocking delays and un-awaited waits
- Catches the fresh-clone trap where class_name types are unknown until --import
You'll need
- Tools:
godot-4,python3
Verification
| Check | Result | What we found |
|---|---|---|
| Listinglisting.yaml matches the schema and declares every file in the folder. | Pass | Schema valid; 8 files declared, none missing or extra
|
| Files parseJSON parses; SKILL.md and .mdc front matter is valid YAML with the fields each agent needs; code fences are closed. | Pass | All 8 files parse; front matter valid |
| AdaptersCLAUDE.md imports @AGENTS.md, GEMINI.md imports @./AGENTS.md, and both match the generator. | Pass | CLAUDE.md and GEMINI.md import AGENTS.md and are up to date
|
| Install pathsEvery file lands where each agent documents it, with no collisions, and the laid-out project is re-discovered correctly. | Pass | Correct paths for all 6 agents; layouts re-discovered
|
| MCP configmcp.json is structurally valid, converts to each agent's format, declares no literal secrets, and referenced packages exist on npm/PyPI. | Not applicable | No MCP servers in this loadout |
| Safety scanThe intake scanner finds no credentials, risky shell, hidden Unicode, instructions hidden in HTML comments or after long blank padding, or prompt-injection and covert wording (such as telling the agent to keep something from you), and the listing's network flag is honest. | Pass | Clean: no credentials, risky shell, hidden text or covert instructions
|
| ReferencesLinks and file paths between the loadout's files resolve, including for each agent's installed layout. | Pass | 1 reference between files resolves for every agent |
| Size limitsFile count and sizes are within the submission limits; SKILL.md bodies stay under 500 lines. | Pass | 8 files, 15.9 KB total; largest skills/godot-headless-tests/scripts/gd_test_lint.py 4.3 KB
|
| LicenceA recognised SPDX licence, with the licence text available. | Pass | MIT, by SetupBay; text in the repository LICENSE |
| Starter buildA minimal fixture project with the loadout applied installs and builds or type-checks, and the loadout's own code examples compile. | Pass | A Godot 4.7.2 project with a Health component, an arena scene and a GameState autoload. gd_test_lint.py passes its suites and flags the skill's bad sample with 7 errors and 4 warnings. Run headless, the skill's runner first fails on a fresh clone with no import cache, then passes all 7 tests after -
|
What this means: a script checked the files themselves: they parse, they install where each agent looks, any MCP config is sound, the safety scan is clean, references between files resolve, and, where a free and repeatable fixture exists, a small starter project builds with the loadout applied. No AI agent was run. Per-agent results from agent runs are separate and stay untested until an agent actually runs.
Install
Choose your agent. Run the commands from your project root, or download a zip already laid out in the right folders.
Claude Code reads CLAUDE.md (imports AGENTS.md with @AGENTS.md) and skills from .claude/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/AGENTS.md curl -fsSL --create-dirs -o CLAUDE.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/CLAUDE.md curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/SKILL.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/SKILL.md curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/references/run_tests.gd https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/run_tests.gd curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/scripts/gd_test_lint.py https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/scripts/gd_test_lint.py curl -fsSL --create-dirs -o .claude/skills/godot-headless-tests/references/bad-test-sample.gd.txt https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/bad-test-sample.gd.txt
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
CLAUDE.md | CLAUDE.md |
skills/godot-headless-tests/SKILL.md | .claude/skills/godot-headless-tests/SKILL.md |
skills/godot-headless-tests/references/run_tests.gd | .claude/skills/godot-headless-tests/references/run_tests.gd |
skills/godot-headless-tests/scripts/gd_test_lint.py | .claude/skills/godot-headless-tests/scripts/gd_test_lint.py |
skills/godot-headless-tests/references/bad-test-sample.gd.txt | .claude/skills/godot-headless-tests/references/bad-test-sample.gd.txt |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want. Which file wins when several are present: what Claude Code reads.
Codex CLI reads AGENTS.md and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/SKILL.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/SKILL.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/run_tests.gd https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/run_tests.gd curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/scripts/gd_test_lint.py https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/scripts/gd_test_lint.py curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/bad-test-sample.gd.txt
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
skills/godot-headless-tests/SKILL.md | .agents/skills/godot-headless-tests/SKILL.md |
skills/godot-headless-tests/references/run_tests.gd | .agents/skills/godot-headless-tests/references/run_tests.gd |
skills/godot-headless-tests/scripts/gd_test_lint.py | .agents/skills/godot-headless-tests/scripts/gd_test_lint.py |
skills/godot-headless-tests/references/bad-test-sample.gd.txt | .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want. Which file wins when several are present: what Codex CLI reads.
Cursor reads AGENTS.md + .cursor/rules/*.mdc and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .cursor/rules/godot-headless-tests.mdc https://setupbay.com/raw/godot-4-headless-tests/1.0.0/.cursor/rules/godot-headless-tests.mdc curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/SKILL.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/SKILL.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/run_tests.gd https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/run_tests.gd curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/scripts/gd_test_lint.py https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/scripts/gd_test_lint.py curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/bad-test-sample.gd.txt
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
.cursor/rules/godot-headless-tests.mdc | .cursor/rules/godot-headless-tests.mdc |
skills/godot-headless-tests/SKILL.md | .agents/skills/godot-headless-tests/SKILL.md |
skills/godot-headless-tests/references/run_tests.gd | .agents/skills/godot-headless-tests/references/run_tests.gd |
skills/godot-headless-tests/scripts/gd_test_lint.py | .agents/skills/godot-headless-tests/scripts/gd_test_lint.py |
skills/godot-headless-tests/references/bad-test-sample.gd.txt | .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want. Which file wins when several are present: what Cursor reads.
Gemini CLI reads GEMINI.md (imports AGENTS.md with @./AGENTS.md) and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/AGENTS.md curl -fsSL --create-dirs -o GEMINI.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/GEMINI.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/SKILL.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/SKILL.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/run_tests.gd https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/run_tests.gd curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/scripts/gd_test_lint.py https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/scripts/gd_test_lint.py curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/bad-test-sample.gd.txt
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
GEMINI.md | GEMINI.md |
skills/godot-headless-tests/SKILL.md | .agents/skills/godot-headless-tests/SKILL.md |
skills/godot-headless-tests/references/run_tests.gd | .agents/skills/godot-headless-tests/references/run_tests.gd |
skills/godot-headless-tests/scripts/gd_test_lint.py | .agents/skills/godot-headless-tests/scripts/gd_test_lint.py |
skills/godot-headless-tests/references/bad-test-sample.gd.txt | .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want. Which file wins when several are present: what Gemini CLI reads.
Antigravity CLI reads AGENTS.md (read natively; GEMINI.md is also read) and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/SKILL.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/SKILL.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/run_tests.gd https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/run_tests.gd curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/scripts/gd_test_lint.py https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/scripts/gd_test_lint.py curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/bad-test-sample.gd.txt
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
skills/godot-headless-tests/SKILL.md | .agents/skills/godot-headless-tests/SKILL.md |
skills/godot-headless-tests/references/run_tests.gd | .agents/skills/godot-headless-tests/references/run_tests.gd |
skills/godot-headless-tests/scripts/gd_test_lint.py | .agents/skills/godot-headless-tests/scripts/gd_test_lint.py |
skills/godot-headless-tests/references/bad-test-sample.gd.txt | .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want. Which file wins when several are present: what Antigravity CLI reads.
GitHub Copilot reads AGENTS.md and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/SKILL.md https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/SKILL.md curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/run_tests.gd https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/run_tests.gd curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/scripts/gd_test_lint.py https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/scripts/gd_test_lint.py curl -fsSL --create-dirs -o .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt https://setupbay.com/raw/godot-4-headless-tests/1.0.0/skills/godot-headless-tests/references/bad-test-sample.gd.txt
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
skills/godot-headless-tests/SKILL.md | .agents/skills/godot-headless-tests/SKILL.md |
skills/godot-headless-tests/references/run_tests.gd | .agents/skills/godot-headless-tests/references/run_tests.gd |
skills/godot-headless-tests/scripts/gd_test_lint.py | .agents/skills/godot-headless-tests/scripts/gd_test_lint.py |
skills/godot-headless-tests/references/bad-test-sample.gd.txt | .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want. Which file wins when several are present: what GitHub Copilot reads.
Or as a Claude Code plugin
Adds the skill to Claude Code for all your projects, without copying files. Plugins can't carry project rules, so add AGENTS.md and CLAUDE.md with the commands above. About the plugin marketplace.
/plugin marketplace add https://setupbay.com/marketplace.json /plugin install godot-4-headless-tests@setupbay
Files
Rules AGENTS.md Commands, test layout, rules for writing headless tests, and the done checklist. 56 lines
# Godot 4 headless tests
This project's game rules are checked by GDScript tests that run in Godot's headless mode, with no
editor and no window. Run them after every change and before you say a change is done. A script that
parses can still be wrong: signal timing, freed nodes and off-by-one damage all parse fine.
## Commands
```bash
godot --headless --import --path . # once per fresh clone or new class_name; builds .godot/
godot --headless --path . --script res://tests/run_tests.gd # runs every test, exit 1 on any failure
python3 .agents/skills/godot-headless-tests/scripts/gd_test_lint.py tests # catches tests that can't fail
```
Use the Godot 4 binary the project pins (`config/features` in project.godot). If it isn't on PATH,
ask for its path rather than downloading one. Wrap the run in `timeout 300` in CI so a hung test fails.
## Layout
- `tests/run_tests.gd`: the runner (copy it from the skill's `references/run_tests.gd`). It extends
SceneTree, finds `tests/test_*.gd`, runs every `test_*()` method, prints `PASS`/`FAIL` per test
and quits with exit code 1 if anything failed.
- `tests/test_<thing>.gd`: one suite per script or scene. A suite `extends RefCounted` and declares
`var t: SceneTree`; the runner sets `t` to itself before each test.
- Optional `before_each()` and `after_each()` run around every test; they may `await`.
## Writing tests
- Check with `t.equal(actual, expected, "label")` and `t.check(condition, "message")`. Every test
needs at least one. `print()` is not a check.
- A script error inside a test (calling a method on null, a bad cast) marks that test as failed. The
runner hooks Godot's Logger for this, because otherwise Godot only prints the error and the test
looks like a pass. Don't "fix" a failing test by guarding the null; find out why it was null.
- Add nodes under `t.root`, never `get_tree()` (a RefCounted suite isn't in the tree). Free what you
add in `after_each()`.
- Scene tests: `load("res://scenes/x.tscn").instantiate()`, add to `t.root`, then
`await t.frames(1)` so `_ready()` and the first process frame have run. `queue_free()` only happens
at the end of a frame, so await a frame before you check a node is gone.
- Always `await t.frames(n)`. Never `OS.delay_msec()`, which blocks the main loop so nothing moves,
and avoid `create_timer()` waits: call the timeout handler, or set the Timer's `wait_time` tiny.
- Seed randomness: pass a `RandomNumberGenerator` with a fixed `seed` into the code under test.
- Never call `quit()` from a test. The runner owns the exit code.
- Autoloads from project.godot are loaded in a `--script` run (Godot 4.7) and live for the whole
run, so state leaks between tests. Reset whatever a test changes in `before_each()`.
- Editor-only code (EditorInterface, EditorPlugin) can't be exercised here. Keep plugin UI thin.
If the project already uses GUT or gdUnit4, use that instead and keep these rules for the tests.
## Done means
1. `--import` has run since the last new `class_name`, or the run fails with "Could not find type".
2. The lint script reports 0 errors on `tests/`.
3. The run ends with `0 failed` and exits 0. Add or change a test for every rule you change, and
say which test covers it.
4. List anything that still needs a human play-test (feel, visuals, audio, input devices). Adapter CLAUDE.md Imports AGENTS.md for Claude Code. 11 lines
# CLAUDE.md
@AGENTS.md
## Claude Code notes
- Skills from this loadout live in `.claude/skills/`: `godot-headless-tests`. Use them when their description matches the task.
- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.
<!-- From SetupBay "Godot 4 headless GDScript tests" v1.0.0 — https://setupbay.com/loadouts/godot-4-headless-tests/ --> Adapter GEMINI.md Imports AGENTS.md for Gemini CLI. 11 lines
# GEMINI.md
@./AGENTS.md
## Gemini CLI notes
- Skills from this loadout live in `.agents/skills/`: `godot-headless-tests`. Use them when their description matches the task.
- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.
<!-- From SetupBay "Godot 4 headless GDScript tests" v1.0.0 — https://setupbay.com/loadouts/godot-4-headless-tests/ --> Cursor rule .cursor/rules/godot-headless-tests.mdc Never/instead table for GDScript test suites under tests/. 24 lines
---
description: Writing Godot 4 GDScript tests that run headless with tests/run_tests.gd
globs: "tests/**/*.gd"
alwaysApply: false
---
# Godot 4 headless tests
Run: `godot --headless --path . --script res://tests/run_tests.gd` (after `--import` on a fresh clone).
| Never | Instead |
| --- | --- |
| A `test_*()` with no check, or `print()` as the check | `t.equal(actual, expected, "label")`, `t.check(cond, "msg")` |
| `get_tree()` in a suite | `t.root.add_child(node)`; free it in `after_each()` |
| `OS.delay_msec()` | `await t.frames(n)` |
| `t.frames(n)` without `await` | `await t.frames(n)` |
| `await t.create_timer(2.0).timeout` | Call the timeout handler, or set `wait_time` tiny |
| `randi()` / `randf_range()` in the code under test | A seeded `RandomNumberGenerator` passed in |
| `t.quit()` or `get_tree().quit()` in a test | Let the runner exit with the right code |
| Assuming an autoload starts fresh for each test | Reset its state in `before_each()`; it lives for the whole run |
| Guarding a null so a red test goes green | Find why it was null; script errors fail the test on purpose |
Each suite: `extends RefCounted`, `var t: SceneTree`, methods named `test_*`. Skill skills/godot-headless-tests/SKILL.md Set up the runner, write a suite, lint it, run it headless and report. 82 lines
---
name: godot-headless-tests
description: Set up and run GDScript tests for a Godot 4 project in headless mode, with a SceneTree runner that fails on script errors and exits non-zero. Use when adding a test, changing game rules, or before saying a Godot change is done.
---
# Godot 4 headless tests
## 1. Set up once
If `tests/run_tests.gd` doesn't exist, copy `references/run_tests.gd` from this skill to
`tests/run_tests.gd`. It needs Godot 4.5 or later (it uses the `Logger` class); check
`config/features` in project.godot. Then build the import cache:
```bash
godot --headless --import --path .
```
Run `--import` again whenever you add a `class_name` or a new resource. Without it a fresh clone
fails with `Parse Error: Could not find type "Health"` even though the code is fine.
## 2. Write the test
One suite per script or scene, in `tests/test_<thing>.gd`:
```gdscript
extends RefCounted
var t: SceneTree # set by the runner
var health: Health
func before_each() -> void:
health = Health.new()
health.maximum = 50
t.root.add_child(health) # runs _ready()
func after_each() -> void:
health.free()
func test_damage_clamps_at_zero_and_emits_died_once() -> void:
var deaths := [0]
health.died.connect(func() -> void: deaths[0] += 1)
health.damage(80)
health.damage(10)
t.equal(health.current, 0)
t.equal(deaths[0], 1, "died emitted")
```
For a scene, instance it, add it under `t.root`, then `await t.frames(1)` before you poke it.
After `queue_free()`, `await t.frames(1)` before checking the node has gone. Capture signal counts
in an array (lambdas capture by value, so a plain `int` won't update).
## 3. Lint the tests
```bash
python3 scripts/gd_test_lint.py tests
```
Errors: a test with no check, `get_tree()` in a suite, `OS.delay_msec()`, `t.frames()` without
`await`, `quit()` in a test, editor-only APIs. Warnings: `create_timer()` waits, unseeded random
calls, `print()` used as a check, bare `assert()`. Fix errors; fix or justify warnings
(`# lint: ok` on the line). Try it on `references/bad-test-sample.gd.txt` (7 errors, 4 warnings).
## 4. Run
```bash
timeout 300 godot --headless --path . --script res://tests/run_tests.gd
```
Each test prints `PASS suite::test_name` or `FAIL suite::test_name: reason`, then
`N passed, M failed`. Exit code 1 means at least one failure. A script error inside a test is a
failure with the message and line, such as `script error: Cannot call method 'get_name' on a null
value. (test_arena.gd:14)`. A suite that doesn't parse shows as `FAIL test_x.gd: script did not
load`.
## 5. Report
Give the last line of the run, the tests you added or changed and which rule each covers, and
anything left for a human play-test. Skill file skills/godot-headless-tests/references/run_tests.gd The SceneTree test runner to copy to tests/run_tests.gd. 100 lines
extends SceneTree
## Headless test runner: godot --headless --path . --script res://tests/run_tests.gd
## Runs every test_*() method in res://tests/test_*.gd, prints PASS/FAIL, and exits 1 on any failure.
const TEST_DIR := "res://tests"
var _failures: Array[String] = []
var _current := ""
var _errors := ErrorCatcher.new()
## Script errors (null calls, bad casts, failed asserts) only print by default: the test
## function stops, but the run still counts it as passed. This logger turns each one into a
## failure of the running test. Warnings are ignored.
class ErrorCatcher extends Logger:
var _lock := Mutex.new()
var _seen: Array[String] = []
func _log_error(_function: String, file: String, line: int, code: String, rationale: String, _editor_notify: bool, error_type: int, _script_backtrace: Array[ScriptBacktrace]) -> void:
if error_type == ERROR_TYPE_WARNING:
return
_lock.lock()
_seen.append("%s (%s:%d)" % [rationale if not rationale.is_empty() else code, file.get_file(), line])
_lock.unlock()
func take() -> Array[String]:
_lock.lock()
var out := _seen.duplicate()
_seen.clear()
_lock.unlock()
return out
func _initialize() -> void:
OS.add_logger(_errors)
_run.call_deferred()
func _run() -> void:
var passed := 0
var failed := 0
for file in _test_files():
var script: GDScript = load(TEST_DIR.path_join(file))
if script == null or not script.can_instantiate():
failed += 1
print("FAIL %s: script did not load (parse error?)" % file)
continue
for method in script.get_script_method_list():
var name: String = method["name"]
if not name.begins_with("test_"):
continue
var suite: Object = script.new()
suite.set("t", self)
if suite.has_method("before_each"):
await suite.before_each()
_failures.clear()
_errors.take()
_current = "%s::%s" % [file.get_basename(), name]
await suite.call(name)
if suite.has_method("after_each"):
await suite.after_each()
for e in _errors.take():
_failures.append("script error: " + e)
if suite is Node and is_instance_valid(suite):
suite.free()
if _failures.is_empty():
passed += 1
print("PASS %s" % _current)
else:
failed += 1
print("FAIL %s: %s" % [_current, "; ".join(_failures)])
print("%d passed, %d failed" % [passed, failed])
quit(1 if failed > 0 else 0)
func _test_files() -> PackedStringArray:
var files := PackedStringArray()
for f in DirAccess.get_files_at(TEST_DIR):
if f.begins_with("test_") and f.ends_with(".gd"):
files.append(f)
files.sort()
return files
## Assertions, called from tests as t.check(...) and t.equal(...).
func check(condition: bool, message := "expected true") -> void:
if not condition:
_failures.append(message)
func equal(actual: Variant, expected: Variant, message := "") -> void:
if actual != expected:
_failures.append("%sexpected %s, got %s" % ["" if message.is_empty() else message + ": ", var_to_str(expected), var_to_str(actual)])
## Wait for n frames of the real main loop (process and physics run headless too).
func frames(n := 1) -> void:
for i in n:
await process_frame Skill file skills/godot-headless-tests/scripts/gd_test_lint.py Read-only linter for test suites that can't fail or that block the run. 110 lines
#!/usr/bin/env python3
"""Read-only linter for Godot 4 headless test suites (tests/test_*.gd).
Finds tests that can't fail, tests that block or end the run, and timing that makes
headless runs slow or flaky. Prints findings and exits 1 if there are errors.
Usage: python3 gd_test_lint.py [path ...] (default: tests)
A line ending in "# lint: ok" is skipped.
"""
import re
import sys
from pathlib import Path
ASSERT = re.compile(r"\bt\.(check|equal|fail)\s*\(")
LINE_RULES = [
# (level, id, pattern, advice)
("error", "get-tree-in-suite", re.compile(r"(?<![.\w])get_tree\s*\("),
"a RefCounted suite isn't in the tree; use t (the runner's SceneTree), e.g. t.root.add_child()"),
("error", "blocking-delay", re.compile(r"\bOS\.delay_(msec|usec)\s*\("),
"blocks the main loop, so no frames run; await t.frames(n) instead"),
("error", "frames-without-await", re.compile(r"^(?!.*\bawait\b).*\bt\.frames\s*\("),
"t.frames() is a coroutine; without await the test carries on at once"),
("error", "quit-in-test", re.compile(r"(\bt\.|get_tree\(\)\.)quit\s*\("),
"ends the whole run early and hides the remaining results; let the runner quit"),
("error", "editor-only-api", re.compile(r"\b(EditorInterface|EditorPlugin|EditorScript)\b"),
"the EditorInterface singleton and editor plugins are not available in a --script run; test the game code, not the plugin UI"),
("warn", "wall-clock-timer", re.compile(r"\bcreate_timer\s*\("),
"real-time waits make headless runs slow and flaky; call the timeout handler or set Timer.wait_time tiny"),
("warn", "unseeded-random", re.compile(r"(?<![.\w])(randi|randf|randi_range|randf_range)\s*\("),
"global RNG differs per run; use RandomNumberGenerator with a fixed seed"),
("warn", "print-as-check", re.compile(r"(?<![.\w])print\s*\("),
"a printed value is not a check; use t.equal() so the run fails"),
("warn", "bare-assert", re.compile(r"(?<![.\w])assert\s*\("),
"assert() is stripped from release builds and gives no expected/actual; use t.check() or t.equal()"),
]
def strip_comment(line: str) -> str:
out, quote = [], ""
for ch in line:
if quote:
out.append(ch)
if ch == quote:
quote = ""
elif ch in "\"'":
quote = ch
out.append(ch)
elif ch == "#":
break
else:
out.append(ch)
return "".join(out)
def lint(path: Path):
findings = []
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
test_name, test_line, has_assert = None, 0, False
def close_test():
if test_name and not has_assert:
findings.append(("error", "no-assertion", test_line,
f"{test_name}() has no t.check/t.equal/t.fail, so it can only pass"))
for n, raw in enumerate(lines, 1):
if raw.rstrip().endswith("# lint: ok"):
continue
code = strip_comment(raw)
if not code.strip():
continue
m = re.match(r"^func\s+(\w+)\s*\(", code)
if m or re.match(r"^\S", code):
close_test()
test_name = m.group(1) if m and m.group(1).startswith("test_") else None
test_line, has_assert = n, False
if test_name and ASSERT.search(code):
has_assert = True
for level, rid, pat, advice in LINE_RULES:
if pat.search(code):
findings.append((level, rid, n, advice))
close_test()
return sorted(findings, key=lambda f: f[2])
def main(argv):
targets = [Path(a) for a in argv] or [Path("tests")]
files = []
for t in targets:
if t.is_dir():
files += sorted(p for p in t.rglob("test_*.gd") if ".godot" not in p.parts)
elif t.is_file():
files.append(t)
else:
print(f"gd_test_lint: no such path: {t}", file=sys.stderr)
return 2
errors = warnings = 0
for f in files:
for level, rid, line, advice in lint(f):
print(f"{f}:{line}: {level} [{rid}] {advice}")
if level == "error":
errors += 1
else:
warnings += 1
print(f"gd_test_lint: {len(files)} file(s), {errors} error(s), {warnings} warning(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:])) Skill file skills/godot-headless-tests/references/bad-test-sample.gd.txt A suite with the usual mistakes, for checking the linter. 43 lines
extends RefCounted
## A suite with the usual headless-test mistakes. Copy it to test_bad.gd to try the linter.
var t: SceneTree
var player: Node
func before_each() -> void:
player = load("res://scenes/player.tscn").instantiate()
get_tree().root.add_child(player)
func test_spawns() -> void:
print(player.position)
func test_dash_cooldown() -> void:
player.dash()
OS.delay_msec(500)
t.equal(player.can_dash, true)
func test_respawn() -> void:
player.die()
await t.create_timer(2.0).timeout
t.frames(1)
t.check(player.is_alive())
func test_loot_roll() -> void:
var roll := randi_range(1, 6)
assert(roll >= 1)
func test_editor_gizmo() -> void:
var ei = EditorInterface.get_selection()
t.check(ei != null)
func test_last() -> void:
t.equal(player.lives, 3)
t.quit() All files: source zip · Licence MIT · By SetupBay · First listed
Credits
Public work that inspired this loadout. The wording here is our own; follow the links for the originals.
- Godot docs: command line tutorial (--headless, --script, --import)
- Godot docs: Logger class and OS.add_logger()
- Godot docs: SceneTree (process_frame, quit)
- @Spidermanzdev: 65 test suites run headless from the Godot CLI after every change
- @jvan253: signal timing bugs parse fine, so make the agent run the project headless
- @GrizzlyKiwidev: if anything throws an error headless, the agent knows its change broke it
Installing and checks
How do I install Godot 4 headless GDScript tests in Claude Code?
From your project root, put AGENTS.md, CLAUDE.md, skills/godot-headless-tests/SKILL.md at .claude/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .claude/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .claude/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .claude/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Claude Code" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/claude-code.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Godot 4 headless GDScript tests in Codex CLI?
From your project root, put AGENTS.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Codex CLI" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/codex.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Godot 4 headless GDScript tests in Cursor?
From your project root, put AGENTS.md, .cursor/rules/godot-headless-tests.mdc, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Cursor" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/cursor.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Godot 4 headless GDScript tests in Gemini CLI?
From your project root, put AGENTS.md, GEMINI.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Gemini CLI" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/gemini-cli.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Godot 4 headless GDScript tests in Antigravity CLI?
From your project root, put AGENTS.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "Antigravity CLI" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/antigravity-cli.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Godot 4 headless GDScript tests in GitHub Copilot?
From your project root, put AGENTS.md, skills/godot-headless-tests/SKILL.md at .agents/skills/godot-headless-tests/SKILL.md, skills/godot-headless-tests/references/run_tests.gd at .agents/skills/godot-headless-tests/references/run_tests.gd, skills/godot-headless-tests/scripts/gd_test_lint.py at .agents/skills/godot-headless-tests/scripts/gd_test_lint.py, skills/godot-headless-tests/references/bad-test-sample.gd.txt at .agents/skills/godot-headless-tests/references/bad-test-sample.gd.txt. The "GitHub Copilot" tab on this page has the curl commands, or download https://setupbay.com/download/godot-4-headless-tests/copilot.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
What has been checked in Godot 4 headless GDScript tests?
It passed the Level 1 automated install checks on 2026-10-29: the files parse, install to each agent's documented paths, and pass the safety scan. No AI agent has run it yet, so no agent pass or fail is claimed.
More for Claude Code, Codex, Cursor, Gemini CLI, Antigravity CLI and GitHub Copilot.