Skip to content

Commit 7c660d1

Browse files
authored
Merge pull request #529 from pact-foundation/html-verification-report
Feat: HTML verification report
2 parents 4420a87 + d14e629 commit 7c660d1

15 files changed

Lines changed: 1620 additions & 12 deletions

rust/Cargo.lock

Lines changed: 313 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/pact_verifier_cli/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,18 @@ junit = ["dep:junit-report", "dep:strip-ansi-escapes"] # support for Junit forma
2525
ansi_term = "0.12.1"
2626
anyhow = "1.0.98"
2727
clap = { version = "4.5.40", features = ["cargo", "env"] }
28-
env_logger = "0.11.8"
2928
junit-report = { version = "0.8.3", optional = true }
3029
lazy_static = "1.5.0"
3130
log = "0.4.27"
3231
maplit = "1.0.2"
32+
pact_matching = { version = "~2.0.4", path = "../pact_matching", default-features = false }
3333
pact_models = { version = "~1.3.10", default-features = false }
3434
pact_verifier = { version = "~1.3.5", path = "../pact_verifier", default-features = false }
35+
quick-xml = { version = "0.39.2", features = ["serde", "serialize"] }
36+
xrust = "2.0.3"
3537
regex = "1.11.1"
3638
reqwest = { version = "0.12.20", default-features = false, features = ["rustls-tls-native-roots", "blocking", "json"] }
39+
serde = "1.0.228"
3740
serde_json = "1.0.140"
3841
strip-ansi-escapes = { version = "0.2.1", optional = true }
3942
time = "0.3.47"
@@ -46,6 +49,7 @@ tempfile = "3.20.0"
4649

4750
[dev-dependencies]
4851
expectest = "0.12.0"
52+
insta = "1.43.1"
4953
rstest = "0.24.0"
5054
tempfile = "3.20.0"
5155
trycmd = "0.15.9"

rust/pact_verifier_cli/src/args.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
//! Command Line Arguments for the Verifier
2+
13
use clap::{Arg, ArgAction, ArgGroup, Command, command};
24
use clap::builder::{FalseyValueParser, NonEmptyStringValueParser, PossibleValuesParser};
35
use lazy_static::lazy_static;
@@ -99,6 +101,18 @@ pub fn setup_app() -> Command {
99101
.action(ArgAction::Set)
100102
.value_parser(NonEmptyStringValueParser::new())
101103
.help("Generate a JUnit XML report of the verification (requires the junit feature)"))
104+
.arg(Arg::new("html-file")
105+
.long("html")
106+
.env("PACT_VERIFIER_HTML_REPORT")
107+
.action(ArgAction::Set)
108+
.value_parser(NonEmptyStringValueParser::new())
109+
.help("Generate an HTML report of the verification"))
110+
.arg(Arg::new("html-file-xslt")
111+
.long("xslt")
112+
.action(ArgAction::Set)
113+
.value_parser(NonEmptyStringValueParser::new())
114+
.help("XSLT to use when generating the HTML report of the verification")
115+
.requires("html-file"))
102116
.arg(Arg::new("no-colour")
103117
.long("no-colour")
104118
.action(ArgAction::SetTrue)

rust/pact_verifier_cli/src/lib.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ pub async fn handle_cli() -> Result<(), i32> {
462462
}
463463
}
464464

465+
/// Run the verifier parsed arguments with a new Tokio runtime
465466
pub fn process_verifier_command(args: &ArgMatches) -> Result<(), ExitCode> {
466467
tokio::runtime::Runtime::new().unwrap().block_on(async {
467468
let res = handle_matches(args).await;
@@ -472,6 +473,7 @@ pub fn process_verifier_command(args: &ArgMatches) -> Result<(), ExitCode> {
472473
})
473474
}
474475

476+
/// Process the parsed arguments
475477
pub async fn handle_matches(matches: &ArgMatches) -> Result<(), i32> {
476478
let coloured_output = setup_output(matches);
477479

@@ -562,6 +564,14 @@ pub async fn handle_matches(matches: &ArgMatches) -> Result<(), i32> {
562564
warn!("junit feature is not enabled, ignoring junit-file option");
563565
}
564566

567+
if let Some(html_file) = matches.get_one::<String>("html-file") {
568+
if let Err(err) = reports::write_html_report(&result, html_file.as_str(),
569+
&provider_name, matches.get_one::<String>("html-file-xslt")) {
570+
error!("Failed to write HTML report to '{html_file}' - {err}");
571+
return Err(2)
572+
}
573+
}
574+
565575
if result.result { Ok(()) } else { Err(1) }
566576
})
567577
}
@@ -819,14 +829,15 @@ fn interaction_filter(matches: &ArgMatches) -> FilterInfo {
819829
}
820830

