"""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)