---
name: godot-test-instrumentation
description: Automates Godot testing for coding agents via headless execution, method simulation, and stdout validation.
---

# Godot Test Instrumentation

Enables coding agents to automatically test game changes by running Godot in headless mode, simulating user actions via method calls, and validating results through structured stdout output.

## Use This Skill When

- A coding agent needs to verify gameplay changes work correctly
- Testing generator purchases, currency gains, prestige mechanics, or buff systems
- Running automated validation in CI/CD pipelines
- Debugging features without manual GUI interaction

## Core Workflow

```
1. Agent writes/modifies game code
   ↓
2. Agent creates or updates test script in res://tests/
   ↓
3. Agent runs: godot --headless --path . -s res://tests/test_x.gd
   ↓
4. Test loads gym scene, simulates actions, prints [TAG] results
   ↓
5. Agent parses stdout for [PASS]/[FAIL]/[RESULT]
   ↓
6. Exit code 0 = PASS, Exit code 1 = FAIL
```

---

## Directory Structure

```
res://
├── tests/
│   ├── test_runner.gd          # Cross-platform test discovery & execution
│   ├── test_utils.gd           # Shared assertion helpers
│   ├── test_gold_mine_click.gd # Example: generator click → currency gain
│   └── test_prestige.gd        # Example: prestige mechanics validation
├── docs/gyms/                  # Test scenes (e.g., tiny_sword.tscn)
└── core/                       # Game logic (LevelGameState, generators, etc.)
```

---

## 1. Test Utils (res://tests/test_utils.gd)

Shared assertion helpers with structured stdout output.

```gdscript
class_name TestUtils
extends RefCounted

static var _test_name: String = ""
static var _passed: int = 0
static var _failed: int = 0

static func set_test_name(name: String) -> void:
    _test_name = name
    _passed = 0
    _failed = 0

static func assert_equals(expected: Variant, actual: Variant, message: String) -> void:
    if expected == actual:
        print("[PASS] %s: %s" % [_test_name, message])
        _passed += 1
    else:
        print("[FAIL] %s: %s (expected %s, got %s)" % [
            _test_name, message, str(expected), str(actual)
        ])
        _failed += 1

static func assert_true(condition: bool, message: String) -> void:
    if condition:
        print("[PASS] %s: %s" % [_test_name, message])
        _passed += 1
    else:
        print("[FAIL] %s: %s" % [_test_name, message])
        _failed += 1

static func assert_greater_than(a: float, b: float, message: String) -> void:
    if a > b:
        print("[PASS] %s: %s" % [_test_name, message])
        _passed += 1
    else:
        print("[FAIL] %s: %s (expected %s > %s)" % [_test_name, message, str(a), str(b)])
        _failed += 1

static func print_result() -> void:
    print("[SUMMARY] %s: %d passed, %d failed" % [_test_name, _passed, _failed])
    if _failed == 0:
        print("[RESULT] PASS")
    else:
        print("[RESULT] FAIL")
```

---

## 2. Test Runner (res://tests/test_runner.gd)

Cross-platform test discovery and execution. Runs all `test_*.gd` scripts in the tests directory.

```gdscript
extends SceneTree

var test_scripts: Array[String] = []
var test_index: int = 0
var total_passed: int = 0
var total_failed: int = 0

func _init():
    print("\n=== GODOT TEST RUNNER ===\n")
    _discover_tests()
    
    if test_scripts.is_empty():
        print("[ERROR] No test scripts found in res://tests/")
        quit(1)
        return
    
    print("Found %d test(s): %s\n" % [test_scripts.size(), str(test_scripts)])
    await _run_next_test()
    
    print("\n=== TEST SUMMARY ===")
    print("Total passed: %d" % total_passed)
    print("Total failed: %d" % total_failed)
    
    if total_failed == 0:
        print("[OVERALL_RESULT] PASS")
        quit(0)
    else:
        print("[OVERALL_RESULT] FAIL")
        quit(1)

func _discover_tests() -> void:
    var dir = DirAccess.open("res://tests")
    if dir:
        dir.list_dir_begin()
        var file_name = dir.get_next()
        while file_name != "":
            if file_name.ends_with(".gd") and file_name.begins_with("test_") and file_name != "test_runner.gd" and file_name != "test_utils.gd":
                test_scripts.append("res://tests/%s" % file_name)
            file_name = dir.get_next()
        dir.list_dir_end()
    test_scripts.sort()

func _run_next_test() -> void:
    if test_index >= test_scripts.size():
        return
    
    var test_script = test_scripts[test_index]
    print("--- Running test %d/%d: %s ---" % [test_index + 1, test_scripts.size(), test_script])
    
    var test_instance = load(test_script).new()
    await test_instance.run()
    
    total_passed += test_instance.passed
    total_failed += test_instance.failed
    
    test_index += 1
    await _run_next_test()

class TestScript:
    var passed: int = 0
    var failed: int = 0
    
    func run():
        pass
```

