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 }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user