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 }))
}