- 7 archetype→monologue regression tests (smuggler/detective pool partitioning) - 3 tell escalation unit tests (RoutineDeviation insertion + expiry) - 6 news ticker tests (pool loading, SimRng rotation, zone gating) - 3 live integration tests against real server binary (Layer 3) - Update existing tests for current_ticker field and protocol v19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
153 lines
5.8 KiB
Rust
153 lines
5.8 KiB
Rust
//! 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::<u16>()
|
|
.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. Read the protocol handshake (first framed message, #555)
|
|
let handshake_frame = read_framed(&mut reader)
|
|
.expect("read handshake frame")
|
|
.expect("server closed connection before sending handshake");
|
|
let handshake: HandshakeMessage =
|
|
rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage");
|
|
assert_eq!(
|
|
handshake.protocol_version, PROTOCOL_VERSION,
|
|
"handshake protocol_version mismatch: got {}, expected {}",
|
|
handshake.protocol_version, PROTOCOL_VERSION
|
|
);
|
|
|
|
// 5. Send StartupMessage with world_seed (#175)
|
|
let startup = StartupMessage {
|
|
world_seed: 42,
|
|
character_archetype: settled_reach_server::bridge::types::CharacterArchetype::default(),
|
|
};
|
|
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
|
|
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");
|
|
|
|
// 6. 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");
|
|
|
|
// 7. 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");
|
|
|
|
// 8. 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");
|
|
|
|
// 9. 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;
|
|
}
|
|
}
|
|
}
|
|
}
|