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:
2026-05-18 23:54:10 +02:00
parent 8b3aefd2db
commit a8edcd7450
5 changed files with 1599 additions and 2 deletions
File diff suppressed because it is too large Load Diff
+58 -2
View File
@@ -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 }))
}
+8
View File
@@ -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
+30
View File
@@ -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);