Skip to main content

ferridriver_bdd/
lib.rs

1#![allow(
2  clippy::missing_errors_doc,
3  clippy::missing_panics_doc,
4  clippy::must_use_candidate,
5  clippy::doc_markdown,
6  clippy::module_name_repetitions,
7  clippy::cast_possible_truncation,
8  clippy::cast_precision_loss,
9  clippy::cast_sign_loss,
10  clippy::redundant_closure_for_method_calls,
11  clippy::implicit_clone,
12  clippy::too_many_lines,
13  clippy::uninlined_format_args,
14  clippy::type_complexity,
15  clippy::unnecessary_map_or,
16  clippy::match_same_arms,
17  clippy::should_implement_trait,
18  clippy::unnecessary_wraps,
19  clippy::unused_async,
20  clippy::items_after_statements,
21  clippy::needless_pass_by_value,
22  clippy::single_match_else,
23  clippy::vec_init_then_push,
24  clippy::from_over_into,
25  clippy::single_char_pattern,
26  clippy::ptr_arg,
27  clippy::unnecessary_sort_by,
28  clippy::collapsible_match,
29  clippy::if_same_then_else,
30  clippy::single_match
31)]
32// BDD step proc macros use BrowserWorld, DataTable etc. in expanded code; clippy can't see through macros.
33#![allow(unused_imports)]
34//! ferridriver-bdd: BDD/Cucumber/Gherkin support for ferridriver.
35//!
36//! This crate provides:
37//! - Gherkin `.feature` file parsing and scenario expansion
38//! - Cucumber expression step matching
39//! - Step registry with proc macro registration (`#[given]`, `#[when]`, `#[then]`)
40//! - Hook system with tag filtering
41//! - Translation of Gherkin features into `TestPlan` for the core `TestRunner`
42//! - 109 built-in step definitions covering navigation, interaction, assertions, etc.
43//! - BDD-specific reporters (Gherkin terminal, Cucumber JSON, JUnit, JSON)
44//!
45//! # Quick Start
46//!
47//! Create a binary crate with custom steps and call `bdd_main!()`:
48//!
49//! ```ignore
50//! use ferridriver_bdd::prelude::*;
51//!
52//! // Custom step definitions -- auto-registered via inventory
53//! #[given("I am logged in as {string}")]
54//! async fn login(world: &mut BrowserWorld, username: String) {
55//!     world.page().goto("https://app.example.com/login", None).await.map_err(|e| step_err!("{e}"))?;
56//!     world.page().locator("#email", None).fill(&username, None).await.map_err(|e| step_err!("{e}"))?;
57//!     world.page().locator("#password", None).fill("secret", None).await.map_err(|e| step_err!("{e}"))?;
58//!     world.page().locator("button[type=submit]", None).click(None).await.map_err(|e| step_err!("{e}"))?;
59//! }
60//!
61//! #[then("I should see the dashboard")]
62//! async fn see_dashboard(world: &mut BrowserWorld) {
63//!     let loc = world.page().locator("[data-testid=dashboard]", None);
64//!     ferridriver_test::expect::expect(&loc).to_be_visible().await.map_err(|e| step_err!("{e}"))?;
65//! }
66//!
67//! // Entry point -- discovers .feature files, collects all steps, runs via TestRunner
68//! ferridriver_bdd::bdd_main!();
69//! ```
70//!
71//! Then run: `cargo run -- features/**/*.feature --tags "@smoke"`
72
73// Allow the proc macros to reference `ferridriver_bdd::` paths within this crate.
74extern crate self as ferridriver_bdd;
75
76// Re-export proc macros.
77pub use ferridriver_bdd_macros::{after, before, given, param_type, step, then, when};
78
79// Re-export inventory so proc macro expansions can find it in downstream crates.
80pub use inventory;
81
82pub mod data_table;
83pub mod executor;
84pub mod expression;
85pub mod feature;
86pub mod filter;
87pub mod hook;
88pub mod js;
89pub mod param_type;
90pub mod registry;
91// Reporters have been unified into ferridriver_test::reporter (including bdd/ submodule).
92pub mod scenario;
93pub mod snippet;
94pub mod step;
95pub mod steps;
96pub mod translate;
97pub mod world;
98
99/// Prelude: commonly used types for step definition files.
100pub mod prelude {
101  pub use crate::step::{DataTable, StepError, StepParam};
102  pub use crate::step_err;
103  pub use crate::world::BrowserWorld;
104
105  // Re-export proc macros.
106  pub use ferridriver_bdd_macros::{after, before, given, param_type, step, then, when};
107
108  // Re-export ferridriver types commonly used in steps.
109  pub use ferridriver::Page;
110}
111
112/// Convenience macro for creating step errors.
113#[macro_export]
114macro_rules! step_err {
115  ($($arg:tt)*) => {
116    $crate::step::StepError::from(format!($($arg)*))
117  };
118}
119
120/// BDD test harness entry point.
121///
122/// Generates a `main()` that:
123/// 1. Collects all `#[given]`/`#[when]`/`#[then]` steps via `inventory`
124/// 2. Discovers and parses `.feature` files
125/// 3. Translates scenarios into a `TestPlan`
126/// 4. Runs via the core `TestRunner` (same worker pool, parallel dispatch,
127///    retries, reporters as E2E and component tests)
128///
129/// # Usage
130///
131/// ```ignore
132/// use ferridriver_bdd::prelude::*;
133///
134/// #[given("I do something")]
135/// async fn my_step(world: &mut BrowserWorld) { /* ... */ }
136///
137/// ferridriver_bdd::bdd_main!();
138/// ```
139///
140/// Run with:
141/// ```sh
142/// cargo test -p my-bdd-tests                          # run all
143/// cargo test -p my-bdd-tests -- --headed --workers 2  # headed, 2 workers
144/// ```
145///
146/// Environment variables for BDD-specific config:
147/// - `FERRIDRIVER_FEATURES` -- comma-separated feature file globs (default: `features/**/*.feature`)
148/// - `FERRIDRIVER_TAGS` -- tag filter expression (e.g., `@smoke and not @wip`)
149#[macro_export]
150macro_rules! bdd_main {
151  () => {
152    fn main() {
153      $crate::run_bdd_harness();
154    }
155  };
156}
157
158/// Entry point called by `bdd_main!()`.
159///
160/// Discovers features, builds step registry, translates to `TestPlan`,
161/// and runs via the core `TestRunner` with full parallel execution,
162/// retries, sharding, and reporter support.
163pub fn run_bdd_harness() {
164  ferridriver_test::logging::init_from_env();
165
166  let rt = tokio::runtime::Builder::new_multi_thread()
167    .enable_all()
168    .build()
169    .expect("failed to build tokio runtime");
170
171  let exit_code = rt.block_on(async {
172    let overrides = ferridriver_test::parse_common_cli_args();
173    let config = ferridriver_test::config::resolve_config(&overrides).unwrap_or_else(|e| {
174      eprintln!("config error: {e}");
175      std::process::exit(1);
176    });
177    run_bdd_with(config, overrides).await
178  });
179
180  std::process::exit(exit_code);
181}
182
183/// Run BDD scenarios against a pre-resolved `TestConfig` and `CliOverrides`.
184///
185/// Used by both [`run_bdd_harness`] (for `bdd_main!()` binaries) and the
186/// unified `ferridriver bdd` CLI subcommand. The caller is responsible for
187/// loading `FerridriverConfig` and applying any subcommand-specific overrides
188/// before calling this function.
189///
190/// Returns the exit code for the run (0 = success).
191pub async fn run_bdd_with(
192  mut config: ferridriver_test::config::TestConfig,
193  overrides: ferridriver_test::config::CliOverrides,
194) -> i32 {
195  use std::sync::Arc;
196
197  // BDD-specific feature glob defaults from env vars (used by the macro entry
198  // point; the CLI subcommand sets `config.features` explicitly).
199  let feature_patterns = std::env::var("FERRIDRIVER_FEATURES")
200    .ok()
201    .map(|s| s.split(',').map(String::from).collect::<Vec<_>>())
202    .unwrap_or_else(|| vec!["features/**/*.feature".to_string()]);
203
204  if config.features.is_empty() {
205    config.features = feature_patterns;
206  }
207
208  if let Ok(tags) = std::env::var("FERRIDRIVER_TAGS") {
209    if config.tags.is_none() {
210      config.tags = Some(tags);
211    }
212  }
213
214  // BDD-specific CLI overrides.
215  if let Some(ref tags) = overrides.bdd_tags {
216    config.tags = Some(tags.clone());
217  }
218  if overrides.bdd_dry_run {
219    config.dry_run = true;
220  }
221  if overrides.bdd_fail_fast {
222    config.fail_fast = true;
223  }
224  if let Some(t) = overrides.bdd_step_timeout {
225    config.timeout = t;
226  }
227  if overrides.bdd_strict {
228    config.strict = true;
229  }
230  if let Some(ref order) = overrides.bdd_order {
231    config.order = order.clone();
232  }
233  if overrides.bdd_language.is_some() {
234    config.language = overrides.bdd_language.clone();
235  }
236  if let Some(s) = overrides.world_parameters.as_deref() {
237    match serde_json::from_str::<serde_json::Value>(s) {
238      Ok(v) => config.world_parameters = v,
239      Err(e) => {
240        eprintln!("--world-parameters: invalid JSON: {e}");
241        return 1;
242      },
243    }
244  }
245
246  let feature_set = match feature::FeatureSet::discover_and_parse(&config.features, &config.test_ignore) {
247    Ok(fs) => fs,
248    Err(e) => {
249      eprintln!("feature discovery error: {e}");
250      return 1;
251    },
252  };
253
254  if feature_set.features.is_empty() {
255    eprintln!("no feature files found matching: {:?}", config.features);
256    return 0;
257  }
258
259  // JS step files take the QuickJS path; otherwise inventory-collected
260  // Rust steps. `--steps` overrides `[test].steps`. Top-level
261  // `extensions` (config or `FERRIDRIVER_EXTENSIONS`) are bundled in the
262  // same module so one extension serves MCP tools AND BDD steps.
263  let js_globs: Vec<String> = if overrides.bdd_steps.is_empty() {
264    config.steps.clone()
265  } else {
266    overrides.bdd_steps.clone()
267  };
268  let extensions: Vec<String> = if overrides.extensions.is_empty() {
269    std::env::var("FERRIDRIVER_EXTENSIONS")
270      .ok()
271      .map(|s| {
272        s.split(',')
273          .map(str::trim)
274          .filter(|s| !s.is_empty())
275          .map(String::from)
276          .collect()
277      })
278      .unwrap_or_default()
279  } else {
280    overrides.extensions.clone()
281  };
282  let plan = if js_globs.is_empty() && extensions.is_empty() {
283    let registry = Arc::new(registry::StepRegistry::build());
284    translate::translate_features(&feature_set, registry, &config)
285  } else {
286    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
287    // rolldown-bundle + tree-shake + transpile the whole step +
288    // extension graph to one module, compiled to bytecode once.
289    let bundle = match js::bundle_steps_with(&js_globs, &extensions, &cwd).await {
290      Ok(b) => b,
291      Err(e) => {
292        eprintln!("step bundle error: {e}");
293        return 1;
294      },
295    };
296    js::translate_features_js(&feature_set, &config, bundle, cwd)
297  };
298
299  if plan.total_tests == 0 {
300    eprintln!("no scenarios found");
301    return 0;
302  }
303
304  config.has_bdd = true;
305  let mut runner = ferridriver_test::runner::TestRunner::new(config, overrides);
306  runner.run(plan).await
307}