use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::hook::HookPoint;
use crate::registry::StepRegistry;
use crate::scenario::{ScenarioExecution, ScenarioResult, ScenarioStatus, ScenarioStep, StepResult, StepStatus};
use crate::translate::execute_bdd_step;
use crate::world::BrowserWorld;
pub struct StepEvent<'a> {
pub step: &'a ScenarioStep,
pub text: &'a str,
pub result: &'a StepResult,
}
pub trait StepObserver: Send + Sync {
fn on_step<'a>(&'a self, event: StepEvent<'a>) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
}
pub(crate) struct NoopObserver;
impl StepObserver for NoopObserver {
fn on_step<'a>(&'a self, _event: StepEvent<'a>) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(std::future::ready(()))
}
}
#[derive(Clone)]
pub struct ScenarioExecutor {
registry: Arc<StepRegistry>,
step_timeout: Duration,
strict: bool,
screenshot_on_failure: bool,
}
impl ScenarioExecutor {
pub fn new(registry: Arc<StepRegistry>, step_timeout: Duration, strict: bool, screenshot_on_failure: bool) -> Self {
Self {
registry,
step_timeout,
strict,
screenshot_on_failure,
}
}
pub async fn run_scenario(&self, world: &mut BrowserWorld, scenario: &ScenarioExecution) -> ScenarioResult {
self.run_scenario_observed(world, scenario, &NoopObserver).await
}
pub async fn run_scenario_observed(
&self,
world: &mut BrowserWorld,
scenario: &ScenarioExecution,
observer: &dyn StepObserver,
) -> ScenarioResult {
let start = Instant::now();
if world.registry_arc().as_ref().map(Arc::as_ptr) != Some(Arc::as_ptr(&self.registry)) {
world.set_registry(Arc::clone(&self.registry));
}
if let Some(dir) = scenario.feature_path.parent() {
world.set_feature_dir(dir.to_path_buf());
}
if let Some(values) = &scenario.example_values {
for (key, val) in values {
world.set_var(key, val);
}
}
let mut step_results: Vec<StepResult> = Vec::with_capacity(scenario.steps.len());
let mut had_failure = false;
let mut failure_message: Option<String> = None;
if let Err(e) = self
.registry
.hooks()
.run_scenario(HookPoint::BeforeScenario, world, &scenario.tags)
.await
{
return ScenarioResult {
feature_name: scenario.feature_name.clone(),
feature_path: scenario.feature_path.display().to_string(),
scenario_name: scenario.name.clone(),
status: ScenarioStatus::Failed,
steps: Vec::new(),
duration: start.elapsed(),
attempt: 1,
tags: scenario.tags.clone(),
error: Some(format!("BeforeScenario hook failed: {}", e.display_named())),
failure_screenshot: None,
};
}
for step in &scenario.steps {
if had_failure {
let sr = StepResult {
keyword: step.keyword.clone(),
text: step.text.clone(),
status: StepStatus::Skipped,
duration: Duration::ZERO,
error: Some("skipped due to previous failure".to_string()),
};
observer
.on_step(StepEvent {
step,
text: &step.text,
result: &sr,
})
.await;
step_results.push(sr);
continue;
}
let text = world.interpolate(&step.text);
let step_start = Instant::now();
if let Err(e) = self
.registry
.hooks()
.run_step(HookPoint::BeforeStep, world, &text, &scenario.tags)
.await
{
tracing::warn!("BeforeStep hook failed: {}", e.display_named());
}
let result = execute_bdd_step(&self.registry, world, &text, step, self.step_timeout, self.strict).await;
let step_duration = step_start.elapsed();
let sr = match result {
Ok(()) => StepResult {
keyword: step.keyword.clone(),
text: text.clone(),
status: StepStatus::Passed,
duration: step_duration,
error: None,
},
Err(e) if e.pending && !self.strict => StepResult {
keyword: step.keyword.clone(),
text: text.clone(),
status: StepStatus::Pending,
duration: step_duration,
error: Some(e.to_string()),
},
Err(e) => {
let msg = e.to_string();
had_failure = true;
failure_message = Some(msg.clone());
StepResult {
keyword: step.keyword.clone(),
text: text.clone(),
status: StepStatus::Failed,
duration: step_duration,
error: Some(msg),
}
},
};
observer
.on_step(StepEvent {
step,
text: &text,
result: &sr,
})
.await;
step_results.push(sr);
if let Err(e) = self
.registry
.hooks()
.run_step(HookPoint::AfterStep, world, &text, &scenario.tags)
.await
{
tracing::warn!("AfterStep hook failed: {}", e.display_named());
}
}
if let Err(e) = self
.registry
.hooks()
.run_scenario(HookPoint::AfterScenario, world, &scenario.tags)
.await
{
tracing::warn!("AfterScenario hook failed: {}", e.display_named());
}
let failure_screenshot = if had_failure && self.screenshot_on_failure {
world
.page()
.screenshot(ferridriver::options::ScreenshotOptions::default())
.await
.ok()
} else {
None
};
let status = if had_failure {
ScenarioStatus::Failed
} else if step_results.iter().any(|s| s.status == StepStatus::Pending) {
ScenarioStatus::Undefined
} else {
ScenarioStatus::Passed
};
ScenarioResult {
feature_name: scenario.feature_name.clone(),
feature_path: scenario.feature_path.display().to_string(),
scenario_name: scenario.name.clone(),
status,
steps: step_results,
duration: start.elapsed(),
attempt: 1,
tags: scenario.tags.clone(),
error: failure_message,
failure_screenshot,
}
}
pub async fn run_step(
&self,
world: &mut BrowserWorld,
text: &str,
table: Option<&crate::data_table::DataTable>,
docstring: Option<&str>,
) -> StepResult {
if world.registry_arc().as_ref().map(Arc::as_ptr) != Some(Arc::as_ptr(&self.registry)) {
world.set_registry(Arc::clone(&self.registry));
}
let interpolated = world.interpolate(text);
let start = Instant::now();
let result = match self.registry.find_match(&interpolated) {
Ok(step_match) => {
let handler = &step_match.def.handler;
match tokio::time::timeout(self.step_timeout, handler(world, step_match.params, table, docstring)).await {
Ok(r) => r,
Err(_) => Err(crate::step::StepError::from(format!(
"step timed out after {}ms",
self.step_timeout.as_millis()
))),
}
},
Err(crate::step::MatchError::Undefined { text: t, suggestions }) => {
let mut msg = format!("undefined step: \"{t}\"");
if !suggestions.is_empty() {
msg.push_str("\n did you mean:");
for s in &suggestions {
msg.push_str(&format!("\n - {s}"));
}
}
if self.strict {
Err(crate::step::StepError::from(msg))
} else {
Err(crate::step::StepError::pending(msg))
}
},
Err(crate::step::MatchError::Ambiguous {
text: t,
matches,
expressions,
}) => {
let mut msg = format!("ambiguous step: \"{t}\" matched {} definitions:", matches.len());
for (i, (loc, expr)) in matches.iter().zip(expressions.iter()).enumerate() {
msg.push_str(&format!("\n {}. {} ({})", i + 1, expr, loc));
}
Err(crate::step::StepError::from(msg))
},
};
let duration = start.elapsed();
match result {
Ok(()) => StepResult {
keyword: String::new(),
text: interpolated,
status: StepStatus::Passed,
duration,
error: None,
},
Err(e) if e.pending && !self.strict => StepResult {
keyword: String::new(),
text: interpolated,
status: StepStatus::Pending,
duration,
error: Some(e.to_string()),
},
Err(e) => StepResult {
keyword: String::new(),
text: interpolated,
status: StepStatus::Failed,
duration,
error: Some(e.to_string()),
},
}
}
pub fn registry(&self) -> &StepRegistry {
&self.registry
}
}