Skip to content

A headless test suite for the GDExtension build - #296

Merged
Naruto merged 3 commits into
developfrom
test/headless-gdextension-suite
Aug 28, 2026
Merged

A headless test suite for the GDExtension build#296
Naruto merged 3 commits into
developfrom
test/headless-gdextension-suite

Conversation

@Naruto

@Naruto Naruto commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

This repository had no automated tests. scripts/run-tests.sh (.ps1 twin) now
runs 35 cases / 97 assertions over the bound API, the part override layer and
the five signals — against the GDExtension, through GDScript, which is the
door a user's game goes through.

run-tests.sh: 4.7.2.stable.official.ed1daf0bf at .../godot-bin/Godot.app/Contents/MacOS/Godot
  PASS  test_overrides                9 cases,  17 assertions
  PASS  test_resource                 8 cases,  28 assertions
  PASS  test_signals                  7 cases,  21 assertions
  PASS  test_transport               11 cases,  31 assertions

== RESULT: 35 cases, 97 assertions, 0 failed, 0 skipped ==

The shape, and why this one

It is the shape godot-cpp uses for its own extension: a small project,
assertions in GDScript, a shell wrapper that takes the binary from an
environment variable. Not gdUnit4 or GUT — this repository ships an addons/
folder, and vendoring a second one would mean guarding it out of
build-release.sh forever.

Worth knowing what the field does here: of the six most-starred GDExtensions
(godot-jolt, godot-sqlite, godot_debug_draw_3d, godot-git-plugin,
fmod-gdextension, steam-multiplayer-peer), none has automated tests — build
and clang-format only. The two projects that do test, godot-cpp itself and
godot-rapier-physics, both hand-roll exactly this.

Binaries are never downloaded on your behalf

run-tests looks for one you already have — godot=<path>, $GODOT,
godot-bin/, then PATH — and prints scripts/fetch-godot.sh rather than
pulling tens of MB unasked. That script installs the editor build pinned in
scripts/GODOT_VERSION.txt (74 MB Linux / 162 MB macOS / 82 MB Windows; the
1.2 GB export templates are not needed and not fetched).

godot/bin/* is deliberately not in the search. That is a custom module
build with SpriteStudio compiled in, so loading the extension registers every
class twice and Godot aborts. run-tests recognises that message and explains
it rather than leaving you with the raw error.

Three things a bare loop would not do

  • Preflight. The addon and the .ssab are both build outputs, so a fresh
    clone has neither. run_tests.gd refuses to start and names the script that
    produces each, rather than letting every case skip its way to a green run.
  • A skip is not a pass. A case that declares itself unrunnable here is
    reported apart from the passes and never counted as one.
  • A completion marker. A script error inside a case aborts _init and
    leaves the tree idling forever, so the wrapper passes --quit-after — which
    costs the exit code its meaning, because Godot then leaves 0 on the way out.
    ==== SUITE FINISHED ==== is what says a run completed.

Scope

Cases step with advance() under ANIMATION_PROCESS_MANUAL, never the frame
clock, so a result does not depend on how long a frame took — which is also what
lets the same assertions mean the same thing on three platforms. The existing
pr.yml already gates on Linux alone, and Wgpu's CI tests on one platform and
builds on the others; nothing here asks for more than that.

Drawing is out and cannot be otherwise: --headless installs a dummy
rasteriser, and NOTIFICATION_DRAW is a no-op in the node anyway (the
InternalPlayer issues its own RenderingServer calls). Audio is out too — it is
the one genuinely host-dependent axis.

The custom module build is not tested here. A module is compiled into the
engine, so testing it would mean building Godot rather than downloading one —
a 14 GB tree against a 162 MB editor, and the binary on this machine is already
six days behind ss_player/ and refuses --test for want of tests=yes. The
two builds share one copy of the playback logic; what they do not share is a
layer of #ifdef SPRITESTUDIO_GODOT_EXTENSION adapters — includes and type
conversions, 148 sites, which is what a compiler checks. So the module build's
guard stays "it still builds".

Green was not the bar

Checked by mutation. Dropping the cascade flag in
SsInternalPlayer::set_part_visibility_override:

  FAIL  test_overrides   9 cases, 17 assertions
  FAIL  test_cascade_reaches_the_children: a child of the cascaded part is hidden -- expected true
== RESULT: 35 cases, 97 assertions, 1 failed, 0 skipped ==

One case, the one that names the behaviour, and nothing else. Restored, 35/35.

The preflight and the marker paths were exercised the same way — by taking the
addon away, by taking one .ssab away, and by planting a parse error.

Writing the suite also corrected four assumptions about the API that reading it
had not: set_animation() returns void; the signals carry anim_name (a
zero-argument callable is an error at emit time, not at connect); reverse
play() seeks to the last frame, so a seek before it is overwritten; and an
override lands on the next advance(), not on the call — is_part_hidden
reports what the last computed frame said. Each is now a case.

One wart, and it is not ours

The first headless scan of a project loading any godot-cpp GDExtension aborts
on the way out.
Measured:

first --import second
stock Godot, no extension 0 0
godot-cpp's own test/ extension 134 0
this addon, registering nothing at all 134
this addon, as shipped 134 0

EXC_BAD_ACCESS at 0x8, caught by Godot's own crash handler, with no frame of
ours on the stack.

It is not the import, and it is not the descriptor. A project with zero
importable files crashes identically; deleting only .godot/extension_list.cfg
from an already-imported project brings it straight back. So the trigger is the
run in which Godot first discovers the extension and loads it mid-scan,
rather than at startup from that cache. reloadable unset / false / true
all reproduce, and godot-cpp's own test extension does it with a different
entry_symbol and compatibility_minimum. SSEditorPlugin and the
translations were each disabled and rebuilt to rule them out.

Why the pairing: godot-cpp has no 4.6/4.7 release branch. It went from
godot-4.5-stable (2025-09) straight to the 10.0 line (10.0.0-rc1,
2026-01-26) — before Godot 4.7-stable shipped in June. An extension for 4.7 is
therefore built from master against api_version=4.7, and that is the
combination that does this.

The scan's work completes, so run-tests retries once and requires the second
run to pass
. That is a retry, not a tolerance: a crash that repeats is still a
failure, and the clean second run is the evidence the import finished.

Minimal upstream repro, no SpriteStudio involved:

  1. Fresh Godot project, drop in godot-cpp's own test/ extension
  2. godot --headless --path <proj> --import → SIGSEGV on shutdown
  3. Run it again → clean
  4. rm .godot/extension_list.cfg, run again → SIGSEGV

This is not test-only — anything running godot --headless --import on a fresh
checkout meets it.

Not in this PR

  • CI. The org's Actions have not run since 2026-08-21. Three steps when they
    are back: build-extension, deploy-examples, run-tests, on the Linux job
    pr.yml already has.
  • Pinning godot-cpp. It is cloned unpinned (.gitignored, CI clones
    master), so a test result is not reproducible run to run. Wanted, but a
    decision of its own.
  • examples/*/ssab_generated is untracked and not ignored, unlike the
    dev_* ones. Pre-existing; left alone rather than widened here.

