extends SceneTree
## Gate: ScriptHost compiles, runs, replaces and unloads a runtime GDScript.
##
## Pure: no room, no peer, no mesh. Same reason SkyDefaults and TerrainEditOrder
## are their own files -- the rule should be assertable without infrastructure.
var ok := true
func _p(pass_: bool, msg: String) -> void:
if pass_:
print("ok %s" % msg)
else:
print("FAIL: %s" % msg)
ok = false
func _initialize() -> void:
var Host: GDScript = load("res://autoloads/script_host.gd")
var host: Node = Host.new()
host.name = "ScriptHost"
# Stub AssetSync: the arrival path is testable with no overlay, room or mesh.
# Injected BEFORE add_child, because _ready is where the signal is connected.
var StubGD := GDScript.new()
StubGD.source_code = """
extends Node
signal asset_ready(kind: String, name: String, path: String)
var ensure_returns := ""
var published := []
func ensure(_kind: String, _name: String) -> String:
return ensure_returns
func publish_file(kind: String, name: String, path: String, ext: String) -> String:
published.append([kind, name, path, ext])
return "oid-fake"
"""
StubGD.reload()
var stub: Node = StubGD.new()
host.sync = stub
root.add_child(stub)
# In the tree so _process runs -- the tick path is part of what is under test.
root.add_child(host)
var failures: Array[String] = []
host.script_failed.connect(func(n, r): failures.append("%s: %s" % [n, r]))
# 1. compile + run, entry point fires
var src1 := """
extends RefCounted
var ran := false
var ticks := 0
func _bot_init() -> void:
ran = true
func value() -> int:
return 41
"""
var inst = host.run(src1, "probe")
_p(inst != null, "run() returned an instance")
if inst != null:
_p(inst.ran == true, "_bot_init was called")
_p(inst.value() == 41, "method callable, returned %s" % str(inst.value()))
_p(host.is_loaded("probe"), "is_loaded after run")
# 2. THE UPDATE PATH. Re-running the same NAME with NEW source must run the
# NEW code. This is why compile() uses source_code instead of load(): load()
# caches by path, so the second version of an edited script would silently
# run as the first -- and "edit the file and it propagates" is the entire
# point of the feature.
var src2 := src1.replace("return 41", "return 42")
var inst2 = host.run(src2, "probe")
_p(inst2 != null and inst2.value() == 42,
"re-run same name with new source -> %s (expect 42, NOT a cached 41)"
% (str(inst2.value()) if inst2 != null else "null"))
# 3. a broken script fails gracefully rather than taking the process down
var before := failures.size()
var bad = host.run("extends RefCounted\nthis is not gdscript\n", "broken")
_p(bad == null, "invalid source returns null")
_p(not host.is_loaded("broken"), "invalid source is not held")
_p(failures.size() > before, "script_failed emitted (%d)" % failures.size())
# 4. the world is reachable from a script -- this is what "alter the gamestate"
# rests on. Asserting VISIBILITY, not a durable write: a write needs a room.
var src3 := """
extends RefCounted
var saw_gamestate := false
func _bot_init() -> void:
saw_gamestate = Engine.get_main_loop().root.get_node_or_null("/root/GameState") != null
"""
var inst3 = host.run(src3, "world")
_p(inst3 != null, "world-access script ran")
# 5. unload calls _bot_exit and drops it
var src4 := """
extends RefCounted
static var exited := false
func _bot_exit() -> void:
exited = true
"""
host.run(src4, "exiting")
host.unload("exiting")
_p(not host.is_loaded("exiting"), "unload drops the script")
# 6. tick runs for a script that implements it
var src5 := """
extends RefCounted
var ticks := 0
func _bot_tick(_d: float) -> void:
ticks += 1
"""
var t = host.run(src5, "ticker")
await process_frame
await process_frame
_p(t != null and t.ticks > 0, "_bot_tick ran (%d tick(s))" % (t.ticks if t != null else -1))
# ---- arrival path -------------------------------------------------------
# Write a real script to disk; run_file reads bytes, so a fake path will not do.
var spath := "user://gate_arrived.gd"
var wf := FileAccess.open(spath, FileAccess.WRITE)
wf.store_string("extends RefCounted\nstatic var went := false\nfunc _bot_init() -> void:\n\twent = true\n")
wf.close()
# 7. ARRIVAL DOES NOT RUN. This is the security property, not a detail.
stub.asset_ready.emit("script", "arrived", spath)
await process_frame
_p(host.available_path("arrived") == spath, "arrival recorded as available")
_p(not host.is_loaded("arrived"), "arrival did NOT auto-run it")
# 8. a non-script asset is ignored entirely
stub.asset_ready.emit("tile", "some_tile", spath)
await process_frame
_p(host.available_path("some_tile") == "", "non-script kind ignored")
# 9. sync_and_run on something not local -> pending, not a failure
stub.ensure_returns = ""
var immediate: bool = host.sync_and_run("later")
_p(immediate == false, "sync_and_run returns false while pending")
_p(not host.is_loaded("later"), "pending script is not loaded yet")
# ...and it runs when the bytes turn up
stub.asset_ready.emit("script", "later", spath)
await process_frame
_p(host.is_loaded("later"), "pending script ran on arrival")
# 10. sync_and_run when the bytes are already local runs immediately
stub.ensure_returns = spath
_p(host.sync_and_run("now") == true, "sync_and_run runs immediately when local")
# 11. publish goes through AssetSync with the mutable script kind
host.publish(spath, "pub")
_p(stub.published.size() == 1 and stub.published[0][0] == "script"
and stub.published[0][3] == "gd",
"publish -> AssetSync kind=script ext=gd (%s)" % str(stub.published))
# 12. reachable from admin-cli, or the autoload is unreachable dead code
_p(host.is_in_group("admin_controllable"), "registered admin_controllable")
_p(String(host.get_meta("admin_name", "")) == "scripts", "admin_name=scripts")
var exposed: Array = host.get_meta("admin_exposed_methods", [])
_p(exposed.has("sync_and_run") and exposed.has("run_file"),
"methods exposed: %s" % str(exposed))
# The scope must actually be SETTABLE now. A colon key silently is not --
# that is the whole defect this replaced.
var scopes: Dictionary = host.get_meta("admin_call_scopes", {})
_p(String(scopes.get("sync_and_run", "")) == "local",
"sync_and_run scope=local via dictionary meta (settable)")
host.set_meta("admin_call_scope:probe", "local")
_p(not host.has_meta("admin_call_scope:probe"),
"colon meta still rejected by the engine (why the dict shape exists)")
print("GATE script_host: %s" % ("PASS" if ok else "FAIL"))
quit(0 if ok else 1)