**Usage:**
```bash
# Run all tests via test runner
"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd
```

---

## 3. Test Script Template

Each test extends `SceneTree` and implements a `run()` method.

```gdscript
extends SceneTree

var passed: int = 0
var failed: int = 0

func _init():
    await run()
    quit(0 if failed == 0 else 1)

func run():
    # Setup
    TestUtils.set_test_name("<test_name>")
    
    # Load scene
    var scene = load("<path_to_gym_scene>")  # e.g., "res://docs/gyms/tiny_sword/tiny_sword.tscn"
    var root_node = scene.instantiate()
    add_root_node(root_node)
    
    # Wait for scene initialization
    await _wait()
    
    # Get references
    var game_state: LevelGameState = root_node.find_child("LevelGameState")
    var generator = root_node.find_child("<GeneratorNodeName>") as CurrencyGenerator
    
    if game_state == null:
        print("[ERROR] LevelGameState not found")
        _print_summary()
        return
    
    # Test logic
    var initial_value = game_state.get_currency_amount_by_id(&"<currency_id>")
    print("[TARGET] initial_<currency>: %s" % initial_value.to_string_suffix(2))
    
    # Simulate action (method call, not mouse click)
    generator._on_pressed()
    
    # Wait for signal propagation
    await _wait()
    
    # Validate
    var final_value = game_state.get_currency_amount_by_id(&"<currency_id>")
    print("[TARGET] final_<currency>: %s" % final_value.to_string_suffix(2))
    
    TestUtils.assert_greater_than(
        final_value.mantissa,
        initial_value.mantissa,
        "<currency> increased after action"
    )
    
    _print_summary()

func _wait() -> void:
    await get_tree().create_timer(0.5).timeout

func _print_summary():
    TestUtils.print_result()
    passed = _get_passed_count()
    failed = _get_failed_count()

func _get_passed_count() -> int:
    # Extract from TestUtils or track locally
    return 0

func _get_failed_count() -> int:
    return 0
```

---

## 4. Example Test: Gold Mine Click

Tests that clicking a generator grants currency.

```gdscript
extends SceneTree

var passed: int = 0
var failed: int = 0
var _local_passed: int = 0
var _local_failed: int = 0

func _init():
    await run()
    quit(failed == 0 ? 0 : 1)

func run():
    print("\n=== TEST: Gold Mine Click ===\n")
    TestUtils.set_test_name("gold_mine_click")
    
    # Load tiny_sword scene
    var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn")
    var root_node = scene.instantiate()
    add_root_node(root_node)
    
    await _wait()
    
    var game_state: LevelGameState = root_node.find_child("LevelGameState")
    var gold_mine = root_node.find_child("GoldMine") as CurrencyGenerator
    
    if game_state == null or gold_mine == null:
        print("[ERROR] Missing LevelGameState or GoldMine node")
        _print_summary()
        return
    
    # Record initial gold
    var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold")
    print("[TARGET] initial_gold: %s" % initial_gold.to_string_suffix(2))
    
    # Simulate clicking the generator
    gold_mine._on_pressed()
    
    await _wait()
    
    # Validate gold increased
    var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold")
    print("[TARGET] final_gold: %s" % final_gold.to_string_suffix(2))
    
    TestUtils.assert_greater_than(
        final_gold.mantissa,
        initial_gold.mantissa,
        "Gold increased after generator click"
    )
    
    # Check specific value (adjust based on click_mantissa in data)
    var expected_min: float = initial_gold.mantissa + 1.0
    TestUtils.assert_true(
        final_gold.mantissa >= expected_min,
        "Gold increased by at least 1.0"
    )
    
    _print_summary()

func _wait() -> void:
    await get_tree().create_timer(0.5).timeout

func _print_summary():
    TestUtils.print_result()
    # Note: For accurate counts, modify TestUtils to return counts or track locally
    _local_failed = 0  # Update based on actual assertions
```

