mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
521b8122ac
Every existing test of the client/control-plane boundary is either a Go unit test with a mocked HTTP layer or a GDScript unit test with no network at all (multiplayer-next.md 8.40's own evidence names "live multi-process control-plane/game verification" as remaining). Nothing before this actually ran the real compiled Go binary, a real PostgreSQL instance, and a real headless Godot process talking real HTTP to each other -- and it immediately found a real bug (previous commit). server/cmd/testkit-api is a new, deliberately separate, clearly-marked test-only binary wired identically to cmd/control-plane except for SteamLogin: cmd/control-plane has no way to authenticate against a real Steam Web API from this sandbox (task 8.7's own documented blocker), so testkit-api accepts any non-empty ticket string and derives a deterministic identity instead. This bypass is confined to its own binary -- never a flag on cmd/control-plane, never referenced by any Dockerfile stage or Kubernetes manifest -- specifically so it can't become a footgun on the real one. Game/tests/control_plane_smoke.gd drives the real ControlPlaneClient autoload through login -> queue_create -> heartbeat against a real server and prints SMOKE PASS/FAIL, matching the existing net_smoke.gd convention. scripts/verify_control_plane_integration.sh orchestrates both sides (real postgres:17-alpine, the built testkit-api binary, the Godot client) end to end. Two real bugs surfaced building this, both fixed and re-verified, not just the target bug: the smoke script's own use of `go run` left a zombie process that survived cleanup and squatting on its port corrupted the NEXT run with a misleading "http=401 unauthorized" (now builds and runs a real binary directly, plus a belt-and-suspenders port-kill in cleanup); and calling heartbeat() synchronously from within a request_succeeded handler produced a spurious "Busy" because ControlPlaneClient's own internal resync (see previous commit) was still in flight -- the test now waits for ControlPlaneClient to go idle via a real Timer (call_deferred alone floods the message queue without ever yielding a frame for the in-flight request to complete). Verified stable across 3 consecutive full runs: real PostgreSQL container up, migrations applied, testkit-api built and started, real headless Godot client round-tripping login/queue/heartbeat, clean teardown with no leftover processes, containers, or bound ports each time.
123 lines
4.9 KiB
GDScript
123 lines
4.9 KiB
GDScript
extends Node
|
|
|
|
# Real end-to-end smoke test for ControlPlaneClient against a REAL running
|
|
# control-plane HTTP server backed by REAL PostgreSQL -- proving the actual
|
|
# wire format (GDScript's HTTPRequest/JSON on one side, the real compiled Go
|
|
# api.Service on the other) is compatible, not just that each side's own unit
|
|
# tests pass in isolation. Every other ControlPlaneClient test in this repo
|
|
# is either pure parsing/validation logic or drives the client against a
|
|
# mock; nothing before this exercised a real network round trip end to end
|
|
# (multiplayer-next.md 8.40's own evidence names this "live... verification"
|
|
# as remaining).
|
|
#
|
|
# Run against scripts/verify_control_plane_integration.sh's server/cmd/testkit-api
|
|
# instance -- see that script's own header for why a separate, clearly-marked
|
|
# test-only binary exists rather than a flag on the real cmd/control-plane:
|
|
#
|
|
# godot --headless --path Game res://tests/control_plane_smoke.tscn -- \
|
|
# --control-plane-url=http://127.0.0.1:PORT
|
|
#
|
|
# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1.
|
|
|
|
const TIMEOUT_SECONDS := 10.0
|
|
|
|
var _finished := false
|
|
var _ticket_id := ""
|
|
|
|
|
|
func _ready() -> void:
|
|
var control_plane_url := ""
|
|
for arg in OS.get_cmdline_user_args():
|
|
if arg.begins_with("--control-plane-url="):
|
|
control_plane_url = arg.substr("--control-plane-url=".length())
|
|
if control_plane_url.is_empty():
|
|
_finish(false, "missing --control-plane-url")
|
|
return
|
|
# A syntactically valid but semantically meaningless placeholder token:
|
|
# configure() validates format eagerly, but real auth doesn't exist until
|
|
# login_steam()'s response overwrites it below. There is no other way to
|
|
# set base_url alone.
|
|
if not ControlPlaneClient.configure(control_plane_url, "0:0"):
|
|
_finish(false, "configure() rejected a valid-looking base URL")
|
|
return
|
|
|
|
_ticket_id = "smoke-ticket-%d" % Time.get_unix_time_from_system()
|
|
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
|
|
ControlPlaneClient.request_failed.connect(_on_request_failed)
|
|
|
|
var web_api_ticket := "smoke-web-api-ticket-%d" % Time.get_ticks_usec()
|
|
var err := ControlPlaneClient.login_steam(web_api_ticket)
|
|
if err != OK:
|
|
_finish(false, "login_steam() failed to start: %s" % error_string(err))
|
|
return
|
|
print("SMOKE: logging in against %s..." % control_plane_url)
|
|
|
|
var timer := Timer.new()
|
|
timer.wait_time = TIMEOUT_SECONDS
|
|
timer.one_shot = true
|
|
timer.timeout.connect(func(): _finish(false, "timed out after %.1fs" % TIMEOUT_SECONDS))
|
|
add_child(timer)
|
|
timer.start()
|
|
|
|
|
|
func _on_request_succeeded(operation: String, payload: Dictionary) -> void:
|
|
if _finished:
|
|
return
|
|
match operation:
|
|
"steam_session":
|
|
print("SMOKE: logged in as %s, creating a queue ticket..." % ControlPlaneClient.player_id)
|
|
var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1)
|
|
if err != OK:
|
|
_finish(false, "queue_create() failed to start: %s" % error_string(err))
|
|
"queue_create":
|
|
if payload.get("ticket_id", "") != _ticket_id or payload.get("state", "") != "QUEUED":
|
|
_finish(false, "unexpected queue_create payload: %s" % payload)
|
|
return
|
|
# apply_ticket_update (called for every "queue_"-prefixed response,
|
|
# including this one) can itself decide the ticket needs a resync
|
|
# and fire off a recover_queue() call -- a real, existing part of
|
|
# MatchmakingState's own state machine, not something this test
|
|
# controls. Wait for ControlPlaneClient to go idle before sending
|
|
# the next request rather than assuming queue_create was the only
|
|
# thing in flight.
|
|
print("SMOKE: ticket %s QUEUED at revision %d, heartbeating once idle..." % [_ticket_id, ControlPlaneClient.state.revision])
|
|
_send_heartbeat_once_idle()
|
|
"queue_recover":
|
|
pass # Expected background resync; the idle-wait above handles it.
|
|
"queue_heartbeat":
|
|
if int(payload.get("revision", -1)) <= 0:
|
|
_finish(false, "heartbeat did not advance the revision: %s" % payload)
|
|
return
|
|
_finish(true, "login -> queue_create -> heartbeat all round-tripped against a real server")
|
|
|
|
|
|
func _send_heartbeat_once_idle() -> void:
|
|
if not ControlPlaneClient._operation.is_empty():
|
|
# call_deferred alone floods the message queue without ever letting a
|
|
# real frame (and therefore the in-flight HTTP request) actually
|
|
# process -- a real Timer yields to the engine between checks.
|
|
var poll := get_tree().create_timer(0.05)
|
|
poll.timeout.connect(_send_heartbeat_once_idle)
|
|
return
|
|
var err := ControlPlaneClient.heartbeat(_ticket_id, ControlPlaneClient.state.revision)
|
|
if err != OK:
|
|
_finish(false, "heartbeat() failed to start: %s" % error_string(err))
|
|
|
|
|
|
func _on_request_failed(operation: String, http_code: int, detail: String) -> void:
|
|
if _finished:
|
|
return
|
|
_finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail])
|
|
|
|
|
|
func _finish(passed: bool, detail: String) -> void:
|
|
if _finished:
|
|
return
|
|
_finished = true
|
|
if passed:
|
|
print("SMOKE PASS: %s" % detail)
|
|
get_tree().quit(0)
|
|
else:
|
|
print("SMOKE FAIL: %s" % detail)
|
|
get_tree().quit(1)
|