Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 78 additions & 17 deletions crates/forge-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use forge_core::{
capture_example, from_collection, from_openapi, is_openapi, merge_inherited, parse,
};
use forge_engine::{
execute_with_scripts, inherited_vars, run_file, settings_chain, to_curl, ExecOptions,
ExecutedResponse, RequestRun, Settings, VarContext,
execute_with_scripts, inherited_vars, json_report, junit_xml, run_file, settings_chain,
to_curl, ExecOptions, ExecutedResponse, ReportSuite, RequestRun, Settings, VarContext,
};
use forge_history::History;
use std::collections::HashMap;
Expand Down Expand Up @@ -78,6 +78,12 @@ enum Command {
/// Accept invalid TLS certificates (use with care).
#[arg(long)]
insecure: bool,
/// Write a JUnit XML report to this path (for CI).
#[arg(long = "reporter-junit", value_name = "PATH")]
reporter_junit: Option<PathBuf>,
/// Write a JSON report to this path (for CI).
#[arg(long = "reporter-json", value_name = "PATH")]
reporter_json: Option<PathBuf>,
},
/// Print a request as an equivalent curl command (variables resolved).
Curl {
Expand Down Expand Up @@ -156,7 +162,20 @@ async fn main() {
env_file,
timeout,
insecure,
} => cmd_run_all(&path, &vars, env_file.as_deref(), timeout, insecure).await,
reporter_junit,
reporter_json,
} => {
cmd_run_all(RunAllArgs {
path,
vars,
env_file,
timeout,
insecure,
reporter_junit,
reporter_json,
})
.await
}
Command::Curl {
file,
request,
Expand Down Expand Up @@ -454,28 +473,32 @@ fn cmd_import(file: &Path, out: &Path) {

// ─── run-all (collection runner) ───────────────────────────────────────────────

async fn cmd_run_all(
path: &Path,
vars: &[String],
env_file: Option<&Path>,
struct RunAllArgs {
path: PathBuf,
vars: Vec<String>,
env_file: Option<PathBuf>,
timeout: u64,
insecure: bool,
) {
reporter_junit: Option<PathBuf>,
reporter_json: Option<PathBuf>,
}

async fn cmd_run_all(a: RunAllArgs) {
let mut files: Vec<PathBuf> = Vec::new();
if path.is_dir() {
collect_http(path, &mut files);
if a.path.is_dir() {
collect_http(&a.path, &mut files);
files.sort();
} else {
files.push(path.to_path_buf());
files.push(a.path.clone());
}
if files.is_empty() {
eprintln!("error: no .http files found at {}", path.display());
eprintln!("error: no .http files found at {}", a.path.display());
exit(1);
}

let opts = ExecOptions {
timeout_secs: timeout,
insecure,
timeout_secs: a.timeout,
insecure: a.insecure,
proxy: None,
ca_cert_path: None,
client_cert_path: None,
Expand All @@ -485,6 +508,8 @@ async fn cmd_run_all(
};
let mut passed = 0usize;
let mut failed = 0usize;
// (suite name, runs) groups retained for the CI reporters.
let mut suites: Vec<(String, Vec<RequestRun>)> = Vec::new();

for file in &files {
let mut parsed = parse(&read_file(file), &stem(file));
Expand All @@ -495,7 +520,7 @@ async fn cmd_run_all(
for r in &mut parsed.requests {
*r = merge_inherited(r, &chain);
}
let ctx = match build_context(&parsed, &chain, env_file, &HashMap::new(), vars) {
let ctx = match build_context(&parsed, &chain, a.env_file.as_deref(), &HashMap::new(), &a.vars) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e}");
Expand All @@ -505,22 +530,58 @@ async fn cmd_run_all(
if files.len() > 1 {
println!("\n{}", stem(file));
}
for run in run_file(&parsed, ctx, &opts).await {
print_run(&run);
let runs = run_file(&parsed, ctx, &opts).await;
for run in &runs {
print_run(run);
if run.ok {
passed += 1;
} else {
failed += 1;
}
}
suites.push((stem(file), runs));
}

write_reports(&suites, a.reporter_junit.as_deref(), a.reporter_json.as_deref());

println!("\n{passed} passed, {failed} failed");
if failed > 0 {
exit(1);
}
}

/// Write the requested CI reports from the accumulated run suites.
fn write_reports(
suites: &[(String, Vec<RequestRun>)],
junit: Option<&Path>,
json: Option<&Path>,
) {
if junit.is_none() && json.is_none() {
return;
}
let report_suites: Vec<ReportSuite> = suites
.iter()
.map(|(name, runs)| ReportSuite {
name,
runs,
})
.collect();
if let Some(path) = junit {
if let Err(e) = std::fs::write(path, junit_xml(&report_suites)) {
eprintln!("warning: could not write JUnit report: {e}");
} else {
eprintln!("✎ wrote JUnit report → {}", path.display());
}
}
if let Some(path) = json {
if let Err(e) = std::fs::write(path, json_report(&report_suites)) {
eprintln!("warning: could not write JSON report: {e}");
} else {
eprintln!("✎ wrote JSON report → {}", path.display());
}
}
}

fn collect_http(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
Expand Down
61 changes: 61 additions & 0 deletions crates/forge-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,64 @@ fn run_with_preexisting_session_and_env_file() {
assert!(out.status.success());
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn run_all_writes_junit_and_json_reports() {
let dir = unique_dir("reporters");
let server = TestServer::start();
let pass = dir.join("pass.http");
write(
&pass,
&format!("### ok\nGET {}\n# @assert status == 200\n", server.url("/ok")),
);
let fail = dir.join("fail.http");
write(
&fail,
&format!("### bad\nGET {}\n# @assert status == 200\n", server.url("/fail")),
);

let junit = dir.join("report.xml");
let json = dir.join("report.json");
let out = run(
&[
"run-all",
dir.to_str().unwrap(),
"--reporter-junit",
junit.to_str().unwrap(),
"--reporter-json",
json.to_str().unwrap(),
],
&dir,
);
// One assertion fails → exit 1.
assert_eq!(out.status.code(), Some(1));

let xml = std::fs::read_to_string(&junit).unwrap();
assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<testsuites"));
assert!(xml.contains("<testsuite name=\"pass\""));
assert!(xml.contains("<testsuite name=\"fail\""));
assert!(xml.contains("<failure"));
assert!(xml.contains("assert status == 200"));

let report: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&json).unwrap()).unwrap();
assert_eq!(report["summary"]["total"], 2);
assert_eq!(report["summary"]["passed"], 1);
assert_eq!(report["summary"]["failed"], 1);

std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn run_all_without_reporters_writes_nothing() {
let dir = unique_dir("noreport");
let server = TestServer::start();
let f = dir.join("ok.http");
write(&f, &format!("### ok\nGET {}\n", server.url("/ok")));
let out = run(&["run-all", f.to_str().unwrap()], &dir);
assert!(out.status.success());
// No stray report files created.
assert!(!dir.join("report.xml").exists());
std::fs::remove_dir_all(&dir).ok();
}
2 changes: 2 additions & 0 deletions crates/forge-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod exec;
pub mod graphql;
pub mod inherit;
pub mod interpolate;
pub mod report;
mod resolve;
pub mod runner;
pub mod script;
Expand All @@ -28,6 +29,7 @@ pub use exec::{clear_cookies, execute, execute_with_scripts, ExecError, ExecOpti
pub use graphql::{graphql_payload, GraphQlSchema, parse_introspection, INTROSPECTION_QUERY};
pub use inherit::settings_chain;
pub use interpolate::VarContext;
pub use report::{json_report, junit_xml, ReportSuite};
pub use forge_core::{inherited_vars, merge_inherited, parse_settings, Settings};
pub use forge_core::{ExecutedRequest, ExecutedResponse, TimingBreakdown};
pub use runner::{run_file, RequestRun};
Expand Down
Loading
Loading