use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::core::{RunnerConfig, SourceLocation, TestCase, TestStatus, TestSuite};
pub struct Spec {
name: String,
description: Option<String>,
tags: Vec<String>,
setup: Option<Arc<dyn Fn() + Send + Sync>>,
teardown: Option<Arc<dyn Fn() + Send + Sync>>,
before_each: Option<Arc<dyn Fn() + Send + Sync>>,
after_each: Option<Arc<dyn Fn() + Send + Sync>>,
children: Vec<Spec>,
tests: Vec<TestEntry>,
timeout: Option<Duration>,
retries: u32,
}
struct TestEntry {
name: String,
location: Option<SourceLocation>,
test_fn: Arc<dyn Fn() + Send + Sync>,
}
pub fn describe(name: &str) -> Spec {
Spec::new(name)
}
impl Spec {
pub fn new(name: &str) -> Self {
Spec {
name: name.to_owned(),
description: None,
tags: Vec::new(),
setup: None,
teardown: None,
before_each: None,
after_each: None,
children: Vec::new(),
tests: Vec::new(),
timeout: None,
retries: 0,
}
}
pub fn description(mut self, text: &str) -> Self {
self.description = Some(text.to_owned());
self
}
pub fn tag(mut self, tag: &str) -> Self {
self.tags.push(tag.to_owned());
self
}
pub fn timeout(mut self, duration: Duration) -> Self {
self.timeout = Some(duration);
self
}
pub fn retries(mut self, count: u32) -> Self {
self.retries = count;
self
}
pub fn before_all(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.setup = Some(Arc::new(hook));
self
}
pub fn after_all(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.teardown = Some(Arc::new(hook));
self
}
pub fn before_each(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.before_each = Some(Arc::new(hook));
self
}
pub fn after_each(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.after_each = Some(Arc::new(hook));
self
}
#[track_caller]
pub fn it(mut self, name: &str, test: impl Fn() + Send + Sync + 'static) -> Self {
let loc = std::panic::Location::caller();
self.tests.push(TestEntry {
name: name.to_owned(),
location: Some(SourceLocation {
file: loc.file().to_owned(),
line: loc.line(),
column: None,
}),
test_fn: Arc::new(test),
});
self
}
pub fn describe(mut self, name: &str) -> SpecBuilder {
let child_index = self.children.len();
let child = Spec::new(name);
self.children.push(child);
SpecBuilder { parent: self, path: vec![child_index] }
}
pub fn run(self) -> TestSuite {
let config = RunnerConfig::default();
self.run_with_config(&config)
}
pub fn run_with_config(self, config: &RunnerConfig) -> TestSuite {
let mut suite = TestSuite::new(&self.name);
suite.description = self.description.clone();
if config.output_capture {
crate::capture::set_capture_enabled(true);
}
let start = Instant::now();
let test_cases = self.execute_recursive("", &[], None, 0, &[], &[], config);
suite.duration = start.elapsed();
suite.tests = test_cases;
suite
}
fn has_matching(
&self,
prefix: &str,
inherited_tags: &[String],
config: &RunnerConfig,
) -> bool {
let full_name = if prefix.is_empty() {
self.name.clone()
} else {
format!("{} :: {}", prefix, self.name)
};
let merged_tags: Vec<String> = inherited_tags
.iter()
.cloned()
.chain(self.tags.iter().cloned())
.collect();
for entry in &self.tests {
let test_name = format!("{} :: {}", full_name, entry.name);
if crate::tag::tags_match(&merged_tags, config)
&& crate::tag::name_matches(&test_name, config.filter.as_deref())
{
return true;
}
}
for child in &self.children {
if child.has_matching(&full_name, &merged_tags, config) {
return true;
}
}
false
}
fn execute_recursive(
&self,
prefix: &str,
inherited_tags: &[String],
inherited_timeout: Option<Duration>,
inherited_retries: u32,
inherited_before_each: &[Arc<dyn Fn() + Send + Sync>],
inherited_after_each: &[Arc<dyn Fn() + Send + Sync>],
config: &RunnerConfig,
) -> Vec<TestCase> {
let full_name = if prefix.is_empty() {
self.name.clone()
} else {
format!("{} :: {}", prefix, self.name)
};
let merged_tags: Vec<String> = inherited_tags
.iter()
.cloned()
.chain(self.tags.iter().cloned())
.collect();
let merged_timeout = self.timeout.or(inherited_timeout).or(config.default_timeout);
let merged_retries = if self.retries > 0 {
self.retries
} else {
inherited_retries.max(config.default_retries)
};
let before_each: Vec<Arc<dyn Fn() + Send + Sync>> = inherited_before_each
.iter()
.cloned()
.chain(self.before_each.iter().cloned())
.collect();
let after_each: Vec<Arc<dyn Fn() + Send + Sync>> = inherited_after_each
.iter()
.cloned()
.chain(self.after_each.iter().cloned())
.collect();
if !self.has_matching(prefix, inherited_tags, config) {
return Vec::new();
}
let mut results = Vec::new();
if let Some(ref setup) = self.setup {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| setup()));
}
for entry in &self.tests {
let test_name = format!("{} :: {}", full_name, entry.name);
if !crate::tag::tags_match(&merged_tags, config)
|| !crate::tag::name_matches(&test_name, config.filter.as_deref())
{
continue;
}
let test_start = Instant::now();
let mut hook_failed = false;
for hook in &before_each {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hook())).is_err() {
hook_failed = true;
}
}
let (status, captured_output) = if hook_failed {
(TestStatus::Failed {
reason: "before_each hook failed".to_owned(),
location: None,
}, None)
} else {
execute_with_capture(&entry.test_fn, merged_timeout, merged_retries)
};
let duration = test_start.elapsed();
let is_failed = status.is_failed();
for hook in after_each.iter().rev() {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hook()));
}
results.push(TestCase {
name: test_name,
suite: Some(full_name.clone()),
tags: merged_tags.clone(),
status,
duration,
assertions: 0,
location: entry.location.clone(),
parameters: Vec::new(), captured_output,
});
if config.fail_fast && is_failed {
break;
}
}
let had_failures = results.iter().any(|t| t.status.is_failed());
if !config.fail_fast || !had_failures {
for child in &self.children {
let child_results = child.execute_recursive(
&full_name,
&merged_tags,
merged_timeout,
merged_retries,
&before_each,
&after_each,
config,
);
let child_failed = child_results.iter().any(|t| t.status.is_failed());
results.extend(child_results);
if config.fail_fast && child_failed {
break;
}
}
}
if let Some(ref teardown) = self.teardown {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| teardown()));
}
results
}
}
pub struct SpecBuilder {
parent: Spec,
path: Vec<usize>,
}
impl SpecBuilder {
fn child_mut(&mut self) -> &mut Spec {
let mut current = &mut self.parent;
for &idx in &self.path {
current = &mut current.children[idx];
}
current
}
pub fn description(mut self, text: &str) -> Self {
self.child_mut().description = Some(text.to_owned());
self
}
pub fn tag(mut self, tag: &str) -> Self {
self.child_mut().tags.push(tag.to_owned());
self
}
pub fn timeout(mut self, duration: Duration) -> Self {
self.child_mut().timeout = Some(duration);
self
}
pub fn retries(mut self, count: u32) -> Self {
self.child_mut().retries = count;
self
}
pub fn before_all(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.child_mut().setup = Some(Arc::new(hook));
self
}
pub fn after_all(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.child_mut().teardown = Some(Arc::new(hook));
self
}
pub fn before_each(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.child_mut().before_each = Some(Arc::new(hook));
self
}
pub fn after_each(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
self.child_mut().after_each = Some(Arc::new(hook));
self
}
#[track_caller]
pub fn it(mut self, name: &str, test: impl Fn() + Send + Sync + 'static) -> Self {
let loc = std::panic::Location::caller();
self.child_mut().tests.push(TestEntry {
name: name.to_owned(),
location: Some(SourceLocation {
file: loc.file().to_owned(),
line: loc.line(),
column: None,
}),
test_fn: Arc::new(test),
});
self
}
pub fn describe(mut self, name: &str) -> SpecBuilder {
let child = Spec::new(name);
self.child_mut().children.push(child);
let child_index = self.child_mut().children.len() - 1;
let mut path = self.path;
path.push(child_index);
SpecBuilder { parent: self.parent, path }
}
pub fn run(self) -> TestSuite {
self.parent.run()
}
pub fn run_with_config(self, config: &RunnerConfig) -> TestSuite {
self.parent.run_with_config(config)
}
}
fn run_with_retry(test: &Arc<dyn Fn() + Send + Sync>, retries: u32) -> TestStatus {
let max_attempts = retries.saturating_add(1);
for attempt in 1..=max_attempts {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(test)();
}));
match result {
Ok(_) => return TestStatus::Passed,
Err(panic_info) => {
if attempt == max_attempts {
let reason = extract_panic_message(&panic_info);
return TestStatus::Failed { reason, location: None };
}
}
}
}
TestStatus::Failed {
reason: "exhausted retries".to_owned(),
location: None,
}
}
fn run_with_timeout(
test: &Arc<dyn Fn() + Send + Sync>,
timeout: Duration,
retries: u32,
) -> TestStatus {
let test = Arc::clone(test);
let (tx, rx) = std::sync::mpsc::channel();
let _handle = std::thread::spawn(move || {
let status = run_with_retry(&test, retries);
let _ = tx.send(status);
});
match rx.recv_timeout(timeout) {
Ok(status) => status,
Err(_) => TestStatus::TimedOut { duration: timeout, location: None },
}
}
fn execute_with_capture(
test_fn: &Arc<dyn Fn() + Send + Sync>,
timeout: Option<Duration>,
retries: u32,
) -> (TestStatus, Option<String>) {
if !crate::capture::is_capture_enabled() {
let status = match timeout {
Some(to) => run_with_timeout(test_fn, to, retries),
None => run_with_retry(test_fn, retries),
};
return (status, None);
}
let test_fn = Arc::clone(test_fn);
let (status, stdout, stderr) = crate::capture::capture(move || {
match timeout {
Some(to) => run_with_timeout(&test_fn, to, retries),
None => run_with_retry(&test_fn, retries),
}
});
let output = {
let mut parts: Vec<String> = Vec::new();
if !stdout.is_empty() {
parts.push(format!("stdout:\n{}", stdout));
}
if !stderr.is_empty() {
parts.push(format!("stderr:\n{}", stderr));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
};
(status, output)
}
fn extract_panic_message(panic_info: &Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = panic_info.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.downcast_ref::<String>() {
s.clone()
} else {
"test panicked".to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn describe_creates_spec() {
let s = describe("test");
assert_eq!(s.name, "test");
assert!(s.tests.is_empty());
assert!(s.children.is_empty());
}
#[test]
fn spec_description() {
let s = Spec::new("math").description("arithmetic operations");
assert_eq!(s.description, Some("arithmetic operations".to_owned()));
}
#[test]
fn spec_tag_adds_tag() {
let s = Spec::new("x").tag("smoke").tag("fast");
assert_eq!(s.tags, vec!["smoke", "fast"]);
}
#[test]
fn spec_timeout() {
let s = Spec::new("x").timeout(Duration::from_secs(5));
assert_eq!(s.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn spec_retries() {
let s = Spec::new("x").retries(3);
assert_eq!(s.retries, 3);
}
#[test]
fn spec_before_all() {
let s = Spec::new("x").before_all(|| {});
assert!(s.setup.is_some());
}
#[test]
fn spec_after_all() {
let s = Spec::new("x").after_all(|| {});
assert!(s.teardown.is_some());
}
#[test]
fn spec_before_each() {
let s = Spec::new("x").before_each(|| {});
assert!(s.before_each.is_some());
}
#[test]
fn spec_after_each() {
let s = Spec::new("x").after_each(|| {});
assert!(s.after_each.is_some());
}
#[test]
fn spec_new_defaults() {
let s = Spec::new("empty");
assert_eq!(s.name, "empty");
assert!(s.description.is_none());
assert!(s.tags.is_empty());
assert!(s.setup.is_none());
assert!(s.teardown.is_none());
assert!(s.before_each.is_none());
assert!(s.after_each.is_none());
assert!(s.children.is_empty());
assert!(s.tests.is_empty());
assert!(s.timeout.is_none());
assert_eq!(s.retries, 0);
}
#[test]
fn spec_run_passes() {
let suite = Spec::new("pass")
.it("works", || {})
.run();
assert_eq!(suite.tests.len(), 1);
assert!(suite.tests[0].status.is_passed());
}
#[test]
fn spec_run_with_config() {
let config = RunnerConfig { default_timeout: Some(Duration::from_secs(10)), ..RunnerConfig::default() };
let suite = Spec::new("cfg")
.it("ok", || {})
.run_with_config(&config);
assert!(suite.success());
}
#[test]
fn spec_run_empty() {
let suite = Spec::new("empty").run();
assert!(suite.tests.is_empty());
assert!(suite.success());
}
#[test]
fn spec_builder_methods() {
let suite = describe("root")
.describe("child")
.description("a child spec")
.tag("nested")
.timeout(Duration::from_secs(3))
.retries(1)
.before_all(|| {})
.after_all(|| {})
.before_each(|| {})
.after_each(|| {})
.it("leaf", || {})
.run();
assert_eq!(suite.tests.len(), 1);
}
#[test]
fn run_with_timeout_integration() {
let suite = Spec::new("timeout")
.it("fast", || {})
.timeout(Duration::from_secs(5))
.run();
assert!(suite.success());
}
#[test]
fn has_matching_with_filter() {
let spec = describe("Parent")
.tag("smoke")
.it("child_test", || {});
let yes = spec.has_matching("", &[], &RunnerConfig { filter: Some("child".into()), ..RunnerConfig::default() });
assert!(yes);
let no = spec.has_matching("", &[], &RunnerConfig { filter: Some("nonexistent".into()), ..RunnerConfig::default() });
assert!(!no);
}
#[test]
fn spec_collects_hooks_inherited() {
let ran = Arc::new(std::sync::Mutex::new(Vec::new()));
let r = Arc::clone(&ran);
let spec = describe("root")
.before_each(move || r.lock().unwrap().push("root"))
.describe("child")
.it("test", move || {
ran.lock().unwrap().push("test");
});
let suite = spec.run();
assert_eq!(suite.tests.len(), 1);
}
#[test]
fn spec_run_with_empty_children() {
let suite = describe("root")
.describe("empty_child")
.run();
assert!(suite.success());
assert!(suite.tests.is_empty());
}
#[test]
fn spec_describe_chaining() {
let suite = describe("root")
.describe("a")
.tag("t1")
.it("a1", || {})
.describe("b")
.tag("t2")
.it("b1", || {})
.run();
assert_eq!(suite.tests.len(), 2);
}
#[test]
fn spec_tag_on_child() {
let suite = describe("root")
.describe("child")
.tag("exclude_me")
.it("test", || {})
.run_with_config(&RunnerConfig {
exclude_tags: vec!["exclude_me".into()],
..RunnerConfig::default()
});
assert_eq!(suite.tests.len(), 0);
}
#[test]
fn extract_panic_message_called() {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
panic!("test panic");
}));
let e = result.unwrap_err();
let msg = extract_panic_message(&e);
assert_eq!(msg, "test panic");
}
#[test]
fn spec_builder_description() {
let suite = describe("root")
.describe("child")
.description("a child spec")
.run();
let _ = suite; }
#[test]
fn spec_builder_all_methods() {
let suite = describe("root")
.describe("child")
.tag("smoke")
.timeout(Duration::from_secs(3))
.retries(2)
.before_all(|| {})
.after_all(|| {})
.before_each(|| {})
.after_each(|| {})
.it("test", || {})
.run();
assert_eq!(suite.tests.len(), 1);
assert!(suite.success());
}
#[test]
fn spec_builder_nested_describe() {
let suite = describe("root")
.describe("level1")
.describe("level2")
.it("deep", || {})
.run();
assert_eq!(suite.tests.len(), 1);
assert_eq!(suite.tests[0].name, "root :: level1 :: level2 :: deep");
}
}