test(peer-cli): cover full docker scenario matrix
Merge the S18-S36 scenario ideas into the official peer-cli scenario matrix and add a Docker-backed runner that now exercises S1-S36 with concrete file proofs. The runner creates temporary fixtures under .lanspread-peer-cli, drives JSONL peer containers, checks transferred roots with diff and SHA-256 manifests, and covers startup, discovery, transfer, failure, mutation, concurrency, mesh, lifecycle, and catalog edge cases. The scenarios exposed a few harness/runtime boundary gaps that would otherwise make the contract ambiguous. The peer CLI now rejects self-connects, rejects commands for game IDs outside the receiver catalog, filters unknown remote games from its command/event surface, and reports duplicate active same-game commands as operation-in-progress errors. The peer core also refuses non-catalog download commands before transfer, and PeerGameDB has a unit check that address changes preserve identity and library state. S12 and S28 remain unit-level invariants because the CLI cannot stably race raw serve-gate requests or rebind a live listener without restart. The runner treats those scenarios as covered by just test and checks the expected unit test names appear in the output. Test Plan: - just fmt - python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py - RUSTC_WRAPPER= just test - RUSTC_WRAPPER= just clippy - RUSTC_WRAPPER= just peer-cli-build - just peer-cli-image - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py - git diff --check Refs: PEER_CLI_SCENARIOS.md S1-S36
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,7 @@ struct LocalPeer {
|
||||
struct SharedState {
|
||||
state: RwLock<CliState>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@ async fn main() -> eyre::Result<()> {
|
||||
tx_events,
|
||||
peer_game_db.clone(),
|
||||
unpacker,
|
||||
catalog,
|
||||
catalog.clone(),
|
||||
PeerStartOptions {
|
||||
state_dir: Some(args.state_dir.clone()),
|
||||
},
|
||||
@@ -141,6 +142,7 @@ async fn main() -> eyre::Result<()> {
|
||||
let shared = Arc::new(SharedState {
|
||||
state: RwLock::new(CliState::default()),
|
||||
peer_game_db,
|
||||
catalog: catalog.clone(),
|
||||
notify: Notify::new(),
|
||||
});
|
||||
let writer = JsonlWriter::new();
|
||||
@@ -221,6 +223,8 @@ async fn handle_command(
|
||||
game_id,
|
||||
install_after_download,
|
||||
} => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
let files = game_files_for_download(sender, shared, game_id).await?;
|
||||
sender.send(PeerCommand::DownloadGameFilesWithOptions {
|
||||
id: game_id.clone(),
|
||||
@@ -230,12 +234,16 @@ async fn handle_command(
|
||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
||||
}
|
||||
CliCommand::Install { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::InstallGame {
|
||||
id: game_id.clone(),
|
||||
})?;
|
||||
Ok(json!({"queued": true, "game_id": game_id}))
|
||||
}
|
||||
CliCommand::Uninstall { game_id } => {
|
||||
ensure_catalog_game(shared, game_id).await?;
|
||||
ensure_no_active_operation(shared, game_id).await?;
|
||||
sender.send(PeerCommand::UninstallGame {
|
||||
id: game_id.clone(),
|
||||
})?;
|
||||
@@ -243,6 +251,7 @@ async fn handle_command(
|
||||
}
|
||||
CliCommand::WaitPeers { count, timeout } => wait_peers(shared, *count, *timeout).await,
|
||||
CliCommand::Connect { addr } => {
|
||||
ensure_not_self_connect(shared, *addr).await?;
|
||||
sender.send(PeerCommand::ConnectPeer(*addr))?;
|
||||
Ok(json!({"queued": true, "addr": addr.to_string()}))
|
||||
}
|
||||
@@ -275,7 +284,15 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
|
||||
|
||||
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||
let state = shared.state.read().await;
|
||||
let remote = shared.peer_game_db.read().await.get_all_games();
|
||||
let catalog = shared.catalog.read().await.clone();
|
||||
let remote = shared
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.get_all_games()
|
||||
.into_iter()
|
||||
.filter(|game| catalog.contains(&game.id))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"local": state.local_games.clone(),
|
||||
"remote": remote,
|
||||
@@ -283,6 +300,40 @@ async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||
if shared.catalog.read().await.contains(game_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eyre::bail!("game {game_id} is not in the local catalog");
|
||||
}
|
||||
|
||||
async fn ensure_no_active_operation(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||
let state = shared.state.read().await;
|
||||
if state
|
||||
.active_operations
|
||||
.iter()
|
||||
.any(|operation| operation.id == game_id)
|
||||
{
|
||||
eyre::bail!("operation already in progress for game {game_id}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_not_self_connect(shared: &SharedState, addr: SocketAddr) -> eyre::Result<()> {
|
||||
let state = shared.state.read().await;
|
||||
if state
|
||||
.local_peer
|
||||
.as_ref()
|
||||
.is_some_and(|peer| peer.addr == addr.to_string())
|
||||
{
|
||||
eyre::bail!("cannot connect peer to itself at {addr}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_peers(shared: &SharedState, count: usize, timeout: Duration) -> eyre::Result<Value> {
|
||||
let wait = async {
|
||||
loop {
|
||||
@@ -356,6 +407,11 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
||||
("local-peer-ready", json!(local_peer))
|
||||
}
|
||||
PeerEvent::ListGames(games) => {
|
||||
let catalog = shared.catalog.read().await.clone();
|
||||
let games = games
|
||||
.into_iter()
|
||||
.filter(|game| catalog.contains(&game.id))
|
||||
.collect::<Vec<_>>();
|
||||
shared.state.write().await.remote_games = games.clone();
|
||||
("list-games", json!({ "games": games }))
|
||||
}
|
||||
|
||||
@@ -198,6 +198,14 @@ pub async fn handle_download_game_files_command(
|
||||
install_after_download: bool,
|
||||
) {
|
||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
log::warn!("Ignoring download command for non-catalog game {id}");
|
||||
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
|
||||
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let games_folder = { ctx.game_dir.read().await.clone() };
|
||||
|
||||
// Use majority validation to get trusted file descriptions and peer whitelist
|
||||
|
||||
@@ -948,6 +948,36 @@ mod tests {
|
||||
assert_eq!(db.peer_id_for_transport_addr(&source), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_update_preserves_peer_identity_and_library() {
|
||||
let old_addr = ip_addr([10, 66, 0, 2], 40000);
|
||||
let new_addr = ip_addr([10, 66, 0, 3], 41000);
|
||||
let mut db = PeerGameDB::new();
|
||||
|
||||
let first = db.upsert_peer("peer".to_string(), old_addr);
|
||||
assert!(first.is_new);
|
||||
db.update_peer_games(
|
||||
&"peer".to_string(),
|
||||
vec![summary("game", "20250101", Availability::Ready)],
|
||||
);
|
||||
|
||||
let second = db.upsert_peer("peer".to_string(), new_addr);
|
||||
assert!(!second.is_new);
|
||||
assert!(second.addr_changed);
|
||||
|
||||
let peers = db.peer_snapshots();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].peer_id, "peer");
|
||||
assert_eq!(peers[0].addr, new_addr);
|
||||
assert_eq!(peers[0].games.len(), 1);
|
||||
assert_eq!(peers[0].games[0].id, "game");
|
||||
assert_eq!(db.peer_id_for_addr(&old_addr), None);
|
||||
assert_eq!(
|
||||
db.peer_id_for_addr(&new_addr).map(String::as_str),
|
||||
Some("peer")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_uses_latest_version_file_metadata() {
|
||||
let old_addr = addr(12003);
|
||||
|
||||
Reference in New Issue
Block a user