docs: add compilable doc-snippets crate

This commit is contained in:
andrussal 2025-12-16 06:55:44 +01:00
parent 4141c98d8d
commit de2e043e2a
59 changed files with 1460 additions and 375 deletions

View File

@ -98,6 +98,37 @@ jobs:
restore-keys: ${{ runner.os }}-target-clippy-
- run: cargo +nightly-2025-09-14 clippy --all --all-targets --all-features -- -D warnings
doc_snippets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Load versions
run: |
set -euo pipefail
if [ ! -f versions.env ]; then
echo "versions.env missing; populate VERSION, NOMOS_NODE_REV, NOMOS_BUNDLE_VERSION" >&2
exit 1
fi
set -a
. versions.env
set +a
# $GITHUB_ENV does not accept comments/blank lines; keep only KEY=VALUE exports.
grep -E '^[A-Za-z_][A-Za-z0-9_]*=' versions.env >> "$GITHUB_ENV"
: "${VERSION:?Missing VERSION}"
: "${NOMOS_NODE_REV:?Missing NOMOS_NODE_REV}"
: "${NOMOS_BUNDLE_VERSION:?Missing NOMOS_BUNDLE_VERSION}"
- uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2025-09-14
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- run: cargo +nightly-2025-09-14 check -p doc-snippets
deny:
runs-on: ubuntu-latest
steps:

13
Cargo.lock generated
View File

@ -1698,6 +1698,19 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "doc-snippets"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"testing-framework-core",
"testing-framework-runner-compose",
"testing-framework-runner-k8s",
"testing-framework-runner-local",
"testing-framework-workflows",
]
[[package]]
name = "dtoa"
version = "1.0.10"

View File

@ -1,6 +1,7 @@
[workspace]
members = [
"examples",
"examples/doc-snippets",
"testing-framework/configs",
"testing-framework/core",
"testing-framework/runners/compose",

View File

@ -58,23 +58,20 @@ These binaries use the framework API (`ScenarioBuilder`) to construct and execut
Scenarios are defined using a fluent builder pattern:
```rust
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star() // Topology configuration
.validators(3)
.executors(2)
})
.wallets(50) // Wallet seeding
.transactions_with(|txs| {
txs.rate(5)
.users(20)
})
.da_with(|da| {
da.channel_rate(1)
.blob_rate(2)
})
.expect_consensus_liveness() // Expectations
.with_run_duration(Duration::from_secs(90))
.build();
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn scenario_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(50)
.transactions_with(|txs| txs.rate(5).users(20))
.da_with(|da| da.channel_rate(1).blob_rate(2))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(90))
.build()
}
```
**Key API Points:**

View File

@ -20,26 +20,26 @@ recovery. The built-in restart workload lives in
## Usage
```rust
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::workloads::chaos::RandomRestartWorkload;
let plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(2)
.executors(1)
})
.enable_node_control()
.with_workload(RandomRestartWorkload::new(
Duration::from_secs(45), // min delay
Duration::from_secs(75), // max delay
Duration::from_secs(120), // target cooldown
true, // include validators
true, // include executors
))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(150))
.build();
// deploy with a runner that supports node control and run the scenario
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::{ScenarioBuilderExt, workloads::chaos::RandomRestartWorkload};
pub fn random_restart_plan() -> testing_framework_core::scenario::Scenario<
testing_framework_core::scenario::NodeControlCapability,
> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.enable_node_control()
.with_workload(RandomRestartWorkload::new(
Duration::from_secs(45), // min delay
Duration::from_secs(75), // max delay
Duration::from_secs(120), // target cooldown
true, // include validators
true, // include executors
))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(150))
.build()
}
```
## Expectations to pair

View File

@ -14,10 +14,10 @@ Key ideas:
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::{
DynError, Expectation, RunContext, Workload, runtime::context::RunMetrics,
use testing_framework_core::{
scenario::{DynError, Expectation, RunContext, RunMetrics, Workload},
topology::generation::GeneratedTopology,
};
use testing_framework_core::topology::generation::GeneratedTopology;
pub struct ReachabilityWorkload {
target_idx: usize,
@ -36,16 +36,23 @@ impl Workload for ReachabilityWorkload {
}
fn expectations(&self) -> Vec<Box<dyn Expectation>> {
vec![Box::new(ReachabilityExpectation::new(self.target_idx))]
vec![Box::new(
crate::custom_workload_example_expectation::ReachabilityExpectation::new(
self.target_idx,
),
)]
}
fn init(
&mut self,
topology: &GeneratedTopology,
_metrics: &RunMetrics,
_run_metrics: &RunMetrics,
) -> Result<(), DynError> {
if topology.validators().get(self.target_idx).is_none() {
return Err("no validator at requested index".into());
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"no validator at requested index",
)));
}
Ok(())
}
@ -55,10 +62,19 @@ impl Workload for ReachabilityWorkload {
.node_clients()
.validator_clients()
.get(self.target_idx)
.ok_or("missing target client")?;
.ok_or_else(|| {
Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"missing target client",
)) as DynError
})?;
// Lightweight API call to prove reachability.
client.consensus_info().await.map(|_| ()).map_err(|e| e.into())
client
.consensus_info()
.await
.map(|_| ())
.map_err(|e| e.into())
}
}
```
@ -94,13 +110,18 @@ impl Expectation for ReachabilityExpectation {
.node_clients()
.validator_clients()
.get(self.target_idx)
.ok_or("missing target client")?;
.ok_or_else(|| {
Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"missing target client",
)) as DynError
})?;
client
.consensus_info()
.await
.map(|_| ())
.map_err(|e| format!("target became unreachable during run: {e}").into())
.map_err(|e| e.into())
}
}
```

View File

