mod config;
mod db;
mod dirs;
mod doctor;
mod feature;
mod hawk;
mod http;
mod json;
mod macros;
mod options;
mod plugin;
mod polling;
mod report;
mod resource;
mod runner;
mod srp;
mod steps;
mod unique;
mod validate;
mod vars;
mod world;
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
use std::sync::Arc;
const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
"\nGherkin acceptance testing for backend services.\n",
"\n bddkit steps list every step this binary understands",
"\n bddkit doctor --config suite.yaml check a suite before it runs",
);
#[derive(Parser)]
#[command(
name = "bddkit",
version,
long_version = LONG_VERSION,
about = "Run Gherkin scenarios against an HTTP API"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Run(RunArgs),
Steps(StepsArgs),
Doctor(DoctorArgs),
Resource(ResourceArgs),
Version,
}
#[derive(Args)]
#[command(after_help = "Examples:
bddkit resource fields every resource kind bddkit serves itself
bddkit resource fields db only the database connection keys
bddkit resource fields --config suite.yaml also the groups this suite's plugins serve
bddkit resource fields --json the same listing, machine-readable
bddkit resource add api staging --config suite.yaml --base_url http://staging.local
bddkit resource add db reporting --config suite.yaml --no-check --dsn postgres://...
# --config/--env/--json/--no-check must come before any --<field> value")]
struct ResourceArgs {
#[command(subcommand)]
command: Option<ResourceCommand>,
}
#[derive(Subcommand)]
enum ResourceCommand {
Fields(FieldsArgs),
Add(AddArgs),
}
#[derive(Args)]
struct AddArgs {
group: String,
name: String,
#[arg(long)]
config: PathBuf,
#[arg(long = "env")]
env: Option<String>,
#[arg(long)]
json: Option<String>,
#[arg(long = "no-check")]
no_check: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0..)]
fields: Vec<String>,
}
#[derive(Args)]
struct FieldsArgs {
kind: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
}
async fn resource_command(args: ResourceArgs) -> Result<i32> {
match args.command {
Some(ResourceCommand::Fields(args)) => list_fields(args),
Some(ResourceCommand::Add(mut args)) => {
if let Some(pos) = args.fields.iter().position(|f| f == "--no-check") {
args.fields.remove(pos);
args.no_check = true;
}
resource::add(resource::AddInput {
group: &args.group,
name: &args.name,
config: &args.config,
env: args.env.as_deref(),
json: args.json.as_deref(),
no_check: args.no_check,
flags: &args.fields,
})
.await
}
None => {
use clap::CommandFactory;
let mut cli = Cli::command();
cli.build();
cli.find_subcommand_mut("resource")
.expect("the resource subcommand is declared")
.print_help()?;
println!();
Ok(0)
}
}
}
fn list_fields(args: FieldsArgs) -> Result<i32> {
let mut kinds = resource::host_kinds();
if let Some(path) = &args.config {
let cfg = config::load(path, None)?;
let generator = unique::Generator::new();
if let Some(plugins) = load_plugins(path, &cfg, &generator, &dirs::Env::from_process(None))? {
kinds.extend(resource::plugin_kinds(&plugins));
}
}
if let Some(kind) = &args.kind {
if !kinds.iter().any(|k| &k.kind == kind) {
anyhow::bail!("no such resource: {kind:?}");
}
kinds.retain(|k| &k.kind == kind);
}
if args.json {
println!("{}", serde_json::to_string_pretty(&kinds)?);
} else {
print!("{}", resource::render(&kinds));
}
Ok(0)
}
#[derive(Args)]
#[command(after_help = "Examples:
bddkit doctor --config suite.yaml every static check, no socket opened
bddkit doctor --config suite.yaml --live also probe every API and database
bddkit doctor --config suite.yaml --json the same report, machine-readable
bddkit doctor --config suite.yaml --junit reports/junit.xml
also check that run's report path can be written")]
struct DoctorArgs {
#[arg(long)]
config: PathBuf,
#[arg(long = "env")]
env: Option<String>,
#[arg(long)]
live: bool,
#[arg(long)]
json: bool,
#[command(flatten)]
dir: DirArgs,
#[command(flatten)]
reports: ReportArgs,
}
async fn doctor_command(args: DoctorArgs) -> Result<i32> {
let dir_env = dirs::Env::from_process(args.dir.bddkit_dir.clone());
let report = doctor::check(
&args.config,
args.env.as_deref(),
args.live,
&dir_env,
&args.reports.paths(),
)
.await;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", report.render());
}
Ok(report.exit_code())
}
#[derive(Args)]
struct DirArgs {
#[arg(long = "bddkit-dir")]
bddkit_dir: Option<PathBuf>,
}
#[derive(Args)]
struct RunArgs {
#[arg(long)]
config: PathBuf,
paths: Vec<PathBuf>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long = "env")]
env: Option<String>,
#[arg(long = "fail-fast")]
fail_fast: bool,
#[command(flatten)]
dir: DirArgs,
#[command(flatten)]
reports: ReportArgs,
}
#[derive(Args)]
struct ReportArgs {
#[arg(long)]
junit: Option<PathBuf>,
#[arg(long = "cucumber-json")]
cucumber_json: Option<PathBuf>,
}
impl ReportArgs {
fn paths(&self) -> Vec<&std::path::Path> {
[&self.junit, &self.cucumber_json]
.into_iter()
.flatten()
.map(PathBuf::as_path)
.collect()
}
fn write(&self, results: &[report::FileResult]) -> Result<()> {
if let Some(path) = &self.junit {
report::write_junit(results, path)?;
}
if let Some(path) = &self.cucumber_json {
report::write_cucumber_json(results, path)?;
}
Ok(())
}
}
#[derive(Args)]
#[command(after_help = "Examples:
bddkit steps list every builtin step, grouped by resource
bddkit steps list db only the database steps
bddkit steps list --filter response -v narrow, and describe what is left
bddkit steps list --json the same listing, machine-readable
bddkit steps list --config suite.yaml also the steps of that suite's plugins")]
struct StepsArgs {
#[command(subcommand)]
command: Option<StepsCommand>,
}
#[derive(Subcommand)]
enum StepsCommand {
List(ListArgs),
}
#[derive(Args)]
struct ListArgs {
resource: Option<String>,
#[arg(long)]
filter: Option<String>,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
json: bool,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
lang: Option<String>,
#[command(flatten)]
dir: DirArgs,
}
fn steps_command(args: StepsArgs) -> Result<i32> {
let Some(StepsCommand::List(args)) = args.command else {
use clap::CommandFactory;
let mut cli = Cli::command();
cli.build();
cli.find_subcommand_mut("steps")
.expect("the steps subcommand is declared")
.print_help()?;
println!();
return Ok(0);
};
list_steps(args)
}
fn list_steps(args: ListArgs) -> Result<i32> {
let overlay = steps::help::translations(&steps::help::language(args.lang.as_deref()));
let mut rows = steps::help::builtin_rows(&overlay);
if let Some(path) = &args.config {
let cfg = config::load(path, None)?;
let generator = unique::Generator::new();
let env = dirs::Env::from_process(args.dir.bddkit_dir.clone());
if let Some(plugins) = load_plugins(path, &cfg, &generator, &env)? {
rows.extend(steps::help::plugin_rows(
plugins.described_steps(),
&plugins.group_names(),
&overlay,
));
}
}
if let Some(resource) = &args.resource {
if !rows.iter().any(|row| &row.group == resource) {
anyhow::bail!("no such resource: {resource:?}");
}
rows.retain(|row| &row.group == resource);
}
if let Some(filter) = &args.filter {
let searches_descriptions = args.verbose || args.json;
rows.retain(|row| steps::help::matches_filter(row, filter, searches_descriptions));
}
if args.json {
println!("{}", serde_json::to_string_pretty(&rows)?);
} else {
print!("{}", steps::help::render(&rows, args.verbose));
}
Ok(0)
}
#[tokio::main]
async fn main() {
sqlx::any::install_default_drivers();
let cli = Cli::parse();
let (result, nothing_happened) = match cli.command {
Command::Run(args) => (run(args).await, "run not started"),
Command::Steps(args) => (steps_command(args), "nothing listed"),
Command::Doctor(args) => (doctor_command(args).await, "nothing checked"),
Command::Resource(args) => (resource_command(args).await, "nothing listed"),
Command::Version => {
use clap::CommandFactory;
print!("{}", Cli::command().render_long_version());
(Ok(0), "nothing printed")
}
};
match result {
Ok(code) => std::process::exit(code),
Err(error) => {
eprintln!("error: {error:#}\n\n{nothing_happened}");
std::process::exit(2);
}
}
}
fn load_plugins(
config_path: &std::path::Path,
cfg: &config::Config,
generator: &unique::Generator,
env: &dirs::Env,
) -> Result<Option<Arc<plugin::Plugins>>> {
let groups_in_config: Vec<String> = cfg.group_names().cloned().collect();
let layers = dirs::layers(dirs::Os::current(), env, config_dir(config_path))?;
let mut plugins = plugin::Plugins::load(
plugin::lock::load(&dirs::candidates(&layers, "plugins"))?,
&cfg.plugin_instances,
&groups_in_config,
cfg.concurrency,
&cfg.effective_options,
)?;
if !plugins.is_empty() {
cfg.check_group_defaults()?;
}
let mut defaults = std::collections::BTreeMap::new();
for group in &groups_in_config {
if let Some(name) = cfg.resolve_default_group(group)? {
defaults.insert(group.clone(), name);
}
}
plugins.add_defaults(defaults);
plugins
.set_artifacts_root(std::env::temp_dir().join(format!("bddkit-{}", generator.run_id())));
if plugins.is_empty() {
return Ok(None);
}
let plugins = Arc::new(plugins);
std::mem::forget(plugins.clone());
Ok(Some(plugins))
}
pub(crate) fn config_dir(config_path: &std::path::Path) -> &std::path::Path {
match config_path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => std::path::Path::new("."),
}
}
fn build_registry(
cfg: &config::Config,
plugins: Option<&Arc<plugin::Plugins>>,
) -> std::result::Result<steps::Registry, String> {
let plugin_steps = plugins.map(|p| p.steps()).unwrap_or_default();
let plugin_groups = plugins.map(|p| p.group_names()).unwrap_or_default();
macros::MacroCatalog::load(&cfg.macro_paths).and_then(|catalog| {
steps::Registry::with_macros_and_plugins(catalog, &plugin_steps, &plugin_groups)
})
}
async fn run(cli: RunArgs) -> Result<i32> {
cli.reports.paths().into_iter().try_for_each(report::prepare)?;
let cfg = config::load(&cli.config, cli.env.as_deref())?;
let generator = Arc::new(unique::Generator::new());
let env = dirs::Env::from_process(cli.dir.bddkit_dir.clone());
let plugins = load_plugins(&cli.config, &cfg, &generator, &env)?;
let reg = match build_registry(&cfg, plugins.as_ref()) {
Ok(registry) => registry,
Err(error) => {
eprintln!("error: 1 problem, run not started\n\n {error}");
std::process::exit(2);
}
};
let filter = feature::TagFilter::new(&cli.tags);
let paths = if cli.paths.is_empty() {
cfg.paths.as_slice()
} else {
cli.paths.as_slice()
};
let mut loaded = Vec::new();
for path in feature::discover(paths)? {
let lf = feature::load(&path)?;
if lf.has_selected_scenario(&filter) {
loaded.push(Arc::new(lf));
}
}
if loaded.is_empty() {
eprintln!("error: no scenario selected, run not started");
std::process::exit(2);
}
let for_check: Vec<&feature::LoadedFeature> = loaded.iter().map(Arc::as_ref).collect();
let problems = validate::check(&for_check, ®, &filter);
if !problems.is_empty() {
eprintln!("error: {} problem(s), run not started\n", problems.len());
for p in &problems {
eprintln!("{p}");
}
std::process::exit(2);
}
let chains = runner::build_chains(loaded).map_err(anyhow::Error::msg)?;
let mut by_name = std::collections::HashMap::new();
for (name, api) in &cfg.resources.api {
let headers = api
.default_headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
by_name.insert(
name.clone(),
http::ApiResource::new(
&api.base_url,
api.timeout_secs,
headers,
api.effective_options.clone(),
)?,
);
}
let apis = Arc::new(http::Apis::new(by_name, cfg.resolve_default_api()?)?);
for (name, srp) in &cfg.resources.srp {
srp.to_params()
.with_context(|| format!("resources.srp.{name}"))?;
}
let db = if cfg.resources.db.is_empty() {
None
} else {
Some(Arc::new(
db::Db::connect(&cfg.resources.db, cfg.concurrency as u32)
.await
.map_err(anyhow::Error::msg)?,
))
};
let default_db = cfg.resolve_default_db()?.unwrap_or_default();
let srp = match cfg.resolve_default_srp()? {
Some(name) => Some(Arc::new(cfg.resources.srp[&name].to_params()?)),
None => None,
};
println!("run {}", generator.run_id());
let ctx = Arc::new(runner::RunContext::new(
reg,
apis,
generator.clone(),
filter,
db,
default_db,
srp,
plugins.clone(),
cfg.effective_options.clone(),
cli.fail_fast,
));
let results = runner::run_all(chains, ctx, cfg.concurrency).await;
if let Some(plugins) = &plugins {
plugins.shutdown();
}
let code = report::print_summary(&results, generator.run_id());
if let Err(error) = cli.reports.write(&results) {
eprintln!("error: {error:#}");
return Ok(2);
}
Ok(code)
}