///|
priv struct CaseBuilder {
scope : Int32
id : TestId
name : String
classname : String?
final_results : Array[String]
interim_results : Array[String]
suite_order : Int32
time_us : Int64?
mut failed : Int32
mut errored : Int32
mut skipped : Int32
mut flaky_count : Int32
mut rerun_count : Int32
mut message : String
mut message_truncated : Bool
}
///|
fn CaseBuilder::finish(
self : CaseBuilder,
source : String,
diagnostics : Array[Diagnostic],
document_order : Int32,
source_units : Int64,
) -> TestCase raise InputError {
let retries = self.flaky_count + self.rerun_count
if self.skipped > 0 && retries > 0 {
raise Rejected(
"retry_conflict",
"\{source}: skipped cannot be combined with retry elements",
)
}
let outer_failure = self.failed > 0 || self.errored > 0
if !outer_failure && self.rerun_count > 0 {
raise Rejected(
"retry_conflict",
"\{source}: rerun elements require an outer failure/error",
)
}
if outer_failure && self.flaky_count > 0 {
diagnostics.push(
diagnostic(
source,
"contradictory_retry",
"document index \{document_order}: \{self.id.preview()}; outcome is failure/error; flaky markers retained",
),
)
}
if self.failed + self.errored + self.skipped > 1 {
diagnostics.push(
diagnostic(
source,
"multiple_outcomes",
"document index \{document_order}: \{self.id.preview()}; precedence is error > failure > skipped > passed",
),
)
}
let outcome = if self.errored > 0 {
Errored
} else if self.failed > 0 {
Failed
} else if self.skipped > 0 {
Skipped
} else {
Passed
}
if self.message_truncated {
diagnostics.push(
diagnostic(
source,
"message_truncated",
"document index \{document_order}: \{self.id.preview()}; message clipped at 2048 UTF-16 units",
),
)
}
{
id: self.id,
scope: self.scope,
name: self.name,
classname: self.classname,
final_results: ReadOnlyArray::from_array(self.final_results),
interim_results: ReadOnlyArray::from_array(self.interim_results),
suite_order: self.suite_order,
document_order,
outcome,
attempts: retries + 1,
flaky: self.flaky_count > 0,
time_us: self.time_us,
message: self.message,
source,
source_units,
}
}
///|
fn append_message(builder : CaseBuilder, text : String) -> Unit {
if builder.message_truncated {
return
}
let remaining = 2048 - builder.message.length()
let prefix = truncate_utf16(text, remaining)
builder.message_truncated = prefix.length() < text.length()
builder.message = builder.message + prefix
}
///|
/// Parse the documented unnamespaced JUnit profile; no I/O or external entities.
pub fn parse_document(
text : String,
source~ : String = "",
) -> Report raise InputError {
if text.length() > 10000000 {
raise Rejected(
"limit_exceeded",
"\{source}: XML exceeds 10000000 UTF-16 code units",
)
}
check_xml_attribute_budget(text)
let source_units = string_json_units(source)
let reader = @xml.Reader::from_string(text)
let tags : Array[String] = []
let pool = new_scope_pool()
let suite_tokens : Array[Int32] = []
let suite_orders : Array[Int32] = []
let mut next_suite = 0
let cases : Array[TestCase] = []
let diagnostics : Array[Diagnostic] = []
let unknown : Map[String, Bool] = Map([])
let mut current : CaseBuilder? = None
let mut case_depth = 0
let mut message_depth = 0
let mut message_body_started = false
let mut root_seen = false
let mut events = 0
while true {
events = events + 1
if events > 2000000 {
raise Rejected("limit_exceeded", "\{source}: XML event limit exceeded")
}
let event = reader.read_event() catch {
error => raise Rejected("malformed_xml", "\{source}: \{error}")
}
match event.kind {
Start(element) | Empty(element) => {
let empty = match event.kind {
Empty(_) => true
_ => false
}
let tag = element.name
let parent = tags.last().unwrap_or("")
let depth = tags.length() + 1
if depth > 128 {
raise Rejected("limit_exceeded", "\{source}: XML depth exceeds 128")
}
if root_seen && depth == 1 {
raise Rejected("invalid_structure", "\{source}: multiple XML roots")
}
if !root_seen {
if tag != "testsuite" && tag != "testsuites" {
raise Rejected(
"invalid_structure",
"\{source}: root must be testsuite or testsuites",
)
}
root_seen = true
// Node's JUnit reporter can place cases directly under testsuites.
// Reserve order zero for these cases before any nested suites.
if tag == "testsuites" {
next_suite = 1
}
}
let root_xsi = depth == 1 &&
element.get("xmlns:xsi") ==
Some("http://www.w3.org/2001/XMLSchema-instance")
if tag.contains(":") ||
element.attributes.any(a => {
if root_xsi &&
(
a.name == "xmlns:xsi" ||
a.name == "xsi:noNamespaceSchemaLocation"
) {
false
} else {
a.name == "xmlns" || a.name.contains(":")
}
}) {
raise Rejected(
"unsupported_namespace",
"\{source}: only root Surefire xsi schema metadata is supported; namespaced JUnit elements are not",
)
}
if tag == "testsuite" {
if depth > 1 && parent != "testsuites" && parent != "testsuite" {
raise Rejected(
"invalid_structure",
"\{source}: testsuite in unsupported location",
)
}
if current is Some(_) {
raise Rejected(
"invalid_structure",
"\{source}: suite inside testcase",
)
}
let parent_scope = suite_tokens.last().unwrap_or(0)
suite_tokens.push(
pool.intern(parent_scope, element.get("name").unwrap_or("")),
)
suite_orders.push(next_suite)
next_suite = next_suite + 1
} else if tag == "testsuites" {
if depth > 1 {
raise Rejected(
"invalid_structure",
"\{source}: nested testsuites is unsupported",
)
}
} else if tag == "testcase" {
let root_case = parent == "testsuites" && depth == 2
if (parent != "testsuite" && !root_case) || current is Some(_) {
raise Rejected(
"invalid_structure",
"\{source}: testcase must be directly inside testsuite or root testsuites",
)
}
let name = element.get("name").unwrap_or("")
if name == "" {
raise Rejected(
"missing_name",
"\{source}: testcase requires a nonempty name",
)
}
let scope = match element.get("classname") {
Some(classname) => pool.intern(-1, classname)
None => suite_tokens.last().unwrap_or(0)
}
let id = match element.get("classname") {
Some(classname) => Class(classname, name)
None => SuitePath(pool.scopes[scope].path, name)
}
let time_us = parse_duration(element.get("time").unwrap_or("")) catch {
error => raise Rejected("invalid_time", "\{source}: \{error}")
}
current = Some({
id,
scope,
name,
classname: element.get("classname"),
final_results: [],
interim_results: [],
suite_order: suite_orders.last().unwrap_or(0),
time_us,
failed: 0,
errored: 0,
skipped: 0,
flaky_count: 0,
rerun_count: 0,
message: "",
message_truncated: false,
})
case_depth = depth
} else if tag == "failure" ||
tag == "error" ||
tag == "skipped" ||
tag == "flakyFailure" ||
tag == "flakyError" ||
tag == "rerunFailure" ||
tag == "rerunError" {
guard current is Some(builder) && depth == case_depth + 1 else {
raise Rejected(
"invalid_structure",
"\{source}: \{tag} must be directly inside testcase",
)
}
match tag {
"failure" => builder.failed = builder.failed + 1
"error" => builder.errored = builder.errored + 1
"skipped" => builder.skipped = builder.skipped + 1
"flakyFailure" | "flakyError" =>
builder.flaky_count = builder.flaky_count + 1
_ => builder.rerun_count = builder.rerun_count + 1
}
if tag == "failure" || tag == "error" || tag == "skipped" {
builder.final_results.push(tag)
} else {
builder.interim_results.push(tag)
}
if builder.message != "" {
append_message(builder, "\n")
}
append_message(builder, element.get("message").unwrap_or(""))
if !empty {
message_depth = depth
message_body_started = false
}
} else if tag != "properties" &&
tag != "property" &&
tag != "system-out" &&
tag != "system-err" &&
tag != "stackTrace" {
if !unknown.contains(tag) {
unknown[tag] = true
diagnostics.push(
diagnostic(source, "unknown_element", "ignored element: \{tag}"),
)
}
}
if empty {
if tag == "testcase" {
guard current is Some(builder) else {
raise InvalidInput("missing testcase state")
}
cases.push(
builder.finish(source, diagnostics, cases.length(), source_units),
)
current = None
}
if tag == "testsuite" {
ignore(suite_tokens.pop())
ignore(suite_orders.pop())
}
} else {
tags.push(tag)
}
if cases.length() > 100000 {
raise Rejected(
"limit_exceeded",
"\{source}: more than 100000 testcases",
)
}
}
End(tag) => {
if tags.last() != Some(tag) {
raise Rejected("malformed_xml", "mismatched XML closing element")
}
if tag == "testcase" {
guard current is Some(builder) else {
raise InvalidInput("\{source}: unmatched testcase")
}
cases.push(
builder.finish(source, diagnostics, cases.length(), source_units),
)
current = None
if cases.length() > 100000 {
raise Rejected(
"limit_exceeded",
"\{source}: more than 100000 testcases",
)
}
}
if tag == "testsuite" {
ignore(suite_tokens.pop())
ignore(suite_orders.pop())
}
if tags.length() == message_depth {
message_depth = 0
}
ignore(tags.pop())
}
Text(value) | CData(value) =>
if message_depth > 0 &&
!tags.contains("system-out") &&
!tags.contains("system-err") {
if current is Some(builder) {
if !message_body_started && value != "" {
if builder.message != "" {
append_message(builder, "\n")
}
message_body_started = true
}
append_message(builder, value)
}
}
DocType(_) =>
raise Rejected("doctype", "\{source}: DOCTYPE is unsupported")
Decl(encoding~, ..) =>
if encoding is Some(e) && e.to_lower() != "utf-8" {
raise Rejected(
"unsupported_encoding",
"\{source}: only UTF-8 XML is supported",
)
}
Eof => {
if !tags.is_empty() || current is Some(_) {
raise Rejected("malformed_xml", "truncated XML document")
}
break
}
_ => ()
}
}
if !root_seen {
raise Rejected("malformed_xml", "\{source}: empty XML")
}
// junitparser iterates a suite's direct cases before its nested suites.
cases.sort_by((a, b) => {
let c = a.suite_order.compare(b.suite_order)
if c != 0 {
c
} else {
a.document_order.compare(b.document_order)
}
})
{
cases: ReadOnlyArray::from_array(cases),
diagnostics: ReadOnlyArray::from_array(diagnostics),
scopes: ReadOnlyArray::from_array(pool.scopes),
}
}
///|
/// Normalize one document. Explicit attempts always use original document order.
pub fn parse_report(
text : String,
source~ : String = "",
duplicates~ : DuplicatePolicy = Reject,
) -> Report raise InputError {
let document = parse_document(text, source~)
merge_reports([document], duplicates~)
}