@ -5,123 +5,199 @@ Quick reference for the scenario builder DSL. All methods are chainable.
## Imports
```rust
use std::time::Duration;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_runner_k8s::K8sDeployer;
use testing_framework_workflows::{ScenarioBuilderExt, ChaosBuilderExt};
use std::time::Duration;
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
```
## Topology
```rust
ScenarioBuilder::topology_with(|t| {
t.network_star() // Star topology (all connect to seed node)
.validators(3) // Number of validator nodes
.executors(2) // Number of executor nodes
}) // Finish topology configuration
use testing_framework_core::scenario::{Builder, ScenarioBuilder};
pub fn topology() -> Builder<()> {
ScenarioBuilder::topology_with(|t| {
t.network_star() // Star topology (all connect to seed node)
.validators(3) // Number of validator nodes
.executors(2) // Number of executor nodes
})
}
```
## Wallets
```rust
.wallets(50) // Seed 50 funded wallet accounts
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn wallets_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.wallets(50) // Seed 50 funded wallet accounts
.build()
}
```
## Transaction Workload
```rust
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(20) // Use 20 of the seeded wallets
}) // Finish transaction workload config
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn transactions_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.wallets(50)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(20) // Use 20 of the seeded wallets
}) // Finish transaction workload config
.build()
}
```
## DA Workload
```rust
.da_with(|da| {
da.channel_rate(1) // number of DA channels to run
.blob_rate(2) // target 2 blobs per block (headroom applied)
.headroom_percent(20)// optional headroom when sizing channels
}) // Finish DA workload config
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn da_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(1))
.wallets(50)
.da_with(|da| {
da.channel_rate(1) // number of DA channels to run
.blob_rate(2) // target 2 blobs per block (headroom applied)
.headroom_percent(20) // optional headroom when sizing channels
}) // Finish DA workload config
.build()
}
```
## Chaos Workload (Requires `enable_node_control()`)
```rust
.enable_node_control() // Enable node control capability
.chaos_with(|c| {
c.restart() // Random restart chaos
.min_delay(Duration::from_secs(30)) // Min time between restarts
.max_delay(Duration::from_secs(60)) // Max time between restarts
.target_cooldown(Duration::from_secs(45)) // Cooldown after restart
.apply() // Required for chaos configuration
})
use std::time::Duration;
use testing_framework_core::scenario::{NodeControlCapability, ScenarioBuilder};
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub fn chaos_plan() -> testing_framework_core::scenario::Scenario<NodeControlCapability> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.enable_node_control() // Enable node control capability
.chaos_with(|c| {
c.restart() // Random restart chaos
.min_delay(Duration::from_secs(30)) // Min time between restarts
.max_delay(Duration::from_secs(60)) // Max time between restarts
.target_cooldown(Duration::from_secs(45)) // Cooldown after restart
.apply() // Required for chaos configuration
})
.build()
}
```
## Expectations
```rust
.expect_consensus_liveness() // Assert blocks are produced continuously
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn expectations_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.expect_consensus_liveness() // Assert blocks are produced continuously
.build()
}
```
## Run Duration
```rust
.with_run_duration(Duration::from_secs(120)) // Run for 120 seconds
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn run_duration_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.with_run_duration(Duration::from_secs(120)) // Run for 120 seconds
.build()
}
```
## Build
```rust
.build() // Construct the final Scenario
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn build_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0)).build() // Construct the final Scenario
}
```
## Deployers
```rust
// Local processes
let deployer = LocalDeployer::default();
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_runner_k8s::K8sDeployer;
use testing_framework_runner_local::LocalDeployer;
// Docker Compose
let deployer = ComposeDeployer::default();
pub fn deployers() {
// Local processes
let _deployer = LocalDeployer::default();
// Kubernetes
let deployer = K8sDeployer::default();
// Docker Compose
let _deployer = ComposeDeployer::default();
// Kubernetes
let _deployer = K8sDeployer::default();
}
```
## Execution
```rust
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn execution() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.expect_consensus_liveness()
.build();
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```
## Complete Example
```rust
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
use std::time::Duration;
async fn run_test() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(3)
.executors(2)
})
pub async fn run_test() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(50)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
txs.rate(5) // 5 transactions per block
.users(20)
})
.da_with(|da| {
da.channel_rate(1) // number of DA channels
.blob_rate(2) // target 2 blobs per block
.headroom_percent(20) // optional channel headroom
da.channel_rate(1) // number of DA channels
.blob_rate(2) // target 2 blobs per block
.headroom_percent(20) // optional channel headroom
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(90))
@ -130,7 +206,7 @@ async fn run_test() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```

View File

@ -15,34 +15,30 @@ Realistic advanced scenarios demonstrating framework capabilities for production
Test consensus under progressively increasing transaction load:
```rust
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
use std::time::Duration;
async fn load_progression_test() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
pub async fn load_progression_test() -> Result<()> {
for rate in [5, 10, 20, 30] {
println!("Testing with rate: {}", rate);
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(3)
.executors(2)
})
.wallets(50)
.transactions_with(|txs| {
txs.rate(rate)
.users(20)
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
let mut plan =
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(50)
.transactions_with(|txs| txs.rate(rate).users(20))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
}
Ok(())
}
```
@ -54,26 +50,18 @@ async fn load_progression_test() -> Result<(), Box<dyn std::error::Error + Send
Run high transaction and DA load for extended duration:
```rust
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
use std::time::Duration;
async fn sustained_load_test() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(4)
.executors(2)
})
pub async fn sustained_load_test() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(4).executors(2))
.wallets(100)
.transactions_with(|txs| {
txs.rate(15)
.users(50)
})
.da_with(|da| {
da.channel_rate(2)
.blob_rate(3)
})
.transactions_with(|txs| txs.rate(15).users(50))
.da_with(|da| da.channel_rate(2).blob_rate(3))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(300))
.build();
@ -81,7 +69,7 @@ async fn sustained_load_test() -> Result<(), Box<dyn std::error::Error + Send +
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```
@ -93,23 +81,18 @@ async fn sustained_load_test() -> Result<(), Box<dyn std::error::Error + Send +
Frequent node restarts with active traffic:
```rust
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::{ScenarioBuilderExt, ChaosBuilderExt};
use std::time::Duration;
async fn aggressive_chaos_test() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(4)
.executors(2)
})
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub async fn aggressive_chaos_test() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(4).executors(2))
.enable_node_control()
.wallets(50)
.transactions_with(|txs| {
txs.rate(10)
.users(20)
})
.transactions_with(|txs| txs.rate(10).users(20))
.chaos_with(|c| {
c.restart()
.min_delay(Duration::from_secs(10))
@ -124,7 +107,7 @@ async fn aggressive_chaos_test() -> Result<(), Box<dyn std::error::Error + Send
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```

