Use pure-Rust XML parsing/serializing for JUnit Report

This commit is contained in:
Fabrice Reix 2021-12-07 10:12:59 +01:00 committed by Fabrice Reix
parent ac5c87ef78
commit a17370f9cb
8 changed files with 322 additions and 297 deletions

34
Cargo.lock generated
View File

@ -373,6 +373,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
[[package]]
name = "hashbrown"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
[[package]]
name = "hermit-abi"
version = "0.1.8"
@ -412,6 +418,7 @@ dependencies = [
"hex",
"hex-literal",
"hurl_core",
"indexmap",
"libflate",
"libxml",
"md5",
@ -423,6 +430,7 @@ dependencies = [
"termion",
"url",
"winres",
"xmltree",
]
[[package]]
@ -458,6 +466,16 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5"
dependencies = [
"autocfg",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.1"
@ -1033,3 +1051,19 @@ checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml",
]
[[package]]
name = "xml-rs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3"
[[package]]
name = "xmltree"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb"
dependencies = [
"indexmap",
"xml-rs",
]

View File

@ -30,6 +30,7 @@ glob = "0.3.0"
hex = "0.4.3"
hex-literal = "0.3.4"
hurl_core = { version = "1.6.0-snapshot", path = "../hurl_core" }
indexmap = "1.7.0"
libflate = "1.1.1"
libxml = "0.3.0"
md5 = "0.7.0"
@ -39,6 +40,7 @@ serde = "1.0.131"
serde_json = "1.0.73"
sha2 = "0.10.0"
url = "2.2.2"
xmltree = { version = "0.10", features = ["attribute-order"] }
[target.'cfg(unix)'.dependencies]

View File

@ -45,7 +45,7 @@ pub struct CliOptions {
pub include: bool,
pub insecure: bool,
pub interactive: bool,
pub junit_file: Option<PathBuf>,
pub junit_file: Option<String>,
pub max_redirect: Option<usize>,
pub no_proxy: Option<String>,
pub output: Option<String>,
@ -365,12 +365,9 @@ pub fn parse_options(matches: ArgMatches) -> Result<CliOptions, CliError> {
let include = matches.is_present("include");
let insecure = matches.is_present("insecure");
let interactive = matches.is_present("interactive");
let junit_file = if let Some(filename) = matches.value_of("junit") {
let path = Path::new(filename);
Some(path.to_path_buf())
} else {
None
};
let junit_file = matches
.value_of("junit")
.map(|filename| filename.to_string());
let max_redirect = match matches.value_of("max_redirects") {
None => Some(50),
Some("-1") => None,

View File

@ -298,19 +298,7 @@ fn main() {
};
let start = Instant::now();
let doc = if let Ok(doc) = report::create_or_get_junit_report(cli_options.junit_file.clone()) {
doc
} else {
log_error_message(false, "Error creating Junit XML report");
std::process::exit(EXIT_ERROR_UNDEFINED);
};
let mut testsuite = if let Ok(doc) = report::add_testsuite(&doc) {
doc
} else {
log_error_message(false, "Error creating Junit XML report");
std::process::exit(EXIT_ERROR_UNDEFINED);
};
let mut testcases = vec![];
for (current, filename) in filenames.iter().enumerate() {
let contents = match cli::read_to_string(filename) {
@ -421,18 +409,17 @@ fn main() {
);
}
if cli_options.junit_file.is_some() {
unwrap_or_exit(
&log_error_message,
report::add_testcase(&doc, &mut testsuite, hurl_result, &lines),
);
let testcase = report::Testcase::from_hurl_result(&hurl_result, &lines);
testcases.push(testcase);
}
}
if let Some(file_path) = cli_options.junit_file.clone() {
log_verbose(format!("Writing Junit report to {}", file_path.display()).as_str());
if doc.save_file(&file_path.to_string_lossy()).is_err() {
log_error_message(false, format!("Failed to save to {:?}", file_path).as_str());
}
if let Some(filename) = cli_options.junit_file.clone() {
log_verbose(format!("Writing Junit report to {}", filename).as_str());
unwrap_or_exit(
&log_error_message,
report::create_junit_report(filename, testcases),
);
}
if let Some(dir_path) = cli_options.html_dir {

View File

@ -54,88 +54,75 @@
// </testsuites>
//
use crate::{cli::CliError, runner::HurlResult};
use libxml::parser::Parser;
use libxml::tree::{Document, Node};
use std::fs::File;
mod result;
use xmltree::{Element, XMLNode};
type JunitError = String;
pub use testcase::Testcase;
///
/// Get XML document
/// The document is created if it does not exist
///
pub fn create_or_get_junit_report(
file_path: Option<std::path::PathBuf>,
) -> Result<Document, CliError> {
if let Some(file_path) = file_path {
if file_path.exists() {
let parser = Parser::default();
let doc = 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),
})?;
Ok(doc)
} else {
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)
use crate::cli::CliError;
mod testcase;
pub fn create_report(filename: String, testcases: Vec<Testcase>) -> Result<(), CliError> {
let mut testsuites = vec![];
let path = std::path::Path::new(&filename);
if path.exists() {
let s = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(why) => {
return Err(CliError {
message: format!("Issue reading {} to string to {:?}", path.display(), why),
});
}
};
let root = Element::parse(s.as_bytes()).unwrap();
for child in root.children {
if let XMLNode::Element(_) = child.clone() {
testsuites.push(child.clone());
}
}
} else {
let mut doc = Document::new().map_err(|e| CliError {
}
let testsuite = create_testsuite(testcases);
testsuites.push(testsuite);
let report = Element {
name: "testsuites".to_string(),
prefix: None,
namespace: None,
namespaces: None,
attributes: indexmap::map::IndexMap::new(),
children: testsuites,
};
let file = match File::create(filename) {
Ok(f) => f,
Err(e) => {
return Err(CliError {
message: format!("Failed to produce junit report: {:?}", e),
});
}
};
match report.write(file) {
Ok(_) => Ok(()),
Err(e) => Err(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)
}),
}
}
///
/// Add testsuite to XML document
///
pub fn add_testsuite(doc: &libxml::tree::Document) -> Result<libxml::tree::Node, JunitError> {
let mut testsuite = match Node::new("testsuite", None, doc) {
Ok(v) => v,
Err(_) => return Err("can not create node testsuite".to_string()),
fn create_testsuite(testcases: Vec<Testcase>) -> XMLNode {
let children = testcases
.iter()
.map(|t| XMLNode::Element(t.to_xml()))
.collect();
let element = Element {
name: "testsuite".to_string(),
prefix: None,
namespace: None,
namespaces: None,
attributes: indexmap::map::IndexMap::new(),
children,
};
let mut testsuites = match doc.get_root_element() {
Some(v) => v,
None => return Err("can not get root element".to_string()),
};
match testsuites.add_child(&mut testsuite) {
Ok(_) => {}
Err(_) => return Err("can not add child".to_string()),
}
Ok(testsuite)
}
///
/// Add Testcase in the testsuite the XML document
///
pub fn add_testcase(
doc: &Document,
testsuite: &mut Node,
hurl_result: HurlResult,
lines: &[String],
) -> Result<(), CliError> {
let mut testcase = hurl_result.to_testcase(doc, lines)?;
if testsuite.add_child(&mut testcase).is_err() {
return Err(CliError {
message: "can not add child".to_string(),
});
}
Ok(())
XMLNode::Element(element)
}

View File

@ -1,190 +0,0 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use super::JunitError;
use crate::cli;
use crate::runner::{EntryResult, Error, HurlResult};
use libxml::tree::{Document, Node};
impl HurlResult {
///
/// Export Hurl result to an XML Junit <testcase>
///
pub fn to_testcase(
&self,
doc: &Document,
lines: &[String],
) -> Result<libxml::tree::Node, JunitError> {
let mut node = Node::new("testcase", None, doc).expect("XML Node");
node.set_attribute("id", &self.filename)
.expect("Set attribute id");
let time_in_seconds = format!("{:.3}", self.time_in_ms as f64 / 1000.0);
node.set_attribute("time", &time_in_seconds)
.expect("Set attribute duration");
for entry in self.entries.clone() {
for mut error in entry.errors(doc, lines, self.filename.clone())? {
if node.add_child(&mut error).is_err() {
return Err("can not add child".to_string());
}
}
}
Ok(node)
}
}
impl EntryResult {
///
/// Export entry result errors to XML Junit <error>/<failure>
///
fn errors(
&self,
doc: &Document,
lines: &[String],
filename: String,
) -> Result<Vec<Node>, JunitError> {
let mut errors = vec![];
for error in self.errors.clone() {
let error = error.to_junit_error(doc, lines, filename.clone())?;
errors.push(error);
}
Ok(errors)
}
}
impl Error {
///
/// Export Hurl runner error to an XML Junit <error>/<failure>
///
fn to_junit_error(
&self,
doc: &Document,
lines: &[String],
filename: String,
) -> Result<Node, JunitError> {
let node_name = if self.assert { "failure" } else { "error" };
let mut node = if let Ok(value) = Node::new(node_name, None, doc) {
value
} else {
return Err("Can not create node".to_string());
};
let message = cli::error_string(lines, filename, self);
if node.append_text(message.as_str()).is_err() {
return Err("Can not append text".to_string());
}
Ok(node)
}
}
#[cfg(test)]
mod test {
use crate::runner::{EntryResult, Error, HurlResult, RunnerError};
use hurl_core::ast::SourceInfo;
use libxml::tree::Document;
#[test]
fn test_create_testcase_success() {
let doc = Document::new().unwrap();
let lines = vec![];
let hurl_result = HurlResult {
filename: "test.hurl".to_string(),
entries: vec![],
time_in_ms: 230,
success: true,
cookies: vec![],
};
assert_eq!(
doc.node_to_string(&hurl_result.to_testcase(&doc, &lines).unwrap()),
r#"<testcase id="test.hurl" time="0.230"/>"#
);
}
#[test]
fn test_create_testcase_failure() {
let lines = vec![
"GET http://localhost:8000/not_found".to_string(),
"HTTP/1.0 200".to_string(),
];
let doc = Document::new().unwrap();
let hurl_result = HurlResult {
filename: "test.hurl".to_string(),
entries: vec![EntryResult {
request: None,
response: None,
captures: vec![],
asserts: vec![],
errors: vec![Error {
source_info: SourceInfo::init(2, 10, 2, 13),
inner: RunnerError::AssertStatus {
actual: "404".to_string(),
},
assert: true,
}],
time_in_ms: 0,
}],
time_in_ms: 230,
success: true,
cookies: vec![],
};
assert_eq!(
doc.node_to_string(&hurl_result.to_testcase(&doc, &lines).unwrap()),
r#"<testcase id="test.hurl" time="0.230"><failure>Assert Status
--&gt; test.hurl:2:10
|
2 | HTTP/1.0 200
| ^^^ actual value is &lt;404&gt;
|</failure></testcase>"#
);
}
#[test]
fn test_create_testcase_error() {
let lines = vec!["GET http://unknown".to_string()];
let doc = Document::new().unwrap();
let hurl_result = HurlResult {
filename: "test.hurl".to_string(),
entries: vec![EntryResult {
request: None,
response: None,
captures: vec![],
asserts: vec![],
errors: vec![Error {
source_info: SourceInfo::init(1, 5, 1, 19),
inner: RunnerError::HttpConnection {
url: "http://unknown".to_string(),
message: "(6) Could not resolve host: unknown".to_string(),
},
assert: false,
}],
time_in_ms: 0,
}],
time_in_ms: 230,
success: true,
cookies: vec![],
};
assert_eq!(
doc.node_to_string(&hurl_result.to_testcase(&doc, &lines).unwrap()),
r#"<testcase id="test.hurl" time="0.230"><error>Http Connection
--&gt; test.hurl:1:5
|
1 | GET http://unknown
| ^^^^^^^^^^^^^^ (6) Could not resolve host: unknown
|</error></testcase>"#
);
}
}

View File

@ -0,0 +1,209 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
use crate::cli;
use crate::runner::HurlResult;
use xmltree::{Element, XMLNode};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Testcase {
id: String,
time_in_ms: u128,
failures: Vec<String>,
errors: Vec<String>,
}
impl Testcase {
///
/// create an XML Junit <testcase> from an Hurl result
///
pub fn from_hurl_result(hurl_result: &HurlResult, lines: &[String]) -> Testcase {
let id = hurl_result.filename.clone();
let time_in_ms = hurl_result.time_in_ms;
let mut failures = vec![];
let mut errors = vec![];
for error in hurl_result.errors().clone() {
let message = cli::error_string(lines, hurl_result.filename.clone(), &error);
if error.assert {
failures.push(message);
} else {
errors.push(message);
};
}
Testcase {
id,
time_in_ms,
failures,
errors,
}
}
// Serialize to XML
pub fn to_xml(&self) -> xmltree::Element {
let name = "testcase".to_string();
let mut attributes = indexmap::map::IndexMap::new();
attributes.insert("id".to_string(), self.id.clone());
let time_in_seconds = format!("{:.3}", self.time_in_ms as f64 / 1000.0);
attributes.insert("time".to_string(), time_in_seconds);
let mut children = vec![];
for message in self.failures.clone() {
let element = Element {
prefix: None,
namespace: None,
namespaces: None,
name: "failure".to_string(),
attributes: indexmap::map::IndexMap::new(),
children: vec![XMLNode::Text(message)],
};
children.push(XMLNode::Element(element));
}
for message in self.errors.clone() {
let element = Element {
prefix: None,
namespace: None,
namespaces: None,
name: "error".to_string(),
attributes: indexmap::map::IndexMap::new(),
children: vec![XMLNode::Text(message)],
};
children.push(XMLNode::Element(element));
}
Element {
name,
prefix: None,
namespace: None,
namespaces: None,
attributes,
children,
}
}
}
#[cfg(test)]
mod test {
use crate::report::junit::testcase::Testcase;
use crate::runner::{EntryResult, Error, HurlResult, RunnerError};
use hurl_core::ast::SourceInfo;
#[test]
fn test_create_testcase_success() {
let lines = vec![];
let hurl_result = HurlResult {
filename: "test.hurl".to_string(),
entries: vec![],
time_in_ms: 230,
success: true,
cookies: vec![],
};
let mut buffer = Vec::new();
Testcase::from_hurl_result(&hurl_result, &lines)
.to_xml()
.write(&mut buffer)
.unwrap();
assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
r#"<?xml version="1.0" encoding="UTF-8"?><testcase id="test.hurl" time="0.230" />"#
);
}
#[test]
fn test_create_testcase_failure() {
let lines = vec![
"GET http://localhost:8000/not_found".to_string(),
"HTTP/1.0 200".to_string(),
];
let hurl_result = HurlResult {
filename: "test.hurl".to_string(),
entries: vec![EntryResult {
request: None,
response: None,
captures: vec![],
asserts: vec![],
errors: vec![Error {
source_info: SourceInfo::init(2, 10, 2, 13),
inner: RunnerError::AssertStatus {
actual: "404".to_string(),
},
assert: true,
}],
time_in_ms: 0,
}],
time_in_ms: 230,
success: true,
cookies: vec![],
};
let mut buffer = Vec::new();
Testcase::from_hurl_result(&hurl_result, &lines)
.to_xml()
.write(&mut buffer)
.unwrap();
assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
r#"<?xml version="1.0" encoding="UTF-8"?><testcase id="test.hurl" time="0.230"><failure>Assert Status
--> test.hurl:2:10
|
2 | HTTP/1.0 200
| ^^^ actual value is &lt;404>
|</failure></testcase>"#
);
}
#[test]
fn test_create_testcase_error() {
let lines = vec!["GET http://unknown".to_string()];
let hurl_result = HurlResult {
filename: "test.hurl".to_string(),
entries: vec![EntryResult {
request: None,
response: None,
captures: vec![],
asserts: vec![],
errors: vec![Error {
source_info: SourceInfo::init(1, 5, 1, 19),
inner: RunnerError::HttpConnection {
url: "http://unknown".to_string(),
message: "(6) Could not resolve host: unknown".to_string(),
},
assert: false,
}],
time_in_ms: 0,
}],
time_in_ms: 230,
success: true,
cookies: vec![],
};
let mut buffer = Vec::new();
Testcase::from_hurl_result(&hurl_result, &lines)
.to_xml()
.write(&mut buffer)
.unwrap();
assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
r#"<?xml version="1.0" encoding="UTF-8"?><testcase id="test.hurl" time="0.230"><error>Http Connection
--> test.hurl:1:5
|
1 | GET http://unknown
| ^^^^^^^^^^^^^^ (6) Could not resolve host: unknown
|</error></testcase>"#
);
}
}

View File

@ -24,9 +24,8 @@ use super::runner::HurlResult;
mod html;
mod junit;
pub use junit::add_testcase;
pub use junit::add_testsuite;
pub use junit::create_or_get_junit_report;
pub use junit::create_report as create_junit_report;
pub use junit::Testcase;
pub fn parse_html(path: PathBuf) -> Result<Vec<HurlResult>, CliError> {
if path.exists() {