821831

822-
832+
/// Initialise ANSI terminal support (Windows only)
823833
#[cfg(windows)]
824834
pub fn init_windows() {
825835
if let Err(err) = ansi_term::enable_ansi_support() {
826836
warn!("Could not enable ANSI console support - {err}");
827837
}
828838
}
829839

840+
/// Initialise ANSI terminal support (Windows only)
830841
#[cfg(not(windows))]
831842
pub fn init_windows() { }
832843

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,57 @@
1+
use std::fs;
12
use std::fs::File;
2-
use std::io::Write;
3+
use std::io::{Read, Write};
4+
use std::path::PathBuf;
35

46
#[cfg(feature = "junit")] use junit_report::{ReportBuilder, TestCaseBuilder, TestSuiteBuilder};
57
#[cfg(feature = "junit")] use strip_ansi_escapes;
68
use serde_json::Value;
79
use tracing::debug;
10+
use xrust::{Error, ErrorKind, Item, Node, SequenceTrait};
11+
use xrust::parser::ParseError;
12+
use xrust::parser::xml::parse;
13+
use xrust::transform::context::StaticContextBuilder;
14+
use xrust::trees::smite::RNode;
15+
use xrust::xslt::from_document;
816

917
#[cfg(feature = "junit")] use pact_verifier::{interaction_mismatch_output, MismatchResult};
1018
use pact_verifier::verification_result::VerificationExecutionResult;
1119