View File

@ -21,17 +21,15 @@ and expectations.
Minimal test that validates basic block production:
```rust
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
use std::time::Duration;
async fn simple_consensus() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(3)
.executors(0)
})
pub async fn simple_consensus() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(0))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(30))
.build();
@ -39,7 +37,7 @@ async fn simple_consensus() -> Result<(), Box<dyn std::error::Error + Send + Syn
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```
@ -51,22 +49,17 @@ async fn simple_consensus() -> Result<(), Box<dyn std::error::Error + Send + Syn
Test consensus under transaction load:
```rust
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
use std::time::Duration;
async fn transaction_workload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(2)
.executors(0)
})
pub async fn transaction_workload() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(0))
.wallets(20)
.transactions_with(|txs| {
txs.rate(5)
.users(10)
})
.transactions_with(|txs| txs.rate(5).users(10))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
@ -74,7 +67,7 @@ async fn transaction_workload() -> Result<(), Box<dyn std::error::Error + Send +
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```
@ -86,26 +79,18 @@ async fn transaction_workload() -> Result<(), Box<dyn std::error::Error + Send +
Combined test stressing both transaction and DA layers:
```rust
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
use std::time::Duration;
async fn da_and_transactions() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(3)
.executors(2)
})
pub async fn da_and_transactions() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(30)
.transactions_with(|txs| {
txs.rate(5)
.users(15)
})
.da_with(|da| {
da.channel_rate(2)
.blob_rate(2)
})
.transactions_with(|txs| txs.rate(5).users(15))
.da_with(|da| da.channel_rate(2).blob_rate(2))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(90))
.build();
@ -113,7 +98,7 @@ async fn da_and_transactions() -> Result<(), Box<dyn std::error::Error + Send +
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```
@ -125,23 +110,18 @@ async fn da_and_transactions() -> Result<(), Box<dyn std::error::Error + Send +
Test system resilience under node restarts:
```rust
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::{ScenarioBuilderExt, ChaosBuilderExt};
use std::time::Duration;
async fn chaos_resilience() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(4)
.executors(2)
})
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub async fn chaos_resilience() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(4).executors(2))
.enable_node_control()
.wallets(20)
.transactions_with(|txs| {
txs.rate(3)
.users(10)
})
.transactions_with(|txs| txs.rate(3).users(10))
.chaos_with(|c| {
c.restart()
.min_delay(Duration::from_secs(20))
@ -156,7 +136,7 @@ async fn chaos_resilience() -> Result<(), Box<dyn std::error::Error + Send + Syn
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```

View File

@ -30,92 +30,142 @@ High-level roles of the crates that make up the framework:
### Adding a New Workload
1. **Define the workload** in `testing-framework/workflows/src/workloads/your_workload.rs`:
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::{Workload, RunContext, DynError};
pub struct YourWorkload {
// config fields
}
#[async_trait]
impl Workload for YourWorkload {
fn name(&self) -> &'static str { "your_workload" }
async fn start(&self, ctx: &RunContext) -> Result<(), DynError> {
// implementation
Ok(())
}
}
```
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::{DynError, RunContext, Workload};
pub struct YourWorkload;
#[async_trait]
impl Workload for YourWorkload {
fn name(&self) -> &'static str {
"your_workload"
}
async fn start(&self, _ctx: &RunContext) -> Result<(), DynError> {
// implementation
Ok(())
}
}
```
2. **Add builder extension** in `testing-framework/workflows/src/builder/mod.rs`:
```rust
pub trait ScenarioBuilderExt {
fn your_workload(self) -> YourWorkloadBuilder;
}
```
```rust
pub struct YourWorkloadBuilder;
impl YourWorkloadBuilder {
pub fn some_config(self) -> Self {
self
}
}
pub trait ScenarioBuilderExt: Sized {
fn your_workload(self) -> YourWorkloadBuilder;
}
```
3. **Use in examples** in `examples/src/bin/your_scenario.rs`:
```rust
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(3)
.executors(0)
})
.your_workload_with(|w| { // Your new DSL method with closure
w.some_config()
})
.build();
```
```rust
use testing_framework_core::scenario::ScenarioBuilder;
pub struct YourWorkloadBuilder;
impl YourWorkloadBuilder {
pub fn some_config(self) -> Self {
self
}
}
pub trait YourWorkloadDslExt: Sized {
fn your_workload_with<F>(self, configurator: F) -> Self
where
F: FnOnce(YourWorkloadBuilder) -> YourWorkloadBuilder;
}
impl<Caps> YourWorkloadDslExt for testing_framework_core::scenario::Builder<Caps> {
fn your_workload_with<F>(self, configurator: F) -> Self
where
F: FnOnce(YourWorkloadBuilder) -> YourWorkloadBuilder,
{
let _ = configurator(YourWorkloadBuilder);
self
}
}
pub fn use_in_examples() {
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(0))
.your_workload_with(|w| w.some_config())
.build();
}
```
### Adding a New Expectation
1. **Define the expectation** in `testing-framework/workflows/src/expectations/your_expectation.rs`:
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::{Expectation, RunContext, DynError};
pub struct YourExpectation {
// config fields
}
#[async_trait]
impl Expectation for YourExpectation {
fn name(&self) -> &str { "your_expectation" }
async fn evaluate(&mut self, ctx: &RunContext) -> Result<(), DynError> {
// implementation
Ok(())
}
}
```
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::{DynError, Expectation, RunContext};
pub struct YourExpectation;
#[async_trait]
impl Expectation for YourExpectation {
fn name(&self) -> &'static str {
"your_expectation"
}
async fn evaluate(&mut self, _ctx: &RunContext) -> Result<(), DynError> {
// implementation
Ok(())
}
}
```
2. **Add builder extension** in `testing-framework/workflows/src/builder/mod.rs`:
```rust
pub trait ScenarioBuilderExt {
fn expect_your_condition(self) -> Self;
}
```
```rust
use testing_framework_core::scenario::ScenarioBuilder;
pub trait YourExpectationDslExt: Sized {
fn expect_your_condition(self) -> Self;
}
impl<Caps> YourExpectationDslExt for testing_framework_core::scenario::Builder<Caps> {
fn expect_your_condition(self) -> Self {
self
}
}
pub fn use_in_examples() {
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(0))
.expect_your_condition()
.build();
}
```
### Adding a New Deployer
1. **Implement `Deployer` trait** in `testing-framework/runners/your_runner/src/deployer.rs`:
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::{Deployer, Runner, Scenario};
pub struct YourDeployer;
#[async_trait]
impl Deployer for YourDeployer {
type Error = YourError;
async fn deploy(&self, scenario: &Scenario) -> Result<Runner, Self::Error> {
// Provision infrastructure
// Wait for readiness
// Return Runner
}
}
```
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::{Deployer, Runner, Scenario};
#[derive(Debug)]
pub struct YourError;
pub struct YourDeployer;
#[async_trait]
impl Deployer for YourDeployer {
type Error = YourError;
async fn deploy(&self, _scenario: &Scenario<()>) -> Result<Runner, Self::Error> {
// Provision infrastructure
// Wait for readiness
// Return Runner
todo!()
}
}
```
2. **Provide cleanup** and handle node control if supported.

