use anyhow::Result;
use futures::stream::StreamExt;
use std::path::Path;
use std::sync::Arc;
use tracing::{info, warn};
use crate::cli::Cli;
use crate::cli::args::RunArgs;
use crate::config;
use crate::execution;
use crate::parser;
use crate::parser::ast::{SectionContent, SectionType};
use crate::report;
use crate::state::{TestMeta, TestResult, TestResults};
use crate::utils::FileUtils;
fn extract_test_meta(doc: &parser::ast::GctfDocument) -> TestMeta {
let mut meta = doc
.sections
.iter()
.find_map(|s: &parser::ast::Section| {
if let SectionContent::Meta(m) = &s.content
&& s.section_type == SectionType::Meta
{
return Some(TestMeta::from_file_meta(m));
}
None
})
.unwrap_or_default();
if meta.tags.is_empty() {
for section in &doc.sections {
if let Some(tag_attr) = section.get_attribute("tag") {
for t in tag_attr.value.split(',') {
let trimmed = t.trim();
if !trimmed.is_empty() && !meta.tags.contains(&trimmed.to_string()) {
meta.tags.push(trimmed.to_string());
}
}
}
}
}
if meta.owner.is_none() {
for section in &doc.sections {
if let Some(owner_attr) = section.get_attribute("owner") {
meta.owner = Some(owner_attr.value.clone());
break;
}
}
}
if meta.summary.is_none() {
for section in &doc.sections {
if let Some(summary_attr) = section.get_attribute("summary") {
meta.summary = Some(summary_attr.value.clone());
break;
}
}
}
meta
}
fn file_matches_meta(path: &Path, tags_include: &[String], skip_tags: &[String]) -> bool {
let parse_result = parser::parse_with_recovery(path);
let doc = parse_result.document;
let meta = doc.sections.iter().find_map(|s: &parser::ast::Section| {
if let SectionContent::Meta(m) = &s.content
&& s.section_type == SectionType::Meta
{
Some(m)
} else {
None
}
});
let file_tags: Vec<&str> = meta
.map(|m| m.tags.iter().map(|s| s.as_str()).collect())
.unwrap_or_default();
for tag in tags_include {
if !file_tags.iter().any(|t| t == tag) {
return false;
}
}
if !skip_tags.is_empty() && file_tags.iter().any(|t| skip_tags.iter().any(|e| t == e)) {
return false;
}
true
}
fn should_retry_message(message: &str) -> bool {
let msg = message.to_ascii_lowercase();
msg.contains("deadline")
|| msg.contains("timeout")
|| msg.contains("timed out")
|| msg.contains("connection refused")
|| msg.contains("connection reset")
|| msg.contains("transport")
|| msg.contains("unavailable")
|| msg.contains("broken pipe")
|| msg.contains("temporarily")
|| msg.contains("network")
}
pub async fn run_tests(cli: &Cli, args: &RunArgs) -> Result<()> {
let parallel_jobs = cli.parallel_jobs();
info!("Parallel jobs: {}", parallel_jobs);
if args.dry_run {
info!("Dry-run mode enabled");
}
if args.no_assert {
info!("No-assert mode enabled (skipping assertions)");
}
let mut test_files = Vec::new();
let exclude_patterns = &args.exclude;
for path in &args.test_paths {
if path.is_dir() {
test_files.extend(FileUtils::collect_test_files(path, exclude_patterns));
} else if path.is_file() {
test_files.push(path.clone());
}
}
let has_meta_filters = !args.tags.is_empty() || !args.skip_tags.is_empty();
if has_meta_filters {
let tags_inc: Vec<String> = args
.tags
.iter()
.flat_map(|t| t.split(','))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let tags_exc: Vec<String> = args
.skip_tags
.iter()
.flat_map(|t| t.split(','))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
test_files.retain(|path| file_matches_meta(path, &tags_inc, &tags_exc));
info!("Filtered to {} test file(s) by META", test_files.len());
}
info!("Found {} test file(s)", test_files.len());
if test_files.is_empty() {
warn!("No test files found");
return Ok(());
}
FileUtils::sort_files(&mut test_files, &args.sort);
if args.stream {
} else if test_files.len() == 1 {
println!(
"ℹ️ INFO [{}]: Running 1 test sequentially...",
chrono::Local::now().format("%H:%M:%S")
);
} else if parallel_jobs <= 1 {
println!(
"ℹ️ INFO [{}]: Running {} test(s) sequentially...",
chrono::Local::now().format("%H:%M:%S"),
test_files.len()
);
} else {
println!(
"ℹ️ INFO [{}]: Running {} test(s) in parallel (jobs: {})...",
chrono::Local::now().format("%H:%M:%S"),
test_files.len(),
parallel_jobs
);
}
let mut reporters: Vec<Box<dyn report::Reporter>> = Vec::new();
let env_info = report::console::EnvironmentInfo {
address: std::env::var(config::ENV_GRPCTESTIFY_ADDRESS)
.unwrap_or_else(|_| config::default_address()),
parallel_jobs,
sort_mode: args.sort.clone(),
dry_run: args.dry_run,
};
if args.stream {
reporters.push(Box::new(report::StreamingJsonReporter::new(
test_files.len(),
)));
} else {
let mode = match cli.progress_mode() {
crate::cli::args::ProgressMode::Dots => report::ConsoleMode::Dots,
crate::cli::args::ProgressMode::Verbose => report::ConsoleMode::Verbose,
crate::cli::args::ProgressMode::None => report::ConsoleMode::Silent,
};
reporters.push(Box::new(report::ConsoleReporter::new(
mode,
test_files.len() as u64,
env_info,
)));
}
if let Some(format) = cli.log_format_mode() {
if let Some(output_path) = &args.log_output {
match format {
crate::cli::LogFormat::Json => {
reporters.push(Box::new(report::JsonReporter::new(output_path.clone())));
}
crate::cli::LogFormat::JUnit => {
reporters.push(Box::new(report::JunitReporter::new(output_path.clone())));
}
crate::cli::LogFormat::Allure => {
reporters.push(Box::new(report::AllureReporter::new(output_path.clone())));
}
crate::cli::LogFormat::Yaml => {
reporters.push(Box::new(report::YamlReporter::new(output_path.clone())));
}
_ => {}
}
} else {
warn!(
"--log-format specified but --log-output is missing. File report will be skipped."
);
}
}
let mut test_results = TestResults::new();
let coverage_collector = if args.coverage {
Some(Arc::new(report::CoverageCollector::new()))
} else {
None
};
let start_time = std::time::Instant::now();
let runner = Arc::new(execution::TestRunner::new(
args.dry_run,
args.timeout,
args.no_assert,
args.write,
cli.verbose,
coverage_collector.clone(),
));
let reporters: Arc<Vec<Box<dyn report::Reporter>>> = Arc::new(reporters);
let stream = futures::stream::iter(test_files)
.map(|file| {
let runner = runner.clone();
let reporters = reporters.clone();
let file_path_str = file.to_string_lossy().to_string();
let file_clone = file.clone();
async move {
for r in reporters.iter() {
r.on_test_start(&file_path_str);
}
let test_start = std::time::Instant::now();
let mut test_result = match run_single_test(
&runner,
&file_clone,
args.retry,
args.retry_delay,
args.no_retry,
)
.await
{
Ok(res) => {
let call_duration = res.call_duration_ms;
let meta = res.meta;
match res.status {
execution::TestExecutionStatus::Pass => TestResult::pass_with_meta(
file_path_str.clone(),
0,
call_duration,
meta,
),
execution::TestExecutionStatus::Fail(msg) => {
TestResult::fail_with_meta(
file_path_str.clone(),
msg,
0,
call_duration,
meta,
)
}
}
}
Err(e) => TestResult::fail(
file_path_str.clone(),
format!("Execution error: {}", e),
0,
None,
),
};
test_result.duration_ms = test_start.elapsed().as_millis() as u64;
for r in reporters.iter() {
r.on_test_end(&file_path_str, &test_result);
}
test_result
}
})
.buffer_unordered(parallel_jobs);
let results: Vec<TestResult> = stream.collect().await;
for result in results {
test_results.add(result);
}
let total_duration = start_time.elapsed().as_millis() as u64;
test_results.metrics.total_duration_ms = total_duration;
test_results.metrics.parallel_jobs = parallel_jobs;
for r in reporters.iter() {
r.on_suite_end(&test_results)?;
}
if let Some(collector) = coverage_collector {
if args.is_json_coverage() {
let report = collector.generate_json_report();
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
let report = collector.generate_text_report();
if !args.stream {
println!("\n{}", report);
}
}
}
if !test_results.all_passed() {
std::process::exit(1);
}
Ok(())
}
async fn run_single_test(
runner: &execution::TestRunner,
file: &std::path::Path,
retry: u32,
retry_delay: f64,
no_retry: bool,
) -> Result<execution::TestExecutionResult> {
let doc = match parser::parse_gctf(file) {
Ok(d) => d,
Err(e) => {
return Ok(execution::TestExecutionResult::fail(
format!("Parse error: {}", e),
None,
));
}
};
let test_meta = extract_test_meta(&doc);
if let Err(e) = parser::validate_document(&doc) {
return Ok(
execution::TestExecutionResult::fail(format!("Validation error: {}", e), None)
.with_meta(test_meta),
);
}
let effective_runtime = match execution::runner_helpers::resolve_effective_runtime_options(
&doc,
execution::runner_helpers::CliRuntimeDefaults {
timeout_seconds: 30,
retry,
retry_delay_seconds: retry_delay,
no_retry,
},
) {
Ok(v) => v,
Err(e) => {
return Ok(execution::TestExecutionResult::fail(
format!("Validation error: {}", e),
None,
)
.with_meta(test_meta));
}
};
let max_retries = if effective_runtime.no_retry.value {
0
} else {
effective_runtime.retry.value
};
let mut attempt = 0u32;
let result = loop {
let current = runner.run_test(&doc).await?;
let should_retry = match ¤t.status {
execution::TestExecutionStatus::Pass => false,
execution::TestExecutionStatus::Fail(msg) => should_retry_message(msg),
};
if !should_retry || attempt >= max_retries {
break current;
}
attempt += 1;
if effective_runtime.retry_delay_seconds.value > 0.0 {
tokio::time::sleep(std::time::Duration::from_secs_f64(
effective_runtime.retry_delay_seconds.value,
))
.await;
}
};
if let Some(resp) = &result.captured_response
&& let Err(e) = crate::utils::file::update_test_file(file, &doc, resp)
{
return Ok(execution::TestExecutionResult::fail(
format!("Failed to update test file: {}", e),
result.call_duration_ms,
)
.with_meta(test_meta));
}
Ok(result.with_meta(test_meta))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::ast::{GctfAttribute, GctfDocument, Section, SectionContent, SectionType};
#[test]
fn test_extract_test_meta_from_file_meta() {
let mut doc = GctfDocument::new("test.gctf".to_string());
let file_meta = crate::parser::ast::FileMeta {
name: Some("suite name".to_string()),
tags: vec!["smoke".to_string()],
owner: Some("team-a".to_string()),
summary: Some("test summary".to_string()),
links: vec![],
};
doc.sections.push(Section {
section_type: SectionType::Meta,
content: SectionContent::Meta(file_meta),
inline_options: Default::default(),
raw_content: String::new(),
start_line: 1,
end_line: 5,
attributes: vec![],
});
let meta = extract_test_meta(&doc);
assert_eq!(meta.name, Some("suite name".to_string()));
assert_eq!(meta.tags, vec!["smoke".to_string()]);
assert_eq!(meta.owner, Some("team-a".to_string()));
assert_eq!(meta.summary, Some("test summary".to_string()));
}
#[test]
fn test_extract_test_meta_fallback_tags_from_attributes() {
let mut doc = GctfDocument::new("test.gctf".to_string());
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Empty,
inline_options: Default::default(),
raw_content: String::new(),
start_line: 1,
end_line: 2,
attributes: vec![GctfAttribute::new("tag", "smoke,integration")],
});
let meta = extract_test_meta(&doc);
assert_eq!(
meta.tags,
vec!["smoke".to_string(), "integration".to_string()]
);
}
#[test]
fn test_extract_test_meta_no_fallback_when_meta_has_tags() {
let file_meta = crate::parser::ast::FileMeta {
tags: vec!["smoke".to_string()],
..Default::default()
};
let mut doc = GctfDocument::new("test.gctf".to_string());
doc.sections.push(Section {
section_type: SectionType::Meta,
content: SectionContent::Meta(file_meta),
inline_options: Default::default(),
raw_content: String::new(),
start_line: 1,
end_line: 2,
attributes: vec![],
});
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Empty,
inline_options: Default::default(),
raw_content: String::new(),
start_line: 3,
end_line: 4,
attributes: vec![GctfAttribute::new("tag", "integration")],
});
let meta = extract_test_meta(&doc);
assert_eq!(meta.tags, vec!["smoke".to_string()]);
}
#[test]
fn test_extract_test_meta_fallback_owner_from_attributes() {
let mut doc = GctfDocument::new("test.gctf".to_string());
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Empty,
inline_options: Default::default(),
raw_content: String::new(),
start_line: 1,
end_line: 2,
attributes: vec![GctfAttribute::new("owner", "team-b")],
});
let meta = extract_test_meta(&doc);
assert_eq!(meta.owner, Some("team-b".to_string()));
}
#[test]
fn test_extract_test_meta_fallback_summary_from_attributes() {
let mut doc = GctfDocument::new("test.gctf".to_string());
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Empty,
inline_options: Default::default(),
raw_content: String::new(),
start_line: 1,
end_line: 2,
attributes: vec![GctfAttribute::new("summary", "quick test")],
});
let meta = extract_test_meta(&doc);
assert_eq!(meta.summary, Some("quick test".to_string()));
}
#[test]
fn test_extract_test_meta_dedup_tags() {
let mut doc = GctfDocument::new("test.gctf".to_string());
doc.sections.push(Section {
section_type: SectionType::Request,
content: SectionContent::Empty,
inline_options: Default::default(),
raw_content: String::new(),
start_line: 1,
end_line: 2,
attributes: vec![GctfAttribute::new("tag", "smoke")],
});
doc.sections.push(Section {
section_type: SectionType::Response,
content: SectionContent::Empty,
inline_options: Default::default(),
raw_content: String::new(),
start_line: 3,
end_line: 4,
attributes: vec![GctfAttribute::new("tag", "smoke")],
});
let meta = extract_test_meta(&doc);
assert_eq!(meta.tags, vec!["smoke".to_string()]);
}
#[test]
fn test_extract_test_meta_empty() {
let doc = GctfDocument::new("test.gctf".to_string());
let meta = extract_test_meta(&doc);
assert!(meta.is_empty());
}
}