diff --git a/.github/durable-test-seconds.json b/.github/durable-test-seconds.json new file mode 100644 index 000000000..2923c5585 --- /dev/null +++ b/.github/durable-test-seconds.json @@ -0,0 +1,84 @@ +{ + "account_ownership": 96, + "auction_buyout": 69, + "auction_cancel": 101, + "auction_expiry": 5, + "aura_tick": 7, + "away_and_whisper": 17, + "binding_quests": 16, + "cast_global_cooldown": 28, + "chat_channel_moderation": 46, + "chat_channels": 28, + "corpse_loot_residue": 6, + "creature_advance_persist": 14, + "creature_death_stop": 7, + "creature_path_renewal": 7, + "deadmines_package": 0, + "diminishing_returns": 4, + "distract": 43, + "encounter_move": 25, + "eventai_quest_credit": 4, + "eventai_revision": 8, + "eventai_runtime_boundaries": 15, + "fixture_publish": 13, + "friends": 8, + "go_collision": 7, + "goober_lock": 5, + "guild_chat": 24, + "guild_cleanup": 11, + "guild_core": 4, + "guild_fee": 17, + "guild_membership": 13, + "guild_petition": 9, + "guild_recruitment": 6, + "guild_settings": 17, + "hunter_pet": 6, + "instance_party": 6, + "instance_removal": 29, + "inventory_protocol": 12, + "item_guids": 83, + "lethal_damage_floor": 0, + "loot_roll_promotion": 24, + "loot_tag": 6, + "mail_delivery": 12, + "mail_expiry": 18, + "mail_letter_copy": 60, + "meeting_stone_matching": 123, + "meeting_stone_queue": 27, + "mount": 17, + "operator_gates": 17, + "package_account": 10, + "package_api_lint": 0, + "package_config": 21, + "package_delta_references": 32, + "package_runtime_scripts": 26, + "package_teardown": 11, + "pending_cast_replacement": 4, + "proximity_emotes": 10, + "quest_choice_reward": 7, + "quest_transfer": 9, + "queued_strike_fizzle": 8, + "raid_broadcasts": 33, + "raid_chat": 28, + "raid_convert": 40, + "raid_leadership": 38, + "raid_quest_credit": 4, + "raid_subgroups": 16, + "random_properties": 64, + "random_property_stats": 23, + "realm_chat": 10, + "realm_transfer_locator": 21, + "region_assignment": 4, + "reward_letter": 16, + "scripted_spell_effect": 21, + "self_resurrection": 17, + "session_expiry": 10, + "sessionless_consent": 57, + "teleport_motion": 4, + "transfer_fence": 41, + "typed_taxi_contracts": 8, + "watched_faction": 20, + "weather": 11, + "xp_cap": 7, + "zone_transition": 4 +} diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 16de6bd68..36af2ecac 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -1,19 +1,17 @@ name: Module durable tests and Gateway bindings -# 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. -# -# There is no shared node to start here: `lyracore-test-support` 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. +# Durable tests start private SpacetimeDB instances and publish the Module with test fixtures. +# The Rust workflow's --lib run does not execute these ignored integration tests. on: pull_request: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -24,9 +22,7 @@ env: SPACETIME_ARCHIVE_SHA256: 9b58192135733083479f30aba47dbbbc79b51db368c46cc221baa663e9c3a2c1 jobs: - # The suite is mostly wall-clock waiting on standalones, so three shards run in parallel. Each - # shard takes every third test target by position in the sorted file list, so a new target lands - # in a shard without a workflow edit. The binding checks and the Gateway regressions run once. + # Balance measured target durations, reserving time for each job's builds and Gateway checks. durable: runs-on: ubuntu-latest timeout-minutes: 30 @@ -42,6 +38,7 @@ jobs: # Each test uses its own temporary CLI configuration. - name: Install SpacetimeDB ${{ env.SPACETIME_VERSION }} + timeout-minutes: 3 run: | archive_path="$RUNNER_TEMP/spacetime.tar.gz" cli_dir="$RUNNER_TEMP/spacetime-$SPACETIME_VERSION" @@ -61,9 +58,9 @@ jobs: echo "spacetime $found" [ "$found" = "$SPACETIME_VERSION" ] - - name: Test the Gateway binding check + - name: Test the CI scripts if: matrix.shard == 0 - run: python3 -m unittest scripts/tests/test_check_gateway_bindings.py + run: python3 -m unittest discover -s scripts/tests # This regenerates the complete private schema into a temporary directory. It does not need a # running Realm and never publishes the Module. @@ -74,7 +71,7 @@ jobs: # Build failures report before any fixture starts. Each test process asks Cargo for the # current artifact, caches its bytes, and publishes private copies through --bin-path. - name: Build the module wasm - run: cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers + run: cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers,package_test_fixture # Each test owns a whole database process, so the thread cap is a memory bound, not a CPU one. # @@ -83,13 +80,19 @@ jobs: # is flaky on `main` with or without this change. - name: Run the durable suite, shard ${{ matrix.shard }} of 3 run: | - targets=$(ls module/tests/*.rs | xargs -n1 basename -s .rs | sort | awk -v shard="${{ matrix.shard }}" 'NR % 3 == shard { printf "--test %s ", $0 }') - echo "targets: $targets" - cargo test -p lyracore-module $targets -- --ignored --test-threads=4 --skip deadmines --skip lethal_floor + mapfile -t targets < <(python3 scripts/durable-test-targets.py "${{ matrix.shard }}") + [ "${#targets[@]}" -gt 0 ] + args=() + for target in "${targets[@]}"; do args+=(--test "$target"); done + echo "targets: ${targets[*]}" + cargo test -p lyracore-module "${args[@]}" -- --ignored --test-threads=4 --skip deadmines --skip lethal_floor - name: Run the single-shard auction Refusal regression if: matrix.shard == 0 - 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 + run: | + target_test=stdb::store::auction::auction_reducer_tests::a_local_bid_interaction_refusal_stays_a_client_outcome + cargo test -p lyracore-gateway --bin lyracore-gateway "$target_test" -- --ignored --exact --list | grep -Fx "$target_test: test" + cargo test -p lyracore-gateway --bin lyracore-gateway "$target_test" -- --ignored --exact - name: Run the Gateway ownership and durable projection regressions if: matrix.shard == 0 diff --git a/.github/workflows/runtime-scripts.yml b/.github/workflows/runtime-scripts.yml index 8b6171fb6..3ad076568 100644 --- a/.github/workflows/runtime-scripts.yml +++ b/.github/workflows/runtime-scripts.yml @@ -5,12 +5,17 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: build: runs-on: ubuntu-latest + timeout-minutes: 5 defaults: run: working-directory: datascripts @@ -20,5 +25,6 @@ jobs: with: bun-version: 1.3.7 - run: bun install --frozen-lockfile + timeout-minutes: 2 - name: Test Datascripts and compile Runtime Scripts run: bun test diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 83fcdd00c..604c8387c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -12,12 +12,17 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: fmt: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check formatting @@ -36,6 +41,7 @@ jobs: clippy: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -44,6 +50,7 @@ jobs: test: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -58,6 +65,7 @@ jobs: wasm: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -67,10 +75,12 @@ jobs: # A Core change that breaks an official Package fails here, before the Package's next publish. official-packages: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # A paired packages PR uses the same branch name; build against it when it exists. - id: packages-ref + timeout-minutes: 2 env: BRANCH: ${{ github.head_ref || github.ref_name }} run: | diff --git a/docs/testing.md b/docs/testing.md index b46a9bc49..3a662ece9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,20 +46,34 @@ fails before the Package's next publish. To run the same check, link each Packag From `.github/workflows/module-durable.yml`. Each test in `module/tests/` is `#[ignore]`d because it starts its own SpacetimeDB standalone on a free loopback port, publishes the Module with -`--features=debug_reducers`, calls reducers and reads the result. The `lyracore-test-support` crate -owns that fixture. Standalone logs go to `lyracore-standalone-logs` under the system temp directory. +`--features=debug_reducers,package_test_fixture`, calls reducers and reads the result. The +`lyracore-test-support` crate owns that fixture. Standalone logs go to `lyracore-standalone-logs` +under the system temp directory. Build the Module first, so a build failure shows before any fixture starts. Then run one target: ```bash -cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers +cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers,package_test_fixture cargo test -p lyracore-module --test weather -- --ignored ``` -CI runs every target in three parallel shards with `--test-threads=4 --skip deadmines --skip +CI runs every target in three parallel jobs with `--test-threads=4 --skip deadmines --skip lethal_floor`. The Deadmines targets need the separately installed dungeons Package, and the `lethal_floor` ranged phase is flaky. +`scripts/durable-test-targets.py` assigns targets by the durations in +`.github/durable-test-seconds.json`. It includes every current target once and estimates 20 seconds +for a new target. `JOB_SECONDS` reserves time for each job's builds, setup and Gateway checks. +To refresh the estimates, use a successful main run: record each Module target's reported test +duration, rounded up to whole seconds, in the JSON file. Set each job's `JOB_SECONDS` entry to its +total duration minus those target durations. Keep the run reference in the script current. +Run `python3 -m unittest discover -s scripts/tests` to check the assignment. + +The Session expiry test checks the five-minute production interval before it shortens the schedule +on its private fixture. It then checks two real scheduled cleanups without a five-minute wait. +The Operator-only `debug_accelerate_session_reaper` control is for disposable test instances; +its shorter interval persists if used on an existing Shard. + CI also runs Gateway tests that need a node. The workflow names individual tests with `--ignored --exact` and runs the party command test group with: @@ -74,7 +88,7 @@ directory and compares it with `gateway/src/stdb/bindings`. It needs the Spaceti publishes nothing. ```bash -python3 -m unittest scripts/tests/test_check_gateway_bindings.py +python3 -m unittest discover -s scripts/tests scripts/check-gateway-bindings.py ``` diff --git a/gateway/src/stdb/bindings/debug_accelerate_session_reaper_reducer.rs b/gateway/src/stdb/bindings/debug_accelerate_session_reaper_reducer.rs new file mode 100644 index 000000000..d507f832a --- /dev/null +++ b/gateway/src/stdb/bindings/debug_accelerate_session_reaper_reducer.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugAccelerateSessionReaperArgs {} + +impl From for super::Reducer { + fn from(args: DebugAccelerateSessionReaperArgs) -> Self { + Self::DebugAccelerateSessionReaper + } +} + +impl __sdk::InModule for DebugAccelerateSessionReaperArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_accelerate_session_reaper`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_accelerate_session_reaper { + /// Request that the remote module invoke the reducer `debug_accelerate_session_reaper` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_accelerate_session_reaper:debug_accelerate_session_reaper_then`] to run a callback after the reducer completes. + fn debug_accelerate_session_reaper(&self) -> __sdk::Result<()> { + self.debug_accelerate_session_reaper_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_accelerate_session_reaper` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_accelerate_session_reaper_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_accelerate_session_reaper for super::RemoteReducers { + fn debug_accelerate_session_reaper_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(DebugAccelerateSessionReaperArgs {}, callback) + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index ea3d9d6a1..dd7445d15 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -133,6 +133,7 @@ pub mod creature_spline_type; pub mod creature_template_type; pub mod creature_waypoint_type; pub mod death_condition_type; +pub mod debug_accelerate_session_reaper_reducer; pub mod debug_accept_quest_reducer; pub mod debug_add_threat_reducer; pub mod debug_admit_sessionless_action_reducer; @@ -1316,6 +1317,7 @@ pub use creature_spline_type::CreatureSpline; pub use creature_template_type::CreatureTemplate; pub use creature_waypoint_type::CreatureWaypoint; pub use death_condition_type::DeathCondition; +pub use debug_accelerate_session_reaper_reducer::debug_accelerate_session_reaper; pub use debug_accept_quest_reducer::debug_accept_quest; pub use debug_add_threat_reducer::debug_add_threat; pub use debug_admit_sessionless_action_reducer::debug_admit_sessionless_action; @@ -2511,6 +2513,7 @@ pub enum Reducer { hair_color: u8, facial_hair: u8, }, + DebugAccelerateSessionReaper, DebugAcceptQuest { character_guid: u64, giver_guid: u64, @@ -4462,6 +4465,7 @@ impl __sdk::Reducer for Reducer { Reducer::ConfirmPartyCommandHolder { .. } => "confirm_party_command_holder", Reducer::ConfirmPartyCommandReceipt { .. } => "confirm_party_command_receipt", Reducer::CreateCharacter { .. } => "create_character", + Reducer::DebugAccelerateSessionReaper => "debug_accelerate_session_reaper", Reducer::DebugAcceptQuest { .. } => "debug_accept_quest", Reducer::DebugAddThreat { .. } => "debug_add_threat", Reducer::DebugAdmitSessionlessAction { .. } => "debug_admit_sessionless_action", @@ -5196,7 +5200,9 @@ Reducer::ClaimPartyCommandIntent{ hair_color: hair_color.clone(), facial_hair: facial_hair.clone(), }), - Reducer::DebugAcceptQuest{ + Reducer::DebugAccelerateSessionReaper => __sats::bsatn::to_vec(&debug_accelerate_session_reaper_reducer::DebugAccelerateSessionReaperArgs { + }), +Reducer::DebugAcceptQuest{ character_guid, giver_guid, quest_entry, diff --git a/module/src/debug/auth.rs b/module/src/debug/auth.rs index ec4f29b75..97d67fb85 100644 --- a/module/src/debug/auth.rs +++ b/module/src/debug/auth.rs @@ -1,6 +1,20 @@ -use spacetimedb::{reducer, ReducerContext}; +use spacetimedb::{reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; -use crate::auth::game_session; +use crate::auth::{game_session, game_session_reaper_schedule}; + +/// Exercise the real recurring scheduler without waiting for the production interval. +#[reducer] +pub fn debug_accelerate_session_reaper(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let schedules = ctx.db.game_session_reaper_schedule(); + let mut schedule = schedules + .iter() + .next() + .ok_or_else(|| "Session reaper is not armed".to_string())?; + schedule.scheduled_at = ScheduleAt::Interval(TimeDuration::from_micros(1_000_000)); + schedules.scheduled_id().update(schedule); + Ok(()) +} /// Expire an Account's Session so the handshake and scheduled reaper can be verified. #[reducer] diff --git a/module/tests/session_expiry.rs b/module/tests/session_expiry.rs index 31585498e..90ac2286f 100644 --- a/module/tests/session_expiry.rs +++ b/module/tests/session_expiry.rs @@ -1,14 +1,13 @@ mod support; use std::collections::BTreeMap; -use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use spacetimedb::Timestamp; -use support::Standalone; +use support::{poll_until, Standalone}; #[test] -#[ignore = "requires SpacetimeDB 2.7.1 and waits for the five-minute session reaper"] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] fn logon_renews_expired_sessions_and_the_scheduler_reaps_only_expired_rows() { let mut standalone = Standalone::start("session-expiry"); standalone.publish_module(); @@ -17,6 +16,9 @@ fn logon_renews_expired_sessions_and_the_scheduler_reaps_only_expired_rows() { assert!(standalone .query_rows("SELECT * FROM game_session_reaper_schedule") .is_empty()); + let unarmed = standalone.call("debug_accelerate_session_reaper", &[]); + assert!(!unarmed.status.success()); + assert!(String::from_utf8_lossy(&unarmed.stderr).contains("Session reaper is not armed")); for table in [ "game_account", @@ -69,27 +71,44 @@ fn logon_renews_expired_sessions_and_the_scheduler_reaps_only_expired_rows() { assert!(message.contains("scheduler only"), "{message}"); assert_eq!(session(&standalone, &expired), before); - let deadline = Instant::now() + Duration::from_secs(330); - while !standalone - .query_rows(&format!( - "SELECT * FROM game_session WHERE account_id = {expired}" - )) - .is_empty() - { - assert!( - Instant::now() < deadline, - "the scheduled reaper did not run" - ); - thread::sleep(Duration::from_secs(2)); - } + // The production interval was checked above. Keep the real scheduler and reducer, but bring + // the next invocation forward on this private instance. + assert!(!standalone + .call_anonymous("debug_accelerate_session_reaper", &[]) + .status + .success()); + assert_eq!( + standalone.query_rows("SELECT * FROM game_session_reaper_schedule"), + schedule + ); + standalone.assert_call("debug_accelerate_session_reaper", &[]); + let accelerated = standalone.query_rows("SELECT * FROM game_session_reaper_schedule"); + assert_eq!(accelerated.len(), 1); + assert_eq!(accelerated[0]["scheduled_id"], schedule[0]["scheduled_id"]); + assert!(accelerated[0]["scheduled_at"].contains("+1.000000")); + assert_reaped(&standalone, &expired); assert_eq!(session(&standalone, &renewed), fresh); assert_eq!( standalone.query_rows("SELECT * FROM game_session_reaper_schedule"), - schedule, + accelerated, "the recurring schedule remains armed after its first invocation" ); establish(&standalone, &expired, 6); assert_lifetime(&session(&standalone, &expired)); + standalone.assert_call("debug_expire_session", &[&expired]); + assert_reaped(&standalone, &expired); + assert_eq!(session(&standalone, &renewed), fresh); +} + +fn assert_reaped(standalone: &Standalone, account: &str) { + assert!( + poll_until(Duration::from_secs(15), || standalone + .query_rows(&format!( + "SELECT * FROM game_session WHERE account_id = {account}" + )) + .is_empty()), + "the scheduled reaper did not run" + ); } fn provision(standalone: &Standalone, name: &str) -> String { diff --git a/scripts/durable-test-targets.py b/scripts/durable-test-targets.py new file mode 100644 index 000000000..e9501eaa9 --- /dev/null +++ b/scripts/durable-test-targets.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Distribute Module targets by duration, including each CI job's other work.""" + +import argparse +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +# Seconds from Actions run 38090384855, including builds and Gateway checks. +# .github/durable-test-seconds.json records its Module targets; session_expiry allows 10s +# after shortening its schedule interval. New targets use a 20s estimate until measured. +JOB_SECONDS = (615, 240, 135) +DEFAULT_TARGET_SECONDS = 20 + + +def partition(targets, seconds, job_seconds=JOB_SECONDS): + jobs = [[] for _ in job_seconds] + totals = list(job_seconds) + for target in sorted( + targets, key=lambda name: (-seconds.get(name, DEFAULT_TARGET_SECONDS), name) + ): + job = min(range(len(jobs)), key=lambda index: totals[index]) + jobs[job].append(target) + totals[job] += seconds.get(target, DEFAULT_TARGET_SECONDS) + return jobs, totals + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("job", type=int, choices=range(len(JOB_SECONDS))) + args = parser.parse_args() + seconds = json.loads((ROOT / ".github/durable-test-seconds.json").read_text()) + targets = [path.stem for path in (ROOT / "module/tests").glob("*.rs")] + jobs, totals = partition(targets, seconds) + if not all(jobs): + parser.error("every durable job must select at least one Module target") + print(f"Estimated job durations in seconds: {totals}", file=sys.stderr) + print("\n".join(sorted(jobs[args.job]))) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_durable_test_targets.py b/scripts/tests/test_durable_test_targets.py new file mode 100644 index 000000000..9b54ac6e3 --- /dev/null +++ b/scripts/tests/test_durable_test_targets.py @@ -0,0 +1,56 @@ +import importlib.util +import json +import subprocess +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts/durable-test-targets.py" +spec = importlib.util.spec_from_file_location("durable_test_targets", SCRIPT) +selector = importlib.util.module_from_spec(spec) +spec.loader.exec_module(selector) + + +class DurableTargetTests(unittest.TestCase): + def test_ci_selects_every_current_target_once(self): + selected = [] + for job in range(3): + result = subprocess.run( + [sys.executable, str(SCRIPT), str(job)], + check=True, + capture_output=True, + text=True, + ) + targets = result.stdout.splitlines() + self.assertTrue(targets) + selected.extend(targets) + expected = [path.stem for path in (ROOT / "module/tests").glob("*.rs")] + self.assertCountEqual(selected, expected) + + def test_new_targets_are_included_and_deleted_targets_are_not(self): + jobs, _ = selector.partition( + ["new", "existing"], {"existing": 30, "deleted": 600} + ) + self.assertCountEqual([target for job in jobs for target in job], ["new", "existing"]) + + def test_assignment_does_not_depend_on_directory_order(self): + targets = ["a", "b", "c", "d"] + self.assertEqual( + selector.partition(targets, {}), selector.partition(reversed(targets), {}) + ) + + def test_measured_work_is_balanced_including_gateway_checks(self): + seconds = json.loads((ROOT / ".github/durable-test-seconds.json").read_text()) + targets = sorted(path.stem for path in (ROOT / "module/tests").glob("*.rs")) + _, totals = selector.partition(targets, seconds) + previous = list(selector.JOB_SECONDS) + for position, target in enumerate(targets, 1): + previous[position % 3] += seconds.get(target, selector.DEFAULT_TARGET_SECONDS) + self.assertLess(max(totals), max(previous)) + self.assertLessEqual(max(totals) - min(totals), selector.DEFAULT_TARGET_SECONDS) + + +if __name__ == "__main__": + unittest.main()