use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::process::{Command, Stdio};
use std::str::FromStr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use clap::Parser;
use miden_client::grpc_support::{DEVNET_PROVER_ENDPOINT, TESTNET_PROVER_ENDPOINT};
use miden_client::rpc::Endpoint;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
use crate::tests::config::{ClientConfig, NoteTransportEndpoint};
mod generated_tests;
mod tests;
fn main() {
let args = Args::parse();
init_tracing(args.verbose);
if let Some(ref test_name) = args.internal_run_test {
run_single_test_subprocess(&args, test_name);
return;
}
let all_tests = generated_tests::get_all_tests();
let filtered_tests = filter_tests(all_tests, &args);
if args.list {
list_tests(&filtered_tests);
return;
}
if filtered_tests.is_empty() {
println!("No tests match the specified filters.");
return;
}
let base_config = match BaseConfig::try_from(args.clone()) {
Ok(config) => config,
Err(e) => {
eprintln!("Error: Failed to create configuration: {e}");
std::process::exit(1);
},
};
let start_time = Instant::now();
let results = run_tests_with_retries(filtered_tests, base_config, args.jobs, args.retry_count);
let total_duration = start_time.elapsed();
print_summary(&results, total_duration);
let final_failed_count = results.iter().filter(|r| r.failed()).count();
if final_failed_count > 0 {
std::process::exit(1);
}
}
fn init_tracing(verbose: bool) {
if std::env::var("RUST_LOG").is_ok() {
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer().with_target(true).with_writer(std::io::stderr))
.init();
return;
}
if !verbose {
return;
}
tracing_subscriber::registry()
.with(EnvFilter::new(
"miden_client_integration_tests=info,miden_client::test_utils=info",
))
.with(tracing_subscriber::fmt::layer().with_target(true).with_writer(std::io::stderr))
.init();
}
#[derive(Parser, Clone)]
#[command(
name = "miden-client-integration-tests",
about = "Integration tests for the Miden client library",
version
)]
struct Args {
#[arg(short, long, default_value = "localhost", env = "TEST_MIDEN_NETWORK")]
network: Network,
#[arg(long, env = "TEST_MIDEN_RPC_URL")]
rpc_url: Option<String>,
#[arg(short, long, default_value = "10000")]
timeout: u64,
#[arg(short, long, default_value_t = num_cpus::get())]
jobs: usize,
#[arg(short, long)]
filter: Option<String>,
#[arg(long)]
list: bool,
#[arg(long)]
contains: Option<String>,
#[arg(long)]
exclude: Option<String>,
#[arg(long, default_value = "3")]
retry_count: usize,
#[arg(long, env = "TEST_MIDEN_PROVER_URL")]
prover_url: Option<String>,
#[arg(long, env = "TEST_MIDEN_NOTE_TRANSPORT_URL")]
note_transport_url: Option<String>,
#[arg(short, long)]
verbose: bool,
#[arg(long, hide = true)]
internal_run_test: Option<String>,
}
#[derive(Clone)]
struct BaseConfig {
rpc_endpoint: Endpoint,
timeout: u64,
prover_endpoint: Option<String>,
note_transport_endpoint: Option<NoteTransportEndpoint>,
verbose: bool,
}
impl TryFrom<Args> for BaseConfig {
type Error = anyhow::Error;
fn try_from(args: Args) -> Result<Self, Self::Error> {
let endpoint = if let Some(ref rpc_url) = args.rpc_url {
Endpoint::try_from(rpc_url.as_str())
.map_err(|e| anyhow::anyhow!("Invalid RPC URL: {rpc_url}: {e}"))?
} else {
Endpoint::try_from(args.network.to_rpc_endpoint().as_str())
.map_err(|e| anyhow::anyhow!("Invalid network: {:?}: {}", args.network, e))?
};
let timeout_ms = args.timeout;
let prover_endpoint = if let Some(url) = args.prover_url {
match url.to_lowercase().as_str() {
"localhost" => None,
"devnet" => Some(DEVNET_PROVER_ENDPOINT.to_string()),
"testnet" => Some(TESTNET_PROVER_ENDPOINT.to_string()),
_ => Some(url),
}
} else {
match &args.network {
Network::Testnet => Some(TESTNET_PROVER_ENDPOINT.to_string()),
Network::Devnet => Some(DEVNET_PROVER_ENDPOINT.to_string()),
_ => None,
}
};
let note_transport_endpoint = if let Some(url) = args.note_transport_url {
Some(url.parse::<NoteTransportEndpoint>().unwrap())
} else {
match &args.network {
Network::Testnet => Some(NoteTransportEndpoint::Testnet),
Network::Devnet => Some(NoteTransportEndpoint::Devnet),
_ => None,
}
};
Ok(BaseConfig {
rpc_endpoint: endpoint,
timeout: timeout_ms,
prover_endpoint,
note_transport_endpoint,
verbose: args.verbose,
})
}
}
type TestFunction = Box<
dyn Fn(ClientConfig) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> + Send + Sync,
>;
struct TestCase {
name: String,
category: TestCategory,
function: TestFunction,
}
impl TestCase {
fn new<F, Fut>(name: &str, category: TestCategory, func: F) -> Self
where
F: Fn(ClientConfig) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<(), anyhow::Error>> + 'static,
{
Self {
name: name.to_string(),
category,
function: Box::new(move |config| Box::pin(func(config))),
}
}
}
impl std::fmt::Debug for TestCase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TestCase")
.field("name", &self.name)
.field("category", &self.category)
.field("function", &"<function>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum TestCategory {
Agglayer,
Batch,
Client,
CustomTransaction,
Fpi,
NetworkFpi,
NetworkTransaction,
NoteTags,
Onchain,
PassThrough,
PswapTransaction,
SwapTransaction,
Transport,
}
impl AsRef<str> for TestCategory {
fn as_ref(&self) -> &str {
match self {
TestCategory::Agglayer => "agglayer",
TestCategory::Batch => "batch",
TestCategory::Client => "client",
TestCategory::CustomTransaction => "custom_transaction",
TestCategory::Fpi => "fpi",
TestCategory::NetworkFpi => "network_fpi",
TestCategory::NetworkTransaction => "network_transaction",
TestCategory::NoteTags => "note_tags",
TestCategory::Onchain => "onchain",
TestCategory::PassThrough => "pass_through",
TestCategory::PswapTransaction => "pswap_transaction",
TestCategory::SwapTransaction => "swap_transaction",
TestCategory::Transport => "transport",
}
}
}
#[derive(Debug, Clone)]
struct AttemptResult {
name: String,
category: String,
passed: bool,
duration: Duration,
error_message: Option<String>,
captured_output: Option<String>,
}
#[derive(Debug, Clone)]
struct TestResult {
name: String,
category: String,
attempts: Vec<AttemptResult>,
}
impl TestResult {
fn passed(&self) -> bool {
self.attempts.last().is_some_and(|a| a.passed)
}
fn failed(&self) -> bool {
!self.passed()
}
fn retries(&self) -> usize {
self.attempts.len().saturating_sub(1)
}
fn is_flaky(&self) -> bool {
self.passed() && self.retries() > 0
}
fn duration(&self) -> Duration {
self.attempts.last().map_or(Duration::ZERO, |a| a.duration)
}
fn error_message(&self) -> Option<&str> {
self.attempts.last().and_then(|a| a.error_message.as_deref())
}
fn captured_output(&self) -> Option<&str> {
self.attempts.last().and_then(|a| a.captured_output.as_deref())
}
}
#[derive(Debug, Serialize, Deserialize)]
struct SubprocessResult {
name: String,
category: String,
passed: bool,
duration_secs: f64,
error_message: Option<String>,
}
impl SubprocessResult {
fn passed(name: &str, category: &TestCategory, duration: Duration) -> Self {
Self {
name: name.to_string(),
category: category.as_ref().to_string(),
passed: true,
duration_secs: duration.as_secs_f64(),
error_message: None,
}
}
fn failed(name: &str, category: &TestCategory, duration: Duration, error: &str) -> Self {
Self {
name: name.to_string(),
category: category.as_ref().to_string(),
passed: false,
duration_secs: duration.as_secs_f64(),
error_message: Some(error.to_string()),
}
}
fn error(name: &str, error: &str) -> Self {
Self {
name: name.to_string(),
category: "unknown".to_string(),
passed: false,
duration_secs: 0.0,
error_message: Some(error.to_string()),
}
}
}
fn run_single_test_subprocess(args: &Args, test_name: &str) {
let all_tests = generated_tests::get_all_tests();
let test = all_tests.into_iter().find(|t| t.name == test_name);
let Some(test) = test else {
let result = SubprocessResult::error(test_name, "Test not found");
println!("{}", serde_json::to_string(&result).unwrap());
std::process::exit(1);
};
let base_config = match BaseConfig::try_from(args.clone()) {
Ok(c) => c,
Err(e) => {
let result = SubprocessResult::error(test_name, &e.to_string());
println!("{}", serde_json::to_string(&result).unwrap());
std::process::exit(1);
},
};
let start = Instant::now();
let rt = tokio::runtime::Runtime::new().unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
rt.block_on(async {
let config = ClientConfig::new(base_config.rpc_endpoint.clone(), base_config.timeout)
.with_prover_endpoint(base_config.prover_endpoint.clone())
.with_note_transport_endpoint(base_config.note_transport_endpoint.clone());
(test.function)(config).await
})
}));
let duration = start.elapsed();
let subprocess_result = match result {
Ok(Ok(_)) => SubprocessResult::passed(test_name, &test.category, duration),
Ok(Err(e)) => {
SubprocessResult::failed(test_name, &test.category, duration, &format_error_report(e))
},
Err(panic) => {
let msg = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "Unknown panic".into());
SubprocessResult::failed(test_name, &test.category, duration, &msg)
},
};
println!("{}", serde_json::to_string(&subprocess_result).unwrap());
std::process::exit(if subprocess_result.passed { 0 } else { 1 });
}
fn filter_tests(tests: Vec<TestCase>, args: &Args) -> Vec<TestCase> {
let mut filtered_tests = tests;
if let Some(ref filter_pattern) = args.filter {
if let Ok(regex) = Regex::new(filter_pattern) {
filtered_tests.retain(|test| regex.is_match(&test.name));
} else {
eprintln!("Warning: Invalid regex pattern in filter: {filter_pattern}");
}
}
if let Some(ref contains) = args.contains {
filtered_tests.retain(|test| test.name.contains(contains));
}
if let Some(ref exclude_pattern) = args.exclude {
if let Ok(regex) = Regex::new(exclude_pattern) {
filtered_tests.retain(|test| !regex.is_match(&test.name));
} else {
eprintln!("Warning: Invalid regex pattern in exclude: {exclude_pattern}");
}
}
filtered_tests
}
fn list_tests(tests: &[TestCase]) {
println!("Available tests:");
println!("================");
let mut tests_by_category: BTreeMap<TestCategory, Vec<&TestCase>> = BTreeMap::new();
for test in tests {
tests_by_category.entry(test.category.clone()).or_default().push(test);
}
for (category, tests) in tests_by_category {
println!("\n{}:", category.as_ref().to_uppercase());
for test in tests {
println!(" - {}", test.name);
}
}
println!("\nTotal: {} tests", tests.len());
}
fn format_error_report(error: anyhow::Error) -> String {
let mut output = String::new();
let mut first = true;
for err in error.chain() {
if !first {
output.push_str("\n Caused by: ");
}
output.push_str(&format!("{err}"));
first = false;
}
output
}
fn run_tests_with_retries(
tests: Vec<TestCase>,
base_config: BaseConfig,
jobs: usize,
retry_count: usize,
) -> Vec<TestResult> {
let initial_attempts = run_tests_parallel(tests, base_config.clone(), jobs, false);
let mut results: Vec<TestResult> = initial_attempts
.into_iter()
.map(|attempt| TestResult {
name: attempt.name.clone(),
category: attempt.category.clone(),
attempts: vec![attempt],
})
.collect();
for retry in 1..=retry_count {
let failed_names: Vec<&str> =
results.iter().filter(|r| r.failed()).map(|r| r.name.as_str()).collect();
if failed_names.is_empty() {
break;
}
println!("\n=== RETRY ATTEMPT {retry}/{retry_count} ===");
println!("Retrying {} failed test(s)...", failed_names.len());
let tests_to_retry: Vec<TestCase> = generated_tests::get_all_tests()
.into_iter()
.filter(|t| failed_names.contains(&t.name.as_str()))
.collect();
for attempt in run_tests_parallel(tests_to_retry, base_config.clone(), jobs, true) {
if let Some(result) = results.iter_mut().find(|r| r.name == attempt.name) {
result.attempts.push(attempt);
}
}
}
results
}
fn run_tests_parallel(
tests: Vec<TestCase>,
base_config: BaseConfig,
jobs: usize,
is_retry: bool,
) -> Vec<AttemptResult> {
let total_tests = tests.len();
let current_exe = std::env::current_exe().expect("Failed to get current executable");
println!();
let run_type = if is_retry { "Retrying" } else { "Starting" };
println!("{run_type} {total_tests} tests across {jobs} workers");
if !is_retry {
println!("─────────────────────────────────────────────────────────");
println!(" RPC endpoint: {}", base_config.rpc_endpoint);
println!(
" Prover: {}",
base_config.prover_endpoint.as_deref().unwrap_or("localhost")
);
println!(
" Transport: {}",
base_config
.note_transport_endpoint
.as_ref()
.map(|e| e.to_string())
.as_deref()
.unwrap_or("none")
);
println!(" Timeout: {}ms", base_config.timeout);
println!("─────────────────────────────────────────────────────────");
}
println!();
let test_info: Vec<(String, String)> = tests
.iter()
.map(|t| (t.name.clone(), t.category.as_ref().to_string()))
.collect();
let results = Arc::new(Mutex::new(Vec::new()));
let completed_count = Arc::new(AtomicUsize::new(0));
let work_queue = Arc::new(Mutex::new(test_info));
let output_mutex = Arc::new(Mutex::new(()));
let network_endpoint = base_config.rpc_endpoint.to_string();
let prover_endpoint = base_config.prover_endpoint.clone();
let note_transport_endpoint = base_config.note_transport_endpoint.clone();
let timeout = base_config.timeout;
let verbose = base_config.verbose;
let mut handles = Vec::new();
for _worker_id in 0..jobs {
let work_queue = Arc::clone(&work_queue);
let results = Arc::clone(&results);
let completed_count = Arc::clone(&completed_count);
let output_mutex = Arc::clone(&output_mutex);
let current_exe = current_exe.clone();
let network_endpoint = network_endpoint.clone();
let prover_endpoint = prover_endpoint.clone();
let note_transport_endpoint = note_transport_endpoint.clone();
let handle = thread::spawn(move || {
loop {
let test = {
let mut queue = work_queue.lock().unwrap();
queue.pop()
};
let Some((test_name, test_category)) = test else {
break; };
{
let _lock = output_mutex.lock().unwrap();
println!(" START {}::{}", test_category, test_name);
}
let mut cmd = Command::new(¤t_exe);
cmd.arg("--internal-run-test")
.arg(&test_name)
.arg("--network")
.arg(&network_endpoint)
.arg("--timeout")
.arg(timeout.to_string());
if let Some(ref prover_url) = prover_endpoint {
cmd.arg("--prover-url").arg(prover_url);
}
if let Some(ref transport) = note_transport_endpoint {
cmd.arg("--note-transport-url").arg(transport.to_url());
}
if verbose {
cmd.arg("--verbose");
}
let output = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output();
let progress = completed_count.fetch_add(1, Ordering::SeqCst) + 1;
let result = match output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let subprocess_result: Option<SubprocessResult> =
stdout.lines().last().and_then(|line| serde_json::from_str(line).ok());
let stdout_lines: Vec<&str> = stdout.lines().collect();
let captured_stdout: String = if stdout_lines.len() > 1 {
stdout_lines[..stdout_lines.len() - 1].join("\n")
} else {
String::new()
};
let captured_output =
if captured_stdout.trim().is_empty() && stderr.trim().is_empty() {
None
} else if captured_stdout.trim().is_empty() {
Some(stderr.to_string())
} else if stderr.trim().is_empty() {
Some(captured_stdout)
} else {
Some(format!("{}\n{}", captured_stdout, stderr))
};
match subprocess_result {
Some(sr) => AttemptResult {
name: sr.name,
category: sr.category,
passed: sr.passed,
duration: Duration::from_secs_f64(sr.duration_secs),
error_message: sr.error_message,
captured_output,
},
None => AttemptResult {
name: test_name.clone(),
category: test_category.clone(),
passed: false,
duration: Duration::ZERO,
error_message: Some(format!(
"Failed to parse subprocess output: {}",
stdout
)),
captured_output: Some(stderr.to_string()),
},
}
},
Err(e) => AttemptResult {
name: test_name.clone(),
category: test_category.clone(),
passed: false,
duration: Duration::ZERO,
error_message: Some(format!("Failed to spawn subprocess: {}", e)),
captured_output: None,
},
};
{
let _lock = output_mutex.lock().unwrap();
let status = if result.passed { "PASS" } else { "FAIL" };
let retry_marker = if is_retry { " (retry)" } else { "" };
let duration_str = format_duration(result.duration);
println!(
"[{:>3}/{:>3}] {:>4}{} {:>8} {}::{}",
progress,
total_tests,
status,
retry_marker,
duration_str,
result.category,
result.name,
);
if !result.passed
&& let Some(ref error) = result.error_message
{
println!(" Error: {error}");
}
if (verbose || !result.passed)
&& let Some(ref output) = result.captured_output
&& !output.trim().is_empty()
{
for line in output.lines() {
println!(" {line}");
}
}
}
results.lock().unwrap().push(result);
}
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
Arc::try_unwrap(results).unwrap().into_inner().unwrap()
}
fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs_f64();
if secs >= 60.0 {
let mins = (secs / 60.0).floor() as u64;
let remaining_secs = secs - (mins as f64 * 60.0);
format!("{}m {:.1}s", mins, remaining_secs)
} else if secs >= 1.0 {
format!("{:.2}s", secs)
} else {
format!("{}ms", duration.as_millis())
}
}
fn print_summary(results: &[TestResult], total_duration: Duration) {
let passed_count = results.iter().filter(|r| r.passed()).count();
let failed_count = results.iter().filter(|r| r.failed()).count();
let flaky_tests: Vec<_> = results.iter().filter(|r| r.is_flaky()).collect();
let has_retries = results.iter().any(|r| r.retries() > 0);
println!();
println!("─────────────────────────────────────────────────────────");
println!(" Summary");
println!("─────────────────────────────────────────────────────────");
if has_retries {
println!(
" {} passed, {} failed, {} flaky in {}",
passed_count,
failed_count,
flaky_tests.len(),
format_duration(total_duration)
);
} else {
println!(
" {} passed, {} failed in {}",
passed_count,
failed_count,
format_duration(total_duration)
);
}
if !flaky_tests.is_empty() {
println!();
println!(" Flaky tests (passed on retry):");
for result in &flaky_tests {
println!(" {}::{}", result.category, result.name);
}
}
let failures: Vec<_> = results.iter().filter(|r| r.failed()).collect();
if !failures.is_empty() {
println!();
println!(" Failures:");
for result in &failures {
println!(" FAIL {}::{}", result.category, result.name);
if let Some(error) = result.error_message() {
println!(" Error: {error}");
}
if let Some(output) = result.captured_output() {
println!(" ─── Captured stdout ───");
for line in output.lines() {
println!(" {line}");
}
println!(" ─── End stdout ───");
}
}
}
if results.len() > 1 {
let mut durations: Vec<_> = results.iter().map(|r| r.duration()).collect();
durations.sort();
let avg_duration = durations.iter().sum::<Duration>() / durations.len() as u32;
let median_duration = durations[durations.len() / 2];
let slowest = results.iter().max_by_key(|r| r.duration()).unwrap();
println!();
println!(
" Timing: avg {} | median {} | slowest {} ({}::{})",
format_duration(avg_duration),
format_duration(median_duration),
format_duration(slowest.duration()),
slowest.category,
slowest.name,
);
}
println!("─────────────────────────────────────────────────────────");
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum Network {
Custom(String),
Devnet,
Localhost,
Testnet,
}
impl FromStr for Network {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"devnet" => Ok(Network::Devnet),
"localhost" => Ok(Network::Localhost),
"testnet" => Ok(Network::Testnet),
custom => Ok(Network::Custom(custom.to_string())),
}
}
}
impl Network {
#[allow(dead_code)]
pub fn to_rpc_endpoint(&self) -> String {
match self {
Network::Custom(custom) => custom.clone(),
Network::Devnet => Endpoint::devnet().to_string(),
Network::Localhost => Endpoint::default().to_string(),
Network::Testnet => Endpoint::testnet().to_string(),
}
}
}