---

## 5. Example Test: Prestige Mechanics

Tests prestige threshold and currency gain.

```gdscript
extends SceneTree

var passed: int = 0
var failed: int = 0

func _init():
    await run()
    quit(failed == 0 ? 0 : 1)

func run():
    print("\n=== TEST: Prestige Mechanics ===\n")
    TestUtils.set_test_name("prestige")
    
    var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn")
    var root_node = scene.instantiate()
    add_root_node(root_node)
    
    await _wait()
    
    var game_state: LevelGameState = root_node.find_child("LevelGameState")
    var prestige_manager = root_node.find_child("PrestigeManager") as PrestigeManager
    
    if game_state == null or prestige_manager == null:
        print("[ERROR] Missing LevelGameState or PrestigeManager")
        _print_summary()
        return
    
    # Setup: Accumulate currencies to meet prestige threshold
    game_state.add_currency_by_id(&"gold", BigNumber.from_float(100000.0))
    game_state.add_currency_by_id(&"gems", BigNumber.from_float(100.0))
    
    await _wait()
    
    # Check if can prestige
    var can_prestige: bool = prestige_manager.can_prestige()
    print("[TARGET] can_prestige: %s" % str(can_prestige))
    
    TestUtils.assert_true(can_prestige, "Should be able to prestige after accumulating currencies")
    
    if can_prestige:
        # Perform prestige
        prestige_manager.perform_prestige()
        
        await _wait()
        
        # Check ascension currency
        var ascension: BigNumber = game_state.get_currency_amount_by_id(&"ascension")
        print("[TARGET] ascension_currency: %s" % ascension.to_string_suffix(2))
        
        TestUtils.assert_true(
            ascension.mantissa > 0,
            "Should receive ascension currency after prestige"
        )
        
        # Check prestige multiplier increased
        var total_multiplier = prestige_manager.get_total_multiplier()
        print("[TARGET] total_multiplier: %s" % str(total_multiplier))
        
        TestUtils.assert_greater_than(
            total_multiplier if total_multiplier is float else float(total_multiplier),
            1.0,
            "Prestige multiplier should be > 1.0"
        )
    
    _print_summary()

func _wait() -> void:
    await get_tree().create_timer(0.5).timeout

func _print_summary():
    TestUtils.print_result()
```

---

## 6. Shell Scripts

### run_test.sh

```bash
#!/bin/bash
# Usage: ./run_test.sh <test_script_path>
# Example: ./run_test.sh res://tests/test_gold_mine_click.gd

set -e

TEST_SCRIPT="$1"
GODOT_BIN="${GODOT_BIN:-godot}"

if [ -z "$TEST_SCRIPT" ]; then
    echo "Usage: $0 <test_script_path>"
    exit 1
fi

echo "Running test: $TEST_SCRIPT"
echo ""

"$GODOT_BIN" --headless --path . -s "$TEST_SCRIPT"
EXIT_CODE=$?

echo ""
if [ $EXIT_CODE -eq 0 ]; then
    echo "✓ Test passed"
else
    echo "✗ Test failed with exit code: $EXIT_CODE"
fi

exit $EXIT_CODE
```

---

### run_all.sh

```bash
#!/bin/bash
# Usage: ./run_all.sh

set -e

GODOT_BIN="${GODOT_BIN:-godot}"
TEST_DIR="res://tests"

echo "=== Running All Tests ==="
echo ""

# Run test runner (discovers all tests automatically)
"$GODOT_BIN" --headless --path . -s "$TEST_DIR/test_runner.gd"
EXIT_CODE=$?

echo ""
if [ $EXIT_CODE -eq 0 ]; then
    echo "✓ All tests passed"
else
    echo "✗ Some tests failed"
fi

exit $EXIT_CODE
```

---

### check_syntax.sh

```bash
#!/bin/bash
# Usage: ./check_syntax.sh <script_path>

SCRIPT="$1"
GODOT_BIN="${GODOT_BIN:-godot}"

if [ -z "$SCRIPT" ]; then
    echo "Usage: $0 <script_path>"
    exit 1
fi

echo "Checking syntax: $SCRIPT"
"$GODOT_BIN" --headless --path . --check-only --script "$SCRIPT"
EXIT_CODE=$?

if [ $EXIT_CODE -eq 0 ]; then
    echo "✓ Syntax valid"
else
    echo "✗ Syntax errors found"
fi

exit $EXIT_CODE
```

