Files
magicka-vm/crates/web_tests/tests/e2e.rs
T
linus-dandClaude Opus 4.8 9d9d5ce41c Add web game (plan2.md) on the independent runtime, merge-blocking gates
Builds the browser game around the existing Rust runtime: a window into the
universe, not a second simulation. Pure std, no external crates.

New crates:
- protocol: versioned, hashable client/server messages + hand-rolled JSON value
  and total parser (malformed packet -> Err, never panic).
- game_runtime: authoritative match state. Resolves turns through the
  INDEPENDENT interpreter (runtime_under_test::native_resolve), not the
  reference engine; filters visibility/knowledge; records and regenerates
  replays. A match is a pure function of (seed, roster, ordered inputs).
- web_assets/web_client: embedded browser client (arena, rune editor,
  knowledge panels, replay viewer) + static HTTP delivery.
- server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing),
  turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock.
- web_tests: dependency-free WebSocket test client + Phase H gates.

Trust hardening per review:
- game_runtime no longer delegates to reference_runtime::execute; it runs the
  independent interpreter that the runtime-equivalence gate proves correct.
- Protocol/socket/replay/visibility/resilience gates are merge-blocking
  (added to the merge_group-required job in merge-gates.yml): 1k matches/0
  drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks.
- Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only
  (continue-on-error, artifacts) until CI infrastructure with a browser exists;
  it is treated as unsatisfied, not green. The headless 100-match gate is
  labeled protocol-level coverage, not rendered-browser coverage.
- README documents the hand-rolled crypto/parser audit risk explicitly.

Fixes an integer-overflow panic in observed-volatility inference (i64 sum /
abs near i64::MIN) that could poison the server mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:20:46 -07:00

146 lines
6.0 KiB
Rust

//! 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<Played> {
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");
}