View File

@ -39,7 +39,9 @@ struct RestartWorkload;
#[async_trait]
impl Workload for RestartWorkload {
fn name(&self) -> &str { "restart_workload" }
fn name(&self) -> &str {
"restart_workload"
}
async fn start(&self, ctx: &RunContext) -> Result<(), DynError> {
if let Some(control) = ctx.node_control() {
@ -59,6 +61,10 @@ scenario builder and deploy with a runner that supports it.
The `NodeControlHandle` trait currently provides:
```rust
use async_trait::async_trait;
use testing_framework_core::scenario::DynError;
#[async_trait]
pub trait NodeControlHandle: Send + Sync {
async fn restart_validator(&self, index: usize) -> Result<(), DynError>;
async fn restart_executor(&self, index: usize) -> Result<(), DynError>;

View File

@ -38,35 +38,37 @@ POL_PROOF_DEV_MODE=true cargo run -p runner-examples --bin local_runner
**Core API Pattern** (simplified example):
```rust
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
use std::time::Duration;
// Define the scenario (1 validator + 1 executor, tx + DA workload)
let mut plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(1)
.executors(1)
})
.wallets(1_000)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(500) // use 500 of the seeded wallets
})
.da_with(|da| {
da.channel_rate(1) // 1 channel
.blob_rate(1) // target 1 blob per block
.headroom_percent(20) // default headroom when sizing channels
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
pub async fn run_local_demo() -> Result<()> {
// Define the scenario (1 validator + 1 executor, tx + DA workload)
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(1))
.wallets(1_000)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(500) // use 500 of the seeded wallets
})
.da_with(|da| {
da.channel_rate(1) // 1 channel
.blob_rate(1) // target 1 blob per block
.headroom_percent(20) // default headroom when sizing channels
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
// Deploy and run
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
// Deploy and run
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```
**Note:** The examples are binaries with `#[tokio::main]`, not test functions. If you want to write integration tests, wrap this pattern in `#[tokio::test]` functions in your own test suite.
@ -87,11 +89,15 @@ Let's unpack the code:
### 1. Topology Configuration
```rust
ScenarioBuilder::topology_with(|t| {
t.network_star() // Star topology: all nodes connect to seed
.validators(1) // 1 validator node
.executors(1) // 1 executor node (validator + DA dispersal)
use testing_framework_core::scenario::ScenarioBuilder;
pub fn step_1_topology() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::topology_with(|t| {
t.network_star() // Star topology: all nodes connect to seed
.validators(1) // 1 validator node
.executors(1) // 1 executor node (validator + DA dispersal)
})
}
```
This defines **what** your test network looks like.
@ -99,7 +105,12 @@ This defines **what** your test network looks like.
### 2. Wallet Seeding
```rust
.wallets(1_000) // Seed 1,000 funded wallet accounts
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn step_2_wallets() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1).wallets(1_000) // Seed 1,000 funded wallet accounts
}
```
Provides funded accounts for transaction submission.
@ -107,15 +118,22 @@ Provides funded accounts for transaction submission.
### 3. Workloads
```rust
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(500) // Use 500 of the 1,000 wallets
})
.da_with(|da| {
da.channel_rate(1) // 1 DA channel (more spawned with headroom)
.blob_rate(1) // target 1 blob per block
.headroom_percent(20)// default headroom when sizing channels
})
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn step_3_workloads() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1)
.wallets(1_000)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(500) // Use 500 of the 1,000 wallets
})
.da_with(|da| {
da.channel_rate(1) // 1 DA channel (more spawned with headroom)
.blob_rate(1) // target 1 blob per block
.headroom_percent(20) // default headroom when sizing channels
})
}
```
Generates both transaction and DA traffic to stress both subsystems.
@ -123,7 +141,12 @@ Generates both transaction and DA traffic to stress both subsystems.
### 4. Expectation
```rust
.expect_consensus_liveness()
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn step_4_expectation() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1).expect_consensus_liveness() // This says what success means: blocks must be produced continuously.
}
```
This says **what success means**: blocks must be produced continuously.
@ -131,7 +154,13 @@ This says **what success means**: blocks must be produced continuously.
### 5. Run Duration
```rust
.with_run_duration(Duration::from_secs(60))
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
pub fn step_5_run_duration() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1).with_run_duration(Duration::from_secs(60))
}
```
Run for 60 seconds (~27 blocks with default 2s slots, 0.9 coefficient). Framework ensures this is at least 2× the consensus slot duration.
@ -139,9 +168,19 @@ Run for 60 seconds (~27 blocks with default 2s slots, 0.9 coefficient). Framewor
### 6. Deploy and Execute
```rust
let deployer = LocalDeployer::default(); // Use local process deployer
let runner = deployer.deploy(&plan).await?; // Provision infrastructure
let _handle = runner.run(&mut plan).await?; // Execute workloads & expectations
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
pub async fn step_6_deploy_and_execute() -> Result<()> {
let mut plan = ScenarioBuilder::with_node_counts(1, 1).build();
let deployer = LocalDeployer::default(); // Use local process deployer
let runner = deployer.deploy(&plan).await?; // Provision infrastructure
let _handle = runner.run(&mut plan).await?; // Execute workloads & expectations
Ok(())
}
```
**Deployer** provisions the infrastructure. **Runner** orchestrates execution.
@ -207,13 +246,20 @@ cargo run -p runner-examples --bin compose_runner
**In code:** Just swap the deployer:
```rust
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
// ... same scenario definition ...
pub async fn run_with_compose_deployer() -> Result<()> {
// ... same scenario definition ...
let mut plan = ScenarioBuilder::with_node_counts(1, 1).build();
let deployer = ComposeDeployer::default(); // Use Docker Compose
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
let deployer = ComposeDeployer::default(); // Use Docker Compose
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}
```
## Next Steps

View File

@ -9,21 +9,22 @@ interpret results correctly.
Describe **what** you want to test, not **how** to orchestrate it:
```rust
// Good: declarative
ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(2)
.executors(1)
})
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
// Bad: imperative (framework doesn't work this way)
// spawn_validator(); spawn_executor();
// loop { submit_tx(); check_block(); }
pub fn declarative_over_imperative() {
// Good: declarative
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
// Bad: imperative (framework doesn't work this way)
// spawn_validator(); spawn_executor();
// loop { submit_tx(); check_block(); }
}
```
**Why it matters:** The framework handles deployment, readiness, and cleanup.
@ -39,22 +40,25 @@ Reason in **blocks** and **consensus intervals**, not wall-clock seconds.
- Expected rate: ~27 blocks per minute
```rust
// Good: protocol-oriented thinking
let plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(2)
.executors(1)
})
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.with_run_duration(Duration::from_secs(60)) // Let framework calculate expected blocks
.expect_consensus_liveness() // "Did we produce the expected blocks?"
.build();
use std::time::Duration;
// Bad: wall-clock assumptions
// "I expect exactly 30 blocks in 60 seconds"
// This breaks on slow CI where slot timing might drift
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn protocol_time_not_wall_time() {
// Good: protocol-oriented thinking
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.with_run_duration(Duration::from_secs(60)) // Let framework calculate expected blocks
.expect_consensus_liveness() // "Did we produce the expected blocks?"
.build();
// Bad: wall-clock assumptions
// "I expect exactly 30 blocks in 60 seconds"
// This breaks on slow CI where slot timing might drift
}
```
**Why it matters:** Slot timing is fixed (2s by default, NTP-synchronized), so the
@ -73,37 +77,37 @@ not "blocks produced in exact wall-clock seconds".
**Chaos is opt-in:**
```rust
// Separate: functional test (deterministic)
let plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(2)
.executors(1)
})
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
use std::time::Duration;
// Separate: chaos test (introduces randomness)
let chaos_plan = ScenarioBuilder::topology_with(|t| {
t.network_star()
.validators(3)
.executors(2)
})
.enable_node_control()
.chaos_with(|c| {
c.restart()
.min_delay(Duration::from_secs(30))
.max_delay(Duration::from_secs(60))
.target_cooldown(Duration::from_secs(45))
.apply()
})
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub fn determinism_first() {
// Separate: functional test (deterministic)
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
// Separate: chaos test (introduces randomness)
let _chaos_plan =
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.enable_node_control()
.chaos_with(|c| {
c.restart()
.min_delay(Duration::from_secs(30))
.max_delay(Duration::from_secs(60))
.target_cooldown(Duration::from_secs(45))
.apply()
})
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
}
```
**Why it matters:** Mixing determinism with chaos creates noisy, hard-to-debug
@ -132,11 +136,25 @@ perspective.
Always run long enough for **meaningful block production**:
```rust
// Bad: too short
.with_run_duration(Duration::from_secs(5)) // ~2 blocks (with default 2s slots, 0.9 coeff)
use std::time::Duration;
// Good: enough blocks for assertions
.with_run_duration(Duration::from_secs(60)) // ~27 blocks (with default 2s slots, 0.9 coeff)
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn minimum_run_windows() {
// Bad: too short (~2 blocks with default 2s slots, 0.9 coeff)
let _too_short = ScenarioBuilder::with_node_counts(1, 0)
.with_run_duration(Duration::from_secs(5))
.expect_consensus_liveness()
.build();
// Good: enough blocks for assertions (~27 blocks with default 2s slots, 0.9
// coeff)
let _good = ScenarioBuilder::with_node_counts(1, 0)
.with_run_duration(Duration::from_secs(60))
.expect_consensus_liveness()
.build();
}
```
**Note:** Block counts assume default consensus parameters:

View File

@ -0,0 +1,23 @@
[package]
categories.workspace = true
description.workspace = true
edition.workspace = true
keywords.workspace = true
license.workspace = true
name = "doc-snippets"
publish = false
readme.workspace = true
repository.workspace = true
version.workspace = true
[dependencies]
anyhow = "1"
async-trait = { workspace = true }
testing-framework-core = { workspace = true }
testing-framework-runner-compose = { workspace = true }
testing-framework-runner-k8s = { workspace = true }
testing-framework-runner-local = { workspace = true }
testing-framework-workflows = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,14 @@
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn scenario_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(50)
.transactions_with(|txs| txs.rate(5).users(20))
.da_with(|da| da.channel_rate(1).blob_rate(2))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(90))
.build()
}

