diff --git a/scratchpad/test_remote_console_e2e.py b/scratchpad/test_remote_console_e2e.py new file mode 100644 index 0000000..3dbbb38 --- /dev/null +++ b/scratchpad/test_remote_console_e2e.py @@ -0,0 +1,231 @@ +"""END-TO-END test of the PARKED-RELAY / REMOTE-CONSOLE deployment (2026-07-26). + +The operator is travelling: the relay stays parked on the home machine (so no +player ever edits join.bat) and the console dials in from anywhere. Previous +verification of this mode was unit-level (relay lines fed to an offscreen +widget); this drives the WHOLE path for real: + + parked relay (standalone btconsole.py, bound to all interfaces, cwd=content) + + 2 real pods dialling in over the LAN address + + the REAL operator GUI in REMOTE mode over a real TCP control socket + +and asserts an entire night's workflow: AUTH, roster lights, LAUNCH enabling +and actually launching, mission settings applied to the relay's own egg, END +MISSION, and re-arm. + +NOTE the reason a LAN address is used rather than loopback: the GUI treats +"localhost"/"127.0.0.1" in the relay-host field as LOCAL mode (btoperator.py +_start_session), so a loopback test would silently exercise the wrong path. + +Spawns its own relay + pods and kills ONLY those PIDs. +""" +import os +import re +import socket +import subprocess +import sys +import time + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +REPO = r"C:\git\bt411" +CONTENT = os.path.join(REPO, "content") +sys.path.insert(0, os.path.join(REPO, "tools")) + +PORT = 1500 +SRC_EGG = "FOGDAY.EGG" # the rig's known-good 2-seat egg +EGG = "_e2e_parked.EGG" # a COPY: `set` really rewrites this file +RELAY_LOG = os.path.join(os.environ.get("TEMP", "."), "parked_relay.log") + +fails = [] +procs = [] + + +def check(label, ok, extra=""): + print(" %-56s %s %s" % (label, "OK" if ok else "*** FAIL ***", extra)) + if not ok: + fails.append(label) + + +def lan_ip(): + """This machine's primary LAN address (no traffic actually sent).""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + finally: + s.close() + + +def relay_text(): + try: + with open(RELAY_LOG, "r", encoding="utf-8", errors="replace") as f: + return f.read() + except OSError: + return "" + + +def wait_for(pattern, timeout, app=None): + """Pump the Qt loop (if given) while waiting for a relay-log pattern.""" + deadline = time.time() + timeout + while time.time() < deadline: + if re.search(pattern, relay_text()): + return True + if app is not None: + app.processEvents() + time.sleep(0.25) + return False + + +def cleanup(): + """Kill what we spawned -- INCLUDING DESCENDANTS. + + btl4's front-end RELAUNCHES itself for the mission, so the process holding + the pod at the end is a CHILD of the PID we started: killing only our own + Popen handles left a live pod behind (observed 2026-07-26) which then held + its log file open. Walk the tree by PID; never kill by image name (that + would take out the operator's own session). + """ + ours = set() + for p in procs: + ours.add(p.pid) + try: + out = subprocess.run( + ["wmic", "process", "get", "ProcessId,ParentProcessId"], + capture_output=True, text=True).stdout + pairs = [] + for line in out.splitlines(): + bits = line.split() + if len(bits) == 2 and bits[0].isdigit() and bits[1].isdigit(): + pairs.append((int(bits[0]), int(bits[1]))) # parent, pid + for _ in range(4): # a few generations + for parent, pid in pairs: + if parent in ours: + ours.add(pid) + except Exception: + pass + for p in procs: # our own handles first + try: + if p.poll() is None: + p.kill() + except Exception: + pass + for pid in ours: # then any surviving descendant + subprocess.run(["taskkill", "/PID", str(pid), "/F", "/T"], + capture_output=True) + + +try: + ip = lan_ip() + print("PARKED-RELAY E2E -- relay/pods on %s:%d" % (ip, PORT)) + + pre = subprocess.run(["tasklist", "/FI", "IMAGENAME eq btl4.exe", "/NH"], + capture_output=True, text=True).stdout + if "btl4.exe" in pre: + sys.exit("ABORT: btl4.exe already running -- refusing to disturb it") + + # ---- park the relay exactly as the guide says (cwd=content, no --bind) ---- + secret_path = os.path.join(CONTENT, "operator_secret.txt") + with open(secret_path, "r", encoding="ascii") as f: + secret = f.read().strip() + print(" secret: content\\operator_secret.txt (%d chars)" % len(secret)) + + import shutil + shutil.copyfile(os.path.join(CONTENT, SRC_EGG), os.path.join(CONTENT, EGG)) + print(" egg: %s (copy of %s -- the tracked file is never written)" + % (EGG, SRC_EGG)) + + log = open(RELAY_LOG, "w", encoding="utf-8") + procs.append(subprocess.Popen( + [sys.executable, "-u", os.path.join(REPO, "tools", "btconsole.py"), + "--relay", str(PORT), os.path.join(CONTENT, EGG), "--manual-launch"], + cwd=CONTENT, stdout=log, stderr=subprocess.STDOUT)) + check("parked relay started", wait_for(r"operator control on port", 20)) + check("control port is %d" % (PORT + 7), + ("operator control on port %d" % (PORT + 7)) in relay_text()) + + # ---- two real pods dial in over the LAN address ---- + env = dict(os.environ) + env.update({"BT_START_INSIDE": "1", "BT_DEV_GAUGES": "1", + "BT_MATCHLOG": "0", "BT_RELAY": "%s:%d" % (ip, PORT)}) + exe = os.path.join(REPO, "build", "Release", "btl4.exe") + for tag, netport in (("r_a.log", 1502), ("r_b.log", 1602)): + e = dict(env) + e["BT_LOG"] = tag + e["BT_SELF"] = "127.0.0.1:%d" % netport # must match the egg roster + procs.append(subprocess.Popen( + [exe, "-egg", EGG, "-net", str(netport)], cwd=CONTENT, env=e)) + time.sleep(2) + check("both pods staged on the parked relay", + wait_for(r"WAITING FOR OPERATOR", 90)) + + # ---- the console attaches REMOTELY, as from the road ---- + from PySide6.QtWidgets import QApplication + import btoperator + + app = QApplication.instance() or QApplication([]) + win = btoperator.Operator() + win.mode.setCurrentIndex(0) # relay mode + win.f_relayhost.setText(ip) # non-loopback => REMOTE path + win.f_port.setValue(PORT) + win.f_secret.setText(secret) + win._start_session() + check("GUI took the REMOTE path (remote_link exists, no child relay)", + getattr(win, "remote_link", None) is not None + and getattr(win, "console_proc", None) is None) + + check("relay AUTHENTICATED the remote operator", + wait_for(r"operator AUTHENTICATED", 20, app)) + + # roster adoption + LAUNCH enabling are the two things that were broken + deadline = time.time() + 30 + while time.time() < deadline and not win.launch_btn.isEnabled(): + app.processEvents() + time.sleep(0.25) + registered = len(re.findall(r"REGISTERED", relay_text())) + check("relay REGISTERED both pods (live seats, not egg text)", + registered >= 2, "registered lines=%d" % registered) + check("GUI monitor sees seated pilots", + getattr(win.monitor, "seated", lambda: 0)() >= 2 + if callable(getattr(win.monitor, "seated", None)) + else sum(1 for v in getattr(win.monitor, "state", {}).values() + if getattr(v, "registered", v) )) + check("LAUNCH enabled remotely", win.launch_btn.isEnabled()) + check("END MISSION enabled remotely", win.stop_btn.isEnabled()) + check("Re-arm enabled remotely", win.rearm_btn.isEnabled()) + + # ---- mission settings over the wire, into the relay's OWN egg ---- + win.f_weather.setCurrentText(win.values.weathers[-1]) + win._apply_mission_settings() + check("mission settings applied by the remote operator", + wait_for(r"mission settings applied by", 20, app)) + + # ---- launch, for real ---- + win._launch_mission() + check("LAUNCH reached the pods (RunMission pair)", + wait_for(r"RunMission #2 sent to 2 pod", 40, app)) + + # ---- end the round, then re-arm ---- + win._end_mission() + check("END MISSION reached the pods", + wait_for(r"StopMission sent to 2 pod", 30, app)) + win._rearm_round() + check("re-arm accepted after the round", + wait_for(r"re-arm|round RESET", 30, app)) + + # ---- disconnecting the operator must NOT kill the parked relay ---- + win._stop_session() + time.sleep(2) + check("parked relay still alive after the operator disconnects", + procs[0].poll() is None) + +finally: + cleanup() + try: + os.remove(os.path.join(CONTENT, EGG)) + except OSError: + pass + +print() +print("ALL PASS" if not fails else "FAILURES: %s" % fails) +sys.exit(1 if fails else 0)