---

## 7. Godot CLI Command Reference

| Command | Purpose |
|---------|---------|
| `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd` | Run single test in headless mode |
| `"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd` | Run all tests via test runner |
| `"$GODOT_BIN" --headless --path . --check-only --script res://test.gd` | Syntax validation only |
| `"$GODOT_BIN" --headless --verbose --path . -s res://tests/test_x.gd` | Verbose output for debugging |
| `"$GODOT_BIN" --headless --path . --quit-after 100 -s res://tests/test_x.gd` | Run for 100 frames then quit |
| `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd --log-file /tmp/test.log` | Log output to file |

**Important:** Always use `--headless` for automated testing. This sets `--display-driver headless --audio-driver Dummy`.

---

## 8. Output Parsing Protocol

Agents should parse stdout for these tags:

| Tag | Purpose | Example |
|-----|---------|---------|
| `[TARGET]` | Values to validate | `[TARGET] gold: 100.00` |
| `[PASS]` | Assertion passed | `[PASS] gold_mine_click: Gold increased]` |
| `[FAIL]` | Assertion failed | `[FAIL] gold_mine_click: Expected gold > 0]` |
| `[SUMMARY]` | Test completion stats | `[SUMMARY] gold_mine_click: 2 passed, 0 failed]` |
| `[RESULT]` | Overall test result | `[RESULT] PASS` or `[RESULT] FAIL]` |
| `[ERROR]` | Setup/runtime error | `[ERROR] Missing LevelGameState node]` |
| `[OVERALL_RESULT]` | Test runner summary | `[OVERALL_RESULT] PASS` or `[OVERALL_RESULT] FAIL]` |

**Exit Codes:**
- `0` = All assertions passed
- `1` = One or more assertions failed or error occurred

---

## 9. Best Practices

### Simulating User Actions

In headless mode, **do not** simulate mouse clicks with `InputEventMouseButton`. Instead, call methods directly:

```gdscript
# ✅ DO: Call the method directly
generator._on_pressed()
game_state.add_currency_by_id(&"gold", BigNumber.from_float(100.0))
prestige_manager.perform_prestige()

# ❌ DON'T: Try to synthesize mouse events (won't work in headless)
var event = InputEventMouseButton.new()
Input.parse_input_event(event)  # Ineffective without GUI
```

### Async Handling

Use standard wait time for signal propagation:

```gdscript
await get_tree().create_timer(0.5).timeout
```

Adjust based on test needs:
- `0.1s` - Simple state changes
- `0.5s` - Signal propagation (recommended default)
- `1.0s+` - Complex async operations

### Scene Selection

Use gym scenes from `res://docs/gyms/`:
- `res://docs/gyms/tiny_sword/tiny_sword.tscn` - Full game with generators, currencies, prestige
- Create minimal test scenes if testing isolated features

### Test Naming

- Use `test_<feature>.gd` naming convention
- Test name in `TestUtils.set_test_name()` should match filename (without `.gd`)

### Assertions

- One assertion per line for clear parsing
- Include descriptive messages
- Use `[TARGET]` to log values before/after actions

---

## 10. Quick Start for Agents

1. **Create test file:**
   ```bash
   touch res://tests/test_my_feature.gd
   ```

2. **Copy template:**
   ```gdscript
   extends SceneTree
   var passed: int = 0
   var failed: int = 0
   
   func _init():
       await run()
       quit(failed == 0 ? 0 : 1)
   
   func run():
       print("\n=== TEST: My Feature ===\n")
       TestUtils.set_test_name("my_feature")
       
       var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn")
       var root = scene.instantiate()
       add_root_node(root)
       
       await get_tree().create_timer(0.5).timeout
       
       # Your test logic here
       
       TestUtils.print_result()
   ```

3. **Run test:**
   ```bash
   godot --headless --path . -s res://tests/test_my_feature.gd
   ```

4. **Parse output:**
   - Look for `[PASS]`, `[FAIL]`, `[RESULT]` tags
   - Check exit code (0 = pass, 1 = fail)

---

## References

- [Godot CLI Tutorial](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html)
- [Godot Headless Mode](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html#run-options)
- [InputEvent Documentation](https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html)

</content>
<parameter=filePath>
/home/mikymod/work/jmp/idle/.agents/skills/godot-test-instrumentation/SKILL.md