View File

@ -0,0 +1,21 @@
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::{ScenarioBuilderExt, workloads::chaos::RandomRestartWorkload};
pub fn random_restart_plan() -> testing_framework_core::scenario::Scenario<
testing_framework_core::scenario::NodeControlCapability,
> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.enable_node_control()
.with_workload(RandomRestartWorkload::new(
Duration::from_secs(45), // min delay
Duration::from_secs(75), // max delay
Duration::from_secs(120), // target cooldown
true, // include validators
true, // include executors
))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(150))
.build()
}

View File

@ -0,0 +1,38 @@
use async_trait::async_trait;
use testing_framework_core::scenario::{DynError, Expectation, RunContext};
pub struct ReachabilityExpectation {
target_idx: usize,
}
impl ReachabilityExpectation {
pub fn new(target_idx: usize) -> Self {
Self { target_idx }
}
}
#[async_trait]
impl Expectation for ReachabilityExpectation {
fn name(&self) -> &str {
"target_reachable"
}
async fn evaluate(&mut self, ctx: &RunContext) -> Result<(), DynError> {
let client = ctx
.node_clients()
.validator_clients()
.get(self.target_idx)
.ok_or_else(|| {
Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"missing target client",
)) as DynError
})?;
client
.consensus_info()
.await
.map(|_| ())
.map_err(|e| e.into())
}
}

