//! Layer 3 integration test: real subprocess IPC (D-030) //! //! Spawns the server binary as a child process with --test-mode --port 0, //! parses the LISTENING:{port} handshake from stdout, connects via TCP, //! sends a PlayerInput, and reads back an ObserverSnapshot. //! //! This is the highest-fidelity test layer: no mocks, no in-process bridge. //! The server runs as a separate OS process, exactly as it does in production. //! //! Spec references: D-020 (subprocess IPC), D-030 (Layer 3 integration tests) use settled_reach_server::bridge::framing::{read_framed, write_framed}; use settled_reach_server::bridge::types::*; use std::io::{BufRead, BufReader, BufWriter}; use std::net::TcpStream; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; /// Timeout for the server to emit LISTENING:{port} on stdout. const LISTEN_TIMEOUT: Duration = Duration::from_secs(15); /// Timeout for the client to receive a snapshot after sending input. const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); #[test] fn server_subprocess_sends_snapshot_on_connect() { // 1. Spawn server binary with --test-mode --port 0 let server_bin = env!("CARGO_BIN_EXE_settled-reach-server"); let mut child = Command::new(server_bin) .args(["--test-mode", "--port", "0"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("failed to spawn server binary"); let stdout = child.stdout.take().expect("stdout not captured"); let mut stdout_reader = BufReader::new(stdout); // 2. Parse LISTENING:{port} from stdout let port = { let deadline = Instant::now() + LISTEN_TIMEOUT; let mut line = String::new(); loop { line.clear(); match stdout_reader.read_line(&mut line) { Ok(0) => panic!("server stdout closed before LISTENING signal"), Ok(_) => { let trimmed = line.trim(); if let Some(port_str) = trimmed.strip_prefix("LISTENING:") { break port_str .parse::() .unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e)); } } Err(e) => panic!("failed to read server stdout: {}", e), } assert!( Instant::now() < deadline, "timed out waiting for LISTENING signal" ); } }; // 3. Connect to the server via TCP let addr = format!("127.0.0.1:{}", port); let stream = TcpStream::connect(&addr) .unwrap_or_else(|e| panic!("failed to connect to server at {}: {}", addr, e)); stream .set_read_timeout(Some(SNAPSHOT_TIMEOUT)) .expect("set read timeout"); let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader")); let mut writer = BufWriter::new(stream); // 4. Send one PlayerInput (idle tick 0) let inputs = vec![PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }]; let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput"); write_framed(&mut writer, &payload).expect("send PlayerInput to server"); // 5. Read one ObserverSnapshot let response = read_framed(&mut reader) .expect("read snapshot frame") .expect("server closed connection before sending snapshot"); let snapshot: ObserverSnapshot = rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot"); // 6. Assert protocol correctness (D-020) assert_eq!( snapshot.version, PROTOCOL_VERSION, "protocol version mismatch: got {}, expected {}", snapshot.version, PROTOCOL_VERSION ); assert!( snapshot.entities.len() > 0, "snapshot should contain at least one entity (the player), got 0" ); // The proof room has a player + NPCs. Verify the player entity exists. let has_player = snapshot .entities .iter() .any(|e| matches!(e.kind, EntityKind::Player)); assert!(has_player, "snapshot must contain a Player entity"); // 7. Clean up: drop connection so the server exits its game loop drop(reader); drop(writer); // Wait for child to exit (with timeout) let exit_deadline = Instant::now() + Duration::from_secs(5); loop { match child.try_wait() { Ok(Some(_status)) => break, Ok(None) => { if Instant::now() > exit_deadline { child.kill().ok(); child.wait().ok(); break; } std::thread::sleep(Duration::from_millis(50)); } Err(e) => { eprintln!("error waiting for server process: {}", e); child.kill().ok(); break; } } } }