Skip to main content

thruput_testkit/
scenario.rs

1//! Scenario builder, runner, and result types.
2
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU32, Ordering};
7
8use thruput_node_supervisor::{HogdDevConfig, NodeSupervisorConfig, NodeSupervisorHandle};
9use tokio::sync::mpsc;
10use tokio::task::JoinSet;
11
12use crate::account::Account;
13use crate::agent::{Agent, AgentContext, AgentId};
14use crate::artifact::{ArtifactKind, ArtifactSpec, Registry};
15use crate::clock::Clock;
16use crate::error::{AgentFailure, AgentFailures, Result, TestkitError};
17use crate::mailbox::{Envelope, Mailbox};
18use crate::manifest::{AccountSource, load_runtime_manifest};
19use crate::network::{LocalNetworkConfig, Network, NetworkConfig};
20use crate::nonce::NonceNamespace;
21use crate::rng::AgentRng;
22
23/// Builder for a [`Scenario`]. Every setting is explicit.
24#[derive(Debug)]
25pub struct ScenarioBuilder {
26    seed: String,
27    network: Option<NetworkConfig>,
28    artifacts: Vec<ArtifactSpec>,
29    use_bootstrap_accounts: bool,
30    accounts: usize,
31}
32
33impl ScenarioBuilder {
34    /// Use hosted schema-3 deterministic bootstrap accounts. Local schema-4 rejects this adapter.
35    ///
36    /// A local manifest carries public keys only, so it cannot supply signing
37    /// keys. For an existing local node, use the scenario-owned accounts and
38    /// fund them explicitly.
39    #[must_use]
40    pub fn bootstrap_accounts(mut self) -> Self {
41        self.use_bootstrap_accounts = true;
42        self
43    }
44
45    /// Number of scenario-owned fixture accounts for live targets (default: 10).
46    /// The scenario seed owns these keys; the runtime receives only public allocations.
47    #[must_use]
48    pub fn accounts(mut self, count: usize) -> Self {
49        self.accounts = count;
50        self
51    }
52
53    /// Set the network the scenario runs against. Required.
54    #[must_use]
55    pub fn network(mut self, network: NetworkConfig) -> Self {
56        self.network = Some(network);
57        self
58    }
59
60    /// Register a contract artifact loaded from a file, inferring its kind from
61    /// the `.rv32a` extension. The file is read and validated at
62    /// [`ScenarioBuilder::build`]. Agents deploy it by name.
63    #[must_use]
64    pub fn artifact(mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
65        self.artifacts.push(ArtifactSpec::Path {
66            name: name.into(),
67            path: path.into(),
68            kind: None,
69        });
70        self
71    }
72
73    /// Register a contract artifact from a file with an explicit kind.
74    #[must_use]
75    pub fn artifact_with_kind(
76        mut self,
77        name: impl Into<String>,
78        path: impl Into<PathBuf>,
79        kind: ArtifactKind,
80    ) -> Self {
81        self.artifacts.push(ArtifactSpec::Path {
82            name: name.into(),
83            path: path.into(),
84            kind: Some(kind),
85        });
86        self
87    }
88
89    /// Register a contract artifact from in-memory bytes (for example via
90    /// `include_bytes!`) with an explicit kind.
91    #[must_use]
92    pub fn artifact_bytes(
93        mut self,
94        name: impl Into<String>,
95        bytes: impl Into<Vec<u8>>,
96        _kind: ArtifactKind,
97    ) -> Self {
98        self.artifacts.push(ArtifactSpec::Bytes {
99            name: name.into(),
100            bytes: bytes.into(),
101        });
102        self
103    }
104
105    /// Acquire the network, load artifacts, and produce a ready [`Scenario`].
106    pub async fn build(self) -> Result<Scenario> {
107        let config = self.network.ok_or(TestkitError::NoNetwork)?;
108        let artifacts = Arc::new(Registry::from_specs(self.artifacts)?);
109        let (network, accounts, guard) = acquire_network(
110            config,
111            &self.seed,
112            self.use_bootstrap_accounts,
113            self.accounts,
114        )
115        .await?;
116        Ok(Scenario {
117            seed: self.seed,
118            network,
119            clock: Clock::new(),
120            accounts,
121            artifacts,
122            runs: AtomicU32::new(0),
123            guard,
124        })
125    }
126}
127
128/// A ready scenario: an explicit fixture the test holds.
129///
130/// Dropping the scenario tears down anything the harness created for the
131/// network (a managed local node is force-killed (`SIGKILL`) as a backstop);
132/// [`Scenario::stop`] does the same gracefully and reports teardown errors.
133#[derive(Debug)]
134pub struct Scenario {
135    seed: String,
136    network: Network,
137    clock: Clock,
138    accounts: AccountSource,
139    artifacts: Arc<Registry>,
140    /// Number of [`run_agents`](Scenario::run_agents) calls so far; each run
141    /// takes the next index for a fresh nonce namespace.
142    runs: AtomicU32,
143    guard: NetworkGuard,
144}
145
146impl Scenario {
147    /// Start building a scenario with the given deterministic `seed`.
148    #[must_use]
149    pub fn builder(seed: impl Into<String>) -> ScenarioBuilder {
150        ScenarioBuilder {
151            seed: seed.into(),
152            network: None,
153            artifacts: Vec::new(),
154            use_bootstrap_accounts: false,
155            accounts: 10,
156        }
157    }
158
159    /// Handle onto the network the scenario runs against.
160    pub fn network(&self) -> &Network {
161        &self.network
162    }
163
164    /// The scenario's virtual clock.
165    pub fn clock(&self) -> &Clock {
166        &self.clock
167    }
168
169    /// Run a set of agents concurrently, returning once all have finished.
170    ///
171    /// Each agent is assigned a distinct account (by position), its own
172    /// deterministic random stream, and a mailbox. After every non-panicking
173    /// agent finishes, returned errors are aggregated in
174    /// [`TestkitError::AgentsFailed`]. An agent panic (for example a failed
175    /// assertion) propagates immediately.
176    ///
177    /// May be called more than once on the same scenario: each call runs in a
178    /// fresh nonce namespace, so a later run against the same accounts does
179    /// not collide with an earlier run's transactions.
180    pub async fn run_agents<I>(&self, agents: I) -> Result<ScenarioReport>
181    where
182        I: IntoIterator<Item = Agent>,
183    {
184        let agents: Vec<Agent> = agents.into_iter().collect();
185        let count = agents.len();
186
187        let names: Vec<Arc<str>> = agents.iter().map(Agent::name_arc).collect();
188        validate_unique_agent_names(&names)?;
189
190        let mut test_accounts = self.accounts.accounts()?;
191        if count > test_accounts.len() {
192            return Err(TestkitError::NotEnoughAccounts {
193                needed: count,
194                available: test_accounts.len(),
195            });
196        }
197        test_accounts.truncate(count);
198
199        // Each run writes in its own nonce namespace so a rerun against the
200        // same accounts does not replay an earlier run's nonces.
201        let namespace =
202            NonceNamespace::for_run(&self.seed, self.runs.fetch_add(1, Ordering::Relaxed));
203
204        let mut senders = Vec::with_capacity(count);
205        let mut receivers = Vec::with_capacity(count);
206        for _ in 0..count {
207            let (tx, rx) = mpsc::unbounded_channel::<Envelope>();
208            senders.push(tx);
209            receivers.push(rx);
210        }
211
212        let mut set: JoinSet<AgentOutcome> = JoinSet::new();
213        for (index, ((agent, inbox), dev)) in agents
214            .into_iter()
215            .zip(receivers)
216            .zip(test_accounts)
217            .enumerate()
218        {
219            let mut peers = HashMap::with_capacity(count.saturating_sub(1));
220            for (peer_index, peer_name) in names.iter().enumerate() {
221                if peer_index != index {
222                    peers.insert(peer_name.to_string(), senders[peer_index].clone());
223                }
224            }
225            let name = Arc::clone(&names[index]);
226            let mailbox = Mailbox::new(Arc::clone(&name), inbox, peers);
227            let ctx = AgentContext::new(
228                AgentId::new(index, Arc::clone(&name)),
229                self.network.clone(),
230                self.clock.clone(),
231                AgentRng::derive(&self.seed, index, &name),
232                mailbox,
233                Account::new(dev, namespace),
234                Arc::clone(&self.artifacts),
235            );
236            let body = agent.into_body();
237            set.spawn(async move {
238                let result = body(ctx).await;
239                AgentOutcome {
240                    name,
241                    index,
242                    result,
243                }
244            });
245        }
246        // Drop the harness's own senders so an agent's inbox closes once every
247        // peer has finished.
248        drop(senders);
249
250        let mut outcomes: Vec<Option<AgentOutcome>> = (0..count).map(|_| None).collect();
251        while let Some(joined) = set.join_next().await {
252            match joined {
253                Ok(outcome) => {
254                    let index = outcome.index;
255                    outcomes[index] = Some(outcome);
256                }
257                Err(join_error) if join_error.is_panic() => {
258                    std::panic::resume_unwind(join_error.into_panic());
259                }
260                Err(join_error) => {
261                    return Err(TestkitError::AgentJoin {
262                        detail: join_error.to_string(),
263                    });
264                }
265            }
266        }
267
268        let outcomes = outcomes
269            .into_iter()
270            .map(|outcome| outcome.expect("bug: every agent produced an outcome"))
271            .collect();
272        let report = ScenarioReport { outcomes };
273        if report.all_ok() {
274            return Ok(report);
275        }
276
277        let failures = report
278            .outcomes
279            .into_iter()
280            .filter_map(|outcome| {
281                outcome
282                    .result
283                    .err()
284                    .map(|error| AgentFailure::new(outcome.name.to_string(), outcome.index, error))
285            })
286            .collect();
287        Err(TestkitError::AgentsFailed {
288            failures: AgentFailures::new(failures),
289        })
290    }
291
292    /// Tear the network down gracefully, reporting any teardown error.
293    pub async fn stop(self) -> Result<()> {
294        let Scenario { guard, .. } = self;
295        match guard {
296            NetworkGuard::Local(handle) => {
297                handle
298                    .stop()
299                    .await
300                    .map_err(|e| TestkitError::NodeSupervisor {
301                        detail: e.to_string(),
302                    })?;
303            }
304            NetworkGuard::Existing => {}
305        }
306        Ok(())
307    }
308}
309
310fn validate_unique_agent_names(names: &[Arc<str>]) -> Result<()> {
311    let mut unique_names = HashSet::with_capacity(names.len());
312    for name in names {
313        if !unique_names.insert(name.as_ref()) {
314            return Err(TestkitError::DuplicateAgent {
315                name: name.to_string(),
316            });
317        }
318    }
319    Ok(())
320}
321
322async fn acquire_network(
323    config: NetworkConfig,
324    seed: &str,
325    use_bootstrap_accounts: bool,
326    accounts: usize,
327) -> Result<(Network, AccountSource, NetworkGuard)> {
328    match config {
329        NetworkConfig::Existing {
330            rpc_url,
331            runtime_manifest,
332        } => {
333            let manifest = load_runtime_manifest(&runtime_manifest)?;
334            let network = Network::connect(&rpc_url, &manifest, None).await?;
335            Ok((
336                network,
337                account_source(manifest, seed, use_bootstrap_accounts, accounts)?,
338                NetworkGuard::Existing,
339            ))
340        }
341        NetworkConfig::Local(config) => {
342            if use_bootstrap_accounts {
343                return Err(TestkitError::Manifest {
344                    field: "bootstrapAccounts",
345                    detail:
346                        "Local nodes require scenario-owned accounts; remove bootstrap_accounts()."
347                            .into(),
348                });
349            }
350            if accounts > 1000
351                || config.initial_account_tokens == 0
352                || config.initial_account_tokens > i64::MAX as u64
353            {
354                return Err(TestkitError::Manifest { field: "accounts", detail: "local fixture accounts require count <= 1000 and positive initial_account_tokens fitting i64".into() });
355            }
356            let allocations = crate::manifest::derive_native_accounts(accounts, seed, None)
357                .iter()
358                .map(|account| {
359                    thruput_json_rpc_types::bootstrap_manifest::BootstrapManifestAllocation::new(
360                        thruput_json_rpc_types::base58::Base58(
361                            account.signer.verifying_key_bytes().into(),
362                        ),
363                        config.initial_account_tokens.to_string(),
364                    )
365                })
366                .collect();
367            let handle = start_local(config, allocations).await?;
368            let rpc_url = format!("http://{}", handle.rpc_addr());
369            let manifest = handle
370                .hogd_readiness()
371                .runtime_manifest
372                .clone()
373                .ok_or(TestkitError::MissingRuntimeManifest)?;
374            let manual_block_producer = handle.manual_block_producer();
375            let network = Network::connect(&rpc_url, &manifest, manual_block_producer).await?;
376            Ok((
377                network,
378                account_source(manifest, seed, use_bootstrap_accounts, accounts)?,
379                NetworkGuard::Local(Box::new(handle)),
380            ))
381        }
382        NetworkConfig::Offline { accounts } => Ok((
383            Network::offline(),
384            AccountSource::Native {
385                count: accounts,
386                seed: seed.to_owned(),
387                network_hash: None,
388            },
389            NetworkGuard::Existing,
390        )),
391    }
392}
393
394fn account_source(
395    manifest: thruput_json_rpc_types::runtime_manifest::RuntimeManifest,
396    seed: &str,
397    use_bootstrap: bool,
398    count: usize,
399) -> Result<AccountSource> {
400    let source = if use_bootstrap {
401        AccountSource::Manifest(Box::new(manifest))
402    } else {
403        AccountSource::Native {
404            count,
405            seed: seed.to_owned(),
406            network_hash: Some(manifest.network_hash.to_network_hash()),
407        }
408    };
409    source.accounts()?;
410    Ok(source)
411}
412
413async fn start_local(
414    config: LocalNetworkConfig,
415    allocations: Vec<thruput_json_rpc_types::bootstrap_manifest::BootstrapManifestAllocation>,
416) -> Result<NodeSupervisorHandle> {
417    let block_driver = config.block_driver;
418    let mut hogd = HogdDevConfig::default()
419        .with_bootstrap_accounts(allocations)
420        .with_startup_timeout(config.startup_timeout)
421        .with_contract_artifact_directories(config.contract_artifact_directories);
422    if let Some(binary) = config.hog_devnode_binary {
423        hogd = hogd.with_binary_path(binary);
424    }
425    if let Some(log_level) = config.log_level {
426        // Print forwarded devnode logs to stderr at the requested verbosity.
427        crate::logging::install_for_level(log_level);
428        hogd = hogd.with_log_level(log_level);
429    }
430    let mut supervisor = NodeSupervisorConfig::default()
431        .with_rpc_listen_address(format!("127.0.0.1:{}", config.rpc_port))
432        .with_hogd_dev(hogd)
433        .with_block_driver(block_driver);
434    if let Some(protocol) = config.hogd_protocol {
435        supervisor = supervisor.with_hogd_protocol_override(protocol);
436    }
437    NodeSupervisorHandle::start(supervisor)
438        .await
439        .map_err(|e| TestkitError::NodeSupervisor {
440            detail: e.to_string(),
441        })
442}
443
444/// Owns resources the harness created for the network and releases them on
445/// drop.
446///
447/// The `existing` and `offline` networks own nothing. The `local` network owns
448/// the node handle; dropping it force-kills the child (`SIGKILL`) as a
449/// backstop, while [`Scenario::stop`] shuts it down gracefully.
450#[derive(Debug)]
451enum NetworkGuard {
452    Existing,
453    Local(Box<NodeSupervisorHandle>),
454}
455
456/// Outcome of a single agent.
457#[derive(Debug)]
458pub struct AgentOutcome {
459    name: Arc<str>,
460    index: usize,
461    result: Result<()>,
462}
463
464impl AgentOutcome {
465    /// Agent name.
466    pub fn name(&self) -> &str {
467        &self.name
468    }
469
470    /// Agent index within the run.
471    pub fn index(&self) -> usize {
472        self.index
473    }
474
475    /// The agent's returned result.
476    pub fn result(&self) -> &Result<()> {
477        &self.result
478    }
479}
480
481/// Aggregate result of a [`Scenario::run_agents`] call.
482#[derive(Debug)]
483pub struct ScenarioReport {
484    outcomes: Vec<AgentOutcome>,
485}
486
487impl ScenarioReport {
488    /// Per-agent outcomes, indexed by agent position.
489    pub fn outcomes(&self) -> &[AgentOutcome] {
490        &self.outcomes
491    }
492
493    /// Whether every agent returned `Ok`.
494    ///
495    /// Reports returned by [`Scenario::run_agents`] always satisfy this; the
496    /// method remains useful for code that receives a report abstractly.
497    pub fn all_ok(&self) -> bool {
498        self.outcomes.iter().all(|outcome| outcome.result.is_ok())
499    }
500}