View File

@ -0,0 +1,64 @@
use async_trait::async_trait;
use testing_framework_core::{
scenario::{DynError, Expectation, RunContext, RunMetrics, Workload},
topology::generation::GeneratedTopology,
};
pub struct ReachabilityWorkload {
target_idx: usize,
}
impl ReachabilityWorkload {
pub fn new(target_idx: usize) -> Self {
Self { target_idx }
}
}
#[async_trait]
impl Workload for ReachabilityWorkload {
fn name(&self) -> &str {
"reachability_workload"
}
fn expectations(&self) -> Vec<Box<dyn Expectation>> {
vec![Box::new(
crate::custom_workload_example_expectation::ReachabilityExpectation::new(
self.target_idx,
),
)]
}
fn init(
&mut self,
topology: &GeneratedTopology,
_run_metrics: &RunMetrics,
) -> Result<(), DynError> {
if topology.validators().get(self.target_idx).is_none() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"no validator at requested index",
)));
}
Ok(())
}
async fn start(&self, ctx: &RunContext) -> Result<(), DynError> {
let client = ctx
.node_clients()
.validator_clients()
.get(self.target_idx)
.ok_or_else(|| {
Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"missing target client",
)) as DynError
})?;
// Lightweight API call to prove reachability.
client
.consensus_info()
.await
.map(|_| ())
.map_err(|e| e.into())
}
}

View File

@ -0,0 +1,6 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn build_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0)).build() // Construct the final Scenario
}

View File

@ -0,0 +1,29 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn run_test() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(50)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(20)
})
.da_with(|da| {
da.channel_rate(1) // number of DA channels
.blob_rate(2) // target 2 blobs per block
.headroom_percent(20) // optional channel headroom
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(90))
.build();
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,14 @@
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_runner_k8s::K8sDeployer;
use testing_framework_runner_local::LocalDeployer;
pub fn deployers() {
// Local processes
let _deployer = LocalDeployer::default();
// Docker Compose
let _deployer = ComposeDeployer::default();
// Kubernetes
let _deployer = K8sDeployer::default();
}

View File

@ -0,0 +1,8 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn expectations_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.expect_consensus_liveness() // Assert blocks are produced continuously
.build()
}

View File

@ -0,0 +1,7 @@
use std::time::Duration;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_runner_k8s::K8sDeployer;
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};

View File

@ -0,0 +1,10 @@
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn run_duration_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.with_run_duration(Duration::from_secs(120)) // Run for 120 seconds
.build()
}

View File

@ -0,0 +1,9 @@
use testing_framework_core::scenario::{Builder, ScenarioBuilder};
pub fn topology() -> Builder<()> {
ScenarioBuilder::topology_with(|t| {
t.network_star() // Star topology (all connect to seed node)
.validators(3) // Number of validator nodes
.executors(2) // Number of executor nodes
})
}

View File

@ -0,0 +1,12 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn transactions_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.wallets(50)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(20) // Use 20 of the seeded wallets
}) // Finish transaction workload config
.build()
}

View File

@ -0,0 +1,8 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn wallets_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.wallets(50) // Seed 50 funded wallet accounts
.build()
}

View File

@ -0,0 +1,17 @@
use std::time::Duration;
use testing_framework_core::scenario::{NodeControlCapability, ScenarioBuilder};
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub fn chaos_plan() -> testing_framework_core::scenario::Scenario<NodeControlCapability> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.enable_node_control() // Enable node control capability
.chaos_with(|c| {
c.restart() // Random restart chaos
.min_delay(Duration::from_secs(30)) // Min time between restarts
.max_delay(Duration::from_secs(60)) // Max time between restarts
.target_cooldown(Duration::from_secs(45)) // Cooldown after restart
.apply() // Required for chaos configuration
})
.build()
}

View File

@ -0,0 +1,13 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn da_plan() -> testing_framework_core::scenario::Scenario<()> {
ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(1))
.wallets(50)
.da_with(|da| {
da.channel_rate(1) // number of DA channels to run
.blob_rate(2) // target 2 blobs per block (headroom applied)
.headroom_percent(20) // optional headroom when sizing channels
}) // Finish DA workload config
.build()
}

View File

