diff --git a/.gitignore b/.gitignore
index aebe389..a237387 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
/target
/dist
/.env
+*.snap.new
diff --git a/Cargo.lock b/Cargo.lock
index ddb1bfd..7238e51 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -156,6 +156,7 @@ dependencies = [
"hex",
"http",
"humansize",
+ "junit-parser",
"mime",
"mime_guess",
"once_cell",
@@ -163,6 +164,7 @@ dependencies = [
"percent-encoding",
"pin-project",
"proptest",
+ "quick-xml",
"quick_cache",
"rand",
"regex",
@@ -497,6 +499,18 @@ dependencies = [
"unicode_categories",
]
+[[package]]
+name = "console"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb"
+dependencies = [
+ "encode_unicode",
+ "lazy_static",
+ "libc",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "constant_time_eq"
version = "0.1.5"
@@ -620,6 +634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
+ "serde",
]
[[package]]
@@ -695,6 +710,12 @@ version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
+[[package]]
+name = "encode_unicode"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
+
[[package]]
name = "entities"
version = "1.0.1"
@@ -1217,6 +1238,19 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "insta"
+version = "1.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "810ae6042d48e2c9e9215043563a58a80b877bc863228a74cf10c49d4620a6f5"
+dependencies = [
+ "console",
+ "lazy_static",
+ "linked-hash-map",
+ "serde",
+ "similar",
+]
+
[[package]]
name = "ipnet"
version = "2.9.0"
@@ -1253,6 +1287,19 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "junit-parser"
+version = "0.1.0"
+dependencies = [
+ "insta",
+ "once_cell",
+ "path_macro",
+ "quick-xml",
+ "serde",
+ "thiserror",
+ "time",
+]
+
[[package]]
name = "lazy_static"
version = "1.4.0"
@@ -1271,6 +1318,12 @@ version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
@@ -1705,6 +1758,15 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+[[package]]
+name = "quick-xml"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quick_cache"
version = "0.5.1"
@@ -2182,6 +2244,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "similar"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640"
+
[[package]]
name = "slab"
version = "0.4.9"
@@ -2356,10 +2424,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
dependencies = [
"deranged",
+ "itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
+ "time-macros",
]
[[package]]
@@ -2368,6 +2438,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+[[package]]
+name = "time-macros"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tinyvec"
version = "1.6.0"
diff --git a/Cargo.toml b/Cargo.toml
index e529eee..8e097e6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,12 +39,14 @@ headers = "0.4.0"
hex = "0.4.3"
http = "1.1.0"
humansize = "2.1.3"
+junit-parser = { path = "crates/junit-parser" }
mime = "0.3.17"
mime_guess = "2.0.4"
once_cell = "1.19.0"
path_macro = "1.0.0"
percent-encoding = "2.3.1"
pin-project = "1.1.5"
+quick-xml = { version = "0.31.0", features = ["escape-html"] }
quick_cache = "0.5.1"
rand = "0.8.5"
regex = "1.10.4"
diff --git a/README.md b/README.md
index bd9424a..e94622e 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ Artifactview is configured using environment variables.
| `REPO_BLACKLIST` | - | List of sites/users/repos that can NOT be accessed. The blacklist takes precedence over the whitelist (repos included in both lists cannot be accessed)
Example: `github.com/evil-corp/world-destruction;codeberg.org/blackhat;example.org` |
| `REPO_WHITELIST` | - | List of sites/users/repos that can ONLY be accessed. If the whitelist is empty, it will be ignored and any repository can be accessed. Uses the same syntax as `REPO_BLACLIST`. |
| `SITE_ALIASES` | - | Aliases for sites to make URLs shorter
Example: `gh => github.com;cb => codeberg.org` |
+| `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer |
## Technical details
diff --git a/crates/junit-parser/Cargo.toml b/crates/junit-parser/Cargo.toml
new file mode 100644
index 0000000..6bc422a
--- /dev/null
+++ b/crates/junit-parser/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "junit-parser"
+version = "0.1.0"
+edition = "2021"
+authors = ["Boris Faure "]
+license = "BSD-2-Clause"
+repository = "https://github.com/borisfaure/junit-parser"
+
+[dependencies]
+quick-xml = { version = "0.31.0", features = ["escape-html"] }
+thiserror = "1.0.61"
+time = { version = "0.3.36", features = ["parsing", "serde-well-known"] }
+serde = { version = "1.0", features = ["derive"] }
+
+[dev-dependencies]
+insta = { version = "1.39.0", features = ["json"] }
+once_cell = "1.19.0"
+path_macro = "1.0.0"
diff --git a/crates/junit-parser/src/errors.rs b/crates/junit-parser/src/errors.rs
new file mode 100644
index 0000000..bd9f70e
--- /dev/null
+++ b/crates/junit-parser/src/errors.rs
@@ -0,0 +1,40 @@
+#![warn(missing_docs)]
+use thiserror::Error;
+
+/// Error enumerates all possible errors returned by this library.
+#[derive(Error, Debug)]
+pub enum Error {
+ /// Error while parsing XML
+ #[error("Error while parsing XML: {0}")]
+ Xml(#[from] ::quick_xml::Error),
+ /// Error while converting f64 attribute
+ #[error("Error while converting f64 attribute: {0}")]
+ ParseFloat(#[from] std::num::ParseFloatError),
+ /// Error while converting u64 attribute
+ #[error("Error while converting u64 attribute: {0}")]
+ ParseInt(#[from] std::num::ParseIntError),
+ /// Error while converting bytes to Utf8
+ #[error("Error while converting bytes to Utf8: {0}")]
+ ParseUt8(#[from] std::str::Utf8Error),
+ #[error("Error while parsing timestamp: {0}")]
+ ParseTimestamp(#[from] time::error::Parse),
+ /// Error parsing the `property` element: missing `name`
+ #[error("Missing `name` attribute in property")]
+ MissingPropertyName,
+}
+
+impl From<::quick_xml::events::attributes::AttrError> for Error {
+ #[inline]
+ /// Convert [`::quick_xml::events::attributes`] into [`Error::XMLError`]
+ fn from(err: ::quick_xml::events::attributes::AttrError) -> Error {
+ Error::Xml(err.into())
+ }
+}
+
+impl From<::quick_xml::escape::EscapeError> for Error {
+ #[inline]
+ /// Convert [`::quick_xml::escape::EscapeError`] into [`Error::XMLError`]
+ fn from(err: ::quick_xml::escape::EscapeError) -> Error {
+ Error::Xml(err.into())
+ }
+}
diff --git a/crates/junit-parser/src/lib.rs b/crates/junit-parser/src/lib.rs
new file mode 100644
index 0000000..b6a3a56
--- /dev/null
+++ b/crates/junit-parser/src/lib.rs
@@ -0,0 +1,564 @@
+use std::borrow::Cow;
+use std::io::BufRead;
+
+use quick_xml::escape::unescape;
+use quick_xml::events::BytesStart as XMLBytesStart;
+use quick_xml::events::Event as XMLEvent;
+use quick_xml::name::QName;
+use quick_xml::Error as XMLError;
+use quick_xml::Reader as XMLReader;
+use serde::{Deserialize, Serialize};
+use std::str;
+use time::OffsetDateTime;
+
+mod errors;
+
+use errors::Error;
+
+/// Struct representing a JUnit report, containing test suites
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct TestSuites {
+ /// Name of the test suites, from the `name` attribute
+ pub name: String,
+ /// List of tests suites represented by [`TestSuite`]
+ pub suites: Vec,
+ /// How long the test suites took to run, from the `time` attribute
+ pub time: f64,
+ /// Number of tests in the test suites, from the `tests` attribute
+ pub tests: u64,
+ /// Number of tests in error in the test suites, from the `errors` attribute
+ pub errors: u64,
+ /// Number of tests in failure in the test suites, from the `failures` attribute
+ pub failures: u64,
+ /// Number of tests skipped in the test suites, from the `skipped` attribute
+ pub skipped: u64,
+ /// Number of tests that passed after failed attempts
+ pub flaky: u64,
+}
+
+/// A test suite, containing test cases [`TestCase`]
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct TestSuite {
+ /// Name of the test suite, from the `name` attribute
+ pub name: String,
+ /// Timestamp when the test suite was run, from the `timestamp` attribute
+ #[serde(with = "time::serde::rfc3339::option")]
+ pub timestamp: Option,
+ /// List of status of tests represented by [`TestCase`]
+ pub cases: Vec,
+ /// How long the test suite took to run, from the `time` attribute
+ pub time: f64,
+ /// Number of tests in the test suite, from the `tests` attribute
+ pub tests: u64,
+ /// Number of tests in error in the test suite, from the `errors` attribute
+ pub errors: u64,
+ /// Number of tests in failure in the test suite, from the `failures` attribute
+ pub failures: u64,
+ /// Number of tests skipped in the test suites, from the `skipped` attribute
+ pub skipped: u64,
+ /// Number of tests that passed after failed attempts
+ pub flaky: u64,
+}
+
+/// A test case
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct TestCase {
+ /// Name of the test case, from the `name` attribute
+ pub name: String,
+ /// Original name, from the `name` attribute
+ pub original_name: String,
+ /// Class name, from the `classname` attribute
+ pub classname: Option,
+ /// Timestamp when the test case was run
+ #[serde(with = "time::serde::rfc3339::option")]
+ pub timestamp: Option,
+ /// Run time in seconds
+ pub time: f64,
+ /// Status of the test case
+ pub status: TestStatus,
+ /// stdout output from the `system-out` element
+ pub system_out: Option,
+ /// stderr output from the `system-err` element
+ pub system_err: Option,
+ /// Previous test attempts, from `rerunFailure` and `flakyFailure` element
+ pub retries: Vec,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub enum TestStatus {
+ #[default]
+ Success,
+ Error(Message),
+ Failure(Message),
+ Flaky,
+ Skipped,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct Message {
+ pub message: String,
+ pub text: String,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct Retry {
+ /// Timestamp when the retry was run
+ #[serde(with = "time::serde::rfc3339::option")]
+ pub timestamp: Option,
+ /// Run time in seconds
+ pub time: f64,
+ /// Status of the retry
+ pub status: TestStatus,
+ /// stdout output from the `system-out` element
+ pub system_out: Option,
+ /// stderr output from the `system-err` element
+ pub system_err: Option,
+}
+
+impl TestSuites {
+ /// Fill up `self` with attributes from the XML tag
+ fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
+ for a in e.attributes() {
+ let a = a?;
+ match a.key {
+ QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
+ QName(b"tests") => self.tests = try_from_attribute_value_u64(a.value)?,
+ QName(b"errors") => self.errors = try_from_attribute_value_u64(a.value)?,
+ QName(b"failures") => self.failures = try_from_attribute_value_u64(a.value)?,
+ QName(b"skipped") => self.skipped = try_from_attribute_value_u64(a.value)?,
+ QName(b"name") => self.name = try_from_attribute_value_string(a.value)?,
+ _ => {}
+ };
+ }
+ Ok(())
+ }
+
+ /// New [`TestSuites`] from empty XML tag
+ fn new_empty(e: &XMLBytesStart) -> Result {
+ let mut ts = Self::default();
+ ts.parse_attributes(e)?;
+ Ok(ts)
+ }
+
+ /// New [`TestSuites`] from XML tree
+ fn from_reader(e: &XMLBytesStart, r: &mut XMLReader) -> Result {
+ let mut ts = Self::default();
+ ts.parse_attributes(e)?;
+ let mut buf = Vec::new();
+ loop {
+ match r.read_event_into(&mut buf) {
+ Ok(XMLEvent::End(ref e)) if e.name() == QName(b"testsuites") => break,
+ Ok(XMLEvent::End(ref e)) if e.name() == QName(b"testrun") => break,
+ Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"testsuite") => {
+ ts.suites.push(TestSuite::from_reader(e, r)?);
+ }
+ Ok(XMLEvent::Empty(ref e)) if e.name() == QName(b"testsuite") => {
+ ts.suites.push(TestSuite::new_empty(e)?);
+ }
+ Ok(XMLEvent::Eof) => {
+ return Err(XMLError::UnexpectedEof("testsuites".to_string()).into())
+ }
+ Err(err) => return Err(err.into()),
+ _ => (),
+ }
+ }
+
+ ts.flaky = ts.suites.iter().map(|s| s.flaky).sum();
+
+ Ok(ts)
+ }
+}
+
+impl TestSuite {
+ /// Fill up `self` with attributes from the XML tag
+ fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
+ for a in e.attributes() {
+ let a = a?;
+ match a.key {
+ QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
+ QName(b"tests") => self.tests = try_from_attribute_value_u64(a.value)?,
+ QName(b"errors") => self.errors = try_from_attribute_value_u64(a.value)?,
+ QName(b"failures") => self.failures = try_from_attribute_value_u64(a.value)?,
+ QName(b"skipped") => self.skipped = try_from_attribute_value_u64(a.value)?,
+ QName(b"name") => self.name = try_from_attribute_value_string(a.value)?,
+ QName(b"timestamp") => {
+ self.timestamp = Some(try_from_attribute_value_timestamp(a.value)?)
+ }
+ _ => {}
+ };
+ }
+ Ok(())
+ }
+
+ /// New [`TestSuite`] from empty XML tag
+ fn new_empty(e: &XMLBytesStart) -> Result {
+ let mut ts = Self::default();
+ ts.parse_attributes(e)?;
+ Ok(ts)
+ }
+
+ /// New [`TestSuite`] from XML tree
+ fn from_reader(e: &XMLBytesStart, r: &mut XMLReader) -> Result {
+ let mut ts = Self::default();
+ ts.parse_attributes(e)?;
+ let mut buf = Vec::new();
+ loop {
+ match r.read_event_into(&mut buf) {
+ Ok(XMLEvent::End(ref e)) if e.name() == QName(b"testsuite") => break,
+ Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"testcase") => {
+ ts.cases.push(TestCase::from_reader(e, r)?);
+ }
+ Ok(XMLEvent::Empty(ref e)) if e.name() == QName(b"testcase") => {
+ ts.cases.push(TestCase::new_empty(e)?);
+ }
+ Ok(XMLEvent::Eof) => {
+ return Err(XMLError::UnexpectedEof("testsuite".to_string()).into())
+ }
+ Err(err) => return Err(err.into()),
+ _ => (),
+ }
+ }
+
+ ts.flaky = ts
+ .cases
+ .iter()
+ .filter(|c| matches!(c.status, TestStatus::Flaky))
+ .count() as u64;
+
+ Ok(ts)
+ }
+}
+
+impl TestCase {
+ /// Fill up `self` with attributes from the XML tag
+ fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
+ for a in e.attributes() {
+ let a = a?;
+ match a.key {
+ QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
+ QName(b"timestamp") => {
+ self.timestamp = Some(try_from_attribute_value_timestamp(a.value)?)
+ }
+ QName(b"name") => self.original_name = try_from_attribute_value_string(a.value)?,
+ QName(b"classname") => {
+ self.classname = Some(try_from_attribute_value_string(a.value)?)
+ }
+ _ => {}
+ }
+ }
+ if let Some(cn) = self.classname.as_ref() {
+ self.name = format!("{}::{}", cn, self.original_name);
+ } else {
+ self.name.clone_from(&self.original_name);
+ }
+ Ok(())
+ }
+
+ /// New [`TestCase`] from empty XML tag
+ fn new_empty(e: &XMLBytesStart) -> Result {
+ let mut tc = Self::default();
+ tc.parse_attributes(e)?;
+ Ok(tc)
+ }
+
+ /// New [`TestCase`] from XML tree
+ fn from_reader(e: &XMLBytesStart, r: &mut XMLReader) -> Result {
+ let mut tc = Self::default();
+ tc.parse_attributes(e)?;
+ let mut buf = Vec::new();
+ loop {
+ match r.read_event_into(&mut buf)? {
+ XMLEvent::End(ref e) if e.name() == QName(b"testcase") => break,
+ XMLEvent::Start(ref e) if e.name() == QName(b"skipped") => {
+ tc.status = TestStatus::Skipped;
+ }
+ XMLEvent::Empty(ref e) if e.name() == QName(b"skipped") => {
+ tc.status = TestStatus::Skipped;
+ }
+ XMLEvent::Start(ref e) if e.name() == QName(b"failure") => {
+ let msg = Message::from_reader(e, r)?;
+ tc.status = TestStatus::Failure(msg);
+ }
+ XMLEvent::Empty(ref e) if e.name() == QName(b"failure") => {
+ let msg = Message::new_empty(e)?;
+ tc.status = TestStatus::Failure(msg);
+ }
+ XMLEvent::Start(ref e) if e.name() == QName(b"error") => {
+ let msg = Message::from_reader(e, r)?;
+ tc.status = TestStatus::Error(msg);
+ }
+ XMLEvent::Empty(ref e) if e.name() == QName(b"error") => {
+ let msg = Message::new_empty(e)?;
+ tc.status = TestStatus::Error(msg);
+ }
+ XMLEvent::Start(ref e) if e.name() == QName(b"system-out") => {
+ tc.system_out = parse_system(e, r)?;
+ }
+ XMLEvent::Start(ref e) if e.name() == QName(b"system-err") => {
+ tc.system_err = parse_system(e, r)?;
+ }
+ XMLEvent::Empty(ref e) if e.name() == QName(b"rerunFailure") => {
+ tc.retries.push(Retry::new_empty(e)?);
+ }
+ XMLEvent::Start(ref e) if e.name() == QName(b"rerunFailure") => {
+ tc.retries.push(Retry::from_reader(e, r)?);
+ }
+ XMLEvent::Empty(ref e) if e.name() == QName(b"flakyFailure") => {
+ tc.status = TestStatus::Flaky;
+ tc.retries.push(Retry::new_empty(e)?);
+ }
+ XMLEvent::Start(ref e) if e.name() == QName(b"flakyFailure") => {
+ tc.status = TestStatus::Flaky;
+ tc.retries.push(Retry::from_reader(e, r)?);
+ }
+ XMLEvent::Eof => return Err(XMLError::UnexpectedEof("testcase".to_string()).into()),
+ _ => (),
+ }
+ }
+ Ok(tc)
+ }
+}
+
+impl Message {
+ /// Fill up `self` with attributes from the XML tag
+ fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
+ for a in e.attributes() {
+ let a = a?;
+ if let QName(b"message") = a.key {
+ self.message = try_from_attribute_value_string(a.value)?
+ }
+ }
+ Ok(())
+ }
+
+ /// New [`Message`] from empty XML tag
+ fn new_empty(e: &XMLBytesStart) -> Result {
+ let mut tf = Self::default();
+ tf.parse_attributes(e)?;
+ Ok(tf)
+ }
+
+ /// New [`Message`] from XML tree
+ fn from_reader(e: &XMLBytesStart, r: &mut XMLReader) -> Result {
+ let name = e.name();
+ let mut msg = Self::default();
+ msg.parse_attributes(e)?;
+ let mut buf = Vec::new();
+ loop {
+ match r.read_event_into(&mut buf) {
+ Ok(XMLEvent::End(ref e)) if e.name() == name => break,
+ Ok(XMLEvent::Text(e)) => {
+ msg.text += e.unescape()?.trim();
+ }
+ Ok(XMLEvent::Eof) => {
+ return Err(XMLError::UnexpectedEof("failure".to_string()).into())
+ }
+ Err(err) => return Err(err.into()),
+ _ => (),
+ }
+ }
+ Ok(msg)
+ }
+}
+
+impl Retry {
+ /// Fill up `self` with attributes from the XML tag
+ fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
+ for a in e.attributes() {
+ let a = a?;
+ match a.key {
+ QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
+ QName(b"timestamp") => {
+ self.timestamp = Some(try_from_attribute_value_timestamp(a.value)?)
+ }
+ _ => {}
+ };
+ }
+ Ok(())
+ }
+
+ fn new_empty(e: &XMLBytesStart) -> Result {
+ let mut rt = Self::default();
+ rt.parse_attributes(e)?;
+ Ok(rt)
+ }
+
+ fn from_reader(e: &XMLBytesStart, r: &mut XMLReader) -> Result {
+ let name = e.name();
+ let mut rt = Self::default();
+ rt.parse_attributes(e)?;
+
+ let mut msg = Message::new_empty(e)?;
+ let mut buf = Vec::new();
+ loop {
+ match r.read_event_into(&mut buf) {
+ Ok(XMLEvent::End(ref e)) if e.name() == name => break,
+ Ok(XMLEvent::Text(e)) => {
+ msg.text += e.unescape()?.trim();
+ }
+ Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"system-out") => {
+ rt.system_out = parse_system(e, r)?;
+ }
+ Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"system-err") => {
+ rt.system_err = parse_system(e, r)?;
+ }
+ Ok(XMLEvent::Eof) => {
+ return Err(XMLError::UnexpectedEof("failure".to_string()).into())
+ }
+ Err(err) => return Err(err.into()),
+ _ => (),
+ }
+ }
+
+ rt.status = TestStatus::Failure(msg);
+ Ok(rt)
+ }
+}
+
+/// Try to decode attribute value as [`f64`]
+fn try_from_attribute_value_f64(value: Cow<[u8]>) -> Result {
+ match str::from_utf8(&value)? {
+ "" => Ok(f64::default()),
+ s => Ok(s.parse::()?),
+ }
+}
+
+/// Try to decode attribute value as [`u64`]
+fn try_from_attribute_value_u64(value: Cow<[u8]>) -> Result {
+ match str::from_utf8(&value)? {
+ "" => Ok(u64::default()),
+ s => Ok(s.parse::()?),
+ }
+}
+
+/// Try to decode and unescape attribute value as [`String`]
+fn try_from_attribute_value_string(value: Cow<[u8]>) -> Result {
+ let s = str::from_utf8(&value)?;
+ let u = unescape(s)?;
+ Ok(u.to_string())
+}
+
+/// Try to decode and unescape attribute value as [`String`]
+fn try_from_attribute_value_timestamp(value: Cow<[u8]>) -> Result {
+ let s = str::from_utf8(&value)?;
+ let t = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)?;
+ Ok(t)
+}
+
+/// Parse a chunk of xml as system-out or system-err
+fn parse_system(
+ orig: &XMLBytesStart,
+ r: &mut XMLReader,
+) -> Result