20+
mod xml;
21+
22+
const XSLT: &str = include_str!("verification-report.xsl");
23+
24+
fn node_from_str(s: &str) -> Result<RNode, Error> {
25+
parse(RNode::new_document(), s, Some(|_: &_| Err(ParseError::MissingNameSpace)))
26+
}
27+
28+
fn apply_xslt(xml_str: &str, xslt: Option<&String>) -> anyhow::Result<String> {
29+
let doc = parse(RNode::new_document(), xml_str, Some(|_: &_| Err(ParseError::MissingNameSpace)))?;
30+
31+
let mut s = String::new();
32+
let xslt_doc = if let Some(xslt) = xslt {
33+
let mut f = File::open(xslt)?;
34+
f.read_to_string(&mut s)?;
35+
s.as_str()
36+
} else {
37+
XSLT
38+
};
39+
let xslt_doc = parse(RNode::new_document(), xslt_doc, Some(|_: &_| Err(ParseError::MissingNameSpace)))?;
40+
41+
let mut static_context = StaticContextBuilder::new()
42+
.message(|_| Ok(()))
43+
.fetcher(|_| Err(Error::new(ErrorKind::NotImplemented, "not implemented")))
44+
.parser(|_| Err::<RNode, Error>(Error::new(ErrorKind::NotImplemented, "not implemented")))
45+
.build();
46+
47+
let mut ctxt = from_document(xslt_doc, None, node_from_str, |_| Ok(String::new()))?;
48+
ctxt.context(vec![Item::Node(doc)], 0);
49+
ctxt.result_document(RNode::new_document());
50+
let seq = ctxt.evaluate(&mut static_context)?;
51+
52+
Ok(seq.to_xml())
53+
}
54+
1255
pub(crate) fn write_json_report(result: &VerificationExecutionResult, file_name: &str) -> anyhow::Result<()> {
1356
debug!("Writing JSON result of the verification to '{file_name}'");
1457
let mut f = File::create(file_name)?;
@@ -67,3 +110,41 @@ pub(crate) fn write_junit_report(result: &VerificationExecutionResult, file_name
67110
report.write_xml(&mut f)?;
68111
Ok(())
69112
}
113+
114+
pub(crate) fn write_html_report(
115+
result: &VerificationExecutionResult,
116+
file_name: &str,
117+
provider: &str,
118+
xslt: Option<&String>
119+
) -> anyhow::Result<()> {
120+
let path = PathBuf::from(file_name);
121+
let parent = if let Some(parent) = path.parent() {
122+
parent
123+
} else {
124+
return Err(anyhow::anyhow!("No parent directory found for {}", file_name));
125+
};
126+
fs::create_dir_all(parent)?;
127+
128+
let filename = if let Some(filename) = path.file_name() {
129+
filename
130+
} else {
131+
return Err(anyhow::anyhow!("Failed to get file name of '{}'", path.display()));
132+
};
133+
134+
let xml_path = if let Some(_extension) = path.extension() {
135+
path.with_extension("xml")
136+
} else {
137+
parent.join(filename).with_extension("xml")
138+
};
139+
140+
let xml_str = xml::to_xml_string(result, provider)?;
141+
142+
debug!("Writing XML report of the verification to '{}'", xml_path.display());
143+
File::create(&xml_path)?.write_all(xml_str.as_bytes())?;
144+
145+
debug!("Writing HTML report of the verification to '{file_name}'");
146+
let html = apply_xslt(&xml_str, xslt)?;
147+
File::create(file_name)?.write_all(html.as_bytes())?;
148+
149+
Ok(())
150+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: pact_verifier_cli/src/reports/xml.rs
3+
expression: "to_xml(&result, \"My Provider\")"
4+
---
5+
<report><provider>My Provider</provider><result>false</result><errors><error><interaction>Complex interaction</interaction><mismatch type="mismatches"><interaction_id>complex-123</interaction_id><mismatches><mismatch type="MethodMismatch"><description>Expected method GET but received POST</description><expected>GET</expected><actual>POST</actual></mismatch><mismatch type="PathMismatch"><description>Expected path /foo but received /bar</description><expected>/foo</expected><actual>/bar</actual></mismatch><mismatch type="HeaderMismatch"><description>Expected header Content-Type=application/json but received text/plain</description><expected>application/json</expected><actual>text/plain</actual><key>Content-Type</key></mismatch><mismatch type="QueryMismatch"><description>Expected query parameter page=1 but received page=2</description><expected>1</expected><actual>2</actual><parameter>page</parameter></mismatch><mismatch type="BodyTypeMismatch"><description>Expected body content type application/json but received text/plain</description><expected>application/json</expected><actual>text/plain</actual></mismatch><mismatch type="MetadataMismatch"><description>Expected metadata contentType=application/json but received text/xml</description><expected>application/json</expected><actual>text/xml</actual><key>contentType</key></mismatch></mismatches></mismatch></error></errors></report>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: pact_verifier_cli/src/reports/xml.rs
3+
expression: "to_xml(&VerificationExecutionResult::new(), \"My Provider\")"
4+
---
5+
<report><provider>My Provider</provider><result>true</result></report>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: pact_verifier_cli/src/reports/xml.rs
3+
expression: "to_xml(&result, \"My Provider\")"
4+
---
5+
<report><provider>My Provider</provider><result>false</result><errors><error><interaction>GET /foo returns 200</interaction><mismatch type="mismatches"><mismatches><mismatch type="StatusMismatch"><description>Expected status 200 but was 404</description><expected>200</expected><actual>404</actual></mismatch><mismatch type="BodyMismatch"><description>Expected 100 but got 200</description><expected>100</expected><actual>200</actual><path>$.price</path></mismatch></mismatches></mismatch></error></errors></report>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: pact_verifier_cli/src/reports/xml.rs
3+
expression: "to_xml(&result, \"My Provider\")"
4+
---
5+
<report><provider>My Provider</provider><result>false</result><errors><error><interaction>GET /foo returns 200</interaction><mismatch type="error"><error_message>Connection refused</error_message><interaction_id>abc123</interaction_id></mismatch></error></errors><interaction_results><interaction><description>GET /foo returns 200</description><result>Error</result><pending>false</pending><duration_ms>5</duration_ms></interaction></interaction_results></report>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: pact_verifier_cli/src/reports/xml.rs
3+
expression: "to_xml(&result, \"My Provider\")"
4+
---
5+
<report><provider>My Provider</provider><result>true</result><interaction_results><interaction><description>GET /foo returns 200</description><result>OK</result><pending>false</pending><duration_ms>42</duration_ms></interaction><interaction><description>POST /bar returns 201</description><result>OK</result><pending>false</pending><duration_ms>15</duration_ms></interaction></interaction_results></report>

0 commit comments

Comments
 (0)