Files
7dac077487 test: add cucumber environment with initial ported integration tests (#741)
This PR introduces the initial Cucumber-based integration test framework for the LEZ, building on the testing-framework integration work started by @andrussal, adds the first set of Cucumber integration scenarios and establishes reusable infrastructure for future Cucumber scenarios.

---------

Co-authored-by: Andrus Salumets <salumets.andrus@gmail.com>
Co-authored-by: Sergio Chouhy <sergio.chouhy@gmail.com>
Co-authored-by: Sergio Chouhy <41742639+schouhy@users.noreply.github.com>
Co-authored-by: Roman <zajic@zajic.net>
Co-authored-by: Daniil Polyakov <arjentix@gmail.com>
Co-authored-by: Moudy <m.ellaz@hotmail.com>
Co-authored-by: andrussal <salumets.andrus@gmail.com>
2026-08-20 21:15:05 +02:00

237 lines
9.0 KiB
Rust

use std::{
collections::HashMap,
fs::OpenOptions,
io,
path::{Path, PathBuf},
sync::{Arc, Mutex, atomic::AtomicBool},
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::Context as _;
use cucumber::{
StatsWriter as _, World as _, WriterExt as _, event::ScenarioFinished, writer,
writer::Verbosity,
};
use integration_tests::cucumber::{
default::{
ARTEFACTS, CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL, MAX_CUCUMBER_CONCURRENT_SCENARIOS,
RUST_LOG, TF_KEEP_LOGS, create_scenario_output_dir, get_feature_path, get_retries,
},
world::CucumberWorld,
};
use logos_blockchain_testing_framework::{
hash_str, is_truthy_env, reap_all_stale_port_blocks, release_reserved_port_block,
};
use tracing::{info, warn};
use wallet::SUPPRESS_VERBOSE_PRINTS;
pub const TARGET: &str = "cucumber_main";
type ScenarioAttempts = Arc<Mutex<HashMap<String, u8>>>;
fn main() -> anyhow::Result<()> {
logos_blockchain_testing_framework::env::set_default_env(SUPPRESS_VERBOSE_PRINTS, "true");
logos_blockchain_testing_framework::env::set_default_env(RUST_LOG, "info");
logos_blockchain_testing_framework::env::set_default_env(TF_KEEP_LOGS, "true");
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to build Cucumber Tokio runtime")?;
runtime.block_on(async_main())
}
async fn async_main() -> anyhow::Result<()> {
integration_tests::cucumber::default::init_tracing();
reap_all_stale_port_blocks();
info!(target: TARGET, "args: {:?}", std::env::args());
let scenario_attempts: ScenarioAttempts = Arc::new(Mutex::new(HashMap::new()));
let teardown_failed = Arc::new(AtomicBool::new(false));
let output_dir = create_scenario_output_dir()?;
let junit_xml_file = OpenOptions::new()
.append(true)
.create(true)
.open(output_dir.join("cucumber-output-junit.xml"))
.context("failed to create or open Cucumber JUnit output file")?;
let mut world = CucumberWorld::cucumber()
// Re-outputs Failed steps for easier navigation.
.repeat_failed()
// .fail_fast() // Remove comment to enable fail-fast behavior for development
.max_concurrent_scenarios(get_max_concurrent_scenarios())
// Ensure that all the steps were covered.
.fail_on_skipped()
// Replaces Writer.
.with_writer(
writer::Summarize::new(writer::Basic::new(
io::stdout(),
// With `writer::Coloring::Auto`, cucumber treats the output as a TTY and using the
// underlying termcolor/console behaviour that can rewrite/clear lines when
// printing step statuses (✔ ...). That can visually clobber the
// immediately adjacent tracing line, especially the one emitted
// right as the step transitions from “running” to “passed”.
writer::Coloring::Never,
Verbosity::ShowWorldAndDocString,
))
.tee::<CucumberWorld, _>(writer::JUnit::for_tee(junit_xml_file, 0))
.normalized(),
)
// Sets a hook, executed on each Scenario before running all its Steps, including Background
// ones.
.before(move |feature, _rule, scenario, world| {
Box::pin({
let output_dir_clone = output_dir.clone();
let scenario_attempts_clone = ScenarioAttempts::clone(&scenario_attempts);
async move {
info!(target: TARGET,
"\nStarting - {}: {} ({}: {})\n",
scenario.keyword, scenario.name, feature.keyword, feature.name,
);
prepare_world_for_scenario(
world,
&output_dir_clone,
&scenario_attempts_clone,
&feature.name,
&scenario.name,
);
}
})
});
if let Some(retries) = get_retries()? {
// Makes failed Scenarios being retried the specified number of times.
world = world.retries(retries);
}
let teardown_failure_flag = Arc::clone(&teardown_failed);
let failed = world
.after(move |feature, _rule, scenario, scenario_finished, world| {
let teardown_failure_flag = Arc::clone(&teardown_failure_flag);
Box::pin(async move {
// Runs after the scenario has completed; useful for capturing final state/logs.
info!(target: TARGET,
"\nFinished - {}: {} ({}: {})\n",
scenario.keyword, scenario.name, feature.keyword, feature.name,
);
if let Some(world) = world {
let path = world.scenario_base_dir.join("debug_dump_file.log");
if let Some(parent) = path.parent() {
let _unused = std::fs::create_dir_all(parent);
}
let _initial_debug_write = std::fs::write(&path, world.full_debug_info_string());
let teardown_result = world.stop_runtime().await;
if let Err(error) = &teardown_result {
teardown_failure_flag.store(true, std::sync::atomic::Ordering::Relaxed);
warn!(target: TARGET, "Cucumber runtime teardown failed: {error}");
}
// Rewrite the dump after teardown so teardown state and
// any teardown error are retained for diagnostics. The
// first write above deliberately captures the live
// runtime state before handles are released.
let _final_debug_write = std::fs::write(&path, world.full_debug_info_string());
if teardown_result.is_ok()
&& matches!(scenario_finished, ScenarioFinished::StepPassed)
&& is_truthy_env(CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL)
{
info!(target: TARGET,
"Env var '{CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL}' set, removing all \
artefacts\n"
);
if let Err(e) = world.clear_scenario_artifacts() {
warn!(target: TARGET, "{e}");
}
}
}
})
})
// Runs Cucumber. Features sourced from a Parser are fed to a Runner, which
// produces events handled by a Writer.
.run(get_feature_path()?)
.await;
// Clean up manually reserved handshake port block files for this process
release_reserved_port_block();
if failed.execution_has_failed() || teardown_failed.load(std::sync::atomic::Ordering::Relaxed) {
anyhow::bail!("Cucumber scenarios failed");
}
Ok(())
}
// Get the maximum number of concurrent scenarios from env var, defaults to 1
fn get_max_concurrent_scenarios() -> usize {
std::env::var(MAX_CUCUMBER_CONCURRENT_SCENARIOS)
.ok()
.and_then(|val| val.parse().ok())
.unwrap_or(1)
}
fn prepare_world_for_scenario(
world: &mut CucumberWorld,
output_dir: &Path,
scenario_attempts: &ScenarioAttempts,
feature_name: &str,
scenario_name: &str,
) {
let scenario_dir =
scenario_output_dir(output_dir, scenario_attempts, feature_name, scenario_name);
if let Err(err) = std::fs::create_dir_all(&scenario_dir) {
warn!(target: TARGET,
"Failed to create scenario artifact directory '{}': {err}",
scenario_dir.display()
);
}
world.set_scenario_base_dir(&scenario_dir);
world.apply_deployment_config_override_path();
let started_at_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let raw_context = format!("{}::{started_at_ns}", scenario_dir.display());
world.set_test_context(hash_str(&raw_context));
}
fn scenario_output_dir(
output_dir: &Path,
scenario_attempts: &ScenarioAttempts,
feature_name: &str,
scenario_name: &str,
) -> PathBuf {
let run_attempt = increment_attempts(scenario_attempts, feature_name, scenario_name);
output_dir
.join(ARTEFACTS)
.join(feature_name)
.join(scenario_name.trim().replace(' ', "_"))
.join(run_attempt)
}
// Increment and return the attempt count for the given scenario. Counts
// are tracked per-scenario, and keyed by a combination of feature and
// scenario name.
fn increment_attempts(
scenario_attempts: &ScenarioAttempts,
feature: &str,
scenario: &str,
) -> String {
let key = format!("{feature}::{scenario}");
let attempt = {
let mut guard = scenario_attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let entry = guard.entry(key).or_insert(0);
*entry = entry.wrapping_add(1);
*entry
};
format!("attempt_{attempt}")
}