@ -0,0 +1,16 @@
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn execution() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(0))
.expect_consensus_liveness()
.build();
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,29 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub async fn aggressive_chaos_test() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(4).executors(2))
.enable_node_control()
.wallets(50)
.transactions_with(|txs| txs.rate(10).users(20))
.chaos_with(|c| {
c.restart()
.min_delay(Duration::from_secs(10))
.max_delay(Duration::from_secs(20))
.target_cooldown(Duration::from_secs(15))
.apply()
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(180))
.build();
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,26 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn load_progression_test() -> Result<()> {
for rate in [5, 10, 20, 30] {
println!("Testing with rate: {}", rate);
let mut plan =
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(50)
.transactions_with(|txs| txs.rate(rate).users(20))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
}
Ok(())
}

View File

@ -0,0 +1,22 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn sustained_load_test() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(4).executors(2))
.wallets(100)
.transactions_with(|txs| txs.rate(15).users(50))
.da_with(|da| da.channel_rate(2).blob_rate(3))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(300))
.build();
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,29 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub async fn chaos_resilience() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(4).executors(2))
.enable_node_control()
.wallets(20)
.transactions_with(|txs| txs.rate(3).users(10))
.chaos_with(|c| {
c.restart()
.min_delay(Duration::from_secs(20))
.max_delay(Duration::from_secs(40))
.target_cooldown(Duration::from_secs(30))
.apply()
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(120))
.build();
let deployer = ComposeDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,22 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn da_and_transactions() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.wallets(30)
.transactions_with(|txs| txs.rate(5).users(15))
.da_with(|da| da.channel_rate(2).blob_rate(2))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(90))
.build();
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,19 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn simple_consensus() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(0))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(30))
.build();
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,21 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn transaction_workload() -> Result<()> {
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(0))
.wallets(20)
.transactions_with(|txs| txs.rate(5).users(10))
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,19 @@
use async_trait::async_trait;
use testing_framework_core::scenario::{Deployer, Runner, Scenario};
#[derive(Debug)]
pub struct YourError;
pub struct YourDeployer;
#[async_trait]
impl Deployer for YourDeployer {
type Error = YourError;
async fn deploy(&self, _scenario: &Scenario<()>) -> Result<Runner, Self::Error> {
// Provision infrastructure
// Wait for readiness
// Return Runner
todo!()
}
}

View File

@ -0,0 +1,17 @@
use testing_framework_core::scenario::ScenarioBuilder;
pub trait YourExpectationDslExt: Sized {
fn expect_your_condition(self) -> Self;
}
impl<Caps> YourExpectationDslExt for testing_framework_core::scenario::Builder<Caps> {
fn expect_your_condition(self) -> Self {
self
}
}
pub fn use_in_examples() {
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(0))
.expect_your_condition()
.build();
}

View File

@ -0,0 +1,16 @@
use async_trait::async_trait;
use testing_framework_core::scenario::{DynError, Expectation, RunContext};
pub struct YourExpectation;
#[async_trait]
impl Expectation for YourExpectation {
fn name(&self) -> &'static str {
"your_expectation"
}
async fn evaluate(&mut self, _ctx: &RunContext) -> Result<(), DynError> {
// implementation
Ok(())
}
}

View File

@ -0,0 +1,11 @@
pub struct YourWorkloadBuilder;
impl YourWorkloadBuilder {
pub fn some_config(self) -> Self {
self
}
}
pub trait ScenarioBuilderExt: Sized {
fn your_workload(self) -> YourWorkloadBuilder;
}

View File

@ -0,0 +1,16 @@
use async_trait::async_trait;
use testing_framework_core::scenario::{DynError, RunContext, Workload};
pub struct YourWorkload;
#[async_trait]
impl Workload for YourWorkload {
fn name(&self) -> &'static str {
"your_workload"
}
async fn start(&self, _ctx: &RunContext) -> Result<(), DynError> {
// implementation
Ok(())
}
}

View File

@ -0,0 +1,31 @@
use testing_framework_core::scenario::ScenarioBuilder;
pub struct YourWorkloadBuilder;
impl YourWorkloadBuilder {
pub fn some_config(self) -> Self {
self
}
}
pub trait YourWorkloadDslExt: Sized {
fn your_workload_with<F>(self, configurator: F) -> Self
where
F: FnOnce(YourWorkloadBuilder) -> YourWorkloadBuilder;
}
impl<Caps> YourWorkloadDslExt for testing_framework_core::scenario::Builder<Caps> {
fn your_workload_with<F>(self, configurator: F) -> Self
where
F: FnOnce(YourWorkloadBuilder) -> YourWorkloadBuilder,
{
let _ = configurator(YourWorkloadBuilder);
self
}
}
pub fn use_in_examples() {
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(0))
.your_workload_with(|w| w.some_config())
.build();
}

View File

@ -0,0 +1,46 @@
#![allow(dead_code, unused_imports, unused_variables)]
mod architecture_overview_builder_api;
mod chaos_workloads_random_restart;
mod custom_workload_example_expectation;
mod custom_workload_example_workload;
mod dsl_cheat_sheet_build;
mod dsl_cheat_sheet_build_complete_example;
mod dsl_cheat_sheet_deployers;
mod dsl_cheat_sheet_expectations;
mod dsl_cheat_sheet_imports;
mod dsl_cheat_sheet_run_duration;
mod dsl_cheat_sheet_topology;
mod dsl_cheat_sheet_transactions_workload;
mod dsl_cheat_sheet_wallets;
mod dsl_cheat_sheet_workload_chaos;
mod dsl_cheat_sheet_workload_da;
mod dsl_cheat_sheet_workload_execution;
mod examples_advanced_aggressive_chaos_test;
mod examples_advanced_load_progression_test;
mod examples_advanced_sustained_load_test;
mod examples_chaos_resilience;
mod examples_da_and_transactions;
mod examples_simple_consensus;
mod examples_transaction_workload;
mod internal_crate_reference_add_deployer;
mod internal_crate_reference_add_expectation_builder_ext;
mod internal_crate_reference_add_expectation_trait;
mod internal_crate_reference_add_workload_builder_ext;
mod internal_crate_reference_add_workload_trait;
mod internal_crate_reference_add_workload_use_in_examples;
mod node_control_accessing_control;
mod node_control_trait;
mod quickstart_adjust_topology;
mod quickstart_core_api_pattern;
mod quickstart_step_1_topology;
mod quickstart_step_2_wallets;
mod quickstart_step_3_workloads;
mod quickstart_step_4_expectation;
mod quickstart_step_5_run_duration;
mod quickstart_step_6_deploy_and_execute;
mod quickstart_swap_deployer_compose;
mod testing_philosophy_declarative_over_imperative;
mod testing_philosophy_determinism_first;
mod testing_philosophy_minimum_run_windows;
mod testing_philosophy_protocol_time_not_wall_time;

View File

