Skip to main content

ferridriver_bdd/
executor.rs

1//! Single BDD execution engine for all consumers (TestRunner, MCP, standalone).
2//!
3//! `ScenarioExecutor` handles step matching, hooks, variable interpolation,
4//! timeouts, and result collection.  An optional `StepObserver` allows callers
5//! to receive per-step events in real-time (TestInfo reporting in the test
6//! runner, progress notifications in MCP, etc.).
7
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use crate::hook::HookPoint;
14use crate::registry::StepRegistry;
15use crate::scenario::{ScenarioExecution, ScenarioResult, ScenarioStatus, ScenarioStep, StepResult, StepStatus};
16use crate::translate::execute_bdd_step;
17use crate::world::BrowserWorld;
18
19// ── Step observer ───────────────────────────────────────────────────────────
20
21/// Per-step event emitted during scenario execution.
22pub struct StepEvent<'a> {
23  /// The Gherkin step definition (keyword, original text, line number).
24  pub step: &'a ScenarioStep,
25  /// The interpolated step text (after `$variable` substitution).
26  pub text: &'a str,
27  /// The execution result for this step.
28  pub result: &'a StepResult,
29}
30
31/// Callback for observing step execution in real-time.
32///
33/// Implement this to receive `StepEvent`s as each step completes (or is
34/// skipped).  The test runner uses this for `TestInfo` step events; MCP
35/// could use it for progress notifications.
36pub trait StepObserver: Send + Sync {
37  fn on_step<'a>(&'a self, event: StepEvent<'a>) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
38}
39
40/// No-op observer used when no step reporting is needed.
41pub(crate) struct NoopObserver;
42
43impl StepObserver for NoopObserver {
44  fn on_step<'a>(&'a self, _event: StepEvent<'a>) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
45    Box::pin(std::future::ready(()))
46  }
47}
48
49/// Executes BDD scenarios and individual steps without requiring the
50/// `TestRunner` or `FixturePool`.
51///
52/// The caller is responsible for constructing a `BrowserWorld` with the
53/// desired `Page` and `ContextRef`.  The executor handles step matching,
54/// hooks, variable interpolation, and result collection.
55#[derive(Clone)]
56pub struct ScenarioExecutor {
57  registry: Arc<StepRegistry>,
58  step_timeout: Duration,
59  strict: bool,
60  screenshot_on_failure: bool,
61}
62
63impl ScenarioExecutor {
64  /// Create a new executor.
65  pub fn new(registry: Arc<StepRegistry>, step_timeout: Duration, strict: bool, screenshot_on_failure: bool) -> Self {
66    Self {
67      registry,
68      step_timeout,
69      strict,
70      screenshot_on_failure,
71    }
72  }
73
74  /// Run a full scenario with hooks against the given world.
75  ///
76  /// Equivalent to `run_scenario_observed` with a no-op observer.
77  pub async fn run_scenario(&self, world: &mut BrowserWorld, scenario: &ScenarioExecution) -> ScenarioResult {
78    self.run_scenario_observed(world, scenario, &NoopObserver).await
79  }
80
81  /// Run a full scenario with hooks and a per-step observer.
82  ///
83  /// Executes: `BeforeScenario` hooks -> steps (with `BeforeStep`/`AfterStep`
84  /// hooks around each) -> `AfterScenario` hooks.  The observer is notified
85  /// after each step completes or is skipped, enabling real-time reporting.
86  pub async fn run_scenario_observed(
87    &self,
88    world: &mut BrowserWorld,
89    scenario: &ScenarioExecution,
90    observer: &dyn StepObserver,
91  ) -> ScenarioResult {
92    let start = Instant::now();
93
94    // Ensure the world has the registry for step composition (world.run_step()).
95    // Only set if not already pointing to the same registry (avoids Arc clone).
96    if world.registry_arc().as_ref().map(Arc::as_ptr) != Some(Arc::as_ptr(&self.registry)) {
97      world.set_registry(Arc::clone(&self.registry));
98    }
99
100    // Set feature directory for fixture path resolution.
101    if let Some(dir) = scenario.feature_path.parent() {
102      world.set_feature_dir(dir.to_path_buf());
103    }
104
105    // Inject Scenario Outline example values as variables.
106    if let Some(values) = &scenario.example_values {
107      for (key, val) in values {
108        world.set_var(key, val);
109      }
110    }
111
112    let mut step_results: Vec<StepResult> = Vec::with_capacity(scenario.steps.len());
113    let mut had_failure = false;
114    let mut failure_message: Option<String> = None;
115
116    // BeforeScenario hooks.
117    if let Err(e) = self
118      .registry
119      .hooks()
120      .run_scenario(HookPoint::BeforeScenario, world, &scenario.tags)
121      .await
122    {
123      return ScenarioResult {
124        feature_name: scenario.feature_name.clone(),
125        feature_path: scenario.feature_path.display().to_string(),
126        scenario_name: scenario.name.clone(),
127        status: ScenarioStatus::Failed,
128        steps: Vec::new(),
129        duration: start.elapsed(),
130        attempt: 1,
131        tags: scenario.tags.clone(),
132        error: Some(format!("BeforeScenario hook failed: {}", e.display_named())),
133        failure_screenshot: None,
134      };
135    }
136
137    // Execute steps.
138    for step in &scenario.steps {
139      if had_failure {
140        let sr = StepResult {
141          keyword: step.keyword.clone(),
142          text: step.text.clone(),
143          status: StepStatus::Skipped,
144          duration: Duration::ZERO,
145          error: Some("skipped due to previous failure".to_string()),
146        };
147        observer
148          .on_step(StepEvent {
149            step,
150            text: &step.text,
151            result: &sr,
152          })
153          .await;
154        step_results.push(sr);
155        continue;
156      }
157
158      let text = world.interpolate(&step.text);
159      let step_start = Instant::now();
160
161      // BeforeStep hooks.
162      if let Err(e) = self
163        .registry
164        .hooks()
165        .run_step(HookPoint::BeforeStep, world, &text, &scenario.tags)
166        .await
167      {
168        tracing::warn!("BeforeStep hook failed: {}", e.display_named());
169      }
170
171      // Match and execute.
172      let result = execute_bdd_step(&self.registry, world, &text, step, self.step_timeout, self.strict).await;
173
174      let step_duration = step_start.elapsed();
175
176      let sr = match result {
177        Ok(()) => StepResult {
178          keyword: step.keyword.clone(),
179          text: text.clone(),
180          status: StepStatus::Passed,
181          duration: step_duration,
182          error: None,
183        },
184        Err(e) if e.pending && !self.strict => StepResult {
185          keyword: step.keyword.clone(),
186          text: text.clone(),
187          status: StepStatus::Pending,
188          duration: step_duration,
189          error: Some(e.to_string()),
190        },
191        Err(e) => {
192          let msg = e.to_string();
193          had_failure = true;
194          failure_message = Some(msg.clone());
195          StepResult {
196            keyword: step.keyword.clone(),
197            text: text.clone(),
198            status: StepStatus::Failed,
199            duration: step_duration,
200            error: Some(msg),
201          }
202        },
203      };
204
205      observer
206        .on_step(StepEvent {
207          step,
208          text: &text,
209          result: &sr,
210        })
211        .await;
212      step_results.push(sr);
213
214      // AfterStep hooks (always, even on failure).
215      if let Err(e) = self
216        .registry
217        .hooks()
218        .run_step(HookPoint::AfterStep, world, &text, &scenario.tags)
219        .await
220      {
221        tracing::warn!("AfterStep hook failed: {}", e.display_named());
222      }
223    }
224
225    // AfterScenario hooks (always, even on failure).
226    if let Err(e) = self
227      .registry
228      .hooks()
229      .run_scenario(HookPoint::AfterScenario, world, &scenario.tags)
230      .await
231    {
232      tracing::warn!("AfterScenario hook failed: {}", e.display_named());
233    }
234
235    // Screenshot on failure.
236    let failure_screenshot = if had_failure && self.screenshot_on_failure {
237      world
238        .page()
239        .screenshot(ferridriver::options::ScreenshotOptions::default())
240        .await
241        .ok()
242    } else {
243      None
244    };
245
246    let status = if had_failure {
247      ScenarioStatus::Failed
248    } else if step_results.iter().any(|s| s.status == StepStatus::Pending) {
249      ScenarioStatus::Undefined
250    } else {
251      ScenarioStatus::Passed
252    };
253
254    ScenarioResult {
255      feature_name: scenario.feature_name.clone(),
256      feature_path: scenario.feature_path.display().to_string(),
257      scenario_name: scenario.name.clone(),
258      status,
259      steps: step_results,
260      duration: start.elapsed(),
261      attempt: 1,
262      tags: scenario.tags.clone(),
263      error: failure_message,
264      failure_screenshot,
265    }
266  }
267
268  /// Execute a single BDD step (for interactive / REPL use).
269  ///
270  /// Matches the step text against the registry and invokes the handler
271  /// directly.  No hooks are executed -- use `run_scenario` for full
272  /// lifecycle.
273  pub async fn run_step(
274    &self,
275    world: &mut BrowserWorld,
276    text: &str,
277    table: Option<&crate::data_table::DataTable>,
278    docstring: Option<&str>,
279  ) -> StepResult {
280    // Ensure registry is set for step composition (skip if already set to same).
281    if world.registry_arc().as_ref().map(Arc::as_ptr) != Some(Arc::as_ptr(&self.registry)) {
282      world.set_registry(Arc::clone(&self.registry));
283    }
284
285    let interpolated = world.interpolate(text);
286    let start = Instant::now();
287
288    // Match and execute directly -- no ScenarioStep allocation needed.
289    let result = match self.registry.find_match(&interpolated) {
290      Ok(step_match) => {
291        let handler = &step_match.def.handler;
292        match tokio::time::timeout(self.step_timeout, handler(world, step_match.params, table, docstring)).await {
293          Ok(r) => r,
294          Err(_) => Err(crate::step::StepError::from(format!(
295            "step timed out after {}ms",
296            self.step_timeout.as_millis()
297          ))),
298        }
299      },
300      Err(crate::step::MatchError::Undefined { text: t, suggestions }) => {
301        let mut msg = format!("undefined step: \"{t}\"");
302        if !suggestions.is_empty() {
303          msg.push_str("\n  did you mean:");
304          for s in &suggestions {
305            msg.push_str(&format!("\n    - {s}"));
306          }
307        }
308        if self.strict {
309          Err(crate::step::StepError::from(msg))
310        } else {
311          Err(crate::step::StepError::pending(msg))
312        }
313      },
314      Err(crate::step::MatchError::Ambiguous {
315        text: t,
316        matches,
317        expressions,
318      }) => {
319        let mut msg = format!("ambiguous step: \"{t}\" matched {} definitions:", matches.len());
320        for (i, (loc, expr)) in matches.iter().zip(expressions.iter()).enumerate() {
321          msg.push_str(&format!("\n  {}. {} ({})", i + 1, expr, loc));
322        }
323        Err(crate::step::StepError::from(msg))
324      },
325    };
326
327    let duration = start.elapsed();
328
329    match result {
330      Ok(()) => StepResult {
331        keyword: String::new(),
332        text: interpolated,
333        status: StepStatus::Passed,
334        duration,
335        error: None,
336      },
337      Err(e) if e.pending && !self.strict => StepResult {
338        keyword: String::new(),
339        text: interpolated,
340        status: StepStatus::Pending,
341        duration,
342        error: Some(e.to_string()),
343      },
344      Err(e) => StepResult {
345        keyword: String::new(),
346        text: interpolated,
347        status: StepStatus::Failed,
348        duration,
349        error: Some(e.to_string()),
350      },
351    }
352  }
353
354  /// Access the step registry.
355  pub fn registry(&self) -> &StepRegistry {
356    &self.registry
357  }
358}