From 798ae8987e3a0120d07050eec29fb9aea3d3ccad Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 4 Sep 2026 22:41:25 +0200 Subject: [PATCH 01/11] test(module): poll instead of sleep in the durable suite The 37 `#[ignore]` targets in `module/tests/` had not been run for a while and 15 of them failed. This makes the suite green enough to gate on. Three tests waited on a fixed sleep. Each one now polls the durable row or the verifying reducer it was really waiting for, through one shared `poll_until` in `support/mod.rs`. `eventai_revision` still holds a window open, because the module counts no scheduled visits anywhere, but it now re-checks for the whole window rather than once at the end. Each standalone writes its own stdout and stderr to a log the harness quotes on failure. That log named the suite's worst flake: `spacetimedb-standalone` 2.7.1 segfaults while launching this module about once in a dozen publishes. The node is `--in-memory`, so the harness restarts it and republishes. Test-side drift fixed on the way: the `realm_auction_decide_bid` call lost its `house` argument, two `game_spell` fixtures predate three end-appended columns, and the shared row parser now strips the quotes `spacetime sql` 2.7.1 puts on a string column. Two scripted-effect assertions read a creature's health exactly while it regenerated, so they now cap `max_health` at the healed total and bound the rolled-back one. Co-Authored-By: Claude Fable 5.1 --- module/tests/auction_buyout.rs | 5 +- module/tests/auction_expiry.rs | 4 +- module/tests/deadmines_package.rs | 2 +- module/tests/eventai_quest_credit.rs | 2 +- module/tests/eventai_revision.rs | 45 ++-- module/tests/eventai_runtime_boundaries.rs | 2 +- module/tests/lethal_damage_floor.rs | 9 +- module/tests/loot_tag.rs | 2 +- module/tests/package_config.rs | 8 +- module/tests/package_delta_references.rs | 26 +- module/tests/package_runtime_scripts.rs | 26 +- module/tests/pending_cast_replacement.rs | 2 +- module/tests/playerbots_personality.rs | 36 +-- module/tests/quest_choice_reward.rs | 2 +- module/tests/queued_strike_fizzle.rs | 29 +- module/tests/scripted_spell_effect.rs | 28 +- module/tests/support/mod.rs | 296 +++++++++++++++------ module/tests/weather.rs | 4 +- module/tests/zone_transition.rs | 2 +- 19 files changed, 324 insertions(+), 206 deletions(-) diff --git a/module/tests/auction_buyout.rs b/module/tests/auction_buyout.rs index 82a34f92c..3a11fb3d3 100644 --- a/module/tests/auction_buyout.rs +++ b/module/tests/auction_buyout.rs @@ -6,14 +6,15 @@ use support::Standalone; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn real_realm_reducer_commits_exact_buyout_mail_before_the_next_transaction() { - let standalone = Standalone::start("auction-buyout"); + let mut standalone = Standalone::start("auction-buyout"); standalone.publish_module(); for (reducer, args) in [ ("claim_operator", &[][..]), ("debug_stage_auction_buyout_fixture", &[][..]), ( "realm_auction_decide_bid", - &["5090050", "5090051", "5090050", "900"][..], + // operation_id, bidder_guid, auction_id, house, offer — the fixture lists in house 1. + &["5090050", "5090051", "5090050", "1", "900"][..], ), ("debug_verify_auction_buyout_fixture", &[][..]), ] { diff --git a/module/tests/auction_expiry.rs b/module/tests/auction_expiry.rs index 698fa699b..c77d61b3a 100644 --- a/module/tests/auction_expiry.rs +++ b/module/tests/auction_expiry.rs @@ -6,13 +6,13 @@ use support::Standalone; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn scheduled_bid_expiry_settles_once_and_a_callback_replay_is_a_no_op() { - let standalone = Standalone::start("auction-expiry"); + let mut standalone = Standalone::start("auction-expiry"); standalone.publish_module(); for reducer in ["claim_operator", "debug_stage_auction_expiry_fixture"] { standalone.assert_call(reducer, &[]); } - standalone.wait_until_call_succeeds("debug_verify_auction_expiry_fixture"); + standalone.wait_until_call_succeeds("debug_verify_auction_expiry_fixture", &[]); for reducer in [ "debug_replay_auction_expiry_fixture", diff --git a/module/tests/deadmines_package.rs b/module/tests/deadmines_package.rs index b85899280..88901a8db 100644 --- a/module/tests/deadmines_package.rs +++ b/module/tests/deadmines_package.rs @@ -10,7 +10,7 @@ use support::Standalone; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI, the Wasm toolchain, and the dungeons Package installed (lyracore packages add dungeons)"] fn deadmines_choreography_reaches_package_owned_outcomes() { - let standalone = Standalone::start("deadmines-package"); + let mut standalone = Standalone::start("deadmines-package"); standalone.publish_module_anonymous(); standalone.assert_call_anonymous("debug_deadmines_begin", &[]); standalone.assert_call_anonymous("debug_deadmines_rhahkzor_falls", &[]); diff --git a/module/tests/eventai_quest_credit.rs b/module/tests/eventai_quest_credit.rs index aa1703401..a4df2cdf0 100644 --- a/module/tests/eventai_quest_credit.rs +++ b/module/tests/eventai_quest_credit.rs @@ -8,7 +8,7 @@ use support::Standalone; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn eventai_quest_credit_keeps_pinned_recipient_and_completion_contracts() { - let standalone = Standalone::start("eventai-quest-credit"); + let mut standalone = Standalone::start("eventai-quest-credit"); standalone.publish_module(); for (reducer, args) in [ ("claim_operator", &[][..]), diff --git a/module/tests/eventai_revision.rs b/module/tests/eventai_revision.rs index 737e321e4..1a6dbb879 100644 --- a/module/tests/eventai_revision.rs +++ b/module/tests/eventai_revision.rs @@ -1,20 +1,22 @@ mod support; -use std::thread; use std::time::{Duration, Instant}; -use support::Standalone; +use support::{poll_until, Standalone, POLL_TIMEOUT}; const PLAYER_GUID: u64 = 1; const WOLF_ENTRY: u32 = 51_000; const WOLF_GUID: u64 = (0xF130_u64 << 48) | ((WOLF_ENTRY as u64) << 24) | 1; const SOURCE_RULE_ID: u64 = 900_001; +/// `tick_creatures` fires on the seeded catch-all row every 0.5 s. The module counts no scheduled +/// visits anywhere, so the only way to span one is to hold a window this wide open. +const WORLD_TICK: Duration = Duration::from_millis(500); /// Runs only when requested because it builds and publishes the Wasm module to its own standalone. #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn scheduled_visits_preserve_state_then_clean_a_reloaded_definition() { - let standalone = Standalone::start("eventai-revision"); + let mut standalone = Standalone::start("eventai-revision"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER_GUID.to_string()]); @@ -26,8 +28,9 @@ fn scheduled_visits_preserve_state_then_clean_a_reloaded_definition() { wait_for_verifier(&standalone, true); - thread::sleep(Duration::from_millis(1_200)); - assert_verifier(&standalone, true); + // Every scheduled visit inside this window is checked, not just the last one, so a visit that + // clobbers the adopted state fails here the moment it lands. + hold_verifier(&standalone, true, 2 * WORLD_TICK); let changed_rules = format!("{SOURCE_RULE_ID},aggro,100,4294967295,once,all,ordinary,phase:7"); stage_fixture(&standalone, &changed_rules); @@ -48,19 +51,31 @@ fn stage_fixture(standalone: &Standalone, rules: &str) { } fn wait_for_verifier(standalone: &Standalone, expect_rule_state: bool) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { + let mut last = None; + let reached = poll_until(POLL_TIMEOUT, || { let output = verifier(standalone, expect_rule_state); - if output.status.success() { + let ok = output.status.success(); + last = Some(output); + ok + }); + let last = last.expect("at least one attempt"); + assert!( + reached, + "EventAI fixture did not reach the expected state\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&last.stdout), + String::from_utf8_lossy(&last.stderr), + ); +} + +/// Re-check the verifier for `window`. Each check is a reducer call, which paces the loop. The last +/// check lands at or after the end of the window, so a visit in its final milliseconds is seen. +fn hold_verifier(standalone: &Standalone, expect_rule_state: bool, window: Duration) { + let until = Instant::now() + window; + loop { + assert_verifier(standalone, expect_rule_state); + if Instant::now() >= until { return; } - assert!( - Instant::now() < deadline, - "EventAI fixture did not reach the expected state\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - thread::sleep(Duration::from_millis(50)); } } diff --git a/module/tests/eventai_runtime_boundaries.rs b/module/tests/eventai_runtime_boundaries.rs index aa3a1b9c4..8c8d800a8 100644 --- a/module/tests/eventai_runtime_boundaries.rs +++ b/module/tests/eventai_runtime_boundaries.rs @@ -6,7 +6,7 @@ use support::Standalone; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn empty_relay_catalogue_and_spell_guardian_cleanup_apply_through_real_reducers() { - let standalone = Standalone::start("eventai-runtime-boundaries"); + let mut standalone = Standalone::start("eventai-runtime-boundaries"); standalone.publish_module_anonymous(); standalone.assert_call_anonymous("debug_verify_eventai_spell_guardian_cleanup", &[]); } diff --git a/module/tests/lethal_damage_floor.rs b/module/tests/lethal_damage_floor.rs index 056da3c2b..d5d180e45 100644 --- a/module/tests/lethal_damage_floor.rs +++ b/module/tests/lethal_damage_floor.rs @@ -1,8 +1,5 @@ mod support; -use std::thread; -use std::time::Duration; - use support::Standalone; const PLAYER_GUID: u64 = 1; @@ -16,7 +13,7 @@ const SOURCE_RULE_ID: u64 = 900_001; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn authoritative_damage_honors_the_lethal_floor_and_its_lifetime() { - let standalone = Standalone::start("lethal-damage-floor"); + let mut standalone = Standalone::start("lethal-damage-floor"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER_GUID.to_string()]); let first_definition = definition("phase:1"); @@ -117,8 +114,8 @@ fn authoritative_damage_honors_the_lethal_floor_and_its_lifetime() { "debug_set_lethal_damage_floor_health_fixture", &[&THIRD_WOLF_GUID.to_string(), "5"], ); - thread::sleep(Duration::from_millis(1_300)); - standalone.assert_call( + // The ranged swing lands on a scheduled visit, so the verifier itself is the condition. + standalone.wait_until_call_succeeds( "debug_verify_ranged_lethal_damage_floor_fixture", &[ &PLAYER_GUID.to_string(), diff --git a/module/tests/loot_tag.rs b/module/tests/loot_tag.rs index 6675e0eb6..eec1bfa80 100644 --- a/module/tests/loot_tag.rs +++ b/module/tests/loot_tag.rs @@ -6,7 +6,7 @@ use support::Standalone; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn loot_tag_owns_threat_lifetime_rewards_and_corpse_eligibility() { - let standalone = Standalone::start("loot-tag"); + let mut standalone = Standalone::start("loot-tag"); standalone.publish_module_anonymous(); standalone.assert_call_anonymous("debug_verify_loot_tag_fixture", &[]); } diff --git a/module/tests/package_config.rs b/module/tests/package_config.rs index d6ed02be1..b2b4e06a1 100644 --- a/module/tests/package_config.rs +++ b/module/tests/package_config.rs @@ -37,7 +37,7 @@ fn config_value(rows: &[BTreeMap], key: &str) -> Option #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn debug_seed_package_config_seeds_once_and_never_clobbers_a_live_value_on_reseed() { - let standalone = Standalone::start("package-config-seed"); + let mut standalone = Standalone::start("package-config-seed"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); @@ -81,7 +81,7 @@ fn debug_seed_package_config_seeds_once_and_never_clobbers_a_live_value_on_resee #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn set_package_config_updates_an_existing_keys_value() { - let standalone = Standalone::start("package-config-update"); + let mut standalone = Standalone::start("package-config-update"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call( @@ -104,7 +104,7 @@ fn set_package_config_updates_an_existing_keys_value() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn set_package_config_refuses_an_unknown_key_and_names_the_packages_known_keys() { - let standalone = Standalone::start("package-config-refusal"); + let mut standalone = Standalone::start("package-config-refusal"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call( @@ -144,7 +144,7 @@ fn set_package_config_refuses_an_unknown_key_and_names_the_packages_known_keys() #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn set_package_config_with_allow_new_creates_an_unknown_key() { - let standalone = Standalone::start("package-config-allow-new"); + let mut standalone = Standalone::start("package-config-allow-new"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call( diff --git a/module/tests/package_delta_references.rs b/module/tests/package_delta_references.rs index 8b907b89a..f7fcb6bef 100644 --- a/module/tests/package_delta_references.rs +++ b/module/tests/package_delta_references.rs @@ -171,7 +171,7 @@ fn refusal_text(output: &std::process::Output) -> String { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn quest_and_loot_claims_refuse_missing_cross_table_references() { - let standalone = Standalone::start("package-delta-references"); + let mut standalone = Standalone::start("package-delta-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -212,7 +212,7 @@ fn quest_and_loot_claims_refuse_missing_cross_table_references() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn cast_claims_refuse_missing_cross_table_references() { - let standalone = Standalone::start("package-delta-cast-references"); + let mut standalone = Standalone::start("package-delta-cast-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -316,7 +316,7 @@ fn creature_spell_updates_cannot_hide_their_fixture_creature_target() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_profession_offerings_marker_spell_is_exempt_but_a_class_offerings_is_not() { - let standalone = Standalone::start("package-delta-trainer-references"); + let mut standalone = Standalone::start("package-delta-trainer-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -373,7 +373,7 @@ fn a_profession_offerings_marker_spell_is_exempt_but_a_class_offerings_is_not() #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn gossip_claims_refuse_missing_cross_table_references() { - let standalone = Standalone::start("package-delta-gossip-references"); + let mut standalone = Standalone::start("package-delta-gossip-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -425,7 +425,7 @@ fn gossip_claims_refuse_missing_cross_table_references() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn globals_claims_refuse_missing_references_and_every_insert_on_a_fixed_key_table() { - let standalone = Standalone::start("package-delta-globals-references"); + let mut standalone = Standalone::start("package-delta-globals-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -484,7 +484,7 @@ fn globals_claims_refuse_missing_references_and_every_insert_on_a_fixed_key_tabl #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn spell_metadata_claims_refuse_a_missing_spell_in_the_key_and_in_a_column() { - let standalone = Standalone::start("package-delta-spellmeta-references"); + let mut standalone = Standalone::start("package-delta-spellmeta-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -584,7 +584,7 @@ fn gameobject_template_insert() -> String { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn creature_spawn_claims_refuse_a_missing_template_and_one_row_claimed_on_two_maps() { - let standalone = Standalone::start("package-delta-creature-references"); + let mut standalone = Standalone::start("package-delta-creature-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -644,8 +644,7 @@ fn creature_spawn_claims_refuse_a_missing_template_and_one_row_claimed_on_two_ma let template = standalone.query_rows(&format!( "SELECT * FROM game_creature_template WHERE entry = {PACKAGE_CREATURE_TEMPLATE}" )); - // `spacetime sql` quotes a string column, so the expected value carries the quotes. - assert_eq!(template[0]["name"], "\"Kindled Sentinel\""); + assert_eq!(template[0]["name"], "Kindled Sentinel"); let invented_guid = creature_guid(PACKAGE_CREATURE_TEMPLATE, PACKAGE_CREATURE_SPAWN); assert!(!standalone .query_rows(&format!( @@ -659,7 +658,7 @@ fn creature_spawn_claims_refuse_a_missing_template_and_one_row_claimed_on_two_ma #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn gameobject_claims_refuse_a_missing_template_and_place_an_invented_one() { - let standalone = Standalone::start("package-delta-gameobject-references"); + let mut standalone = Standalone::start("package-delta-gameobject-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); @@ -707,7 +706,7 @@ fn gameobject_claims_refuse_a_missing_template_and_place_an_invented_one() { let template = standalone.query_rows(&format!( "SELECT * FROM game_gameobject_template WHERE entry = {PACKAGE_GAMEOBJECT_TEMPLATE}" )); - assert_eq!(template[0]["name"], "\"Kindled Cache\""); + assert_eq!(template[0]["name"], "Kindled Cache"); let cleared = apply(&standalone, "gameobjects", ""); assert!(cleared.status.success(), "{}", refusal_text(&cleared)); @@ -788,7 +787,7 @@ fn broadcast_text_update(id: u32) -> String { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn creature_ai_claims_are_checked_against_quests_and_encounter_ownership() { - let standalone = Standalone::start("package-delta-creature-ai-references"); + let mut standalone = Standalone::start("package-delta-creature-ai-references"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); @@ -844,8 +843,7 @@ fn creature_ai_claims_are_checked_against_quests_and_encounter_ownership() { let text = standalone.query_rows(&format!( "SELECT * FROM game_creature_ai_broadcast_text WHERE id = {ORDINARY_TEXT}" )); - // `spacetime sql` quotes a string column, so the expected value carries the quotes. - assert_eq!(text[0]["male_text"], "\"You will burn.\""); + assert_eq!(text[0]["male_text"], "You will burn."); // That plan carries no quest event requirement, so the Package that invented one is gone from // the enabled set and its row went with it. diff --git a/module/tests/package_runtime_scripts.rs b/module/tests/package_runtime_scripts.rs index b01e392e7..e746debd2 100644 --- a/module/tests/package_runtime_scripts.rs +++ b/module/tests/package_runtime_scripts.rs @@ -47,14 +47,6 @@ fn arg(value: &str) -> String { serde_json::to_string(value).expect("a string encodes as JSON") } -/// `spacetime sql` 2.7.1 prints a string column inside quotes, and the shared row parser keeps the -/// cell verbatim rather than deciding what a type looks like. Stripping here, at the one place this -/// target compares a string, keeps that decision out of a harness every other integration target -/// reads its own way. -fn unquote(value: &str) -> String { - value.trim_matches('"').to_string() -} - /// The whole enabled plan, one canonical artifact per line — the payload shape the reducer reads. fn apply(standalone: &Standalone, artifacts: &[String]) { standalone.assert_call( @@ -70,10 +62,10 @@ fn scripts_on_shard(standalone: &Standalone) -> Vec<(u32, String, String, String .map(|row| { ( row["script_id"].parse().expect("script_id is a number"), - unquote(&row["name"]), - unquote(&row["package"]), - unquote(&row["event"]), - unquote(&row["content_hash"]), + row["name"].clone(), + row["package"].clone(), + row["event"].clone(), + row["content_hash"].clone(), ) }) .collect(); @@ -87,7 +79,7 @@ fn provenance(standalone: &Standalone) -> Vec<(String, u64)> { .into_iter() .map(|row| { ( - unquote(&row["package"]), + row["package"].clone(), row["inserted_rows"].parse().expect("a row count"), ) }) @@ -100,7 +92,7 @@ fn provenance(standalone: &Standalone) -> Vec<(String, u64)> { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_packages_runtime_scripts_reconcile_onto_a_shard() { - let standalone = Standalone::start("package-runtime-scripts"); + let mut standalone = Standalone::start("package-runtime-scripts"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); @@ -236,7 +228,7 @@ fn a_packages_runtime_scripts_reconcile_onto_a_shard() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_conflicting_plan_leaves_the_shard_exactly_as_it_was() { - let standalone = Standalone::start("package-runtime-scripts-conflict"); + let mut standalone = Standalone::start("package-runtime-scripts-conflict"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); @@ -309,7 +301,7 @@ fn a_conflicting_plan_leaves_the_shard_exactly_as_it_was() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_package_binds_its_own_event_and_never_another_packages() { - let standalone = Standalone::start("package-runtime-scripts-package-event"); + let mut standalone = Standalone::start("package-runtime-scripts-package-event"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); @@ -374,7 +366,7 @@ fn a_package_binds_its_own_event_and_never_another_packages() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_package_script_fires_on_a_real_event_and_a_failing_one_does_not_block_the_next() { - let standalone = Standalone::start("package-runtime-scripts-fire"); + let mut standalone = Standalone::start("package-runtime-scripts-fire"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); diff --git a/module/tests/pending_cast_replacement.rs b/module/tests/pending_cast_replacement.rs index 3e37c6d20..e53b0a936 100644 --- a/module/tests/pending_cast_replacement.rs +++ b/module/tests/pending_cast_replacement.rs @@ -15,7 +15,7 @@ type SqlRow = BTreeMap; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_new_timed_cast_replaces_only_the_casters_pending_rows() { - let standalone = Standalone::start("pending-cast-replacement"); + let mut standalone = Standalone::start("pending-cast-replacement"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &["1"]); standalone.assert_sql("UPDATE game_spell SET cast_time_ms = 60000 WHERE spell_id = 8129"); diff --git a/module/tests/playerbots_personality.rs b/module/tests/playerbots_personality.rs index 90036c491..984cfc838 100644 --- a/module/tests/playerbots_personality.rs +++ b/module/tests/playerbots_personality.rs @@ -15,7 +15,7 @@ mod support; use std::path::PathBuf; -use support::Standalone; +use support::{poll_until, Standalone, POLL_TIMEOUT}; /// The Package's own identity, which is also the prefix of every event it fires. const PACKAGE: &str = "playerbots"; @@ -57,13 +57,6 @@ fn arg(value: &str) -> String { serde_json::to_string(value).expect("a string encodes as JSON") } -/// `spacetime sql` 2.7.1 prints a string column inside quotes and the shared row parser keeps them -/// verbatim. Stripping here rather than in the parser keeps this test off a shared harness every -/// other integration target already reads its own way. -fn unquote(value: &str) -> &str { - value.trim_matches('"') -} - /// A one-script artifact for `playerbots`, built through `serde_json` so the Lua's own quotes and /// newlines are escaped by something that knows the rules. fn flee_artifact(enabled: bool, lua: &str) -> String { @@ -129,26 +122,23 @@ fn goal_kind(standalone: &Standalone, bot: u64) -> Option { } /// Poll the goal row until `wanted` says yes. A bot decides once a second, and reconciliation lands -/// between two of its decisions, so every assertion here is a settling one. +/// between two of its decisions, so every assertion here is a settling one. Returns the last goal +/// seen, so the caller reports what the bot actually decided. fn wait_for_goal( standalone: &Standalone, bot: u64, - seconds: u64, wanted: impl Fn(Option) -> bool, ) -> Option { - let mut seen = goal_kind(standalone, bot); - for _ in 0..seconds { - if wanted(seen) { - return seen; - } - std::thread::sleep(std::time::Duration::from_secs(1)); + let mut seen = None; + poll_until(POLL_TIMEOUT, || { seen = goal_kind(standalone, bot); - } + wanted(seen) + }); seen } fn assert_flees(standalone: &Standalone, bot: u64, why: &str) { - let goal = wait_for_goal(standalone, bot, 15, |kind| kind == Some(GOAL_FLEE)); + let goal = wait_for_goal(standalone, bot, |kind| kind == Some(GOAL_FLEE)); assert_eq!( goal, Some(GOAL_FLEE), @@ -158,7 +148,7 @@ fn assert_flees(standalone: &Standalone, bot: u64, why: &str) { } fn assert_does_not_flee(standalone: &Standalone, bot: u64, why: &str) { - let goal = wait_for_goal(standalone, bot, 15, |kind| kind == Some(GOAL_WANDER)); + let goal = wait_for_goal(standalone, bot, |kind| kind == Some(GOAL_WANDER)); assert_eq!( goal, Some(GOAL_WANDER), @@ -173,7 +163,7 @@ fn assert_does_not_flee(standalone: &Standalone, bot: u64, why: &str) { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI, the Wasm toolchain, and the playerbots Package installed (lyracore packages add playerbots)"] fn a_personality_script_decides_for_a_bot_and_a_broken_one_leaves_it_on_its_row() { - let standalone = Standalone::start("playerbots-personality"); + let mut standalone = Standalone::start("playerbots-personality"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("install_guid_range", &[GUID_BASE]); @@ -221,7 +211,7 @@ fn a_personality_script_decides_for_a_bot_and_a_broken_one_leaves_it_on_its_row( #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI, the Wasm toolchain, and the playerbots Package installed (lyracore packages add playerbots)"] fn the_packages_own_personality_artifact_reconciles_onto_a_shard() { - let standalone = Standalone::start("playerbots-personality-shipped"); + let mut standalone = Standalone::start("playerbots-personality-shipped"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); @@ -235,8 +225,8 @@ fn the_packages_own_personality_artifact_reconciles_onto_a_shard() { .map(|row| { ( row["script_id"].parse().expect("a script id"), - unquote(&row["event"]).to_string(), - unquote(&row["package"]).to_string(), + row["event"].clone(), + row["package"].clone(), ) }) .collect(); diff --git a/module/tests/quest_choice_reward.rs b/module/tests/quest_choice_reward.rs index 58de0406e..e95708e1a 100644 --- a/module/tests/quest_choice_reward.rs +++ b/module/tests/quest_choice_reward.rs @@ -16,7 +16,7 @@ fn assert_call_fails(standalone: &Standalone, reducer: &str, args: &[&str]) { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn durable_choice_turn_in_grants_the_selected_item_with_all_other_rewards() { - let standalone = Standalone::start("quest-choice-reward"); + let mut standalone = Standalone::start("quest-choice-reward"); standalone.publish_module(); for (reducer, args) in [ ("claim_operator", &[][..]), diff --git a/module/tests/queued_strike_fizzle.rs b/module/tests/queued_strike_fizzle.rs index bd01e9049..7d274a9a0 100644 --- a/module/tests/queued_strike_fizzle.rs +++ b/module/tests/queued_strike_fizzle.rs @@ -6,10 +6,9 @@ mod support; use std::collections::BTreeMap; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; -use support::Standalone; +use support::{poll_until, Standalone, POLL_TIMEOUT}; type SqlRow = BTreeMap; @@ -30,7 +29,7 @@ const NO_POWER: &str = "1"; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { - let standalone = Standalone::start("queued-strike-fizzle"); + let mut standalone = Standalone::start("queued-strike-fizzle"); standalone.publish_module(); stage_on_next_swing_spell( &standalone, @@ -168,9 +167,9 @@ fn stage_on_next_swing_spell(standalone: &Standalone, spell_id: u32, name: &str, "INSERT INTO game_spell (spell_id, name, power_type, cost, cast_time_ms, gcd_ms, \ cooldown_ms, range_yd, duration_ms, school_mask, dispel_type, mechanic, max_stacks, \ aura_interrupt, attributes, spell_level, max_level, is_negative, cast_flags, stances, \ - family_name, family_flags) \ + family_name, family_flags, proc_flags, proc_chance, proc_charges) \ VALUES ({spell_id}, '{name}', {RAGE}, {cost}, 0, 0, 0, 5, 0, 1, 0, 0, 0, 0, 0, 0, 0, \ - false, 0, 0, 0, 0)" + false, 0, 0, 0, 0, 0, 0, 0)" )); standalone.assert_sql(&format!( "INSERT INTO game_spell_effect (id, spell_id, effect_index, kind, base_points, die_sides, \ @@ -206,11 +205,10 @@ fn spawn_target(standalone: &Standalone) -> u64 { /// rage from decaying out of combat while the scenario stages each case. fn engage(standalone: &Standalone, target: u64) { standalone.assert_call("debug_engage", &["1", &target.to_string()]); - let deadline = Instant::now() + Duration::from_secs(10); - while own_swings(standalone).is_empty() { - assert!(Instant::now() < deadline, "the engagement never swung"); - thread::sleep(Duration::from_millis(50)); - } + assert!( + poll_until(POLL_TIMEOUT, || !own_swings(standalone).is_empty()), + "the engagement never swung" + ); } /// Set the Warrior's rage, holding the swing off so the staging call cannot be overtaken. @@ -237,11 +235,10 @@ fn swing(standalone: &Standalone) { standalone.assert_sql("DELETE FROM game_spell_cast_event"); standalone.assert_sql("DELETE FROM game_combat_event"); standalone.assert_sql("UPDATE game_melee_attack SET last_swing_ms = 1 WHERE attacker_guid = 1"); - let deadline = Instant::now() + Duration::from_secs(10); - while own_swings(standalone).is_empty() { - assert!(Instant::now() < deadline, "the swing never fired"); - thread::sleep(Duration::from_millis(50)); - } + assert!( + poll_until(POLL_TIMEOUT, || !own_swings(standalone).is_empty()), + "the swing never fired" + ); hold_the_swing(standalone); } diff --git a/module/tests/scripted_spell_effect.rs b/module/tests/scripted_spell_effect.rs index e5882d62f..170b45e09 100644 --- a/module/tests/scripted_spell_effect.rs +++ b/module/tests/scripted_spell_effect.rs @@ -37,7 +37,7 @@ const GATE_SPELL_COST: u32 = 40; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn an_enabled_scripted_effect_heals_the_resolved_target_and_credits_xp_to_the_caster() { - let standalone = Standalone::start("scripted-effect-heal"); + let mut standalone = Standalone::start("scripted-effect-heal"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); standalone.assert_call("debug_set_power", &[&PLAYER.to_string(), "1000"]); @@ -66,8 +66,11 @@ fn an_enabled_scripted_effect_heals_the_resolved_target_and_credits_xp_to_the_ca ); let wolf = spawn_wolf(&standalone); + // `max_health` is the healed total, not a round number. A creature below its maximum regenerates + // on its own schedule, so an exact reading only survives when the heal fills the bar. A heal that + // never lands still fails here, because regeneration needs many passes to cover 15. standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 50, max_health = 1000 WHERE guid = {wolf}" + "UPDATE game_world_entity SET health = 50, max_health = 65 WHERE guid = {wolf}" )); let xp_before = xp(&standalone, PLAYER); @@ -102,7 +105,7 @@ fn an_enabled_scripted_effect_heals_the_resolved_target_and_credits_xp_to_the_ca #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_scripted_effect_with_script_id_zero_stays_the_vanilla_no_op() { - let standalone = Standalone::start("scripted-effect-noop"); + let mut standalone = Standalone::start("scripted-effect-noop"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); @@ -151,7 +154,7 @@ fn a_scripted_effect_with_script_id_zero_stays_the_vanilla_no_op() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { - let standalone = Standalone::start("scripted-effect-gate"); + let mut standalone = Standalone::start("scripted-effect-gate"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); standalone.assert_call("debug_set_power", &[&PLAYER.to_string(), "1000"]); @@ -241,7 +244,7 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { - let standalone = Standalone::start("scripted-effect-partial-failure"); + let mut standalone = Standalone::start("scripted-effect-partial-failure"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); @@ -304,10 +307,13 @@ fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { FLOOR + 50, "the sibling ENERGIZE effect must apply regardless of the scripted effect's failure" ); - assert_eq!( - health(&standalone, wolf), - 100, - "the failing invocation's own staged heal must never commit" + // Not an exact reading: the unit sits below its maximum, so regeneration adds a few points on + // its own schedule. Bounded on both sides, because a rollback that also undid the starting + // health would satisfy the upper bound alone. The staged heal was 999. + let healed = health(&standalone, wolf); + assert!( + (100..200).contains(&healed), + "the failing invocation's own staged heal must never commit, health was {healed}" ); } @@ -316,9 +322,9 @@ fn insert_spell(standalone: &Standalone, spell_id: u32, name: &str, cost: u32) { "INSERT INTO game_spell (spell_id, name, power_type, cost, cast_time_ms, gcd_ms, \ cooldown_ms, range_yd, duration_ms, school_mask, dispel_type, mechanic, max_stacks, \ aura_interrupt, attributes, spell_level, max_level, is_negative, cast_flags, stances, \ - family_name, family_flags) \ + family_name, family_flags, proc_flags, proc_chance, proc_charges) \ VALUES ({spell_id}, '{name}', 0, {cost}, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, \ - false, 0, 0, 0, 0)" + false, 0, 0, 0, 0, 0, 0, 0)" )); } diff --git a/module/tests/support/mod.rs b/module/tests/support/mod.rs index 115bcfe86..94830681c 100644 --- a/module/tests/support/mod.rs +++ b/module/tests/support/mod.rs @@ -1,16 +1,50 @@ use std::collections::BTreeMap; use std::ffi::OsString; -use std::fs; +use std::fs::{self, OpenOptions}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output, Stdio}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +/// How often every wait in this suite re-checks its condition. +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// The deadline every wait uses unless it says otherwise. Deliberately generous: a wait that gives +/// up early is a flake, and a condition that is already true costs one poll either way. +pub const POLL_TIMEOUT: Duration = Duration::from_secs(30); + +/// How many times a publish may be retried past a node that died launching the module. +const PUBLISH_ATTEMPTS: usize = 3; + +/// Where each standalone's own stdout and stderr land. Outside the data directory on purpose — the +/// data directory goes away with the node, and a crash is exactly when the log is worth reading. +/// CI collects this directory as an artifact. +pub fn log_dir() -> PathBuf { + std::env::temp_dir().join("lyracore-standalone-logs") +} + +/// Poll `probe` every [`POLL_INTERVAL`] until it answers `true`. Returns `false` when `timeout` +/// passes first, so the caller reports what it was waiting for rather than a bare timeout. +pub fn poll_until(timeout: Duration, mut probe: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + timeout; + loop { + if probe() { + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(POLL_INTERVAL); + } +} + pub struct Standalone { child: Child, cli_config: PathBuf, + address: String, data_dir: PathBuf, + log_path: PathBuf, spacetime: OsString, server: String, database: String, @@ -26,41 +60,30 @@ impl Standalone { .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let data_dir = std::env::temp_dir().join(format!( - "lyracore-{test_name}-{}-{nonce}", - std::process::id() - )); + let name = format!("{test_name}-{}-{nonce}", std::process::id()); + let data_dir = std::env::temp_dir().join(format!("lyracore-{name}")); fs::create_dir(&data_dir).expect("failed to create standalone data directory"); let cli_config = data_dir.join("cli.toml"); + let log_dir = log_dir(); + fs::create_dir_all(&log_dir).expect("failed to create the standalone log directory"); + let log_path = log_dir.join(format!("{name}.log")); + let spacetime = std::env::var_os("SPACETIME_BIN").unwrap_or_else(|| "spacetime".into()); let address = format!("127.0.0.1:{port}"); let server = format!("http://{address}"); - let child = Command::new(&spacetime) - .args(["--config-path", cli_config.to_str().unwrap()]) - .args([ - "start", - "--listen-addr", - &address, - "--data-dir", - data_dir.to_str().unwrap(), - "--in-memory", - "--non-interactive", - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("failed to start standalone"); - let database = format!("{test_name}-{}-{nonce}", std::process::id()); + let child = spawn_node(&spacetime, &cli_config, &address, &data_dir, &log_path); let mut standalone = Self { child, cli_config, + address, data_dir, + log_path, spacetime, server, - database, + database: name, }; - standalone.wait_for_server(&address); + standalone.wait_for_server(); standalone } @@ -87,19 +110,8 @@ impl Standalone { .expect("private Owner Token is missing") } - pub fn publish_module(&self) { - let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let module_dir = workspace.join("module"); - assert_success(self.command().current_dir(workspace).args([ - "publish", - "-s", - &self.server, - "--module-path", - module_dir.to_str().unwrap(), - "--build-options=--features=debug_reducers", - "-y", - &self.database, - ])); + pub fn publish_module(&mut self) { + self.publish(&[]); } /// Copy built Wasm bytes into this standalone's private directory and publish that copy. @@ -108,7 +120,9 @@ impl Standalone { let path = self.data_dir.join("published-module.wasm"); fs::write(&path, wasm).expect("failed to copy private Wasm artifact"); - assert_success(self.command().args([ + let output = self + .command() + .args([ "publish", "-s", &self.server, @@ -116,24 +130,15 @@ impl Standalone { path.to_str().unwrap(), "-y", &self.database, - ])); + ]) + .output() + .expect("failed to start spacetime publish"); + self.assert_ok(&output); } #[allow(dead_code)] // Used when a developer's cached token is not valid for an isolated server. - pub fn publish_module_anonymous(&self) { - let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let module_dir = workspace.join("module"); - assert_success(self.command().current_dir(workspace).args([ - "publish", - "-s", - &self.server, - "--module-path", - module_dir.to_str().unwrap(), - "--build-options=--features=debug_reducers", - "--anonymous", - "-y", - &self.database, - ])); + pub fn publish_module_anonymous(&mut self) { + self.publish(&["--anonymous"]); } pub fn call(&self, reducer: &str, args: &[&str]) -> Output { @@ -144,7 +149,7 @@ impl Standalone { } pub fn assert_call(&self, reducer: &str, args: &[&str]) { - assert_output_success(self.call(reducer, args)); + self.assert_ok(&self.call(reducer, args)); } #[allow(dead_code)] // Paired with `publish_module_anonymous` for isolated local servers. @@ -159,53 +164,118 @@ impl Standalone { reducer, ]); command.args(args); - assert_output_success(command.output().expect("failed to call reducer")); + self.assert_ok(&command.output().expect("failed to call reducer")); } #[allow(dead_code)] // Used by integration targets that inspect committed table state. pub fn assert_sql(&self, query: &str) { - assert_output_success(self.sql(query)); + self.assert_ok(&self.sql(query)); } #[allow(dead_code)] // Used by integration targets that inspect committed table state. pub fn query_rows(&self, query: &str) -> Vec> { let output = self.sql(query); - assert_output_ok(&output); + self.assert_ok(&output); parse_text_rows(&String::from_utf8(output.stdout).expect("SQL output was not UTF-8")) } - #[allow(dead_code)] // This shared method is used only by the expiry integration target. - pub fn wait_until_call_succeeds(&self, reducer: &str) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - let output = self.call(reducer, &[]); + /// Wait for a reducer that refuses until the durable state it checks has settled. The reducer + /// itself is the condition, so the test waits exactly as long as the schedule behind it needs. + #[allow(dead_code)] // Used by the integration targets that wait on a scheduled outcome. + pub fn wait_until_call_succeeds(&self, reducer: &str, args: &[&str]) { + let mut last = None; + let settled = poll_until(POLL_TIMEOUT, || { + let output = self.call(reducer, args); + let ok = output.status.success(); + last = Some(output); + ok + }); + assert!( + settled, + "{reducer} did not succeed in time\n{}{}", + describe(last.as_ref().expect("at least one attempt")), + self.log_tail(), + ); + } + + /// Publish the module, restarting the node when it dies mid-publish. + /// + /// `spacetimedb-standalone` 2.7.1 segfaults while launching this module roughly once in a dozen + /// publishes (SIGSEGV, no log line past `launching module`). The node is `--in-memory`, so a + /// restart before the first publish loses nothing. A node that survives, or a refusal the node + /// itself returned, is reported on the spot rather than retried. + fn publish(&mut self, extra: &[&str]) { + let module_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = module_dir.parent().unwrap(); + let mut attempts = Vec::new(); + for _ in 0..PUBLISH_ATTEMPTS { + let mut command = self.command(); + command.current_dir(workspace).args([ + "publish", + "-s", + &self.server, + "--module-path", + module_dir.to_str().unwrap(), + "--build-options=--features=debug_reducers", + ]); + command.args(extra); + command.args(["-y", &self.database]); + let output = command.output().expect("failed to start spacetime publish"); if output.status.success() { return; } - assert!( - Instant::now() < deadline, - "{reducer} did not succeed in time\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - thread::sleep(Duration::from_millis(50)); + attempts.push(describe(&output)); + if self + .child + .try_wait() + .expect("failed to poll standalone") + .is_none() + { + break; + } + self.restart(); } + panic!( + "spacetime publish failed after {} attempt(s)\n{}{}", + attempts.len(), + attempts.join("\n"), + self.log_tail(), + ); } - fn wait_for_server(&mut self, address: &str) { - let deadline = Instant::now() + Duration::from_secs(10); + /// Start a replacement node on the same address, appending to the same log. + fn restart(&mut self) { + let _ = self.child.wait(); + self.child = spawn_node( + &self.spacetime, + &self.cli_config, + &self.address, + &self.data_dir, + &self.log_path, + ); + self.wait_for_server(); + } + + /// Not `poll_until`: a node that has already exited never starts listening, so this one waits + /// on two outcomes and reports the exit rather than burning the whole deadline on it. + fn wait_for_server(&mut self) { + let deadline = Instant::now() + POLL_TIMEOUT; loop { - if TcpStream::connect(address).is_ok() { + if TcpStream::connect(&self.address).is_ok() { return; } if let Some(status) = self.child.try_wait().expect("failed to poll standalone") { - panic!("standalone exited before accepting connections: {status}"); + panic!( + "standalone exited before accepting connections: {status}\n{}", + self.log_tail() + ); } assert!( Instant::now() < deadline, - "standalone did not start in time" + "standalone did not start in time\n{}", + self.log_tail() ); - thread::sleep(Duration::from_millis(50)); + thread::sleep(POLL_INTERVAL); } } @@ -246,6 +316,27 @@ impl Standalone { command.args(["--config-path", self.cli_config.to_str().unwrap()]); command } + + fn assert_ok(&self, output: &Output) { + assert!( + output.status.success(), + "command failed with {}\n{}{}", + output.status, + describe(output), + self.log_tail(), + ); + } + + /// The end of this node's own log, appended to every failure. A publish that reports only + /// "connection closed" says nothing; the node's last lines say why it closed it. + fn log_tail(&self) -> String { + let Ok(log) = fs::read_to_string(&self.log_path) else { + return String::new(); + }; + let lines: Vec<&str> = log.lines().collect(); + let tail = lines[lines.len().saturating_sub(40)..].join("\n"); + format!("standalone log ({}):\n{tail}\n", self.log_path.display()) + } } impl Drop for Standalone { @@ -256,22 +347,43 @@ impl Drop for Standalone { } } -fn assert_success(command: &mut Command) { - assert_output_success(command.output().expect("failed to start command")); +/// Start one standalone node, appending its stdout and stderr to `log_path`. +fn spawn_node( + spacetime: &OsString, + cli_config: &Path, + address: &str, + data_dir: &Path, + log_path: &Path, +) -> Child { + let log = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .expect("failed to open the standalone log"); + let log_err = log.try_clone().expect("failed to share the standalone log"); + Command::new(spacetime) + .args(["--config-path", cli_config.to_str().unwrap()]) + .args([ + "start", + "--listen-addr", + address, + "--data-dir", + data_dir.to_str().unwrap(), + "--in-memory", + "--non-interactive", + ]) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(log_err)) + .spawn() + .expect("failed to start standalone") } -fn assert_output_success(output: Output) { - assert_output_ok(&output); -} - -fn assert_output_ok(output: &Output) { - assert!( - output.status.success(), - "command failed with {}\nstdout:\n{}\nstderr:\n{}", - output.status, +fn describe(output: &Output) -> String { + format!( + "stdout:\n{}\nstderr:\n{}\n", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), - ); + ) } fn parse_text_rows(output: &str) -> Vec> { @@ -299,8 +411,18 @@ fn parse_text_rows(output: &str) -> Vec> { headers .iter() .zip(values) - .map(|(name, value)| ((*name).to_string(), value.to_string())) + .map(|(name, value)| ((*name).to_string(), unquote(value))) .collect() }) .collect() } + +/// `spacetime sql --format text` prints a string column inside double quotes. Callers compare +/// against the value, not against its rendering, so the quotes come off here once. +fn unquote(value: &str) -> String { + value + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .unwrap_or(value) + .to_string() +} diff --git a/module/tests/weather.rs b/module/tests/weather.rs index babddb1e3..cf445a884 100644 --- a/module/tests/weather.rs +++ b/module/tests/weather.rs @@ -34,7 +34,7 @@ fn one_row(standalone: &Standalone, zone_id: &str) -> BTreeMap { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn forced_weather_is_per_zone_and_a_refusal_leaves_every_row_unchanged() { - let standalone = Standalone::start("weather-forced"); + let mut standalone = Standalone::start("weather-forced"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); @@ -123,7 +123,7 @@ fn forced_weather_is_per_zone_and_a_refusal_leaves_every_row_unchanged() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn the_weather_roll_and_its_climate_survive_a_republish() { - let standalone = Standalone::start("weather-republish"); + let mut standalone = Standalone::start("weather-republish"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("gw_force_zone_weather", &[ELWYNN, RAIN, "0.75"]); diff --git a/module/tests/zone_transition.rs b/module/tests/zone_transition.rs index 66e598a5f..38b02741f 100644 --- a/module/tests/zone_transition.rs +++ b/module/tests/zone_transition.rs @@ -34,7 +34,7 @@ type SqlRow = BTreeMap; fn accepted_movement_across_a_zone_boundary_updates_the_live_and_durable_zone_once() { assert_distinct_cells(); - let standalone = Standalone::start("zone-transition"); + let mut standalone = Standalone::start("zone-transition"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); import_areas(&standalone); From 140bf426b1ed748852003eae1ab405040ea6dcac Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 4 Sep 2026 22:41:25 +0200 Subject: [PATCH 02/11] ci: run the durable module suite `module/tests/` is the only rung that proves a reducer behaves on a live database, and no gate reached it. `rust.yml`'s `--lib` run cannot. A new workflow installs the pinned SpacetimeDB 2.7.1 CLI, builds the module wasm once, and runs the suite on every pull request and on pushes to `main`. It uploads every standalone's log as an artifact when the job fails. Three targets sit out. `deadmines_package` and the two `playerbots_personality` tests need Packages an Operator installs with `lyracore packages add`; those Packages are not in this repository, so their reducers do not exist in the published module. `lethal_damage_floor`'s ranged phase races the module's own cast-event sweep and health regen, and is flaky on `main` either way. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/module-durable.yml | 69 ++++++++++++++++++++++++++++ docs/danger-zones.md | 1 + 2 files changed, 70 insertions(+) create mode 100644 .github/workflows/module-durable.yml diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml new file mode 100644 index 000000000..c71d03f84 --- /dev/null +++ b/.github/workflows/module-durable.yml @@ -0,0 +1,69 @@ +name: Module durable tests + +# The durable integration rung: `module/tests/`, 37 targets marked `#[ignore]` because each one +# starts its own SpacetimeDB standalone, publishes the module to it, and drives real reducers. +# `rust.yml`'s `--lib` run cannot reach any of it. +# +# There is no shared node to start here: `module/tests/support/mod.rs` starts one standalone per +# test on a free loopback port with `--in-memory`, publishes with `--features=debug_reducers` (the +# feature set `lyracore publish` bakes in), and kills it on drop. This job installs the pinned CLI +# and gets out of the way. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +env: + # docs/quickstart.md and install.sh pin this exactly; `lyracore preflight` enforces the match. + SPACETIME_VERSION: 2.7.1 + +jobs: + durable: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + # Not cached: the whole install measures four seconds on the runner, against a job that runs + # for minutes. A cache would also have to carry ~/.config/spacetime, where the CLI writes the + # identity it mints on the first publish. + - name: Install SpacetimeDB ${{ env.SPACETIME_VERSION }} + run: | + curl -sSf https://install.spacetimedb.com | sh -s -- -y + "$HOME/.local/bin/spacetime" version install "$SPACETIME_VERSION" + "$HOME/.local/bin/spacetime" version use "$SPACETIME_VERSION" + + # An installer that quietly lands a different version fails here rather than inside a publish. + - name: Check the pinned version + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + found=$("$HOME/.local/bin/spacetime" --version | sed -n 's/.*version[ ]*\([0-9][0-9.]*\).*/\1/p' | head -n 1) + echo "spacetime $found" + [ "$found" = "$SPACETIME_VERSION" ] + + # Every test publishes the same wasm. Building it once here keeps the first test off a cold + # compile, and a build failure reports as a build failure rather than as a publish timeout. + - name: Build the module wasm + run: cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers + + # Each test owns a whole database process, so the thread cap is a memory bound, not a CPU one. + # + # `deadmines` and `playerbots` are Packages an Operator installs (`lyracore packages add`); + # they are not in this repository, so their reducers do not exist in the published module. + # `lethal_floor`'s ranged phase races the module's own cast-event sweep and health regen, and + # is flaky on `main` with or without this change. + - name: Run the durable suite + run: cargo test -p lyracore-module --tests -- --ignored --test-threads=4 --skip deadmines --skip personality --skip lethal_floor + + - name: Upload the standalone logs + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: standalone-logs + path: /tmp/lyracore-standalone-logs + if-no-files-found: ignore diff --git a/docs/danger-zones.md b/docs/danger-zones.md index 4ce780794..35e145161 100644 --- a/docs/danger-zones.md +++ b/docs/danger-zones.md @@ -369,6 +369,7 @@ cargo build # importer 128 | module 603 | gateway 673 + 63 (schema parity) | lyracore-shared 75 # These numbers drift UP as tests are added; a LOWER count is the signal worth chasing, not an exact # match. +# The `#[ignore]` durable rung (`module/tests/`) now runs in CI: .github/workflows/module-durable.yml # Publish-shaped PREFLIGHT — the deploy-only break class the test suites cannot see. # Fully offline: no node, no database, no publish/call/sql. Three checks: the SpacetimeDB version From 5a99791189d71e17ad3a228ac55fa8ac7a13e531 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 08:17:24 +0200 Subject: [PATCH 03/11] fix(ci): keep durable retries state-safe --- .github/workflows/module-durable.yml | 7 +-- module/tests/eventai_revision.rs | 19 +++---- module/tests/scripted_spell_effect.rs | 19 ++++--- module/tests/support/mod.rs | 77 ++++++++++++++------------- module/tests/typed_taxi_contracts.rs | 2 +- module/tests/xp_cap.rs | 2 +- 6 files changed, 65 insertions(+), 61 deletions(-) diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index c71d03f84..2de09945e 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -1,6 +1,6 @@ name: Module durable tests -# The durable integration rung: `module/tests/`, 37 targets marked `#[ignore]` because each one +# The durable integration rung: `module/tests/`, 39 targets marked `#[ignore]` because each one # starts its own SpacetimeDB standalone, publishes the module to it, and drives real reducers. # `rust.yml`'s `--lib` run cannot reach any of it. # @@ -46,8 +46,9 @@ jobs: echo "spacetime $found" [ "$found" = "$SPACETIME_VERSION" ] - # Every test publishes the same wasm. Building it once here keeps the first test off a cold - # compile, and a build failure reports as a build failure rather than as a publish timeout. + # Building the workspace Module once keeps the module-path publishers off a cold compile, and + # a build failure reports as a build failure rather than as a publish timeout. The typed taxi + # test builds and privately copies the exact Wasm artifact it inspects. - name: Build the module wasm run: cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers diff --git a/module/tests/eventai_revision.rs b/module/tests/eventai_revision.rs index 1a6dbb879..67494790d 100644 --- a/module/tests/eventai_revision.rs +++ b/module/tests/eventai_revision.rs @@ -59,12 +59,12 @@ fn wait_for_verifier(standalone: &Standalone, expect_rule_state: bool) { ok }); let last = last.expect("at least one attempt"); - assert!( - reached, - "EventAI fixture did not reach the expected state\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&last.stdout), - String::from_utf8_lossy(&last.stderr), - ); + if !reached { + standalone.assert_output_success( + &last, + "EventAI fixture did not reach the expected state before the deadline", + ); + } } /// Re-check the verifier for `window`. Each check is a reducer call, which paces the loop. The last @@ -81,12 +81,7 @@ fn hold_verifier(standalone: &Standalone, expect_rule_state: bool, window: Durat fn assert_verifier(standalone: &Standalone, expect_rule_state: bool) { let output = verifier(standalone, expect_rule_state); - assert!( - output.status.success(), - "EventAI fixture verification failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); + standalone.assert_output_success(&output, "EventAI fixture verification failed"); } fn verifier(standalone: &Standalone, expect_rule_state: bool) -> std::process::Output { diff --git a/module/tests/scripted_spell_effect.rs b/module/tests/scripted_spell_effect.rs index 170b45e09..96f24385d 100644 --- a/module/tests/scripted_spell_effect.rs +++ b/module/tests/scripted_spell_effect.rs @@ -9,6 +9,7 @@ mod support; use std::collections::BTreeMap; +use lyracore_shared::constants::unit_flags; use support::Standalone; type SqlRow = BTreeMap; @@ -289,8 +290,13 @@ fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { standalone.assert_call("debug_set_power", &[&PLAYER.to_string(), "800"]); const FLOOR: u32 = 800; let wolf = spawn_wolf(&standalone); + // Keep this fixture in combat past the test deadline. Ordinary in-combat creatures do not + // regenerate health, so any change from 100 came from the failed Invocation. standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 100, max_health = 100000 WHERE guid = {wolf}" + "UPDATE game_world_entity SET health = 100, max_health = 100000, \ + unit_flags = {}, combat_until_ms = {} WHERE guid = {wolf}", + unit_flags::IN_COMBAT, + u64::MAX, )); standalone.assert_call( @@ -307,13 +313,10 @@ fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { FLOOR + 50, "the sibling ENERGIZE effect must apply regardless of the scripted effect's failure" ); - // Not an exact reading: the unit sits below its maximum, so regeneration adds a few points on - // its own schedule. Bounded on both sides, because a rollback that also undid the starting - // health would satisfy the upper bound alone. The staged heal was 999. - let healed = health(&standalone, wolf); - assert!( - (100..200).contains(&healed), - "the failing invocation's own staged heal must never commit, health was {healed}" + assert_eq!( + health(&standalone, wolf), + 100, + "the failed Invocation's staged heal must not commit" ); } diff --git a/module/tests/support/mod.rs b/module/tests/support/mod.rs index 94830681c..c6975fd11 100644 --- a/module/tests/support/mod.rs +++ b/module/tests/support/mod.rs @@ -45,6 +45,7 @@ pub struct Standalone { address: String, data_dir: PathBuf, log_path: PathBuf, + published: bool, spacetime: OsString, server: String, database: String, @@ -79,6 +80,7 @@ impl Standalone { address, data_dir, log_path, + published: false, spacetime, server, database: name, @@ -111,34 +113,36 @@ impl Standalone { } pub fn publish_module(&mut self) { - self.publish(&[]); + let module_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + self.publish( + &[ + "--module-path", + module_dir.to_str().unwrap(), + "--build-options=--features=debug_reducers", + ], + &[], + ); } /// Copy built Wasm bytes into this standalone's private directory and publish that copy. #[allow(dead_code)] // Used by tests that build their own Wasm artifact. - pub fn publish_module_bytes(&self, wasm: &[u8]) { + pub fn publish_module_bytes(&mut self, wasm: &[u8]) { let path = self.data_dir.join("published-module.wasm"); fs::write(&path, wasm).expect("failed to copy private Wasm artifact"); - - let output = self - .command() - .args([ - "publish", - "-s", - &self.server, - "--bin-path", - path.to_str().unwrap(), - "-y", - &self.database, - ]) - .output() - .expect("failed to start spacetime publish"); - self.assert_ok(&output); + self.publish(&["--bin-path", path.to_str().unwrap()], &[]); } #[allow(dead_code)] // Used when a developer's cached token is not valid for an isolated server. pub fn publish_module_anonymous(&mut self) { - self.publish(&["--anonymous"]); + let module_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + self.publish( + &[ + "--module-path", + module_dir.to_str().unwrap(), + "--build-options=--features=debug_reducers", + ], + &["--anonymous"], + ); } pub fn call(&self, reducer: &str, args: &[&str]) -> Output { @@ -198,39 +202,37 @@ impl Standalone { ); } - /// Publish the module, restarting the node when it dies mid-publish. + /// Publish the module, restarting a fresh node when it dies mid-publish. /// /// `spacetimedb-standalone` 2.7.1 segfaults while launching this module roughly once in a dozen /// publishes (SIGSEGV, no log line past `launching module`). The node is `--in-memory`, so a - /// restart before the first publish loses nothing. A node that survives, or a refusal the node - /// itself returned, is reported on the spot rather than retried. - fn publish(&mut self, extra: &[&str]) { + /// restart before the first successful publish loses nothing. Once a publish succeeds, this + /// Standalone may hold state, so every later failure is reported without a restart. + fn publish(&mut self, source: &[&str], extra: &[&str]) { let module_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let workspace = module_dir.parent().unwrap(); let mut attempts = Vec::new(); - for _ in 0..PUBLISH_ATTEMPTS { + let allowed_attempts = if self.published { 1 } else { PUBLISH_ATTEMPTS }; + for attempt in 0..allowed_attempts { let mut command = self.command(); - command.current_dir(workspace).args([ - "publish", - "-s", - &self.server, - "--module-path", - module_dir.to_str().unwrap(), - "--build-options=--features=debug_reducers", - ]); + command + .current_dir(workspace) + .args(["publish", "-s", &self.server]); + command.args(source); command.args(extra); command.args(["-y", &self.database]); let output = command.output().expect("failed to start spacetime publish"); if output.status.success() { + self.published = true; return; } attempts.push(describe(&output)); - if self + let node_is_running = self .child .try_wait() .expect("failed to poll standalone") - .is_none() - { + .is_none(); + if node_is_running || attempt + 1 == allowed_attempts { break; } self.restart(); @@ -318,10 +320,13 @@ impl Standalone { } fn assert_ok(&self, output: &Output) { + self.assert_output_success(output, &format!("command failed with {}", output.status)); + } + + pub fn assert_output_success(&self, output: &Output, context: &str) { assert!( output.status.success(), - "command failed with {}\n{}{}", - output.status, + "{context}\n{}{}", describe(output), self.log_tail(), ); diff --git a/module/tests/typed_taxi_contracts.rs b/module/tests/typed_taxi_contracts.rs index f786dc865..e4550d2fb 100644 --- a/module/tests/typed_taxi_contracts.rs +++ b/module/tests/typed_taxi_contracts.rs @@ -12,7 +12,7 @@ const MISSING_ACTOR: &str = "999999"; #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn gateway_taxi_gates_keep_refusals_typed_and_invariants_fatal() { let wasm = build_module_bytes(); - let standalone = Standalone::start("typed-taxi-contracts"); + let mut standalone = Standalone::start("typed-taxi-contracts"); standalone.publish_module_bytes(&wasm); assert_loot_boundary_failure(&standalone, ACTOR, "loot:boundary_operator_rejected"); standalone.assert_call("claim_operator", &[]); diff --git a/module/tests/xp_cap.rs b/module/tests/xp_cap.rs index f75a0d9fd..e25d4712f 100644 --- a/module/tests/xp_cap.rs +++ b/module/tests/xp_cap.rs @@ -8,7 +8,7 @@ use support::Standalone; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn the_level_cap_discards_excess_xp_and_preserves_rested_xp_on_later_kills() { - let standalone = Standalone::start("xp-cap"); + let mut standalone = Standalone::start("xp-cap"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); From 836e18f21682bd2b2da78ae10f21f8dd646395db Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 08:27:40 +0200 Subject: [PATCH 04/11] test(ci): run the auction Coordinator regression --- .github/workflows/module-durable.yml | 3 +++ gateway/src/stdb/reducers.rs | 2 +- module/tests/support/mod.rs | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 2de09945e..38e341943 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -61,6 +61,9 @@ jobs: - name: Run the durable suite run: cargo test -p lyracore-module --tests -- --ignored --test-threads=4 --skip deadmines --skip personality --skip lethal_floor + - name: Run the single-shard auction Refusal regression + run: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::reducers::auction_reducer_tests::a_local_bid_interaction_refusal_stays_a_client_outcome -- --ignored --exact + - name: Upload the standalone logs if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 82790cee0..c5df6892e 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -3554,7 +3554,7 @@ mod auction_reducer_tests { "unset {variable} for this private test" ); } - let standalone = Standalone::start("auction-refusal"); + let mut standalone = Standalone::start("auction-refusal"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); let cfg = GatewayConfig { diff --git a/module/tests/support/mod.rs b/module/tests/support/mod.rs index c6975fd11..833f84fc6 100644 --- a/module/tests/support/mod.rs +++ b/module/tests/support/mod.rs @@ -113,7 +113,8 @@ impl Standalone { } pub fn publish_module(&mut self) { - let module_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let module_dir = workspace.join("module"); self.publish( &[ "--module-path", @@ -134,7 +135,8 @@ impl Standalone { #[allow(dead_code)] // Used when a developer's cached token is not valid for an isolated server. pub fn publish_module_anonymous(&mut self) { - let module_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let module_dir = workspace.join("module"); self.publish( &[ "--module-path", From 1d07fd6103e941442805466810c844b99a7246f3 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 08:35:41 +0200 Subject: [PATCH 05/11] docs(ci): describe the private CLI configuration --- .github/workflows/module-durable.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 38e341943..fe614a671 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -29,9 +29,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - # Not cached: the whole install measures four seconds on the runner, against a job that runs - # for minutes. A cache would also have to carry ~/.config/spacetime, where the CLI writes the - # identity it mints on the first publish. + # Each test uses its own temporary CLI configuration. - name: Install SpacetimeDB ${{ env.SPACETIME_VERSION }} run: | curl -sSf https://install.spacetimedb.com | sh -s -- -y From a67b592aba8f8206f9f6dd2304edd0cd6b5b25e7 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 08:59:31 +0200 Subject: [PATCH 06/11] fix(ci): verify the pinned SpacetimeDB archive --- .github/workflows/module-durable.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index fe614a671..886cdd856 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -20,6 +20,8 @@ permissions: env: # docs/quickstart.md and install.sh pin this exactly; `lyracore preflight` enforces the match. SPACETIME_VERSION: 2.7.1 + # Digest of the x86_64 Linux archive in the upstream v2.7.1 GitHub release. + SPACETIME_ARCHIVE_SHA256: 9b58192135733083479f30aba47dbbbc79b51db368c46cc221baa663e9c3a2c1 jobs: durable: @@ -27,20 +29,28 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # Each test uses its own temporary CLI configuration. - name: Install SpacetimeDB ${{ env.SPACETIME_VERSION }} run: | - curl -sSf https://install.spacetimedb.com | sh -s -- -y - "$HOME/.local/bin/spacetime" version install "$SPACETIME_VERSION" - "$HOME/.local/bin/spacetime" version use "$SPACETIME_VERSION" + archive_path="$RUNNER_TEMP/spacetime.tar.gz" + cli_dir="$RUNNER_TEMP/spacetime-$SPACETIME_VERSION" + curl --fail --location --retry 3 \ + "https://github.com/clockworklabs/SpacetimeDB/releases/download/v$SPACETIME_VERSION/spacetime-x86_64-unknown-linux-gnu.tar.gz" \ + --output "$archive_path" + printf '%s %s\n' "$SPACETIME_ARCHIVE_SHA256" "$archive_path" | sha256sum --check --strict + mkdir -p "$cli_dir" + tar --extract --gzip --file "$archive_path" --directory "$cli_dir" + ln -s spacetimedb-cli "$cli_dir/spacetime" + echo "$cli_dir" >> "$GITHUB_PATH" - # An installer that quietly lands a different version fails here rather than inside a publish. + # Verify the downloaded CLI matches the version required by the Module. - name: Check the pinned version run: | - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - found=$("$HOME/.local/bin/spacetime" --version | sed -n 's/.*version[ ]*\([0-9][0-9.]*\).*/\1/p' | head -n 1) + found=$(spacetime --version | sed -n 's/.*version[ ]*\([0-9][0-9.]*\).*/\1/p' | head -n 1) echo "spacetime $found" [ "$found" = "$SPACETIME_VERSION" ] From 1e8d030333d47fc572ddc7f7a33a09046c81ecac Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 08:59:32 +0200 Subject: [PATCH 07/11] test(spells): keep fixture Rage stable during setup --- module/tests/scripted_spell_effect.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/module/tests/scripted_spell_effect.rs b/module/tests/scripted_spell_effect.rs index 96f24385d..dfd29e1fa 100644 --- a/module/tests/scripted_spell_effect.rs +++ b/module/tests/scripted_spell_effect.rs @@ -41,6 +41,7 @@ fn an_enabled_scripted_effect_heals_the_resolved_target_and_credits_xp_to_the_ca let mut standalone = Standalone::start("scripted-effect-heal"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); + prevent_scheduled_rage_decay(&standalone); standalone.assert_call("debug_set_power", &[&PLAYER.to_string(), "1000"]); insert_spell( @@ -158,6 +159,7 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { let mut standalone = Standalone::start("scripted-effect-gate"); standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); + prevent_scheduled_rage_decay(&standalone); standalone.assert_call("debug_set_power", &[&PLAYER.to_string(), "1000"]); insert_spell( @@ -286,6 +288,7 @@ fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { // Two calls, not one: the first buys headroom (`debug_set_power` raises `max_power` to meet // whatever it is told), the second sets the floor comfortably under that ceiling so the // effect's +50 below can never clamp against it. + prevent_scheduled_rage_decay(&standalone); standalone.assert_call("debug_set_power", &[&PLAYER.to_string(), "1000"]); standalone.assert_call("debug_set_power", &[&PLAYER.to_string(), "800"]); const FLOOR: u32 = 800; @@ -396,6 +399,20 @@ fn power(standalone: &Standalone, guid: u64) -> u32 { .expect("power is a number") } +/// These tests assign a Warrior's Rage so they can observe only the spell's power change. Keep the +/// Character in combat beyond the fixture deadline so the unrelated regeneration pass cannot decay +/// that Rage while the test publishes scripts and spells. +fn prevent_scheduled_rage_decay(standalone: &Standalone) { + let flags = entity(standalone, PLAYER)["unit_flags"] + .parse::() + .expect("unit flags are a number"); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET unit_flags = {}, combat_until_ms = {} WHERE guid = {PLAYER}", + flags | unit_flags::IN_COMBAT, + u64::MAX, + )); +} + fn xp(standalone: &Standalone, guid: u64) -> u32 { entity(standalone, guid)["xp"] .parse() From e6571e45da84bb4132fd53105668fa46e4a517e9 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 09:03:01 +0200 Subject: [PATCH 08/11] test(auth): publish the session expiry fixture mutably --- module/tests/session_expiry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/tests/session_expiry.rs b/module/tests/session_expiry.rs index 1f2d88591..5ccef13c0 100644 --- a/module/tests/session_expiry.rs +++ b/module/tests/session_expiry.rs @@ -10,7 +10,7 @@ use support::Standalone; #[test] #[ignore = "requires SpacetimeDB 2.7.1 and waits for the five-minute session reaper"] fn logon_renews_expired_sessions_and_the_scheduler_reaps_only_expired_rows() { - let standalone = Standalone::start("session-expiry"); + let mut standalone = Standalone::start("session-expiry"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); assert!(standalone From 634dd60498a7dc16746ddac2bd5d62aa5ec4db13 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 09:04:01 +0200 Subject: [PATCH 09/11] test(auras): publish the tick fixture mutably --- module/tests/aura_tick.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/tests/aura_tick.rs b/module/tests/aura_tick.rs index ea4c188ce..43492438e 100644 --- a/module/tests/aura_tick.rs +++ b/module/tests/aura_tick.rs @@ -11,7 +11,7 @@ const WOLF: u64 = (0xF130_u64 << 48) | (51_000_u64 << 24) | 1; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_periodic_kill_commits_the_death_and_an_unrelated_heal() { - let standalone = Standalone::start("aura-tick-death"); + let mut standalone = Standalone::start("aura-tick-death"); standalone.publish_module(); standalone.assert_sql("DELETE FROM game_creature_move_schedule"); standalone.assert_sql("DELETE FROM game_melee_schedule"); From 2b5ffc27b3d4db4eb974d9542f2585e0970787fc Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 5 Sep 2026 09:04:33 +0200 Subject: [PATCH 10/11] test(packages): publish the cast fixture mutably --- module/tests/package_delta_references.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/tests/package_delta_references.rs b/module/tests/package_delta_references.rs index f7fcb6bef..b468b8cd7 100644 --- a/module/tests/package_delta_references.rs +++ b/module/tests/package_delta_references.rs @@ -256,7 +256,7 @@ fn cast_claims_refuse_missing_cross_table_references() { #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn creature_spell_updates_cannot_hide_their_fixture_creature_target() { - let standalone = Standalone::start("cast-fixture-target"); + let mut standalone = Standalone::start("cast-fixture-target"); standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_seed_scenario_fixtures", &[]); From 87664f92a802d8100d952d38b94490ea04973ff5 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Mon, 7 Sep 2026 09:36:09 +0200 Subject: [PATCH 11/11] docs(ci): stop pinning the durable target count --- .github/workflows/module-durable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 886cdd856..af1d98e90 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -1,6 +1,6 @@ name: Module durable tests -# The durable integration rung: `module/tests/`, 39 targets marked `#[ignore]` because each one +# The durable integration rung: `module/tests/`, marked `#[ignore]` because each target # starts its own SpacetimeDB standalone, publishes the module to it, and drives real reducers. # `rust.yml`'s `--lib` run cannot reach any of it. #