@ -0,0 +1,19 @@
use async_trait::async_trait;
use testing_framework_core::scenario::{DynError, RunContext, Workload};
struct RestartWorkload;
#[async_trait]
impl Workload for RestartWorkload {
fn name(&self) -> &str {
"restart_workload"
}
async fn start(&self, ctx: &RunContext) -> Result<(), DynError> {
if let Some(control) = ctx.node_control() {
// Restart the first validator (index 0) if supported.
control.restart_validator(0).await?;
}
Ok(())
}
}

View File

@ -0,0 +1,8 @@
use async_trait::async_trait;
use testing_framework_core::scenario::DynError;
#[async_trait]
pub trait NodeControlHandle: Send + Sync {
async fn restart_validator(&self, index: usize) -> Result<(), DynError>;
async fn restart_executor(&self, index: usize) -> Result<(), DynError>;
}

View File

@ -0,0 +1,16 @@
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
pub async fn run_with_env_overrides() -> Result<()> {
// Uses NOMOS_DEMO_* env vars (or legacy *_DEMO_* vars)
let mut plan = ScenarioBuilder::with_node_counts(3, 2)
.with_run_duration(std::time::Duration::from_secs(120))
.build();
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,31 @@
use std::time::Duration;
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
use testing_framework_workflows::ScenarioBuilderExt;
pub async fn run_local_demo() -> Result<()> {
// Define the scenario (1 validator + 1 executor, tx + DA workload)
let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(1).executors(1))
.wallets(1_000)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(500) // use 500 of the seeded wallets
})
.da_with(|da| {
da.channel_rate(1) // 1 channel
.blob_rate(1) // target 1 blob per block
.headroom_percent(20) // default headroom when sizing channels
})
.expect_consensus_liveness()
.with_run_duration(Duration::from_secs(60))
.build();
// Deploy and run
let deployer = LocalDeployer::default();
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,9 @@
use testing_framework_core::scenario::ScenarioBuilder;
pub fn step_1_topology() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::topology_with(|t| {
t.network_star() // Star topology: all nodes connect to seed
.validators(1) // 1 validator node
.executors(1) // 1 executor node (validator + DA dispersal)
})
}

View File

@ -0,0 +1,6 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn step_2_wallets() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1).wallets(1_000) // Seed 1,000 funded wallet accounts
}

View File

@ -0,0 +1,16 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn step_3_workloads() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1)
.wallets(1_000)
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
.users(500) // Use 500 of the 1,000 wallets
})
.da_with(|da| {
da.channel_rate(1) // 1 DA channel (more spawned with headroom)
.blob_rate(1) // target 1 blob per block
.headroom_percent(20) // default headroom when sizing channels
})
}

View File

@ -0,0 +1,6 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn step_4_expectation() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1).expect_consensus_liveness() // This says what success means: blocks must be produced continuously.
}

View File

@ -0,0 +1,7 @@
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
pub fn step_5_run_duration() -> testing_framework_core::scenario::Builder<()> {
ScenarioBuilder::with_node_counts(1, 1).with_run_duration(Duration::from_secs(60))
}

View File

@ -0,0 +1,13 @@
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_local::LocalDeployer;
pub async fn step_6_deploy_and_execute() -> Result<()> {
let mut plan = ScenarioBuilder::with_node_counts(1, 1).build();
let deployer = LocalDeployer::default(); // Use local process deployer
let runner = deployer.deploy(&plan).await?; // Provision infrastructure
let _handle = runner.run(&mut plan).await?; // Execute workloads & expectations
Ok(())
}

View File

@ -0,0 +1,14 @@
use anyhow::Result;
use testing_framework_core::scenario::{Deployer, ScenarioBuilder};
use testing_framework_runner_compose::ComposeDeployer;
pub async fn run_with_compose_deployer() -> Result<()> {
// ... same scenario definition ...
let mut plan = ScenarioBuilder::with_node_counts(1, 1).build();
let deployer = ComposeDeployer::default(); // Use Docker Compose
let runner = deployer.deploy(&plan).await?;
let _handle = runner.run(&mut plan).await?;
Ok(())
}

View File

@ -0,0 +1,16 @@
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn declarative_over_imperative() {
// Good: declarative
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
// Bad: imperative (framework doesn't work this way)
// spawn_validator(); spawn_executor();
// loop { submit_tx(); check_block(); }
}

View File

@ -0,0 +1,31 @@
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt};
pub fn determinism_first() {
// Separate: functional test (deterministic)
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
// Separate: chaos test (introduces randomness)
let _chaos_plan =
ScenarioBuilder::topology_with(|t| t.network_star().validators(3).executors(2))
.enable_node_control()
.chaos_with(|c| {
c.restart()
.min_delay(Duration::from_secs(30))
.max_delay(Duration::from_secs(60))
.target_cooldown(Duration::from_secs(45))
.apply()
})
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.expect_consensus_liveness()
.build();
}

View File

@ -0,0 +1,19 @@
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn minimum_run_windows() {
// Bad: too short (~2 blocks with default 2s slots, 0.9 coeff)
let _too_short = ScenarioBuilder::with_node_counts(1, 0)
.with_run_duration(Duration::from_secs(5))
.expect_consensus_liveness()
.build();
// Good: enough blocks for assertions (~27 blocks with default 2s slots, 0.9
// coeff)
let _good = ScenarioBuilder::with_node_counts(1, 0)
.with_run_duration(Duration::from_secs(60))
.expect_consensus_liveness()
.build();
}

View File

@ -0,0 +1,19 @@
use std::time::Duration;
use testing_framework_core::scenario::ScenarioBuilder;
use testing_framework_workflows::ScenarioBuilderExt;
pub fn protocol_time_not_wall_time() {
// Good: protocol-oriented thinking
let _plan = ScenarioBuilder::topology_with(|t| t.network_star().validators(2).executors(1))
.transactions_with(|txs| {
txs.rate(5) // 5 transactions per block
})
.with_run_duration(Duration::from_secs(60)) // Let framework calculate expected blocks
.expect_consensus_liveness() // "Did we produce the expected blocks?"
.build();
// Bad: wall-clock assumptions
// "I expect exactly 30 blocks in 60 seconds"
// This breaks on slow CI where slot timing might drift
}