Naruto added 3 commits August 28, 2026 15:08
The headless suite needs the same two things a sample project does -- the
built addon and the converted .ssab -- and neither is committable: the
addon is a build output, and MAINTAINING_PLAYERS.md is why a .ssab is not
tracked either (it goes stale the moment the SDK's tests/ change).

So the two scripts that already provision the samples provision this one
too. `build-extension` syncs the descriptor, bin/ and icons/ from
dev_gdextension the way it does for overall_gdextension; `deploy-examples`
converts overall and Ringo into its ssab_generated/.

The project is not under examples/ because it is not a sample -- the
samples are what a reader is shown, and one project cannot also be the
scratch pad. It still wears the `_gdextension` suffix, because that is what
tells you which build a project targets: a custom module build has the
classes compiled in already and aborts on double registration.
This repository had no automated tests. `scripts/run-tests.sh` (+ the .ps1
twin) now runs 35 cases over the bound API, the part override layer and the
five signals -- against the GDExtension, through GDScript, which is the door
a user's game goes through.

The shape is the one godot-cpp uses for its own extension: a small project,
assertions in GDScript, a shell wrapper that takes the binary from an
environment variable. Not gdUnit4 or GUT -- this repository ships an
addons/ folder, and vendoring a second one would mean guarding it out of
build-release.sh forever.

Binaries are never downloaded on your behalf. run-tests looks for one you
already have -- godot=<path>, $GODOT, godot-bin/, PATH -- and prints
`scripts/fetch-godot.sh` rather than pulling tens of MB unasked; that script
installs the editor build pinned in scripts/GODOT_VERSION.txt, and nothing
else needs the 1.2 GB of export templates. godot/bin/* is deliberately not
in the search: a custom module build has SpriteStudio compiled in and aborts
on double registration, and run-tests recognises that message and says so.

Three things the harness does that a bare loop would not:

  * Preflight. The addon and the .ssab are both build outputs, so a fresh
    clone has neither; run_tests.gd refuses to start rather than letting
    every case skip its way to a green run.
  * A skip is not a pass. A case that declares itself unrunnable here is
    reported apart from the passes and never counted as one.
  * The completion marker. A script error inside a case aborts _init and
    leaves the tree idling, so the wrapper passes --quit-after -- which
    costs the exit code its meaning, since Godot then leaves 0 on the way
    out. `==== SUITE FINISHED ====` is what says a run completed.

Cases step with advance() under ANIMATION_PROCESS_MANUAL, never the frame
clock, so a result does not depend on how long a frame took -- which is also
what lets the same assertions mean the same thing on three platforms.
Drawing is out of scope and cannot be otherwise: --headless installs a dummy
rasteriser, and NOTIFICATION_DRAW is a no-op in the node anyway.

The custom module build is not tested here. A module is compiled into the
engine, so testing it would mean building Godot rather than downloading it
-- 14 GB of tree against a 74-162 MB editor. The two builds share one copy
of the playback logic; what they do not share is a layer of `#ifdef
SPRITESTUDIO_GODOT_EXTENSION` adapters, which are includes and type
conversions, so the module build's guard is that it still builds.

The suite was checked by mutation, not by being green: dropping the cascade
flag in SsInternalPlayer::set_part_visibility_override fails
test_cascade_reaches_the_children and nothing else.

One wart, and it is not ours. The first headless scan of a project loading
any godot-cpp GDExtension aborts on the way out -- godot-cpp's own test/
extension reproduces it exactly, and a project with no extension does not.
godot-cpp has no 4.6/4.7 release branch (godot-4.5-stable, then the 10.0
line), so an extension for Godot 4.7 is built from master against
api_version=4.7, and that pairing is what does it. The scan's work
completes, so run-tests retries once and requires the second run to pass.
Not the import. A project with zero importable files crashes the same way,
and deleting only .godot/extension_list.cfg from an imported project brings
it back -- so the trigger is the run in which Godot first DISCOVERS the
extension and loads it mid-scan, rather than at startup from that cache.

Also rules out the descriptor as a cause: `reloadable` unset, false and true
all reproduce, and godot-cpp's own test extension does it with a different
entry_symbol and compatibility_minimum.
@Naruto
Naruto merged commit 58f55ce into develop Aug 28, 2026
0 of 2 checks passed
@Naruto
Naruto deleted the test/headless-gdextension-suite branch August 28, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant