From 1466b567b423d44a2aee72d3729c916297e5f46f Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 24 Oct 2021 21:56:18 +0100 Subject: [PATCH] Basic JUnix XML report support --- packages/hurl/src/cli/mod.rs | 26 ++ packages/hurl/src/cli/options.rs | 15 ++ packages/hurl/src/main.rs | 8 + packages/hurl/src/report/junit.rs | 385 ++++++++++++++++++++++++++++++ packages/hurl/src/report/mod.rs | 7 +- 5 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 packages/hurl/src/report/junit.rs diff --git a/packages/hurl/src/cli/mod.rs b/packages/hurl/src/cli/mod.rs index d5dfd3de7..689121ba0 100644 --- a/packages/hurl/src/cli/mod.rs +++ b/packages/hurl/src/cli/mod.rs @@ -16,6 +16,8 @@ * */ +use std::error::Error; + pub use self::fs::read_to_string; pub use self::logger::{ error_string, log_info, make_logger_error_message, make_logger_parser_error, @@ -37,3 +39,27 @@ mod variables; pub struct CliError { pub message: String, } + +impl From> for CliError { + fn from(e: Box) -> Self { + Self { + message: format!("{:?}", e), + } + } +} + +impl From<&str> for CliError { + fn from(e: &str) -> Self { + Self { + message: e.to_string(), + } + } +} + +impl From for CliError { + fn from(e: String) -> Self { + Self { + message: format!("{:?}", e), + } + } +} diff --git a/packages/hurl/src/cli/options.rs b/packages/hurl/src/cli/options.rs index 6adf4e980..e1b38ee2e 100644 --- a/packages/hurl/src/cli/options.rs +++ b/packages/hurl/src/cli/options.rs @@ -55,6 +55,7 @@ pub struct CliOptions { pub insecure: bool, pub interactive: bool, pub json_file: Option, + pub junit_file: Option, pub max_redirect: Option, pub no_proxy: Option, pub output: Option, @@ -172,6 +173,13 @@ pub fn app() -> App<'static, 'static> { .help("Write full session(s) to json file") .takes_value(true), ) + .arg( + clap::Arg::with_name("junit") + .long("report-junit") + .value_name("FILE") + .help("Write a Junit XML report to the given file") + .takes_value(true), + ) .arg( clap::Arg::with_name("max_time") .long("max-time") @@ -334,6 +342,12 @@ pub fn parse_options(matches: ArgMatches) -> Result { } else { None }; + let junit_file = if let Some(filename) = matches.value_of("junit") { + let path = Path::new(filename); + Some(path.to_path_buf()) + } else { + None + }; let max_redirect = match matches.value_of("max_redirects") { None => Some(50), Some("-1") => None, @@ -388,6 +402,7 @@ pub fn parse_options(matches: ArgMatches) -> Result { include, insecure, interactive, + junit_file, json_file, max_redirect, no_proxy, diff --git a/packages/hurl/src/main.rs b/packages/hurl/src/main.rs index 72c8b51a9..5eb72a1b8 100644 --- a/packages/hurl/src/main.rs +++ b/packages/hurl/src/main.rs @@ -410,6 +410,14 @@ fn main() { ); } + if let Some(junit_path) = cli_options.junit_file { + log_verbose(format!("Writing Junit report to {}", junit_path.display()).as_str()); + unwrap_or_exit( + &log_error_message, + report::write_junit_report(junit_path, hurl_results.clone()), + ); + } + if let Some(dir_path) = cli_options.html_dir { log_verbose(format!("Writing html report to {}", dir_path.display()).as_str()); unwrap_or_exit( diff --git a/packages/hurl/src/report/junit.rs b/packages/hurl/src/report/junit.rs new file mode 100644 index 000000000..d5cc8a521 --- /dev/null +++ b/packages/hurl/src/report/junit.rs @@ -0,0 +1,385 @@ +use crate::{cli::CliError, runner::HurlResult}; +use hurl_core::error::Error; +use libxml::{ + parser::Parser, + tree::{Document, Node}, +}; +use std::path::PathBuf; + +/// Generate a JUnix XML report and write it to the specified `PathBuf` +pub fn write_junit_report( + file_path: PathBuf, + hurl_results: Vec, +) -> Result<(), CliError> { + let mut doc = if file_path.exists() { + let parser = Parser::default(); + parser + .parse_string( + std::fs::read_to_string(file_path.clone()).map_err(|e| CliError { + message: format!("Failed to read file {:?}: {:?}", file_path, e), + })?, + ) + .map_err(|e| CliError { + message: format!("Failed to parse file {:?}: {:?}", file_path, e), + })? + } else { + initialise_junit_report()? + }; + let mut testsuites = doc + .get_root_element() + .ok_or_else(|| CliError::from("Missing testsuites element"))?; + + create_junit_report(&mut doc, &mut testsuites, hurl_results)?; + + if !file_path.exists() { + let _ = match std::fs::File::create(&file_path) { + Err(why) => { + return Err(CliError { + message: format!("Issue writing to {}: {:?}", file_path.display(), why), + }); + } + Ok(file) => file, + }; + } else { + } + + write_junit_report_with(file_path, &doc)?; + Ok(()) +} + +pub fn write_junit_report_with(file_path: PathBuf, doc: &Document) -> Result<(), CliError> { + doc.save_file(&file_path.to_string_lossy()) + .map_err(|_| format!("Failed to save to {:?}", file_path))?; + Ok(()) +} + +/// Write a JUnit XML report to the specified `libxml::Document`, appending to the `testsuites` +/// node +pub fn create_junit_report( + doc: &mut Document, + testsuites: &mut Node, + reports: Vec, +) -> Result<(), CliError> { + let test_count: usize = testsuites + .get_attribute("tests") + .unwrap_or_else(|| "0".to_string()) + .parse() + .unwrap_or(0); + let count = test_count + reports.len(); + testsuites.set_attribute("tests", &count.to_string())?; + testsuites.set_attribute("name", "Hurl")?; + + let mut failures: usize = 0; + let mut time: u128 = 0; + + for report in reports { + time += report.time_in_ms; + failures += if report.success { 0 } else { 1 }; + + let mut testsuite = create_test_suite(doc, &report)?; + + testsuites.add_child(&mut testsuite)?; + + append_report_to(doc, &mut testsuite, &report)?; + } + + testsuites.set_attribute("time", &(time / 1000).to_string())?; + testsuites.set_attribute("failures", &failures.to_string())?; + Ok(()) +} + +fn create_test_suite(doc: &mut Document, report: &HurlResult) -> Result { + let mut testsuite = Node::new("testsuite", None, doc).unwrap(); + testsuite.set_attribute("name", &report.filename)?; + testsuite.set_attribute("tests", &report.entries.len().to_string())?; + Ok(testsuite) +} + +fn append_report_to( + doc: &Document, + testsuite: &mut Node, + report: &HurlResult, +) -> Result<(), CliError> { + for er in &report.entries.clone() { + let mut testcase = Node::new("testcase", None, doc).expect("Creating testcase"); + let req = er + .request + .as_ref() + .map(|r| format!("{} {}", r.method, r.url)) + .unwrap_or_else(|| "(No request details)".to_string()); + testcase.set_attribute("name", &req)?; + if !report.success { + for ass in &er.asserts { + let mut failure = Node::new("failure", None, doc).unwrap(); + if let Some(err) = ass.clone().error() { + failure.set_attribute("message", &err.fixme().to_string())?; + } + testcase.add_child(&mut failure)?; + } + + for er in &er.errors { + let mut failure = Node::new("failure", None, doc).unwrap(); + failure.set_attribute("message", &er.description())?; + failure.set_content(&er.fixme().to_string())?; + testcase.add_child(&mut failure)?; + } + } + testsuite.add_child(&mut testcase)?; + } + Ok(()) +} + +fn initialise_junit_report() -> Result { + let mut doc = Document::new().map_err(|e| CliError { + message: format!("Failed to produce junit report: {:?}", e), + })?; + + let testsuites = Node::new("testsuites", None, &doc).expect("Could not create testsuites node"); + doc.set_root_element(&testsuites); + Ok(doc) +} + +#[cfg(test)] +mod test { + use std::{path::PathBuf, time::Duration}; + + use hurl_core::ast::Pos; + + use crate::{ + http::{Request, Response, Version}, + report::junit::{create_junit_report, initialise_junit_report, write_junit_report_with}, + runner::{EntryResult, HurlResult}, + }; + + use libxml::parser::Parser; + + #[test] + fn test_create_jnit_report_empty() { + let mut doc = initialise_junit_report().unwrap(); + let mut testsuites = doc.get_root_element().expect("No root element"); + create_junit_report(&mut doc, &mut testsuites, vec![]).unwrap(); + + assert_eq!(0, testsuites.get_child_nodes().len()); + } + + #[test] + fn test_append_jnit_report() { + let mut doc = initialise_junit_report().unwrap(); + let mut testsuites = doc.get_root_element().expect("No root element"); + create_junit_report(&mut doc, &mut testsuites, vec![]).unwrap(); + + let random_chars = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + .to_string(); + + let random_filename = &format!( + "{}/{}.xml", + std::env::temp_dir().to_str().expect("No temp dir?"), + random_chars + ); + + write_junit_report_with(PathBuf::from(random_filename), &doc).expect("Saving report"); + + assert_eq!( + "0".to_string(), + testsuites.get_attribute("tests").expect("tests attribute") + ); + + assert_eq!(0, testsuites.get_child_nodes().len()); + + let parser = Parser::default(); + let mut doc2 = parser + .parse_string(std::fs::read_to_string(PathBuf::from(random_filename)).unwrap()) + .unwrap(); + + let reports = make_reports(); + let mut testsuites = doc2 + .get_root_element() + .expect("Failed to find root element for existing junit report"); + + create_junit_report(&mut doc2, &mut testsuites, reports.clone()) + .expect("Could not create junit report for append test"); + doc2.save_file(random_filename).unwrap(); + let new_ts = doc2.get_root_element().unwrap(); + + let test_count = new_ts.get_attribute("tests").expect("tests attribute"); + assert_eq!(reports.len().to_string(), test_count); + + let new_tc = new_ts.get_child_elements(); + assert_eq!(reports.len(), new_tc.len()); + } + + #[test] + fn test_junit_report() { + let reports = make_reports(); + + let mut doc = initialise_junit_report().unwrap(); + let mut testsuites = doc.get_root_element().expect("No root element"); + + create_junit_report(&mut doc, &mut testsuites, reports.clone()).unwrap(); + assert_eq!( + testsuites.get_name(), + "testsuites".to_string(), + "Root element must be testsuites" + ); + + assert_eq!( + "Hurl", + testsuites.get_attribute("name").expect("tests attribute") + ); + + assert_eq!( + "2", + testsuites.get_attribute("tests").expect("tests attribute") + ); + + assert_eq!( + "1", + testsuites + .get_attribute("failures") + .expect("failures attribute"), + "failure count" + ); + + let time_attr: u128 = testsuites + .get_attribute("time") + .expect("time attribute") + .parse() + .expect("Time doesn't parse as f32"); + assert_eq!(0, time_attr, "Time attribute in seconds"); // 100 + 200 ms is < 1 second -_- + + let testsuites = testsuites.get_child_nodes(); + assert_eq!(2, testsuites.len()); + { + let first_ts = testsuites.get(0).expect("First testsuite"); + + assert_eq!( + reports.get(0).as_ref().unwrap().filename, + first_ts + .get_attribute("name") + .expect("First testsuite name attribute") + ); + + let first_testcases = first_ts.get_child_nodes(); + assert_eq!( + 1, + first_testcases.len(), + "First testsuite should have 1 test case" + ); + let first_tec = first_testcases + .get(0) + .expect("First testsuite should have 1 test case"); + assert_eq!( + "GET https://www.google.com/", + &first_tec.get_attribute("name").unwrap() + ); + assert!( + first_tec.get_first_child().is_none(), + "No child expected for first successful result" + ); + } + + let snd_ts = testsuites.get(1).expect("Second testsuite"); + { + assert_eq!( + reports.get(1).as_ref().unwrap().filename, + snd_ts + .get_attribute("name") + .expect("2nd testsuite name attribute") + ); + + assert_eq!( + reports.get(1).as_ref().unwrap().entries.len().to_string(), + snd_ts + .get_attribute("tests") + .expect("2nd testsuite tests attribute") + ); + + // under under + let tc = snd_ts.get_child_nodes(); + assert_eq!(1, tc.len(), "2nd testsuite should have 1 test case"); + + let first_tec = tc.get(0).expect("2nd testsuite should have 1 test case"); + assert_eq!( + "GET https://www.legiggle.com/", + &first_tec.get_attribute("name").unwrap() + ); + let failure = first_tec + .get_first_child() + .expect("child expected for first failing result"); + assert_eq!( + "actual value is <405>", + failure.get_attribute("message").expect("message attribute") + ); + } + } + + fn make_reports() -> Vec { + let reports = vec![ + HurlResult { + filename: "tests/hello.hurl".to_string(), + entries: vec![EntryResult { + request: Some(Request { + url: "https://www.google.com/".to_string(), + method: "GET".to_string(), + headers: vec![], + }), + response: Some(Response { + body: vec![], + version: Version::Http11, + status: 200, + headers: vec![], + duration: Duration::from_millis(100), + }), + asserts: vec![], + captures: vec![], + errors: vec![], + time_in_ms: 100, + }], + time_in_ms: 100, + success: true, + cookies: vec![], + }, + HurlResult { + filename: "tests/failure.hurl".to_string(), + entries: vec![EntryResult { + request: Some(Request { + url: "https://www.legiggle.com/".to_string(), + method: "GET".to_string(), + headers: vec![], + }), + response: Some(Response { + body: vec![], + version: Version::Http11, + status: 500, + headers: vec![], + duration: Duration::from_millis(200), + }), + asserts: vec![crate::runner::AssertResult::Status { + actual: 405, + expected: 200, + source_info: hurl_core::ast::SourceInfo { + end: Pos { + line: 0, + column: 15, + }, + start: Pos { + line: 0, + column: 15, + }, + }, + }], + captures: vec![], + errors: vec![], + time_in_ms: 100, + }], + time_in_ms: 100, + success: false, + cookies: vec![], + }, + ]; + return reports; + } +} diff --git a/packages/hurl/src/report/mod.rs b/packages/hurl/src/report/mod.rs index e4b21cf84..a019c43ca 100644 --- a/packages/hurl/src/report/mod.rs +++ b/packages/hurl/src/report/mod.rs @@ -1,6 +1,5 @@ /* * hurl (https://hurl.dev) - * Copyright (C) 2020 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +22,10 @@ use super::cli::CliError; use super::runner::HurlResult; mod html; +mod junit; + +pub use junit::create_junit_report; +pub use junit::write_junit_report; pub fn parse_html(path: PathBuf) -> Result, CliError> { if path.exists() { @@ -259,6 +262,7 @@ fn create_html_result(result: HurlResult) -> html::Element { #[cfg(test)] mod tests { + use super::*; #[test] @@ -290,7 +294,6 @@ mod tests { "#; - // assert_eq!( parse_html_report(html), vec![