A headless test suite for the GDExtension build - #296
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This repository had no automated tests.
scripts/run-tests.sh(.ps1twin) nowruns 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.
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.shforever.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-testslooks for one you already have —godot=<path>,$GODOT,godot-bin/, thenPATH— and printsscripts/fetch-godot.shrather thanpulling 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; the1.2 GB export templates are not needed and not fetched).
godot/bin/*is deliberately not in the search. That is a custom modulebuild with SpriteStudio compiled in, so loading the extension registers every
class twice and Godot aborts.
run-testsrecognises that message and explainsit rather than leaving you with the raw error.
Three things a bare loop would not do
.ssabare both build outputs, so a freshclone has neither.
run_tests.gdrefuses to start and names the script thatproduces each, rather than letting every case skip its way to a green run.
reported apart from the passes and never counted as one.
_initandleaves the tree idling forever, so the wrapper passes
--quit-after— whichcosts 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()underANIMATION_PROCESS_MANUAL, never the frameclock, 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.ymlalready gates on Linux alone, and Wgpu's CI tests on one platform andbuilds on the others; nothing here asks for more than that.
Drawing is out and cannot be otherwise:
--headlessinstalls a dummyrasteriser, and
NOTIFICATION_DRAWis a no-op in the node anyway (theInternalPlayer 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--testfor want oftests=yes. Thetwo builds share one copy of the playback logic; what they do not share is a
layer of
#ifdef SPRITESTUDIO_GODOT_EXTENSIONadapters — includes and typeconversions, 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: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
.ssabaway, 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 carryanim_name(azero-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 anoverride lands on the next
advance(), not on the call —is_part_hiddenreports 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:
--importtest/extensionEXC_BAD_ACCESS at 0x8, caught by Godot's own crash handler, with no frame ofours 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.cfgfrom 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.
reloadableunset /false/trueall reproduce, and godot-cpp's own test extension does it with a different
entry_symbolandcompatibility_minimum.SSEditorPluginand thetranslations 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
masteragainstapi_version=4.7, and that is thecombination that does this.
The scan's work completes, so
run-testsretries once and requires the secondrun 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:
test/extensiongodot --headless --path <proj> --import→ SIGSEGV on shutdownrm .godot/extension_list.cfg, run again → SIGSEGVThis is not test-only — anything running
godot --headless --importon a freshcheckout meets it.
Not in this PR
are back:
build-extension,deploy-examples,run-tests, on the Linux jobpr.ymlalready has.godot-cpp. It is cloned unpinned (.gitignored, CI clonesmaster), so a test result is not reproducible run to run. Wanted, but adecision of its own.
examples/*/ssab_generatedis untracked and not ignored, unlike thedev_*ones. Pre-existing; left alone rather than widened here.