//! Phase H gate: end-to-end matches over the real server, real sockets, real //! protocol. The web CI minimum names "100 browser E2E matches"; a headless //! browser is not available in this CI, so this drives the full //! HTTP+WebSocket+protocol+runtime path *headlessly* (it is protocol-level //! E2E, not a rendered browser — the Playwright harness under `e2e/` covers the //! rendered browser when Node is present). It asserts: a player can join, the //! turn timer drives resolution, casts resolve through the runtime, results //! return as filtered observations, and the recorded replay reproduces the live //! per-turn hashes (0 replay hash mismatches). use protocol::{Action, ClientMessage, MatchId, RuneTokenWire, ServerMessage}; use web_tests::{spawn_test_server, WsClient}; struct Played { match_id: MatchId, live_hashes: Vec<(u64, String)>, replay_hashes: Vec<(u64, String)>, final_hash: String, saw_filtered_snapshot: bool, } /// Play a solo match for `n_turns`, then pull the recorded replay back. fn play_solo(addr: &str, n_turns: u64, program: &[RuneTokenWire]) -> std::io::Result { let mut c = WsClient::connect(addr)?; c.send(&ClientMessage::JoinMatch { name: "e2e".into(), match_id: None })?; let (match_id, _player) = match c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. }))? { ServerMessage::MatchState { match_id, player_id, snapshot, .. } => { // Filtered snapshot sanity: hidden lanes are redacted. assert!(snapshot.hidden_state_redactions > 0, "no redactions in snapshot"); (match_id, player_id) } _ => unreachable!(), }; if !program.is_empty() { c.send(&ClientMessage::EditRuneProgram { tokens: program.to_vec() })?; } let mut live_hashes = Vec::new(); let mut saw_filtered = false; let mut live_turn = 0u64; for _ in 0..n_turns { c.send(&ClientMessage::SubmitTurn { turn: live_turn, action: Action::Cast })?; let expected = live_turn + 1; let resolved = c.recv_until(|m| { matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == expected) })?; if let ServerMessage::TurnResolved { turn, runtime_hash, snapshot, .. } = resolved { // Results come back as filtered observations. if snapshot.hidden_state_redactions > 0 { saw_filtered = true; } live_hashes.push((turn, runtime_hash)); live_turn = turn; } } // Pull the replay back and collect its per-turn hashes. c.send(&ClientMessage::RequestReplay { match_id })?; let mut replay_hashes = Vec::new(); let mut final_hash = String::new(); let mut got = 0u32; let mut total = 1u32; while got < total { match c.recv_until(|m| matches!(m, ServerMessage::ReplayChunk { .. }))? { ServerMessage::ReplayChunk { total: t, turns, final_hash: fh, .. } => { total = t.max(1); got += 1; final_hash = fh; for rt in turns { replay_hashes.push((rt.turn, rt.runtime_hash)); } } _ => break, } } Ok(Played { match_id, live_hashes, replay_hashes, final_hash, saw_filtered_snapshot: saw_filtered }) } #[test] fn single_match_full_playthrough() { let addr = spawn_test_server(15); let program = vec![ RuneTokenWire { op: 0, a: 1, b: 2, c: 3, imm: 5 }, RuneTokenWire { op: 5, a: 2, b: 4, c: 1, imm: -3 }, RuneTokenWire { op: 8, a: 0, b: 6, c: 2, imm: 11 }, ]; let played = play_solo(&addr, 4, &program).expect("play"); assert_ne!(played.match_id.0, 0, "a match id was assigned"); assert_eq!(played.live_hashes.len(), 4, "all turns resolved"); assert!(played.saw_filtered_snapshot, "results returned as filtered observations"); assert!(!played.final_hash.is_empty()); // Replay must reproduce every live per-turn hash (0 mismatches). for (turn, live) in &played.live_hashes { let found = played.replay_hashes.iter().find(|(t, _)| t == turn); assert!(found.is_some(), "replay missing turn {turn}"); assert_eq!(&found.unwrap().1, live, "replay hash mismatch at turn {turn}"); } } #[test] fn inspection_returns_observed_diagnostics_only() { let addr = spawn_test_server(50); let mut c = WsClient::connect(&addr).unwrap(); c.send(&ClientMessage::JoinMatch { name: "inspector".into(), match_id: None }).unwrap(); c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); // Inspect the dummy (entity id 2). c.send(&ClientMessage::InspectTarget { target: 2 }).unwrap(); let obs = c.recv_until(|m| matches!(m, ServerMessage::ObservationResult { .. })).unwrap(); if let ServerMessage::ObservationResult { diagnostics, .. } = obs { // Diagnostics are observed names/counts — never guaranteed truth. // unknown_listeners is a count; reads/writes are domain names. for s in diagnostics.known_reads.iter().chain(diagnostics.known_writes.iter()) { assert!(s.chars().any(|ch| ch.is_alphabetic())); } } else { panic!("expected ObservationResult"); } } #[test] fn one_hundred_e2e_matches_zero_hash_mismatches() { // Single short-turn server; 100 independent matches over real sockets. let addr = spawn_test_server(8); let program = vec![ RuneTokenWire { op: 1, a: 3, b: 1, c: 2, imm: 7 }, RuneTokenWire { op: 6, a: 0, b: 5, c: 3, imm: -9 }, ]; let mut mismatches = 0u32; let mut completed = 0u32; for _ in 0..100 { let played = play_solo(&addr, 3, &program).expect("e2e match"); completed += 1; for (turn, live) in &played.live_hashes { match played.replay_hashes.iter().find(|(t, _)| t == turn) { Some((_, rh)) if rh == live => {} _ => mismatches += 1, } } } assert_eq!(completed, 100, "all 100 matches completed"); assert_eq!(mismatches, 0, "replay hash mismatches across 100 e2e matches"); }