///|
/// A named TAP input stream for suite-level reporting.
pub(all) struct TapNamedInput {
name : String
input : String
} derive(Debug, Eq)
///|
/// A parsed and validated named TAP stream.
pub(all) struct TapNamedReport {
name : String
report : TapReport
} derive(Debug, Eq)
///|
/// Aggregate counters for a collection of named reports.
pub(all) struct TapSuiteSummary {
suites : Int
passed_suites : Int
failed_suites : Int
planned : Int
total : Int
passed : Int
failed : Int
skipped : Int
todo : Int
diagnostics : Int
yaml_blocks : Int
issues : Int
} derive(Debug, Eq)
///|
/// A CI-friendly suite report built from many TAP streams.
pub(all) struct TapSuiteReport {
name : String
reports : Array[TapNamedReport]
summary : TapSuiteSummary
ok : Bool
} derive(Debug, Eq)
///|
/// Construct a named input value.
pub fn named_input(name : String, input : String) -> TapNamedInput {
TapNamedInput::{ name, input }
}
///|
/// Construct a named report from already parsed data.
pub fn named_report(name : String, report : TapReport) -> TapNamedReport {
TapNamedReport::{ name, report }
}
///|
/// Parse multiple TAP streams into a suite report.
pub fn parse_suite(
name : String,
inputs : Array[TapNamedInput],
) -> TapSuiteReport {
let reports : Array[TapNamedReport] = []
for item in inputs {
reports.push(TapNamedReport::{
name: item.name,
report: parse_and_report(item.input),
})
}
build_suite_report(name, reports)
}
///|
/// Build a suite report from named reports.
pub fn build_suite_report(
name : String,
reports : Array[TapNamedReport],
) -> TapSuiteReport {
let summary = summarize_suite(reports)
TapSuiteReport::{ name, reports, summary, ok: summary.failed_suites == 0 }
}
///|
/// Aggregate suite-level counters.
pub fn summarize_suite(reports : Array[TapNamedReport]) -> TapSuiteSummary {
let mut passed_suites = 0
let mut failed_suites = 0
let mut planned = 0
let mut total = 0
let mut passed = 0
let mut failed = 0
let mut skipped = 0
let mut todo = 0
let mut diagnostics = 0
let mut yaml_blocks = 0
let mut issues = 0
for item in reports {
if item.report.ok {
passed_suites += 1
} else {
failed_suites += 1
}
planned += item.report.summary.planned
total += item.report.summary.total
passed += item.report.summary.passed
failed += item.report.summary.failed
skipped += item.report.summary.skipped
todo += item.report.summary.todo
diagnostics += item.report.summary.diagnostics
yaml_blocks += item.report.summary.yaml_blocks
issues += item.report.issues.length()
}
TapSuiteSummary::{
suites: reports.length(),
passed_suites,
failed_suites,
planned,
total,
passed,
failed,
skipped,
todo,
diagnostics,
yaml_blocks,
issues,
}
}
///|
/// Return true when every named report passes.
pub fn suite_passes(suite : TapSuiteReport) -> Bool {
suite.ok
}
///|
/// Return a stable status label for the suite.
pub fn suite_status_label(suite : TapSuiteReport) -> String {
if suite.ok {
"ok"
} else {
"failed"
}
}
///|
/// Return the first failing report, if any.
pub fn first_failing_report(suite : TapSuiteReport) -> TapNamedReport? {
for item in suite.reports {
if !item.report.ok {
return Some(item)
}
}
None
}
///|
/// Return all passing reports.
pub fn passing_reports(suite : TapSuiteReport) -> Array[TapNamedReport] {
let items : Array[TapNamedReport] = []
for item in suite.reports {
if item.report.ok {
items.push(item)
}
}
items
}
///|
/// Return all failing reports.
pub fn failing_reports(suite : TapSuiteReport) -> Array[TapNamedReport] {
let items : Array[TapNamedReport] = []
for item in suite.reports {
if !item.report.ok {
items.push(item)
}
}
items
}
///|
/// Return reports containing a specific issue code.
pub fn reports_with_issue(
suite : TapSuiteReport,
code : String,
) -> Array[TapNamedReport] {
let items : Array[TapNamedReport] = []
for item in suite.reports {
if report_has_issue(item.report, code) {
items.push(item)
}
}
items
}
///|
/// Return true when a report contains the issue code.
pub fn report_has_issue(report : TapReport, code : String) -> Bool {
for item in report.issues {
if item.code == code {
return true
}
}
false
}
///|
/// Return true when any suite report contains the issue code.
pub fn suite_has_issue(suite : TapSuiteReport, code : String) -> Bool {
for item in suite.reports {
if report_has_issue(item.report, code) {
return true
}
}
false
}
///|
/// Return unique issue codes in first-seen order.
pub fn suite_issue_codes(suite : TapSuiteReport) -> Array[String] {
let codes : Array[String] = []
for item in suite.reports {
for issue_item in item.report.issues {
if !codes.contains(issue_item.code) {
codes.push(issue_item.code)
}
}
}
codes
}
///|
/// Count report-level blocking failures across the suite.
pub fn suite_blocking_failures(suite : TapSuiteReport) -> Int {
let mut total = 0
for item in suite.reports {
total += count_blocking_failures(item.report)
}
total
}
///|
/// Calculate suite-level health score.
pub fn suite_health_score(suite : TapSuiteReport) -> Int {
if suite.reports.length() == 0 {
return 0
}
let mut total = 0
for item in suite.reports {
total += health_score(item.report)
}
total / suite.reports.length()
}
///|
/// Apply a policy to every suite member and aggregate the result.
pub fn evaluate_suite_policy(
suite : TapSuiteReport,
policy : TapPolicy,
) -> TapPolicyResult {
let messages : Array[String] = []
let mut errors = 0
let mut warnings = 0
let mut infos = 0
for item in suite.reports {
let result = evaluate_policy(item.report, policy)
if !result.ok {
errors += 1
messages.push("\{item.name}: policy failed")
} else {
infos += 1
messages.push("\{item.name}: policy passed")
}
warnings += result.warnings
infos += result.infos
errors += result.errors
}
if suite.reports.length() == 0 {
errors += 1
messages.push("suite contains no reports")
}
let score = suite_health_score(suite) - errors * 8 - warnings * 3
TapPolicyResult::{
policy_name: "\{policy.name}/suite",
ok: errors == 0,
score: clamp_suite_score(score),
errors,
warnings,
infos,
messages,
}
}
///|
/// Render a suite as Markdown for GitHub job summaries.
pub fn suite_to_markdown(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string("# TAPTrail Suite Report\n\n")
sb.write_string("- suite: ")
sb.write_string(suite.name)
sb.write_string("\n")
sb.write_string("- status: ")
sb.write_string(suite_status_label(suite))
sb.write_string("\n")
sb.write_string("- suites: \{suite.summary.suites}\n")
sb.write_string("- passed_suites: \{suite.summary.passed_suites}\n")
sb.write_string("- failed_suites: \{suite.summary.failed_suites}\n")
sb.write_string("- planned: \{suite.summary.planned}\n")
sb.write_string("- total: \{suite.summary.total}\n")
sb.write_string("- passed: \{suite.summary.passed}\n")
sb.write_string("- failed: \{suite.summary.failed}\n")
sb.write_string("- skipped: \{suite.summary.skipped}\n")
sb.write_string("- todo: \{suite.summary.todo}\n")
sb.write_string("- diagnostics: \{suite.summary.diagnostics}\n")
sb.write_string("- yaml_blocks: \{suite.summary.yaml_blocks}\n")
sb.write_string("- issues: \{suite.summary.issues}\n")
sb.write_string("- health_score: \{suite_health_score(suite)}\n")
if suite.reports.is_empty() {
sb.write_string("\nNo TAP streams were provided.\n")
} else {
sb.write_string("\n## Members\n\n")
sb.write_string("| Name | Status | Total | Passed | Failed | Issues |\n")
sb.write_string("| --- | --- | ---: | ---: | ---: | ---: |\n")
for item in suite.reports {
sb.write_string("| ")
sb.write_string(markdown_cell(item.name))
sb.write_string(" | ")
sb.write_string(if item.report.ok { "ok" } else { "failed" })
sb.write_string(" | \{item.report.summary.total}")
sb.write_string(" | \{item.report.summary.passed}")
sb.write_string(" | \{item.report.summary.failed}")
sb.write_string(" | \{item.report.issues.length()} |\n")
}
}
let codes = suite_issue_codes(suite)
if codes.length() > 0 {
sb.write_string("\n## Issue Codes\n\n")
for code in codes {
let meta = issue_meta(code)
sb.write_string("- `")
sb.write_string(code)
sb.write_string("` ")
sb.write_string(severity_label(meta.severity))
sb.write_string(": ")
sb.write_string(meta.title)
sb.write_string("\n")
}
}
sb.to_string()
}
///|
/// Render a compact JSON object for suite dashboards.
pub fn suite_to_compact_json(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string("{")
sb.write_string("\"name\":\"")
sb.write_string(json_escape(suite.name))
sb.write_string("\",\"ok\":")
sb.write_string(if suite.ok { "true" } else { "false" })
sb.write_string(",\"suites\":\{suite.summary.suites}")
sb.write_string(",\"passed_suites\":\{suite.summary.passed_suites}")
sb.write_string(",\"failed_suites\":\{suite.summary.failed_suites}")
sb.write_string(",\"total\":\{suite.summary.total}")
sb.write_string(",\"passed\":\{suite.summary.passed}")
sb.write_string(",\"failed\":\{suite.summary.failed}")
sb.write_string(",\"skipped\":\{suite.summary.skipped}")
sb.write_string(",\"todo\":\{suite.summary.todo}")
sb.write_string(",\"issues\":\{suite.summary.issues}")
sb.write_string(",\"reports\":[")
for item in suite.reports; first = true {
if first {
()
} else {
sb.write_string(",")
}
sb.write_string("{\"name\":\"")
sb.write_string(json_escape(item.name))
sb.write_string("\",\"ok\":")
sb.write_string(if item.report.ok { "true" } else { "false" })
sb.write_string(",\"total\":\{item.report.summary.total}")
sb.write_string(",\"issues\":\{item.report.issues.length()}")
sb.write_string("}")
continue false
}
sb.write_string("]}")
sb.to_string()
}
///|
/// Render suite rows as CSV with a header.
pub fn suite_to_csv(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string(
"name,status,planned,total,passed,failed,skipped,todo,issues\n",
)
for item in suite.reports {
sb.write_string(csv_escape(item.name))
sb.write_string(",")
sb.write_string(if item.report.ok { "ok" } else { "failed" })
sb.write_string(",\{item.report.summary.planned}")
sb.write_string(",\{item.report.summary.total}")
sb.write_string(",\{item.report.summary.passed}")
sb.write_string(",\{item.report.summary.failed}")
sb.write_string(",\{item.report.summary.skipped}")
sb.write_string(",\{item.report.summary.todo}")
sb.write_string(",\{item.report.issues.length()}\n")
}
sb.to_string()
}
///|
/// Render suite rows as tab-separated values.
pub fn suite_to_tsv(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string(
"name\tstatus\tplanned\ttotal\tpassed\tfailed\tskipped\ttodo\tissues\n",
)
for item in suite.reports {
sb.write_string(tsv_escape(item.name))
sb.write_string("\t")
sb.write_string(if item.report.ok { "ok" } else { "failed" })
sb.write_string("\t\{item.report.summary.planned}")
sb.write_string("\t\{item.report.summary.total}")
sb.write_string("\t\{item.report.summary.passed}")
sb.write_string("\t\{item.report.summary.failed}")
sb.write_string("\t\{item.report.summary.skipped}")
sb.write_string("\t\{item.report.summary.todo}")
sb.write_string("\t\{item.report.issues.length()}\n")
}
sb.to_string()
}
///|
/// Render one merged JUnit XML suite containing named reports.
pub fn suite_to_junit_xml(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string("\n")
sb.write_string("\n")
for item in suite.reports {
for point in item.report.document.points {
sb.write_string(" ")
if point.ok {
match point.directive {
Skip(reason) => {
sb.write_string("\n \n ")
}
Todo(reason) => {
sb.write_string("\n \n ")
}
NoDirective => ()
}
} else {
match point.directive {
Todo(reason) => {
sb.write_string("\n \n ")
}
_ => {
sb.write_string("\n ")
sb.write_string(xml_escape(point.raw))
sb.write_string("\n ")
}
}
}
sb.write_string("\n")
}
}
if suite.summary.issues > 0 {
sb.write_string(" ")
for item in suite.reports {
for issue_item in item.report.issues {
sb.write_string(
xml_escape(
"[\{item.name}] [\{issue_item.code}] \{issue_item.message}\n",
),
)
}
}
sb.write_string("\n")
}
sb.write_string("\n")
sb.to_string()
}
///|
/// Render a compact status block for CI environment files.
pub fn suite_to_env_lines(suite : TapSuiteReport, prefix : String) -> String {
let name = if prefix.length() == 0 { "TAPTRAIL" } else { prefix }
let sb = StringBuilder()
sb.write_string("\{name}_STATUS=\{suite_status_label(suite)}\n")
sb.write_string("\{name}_SUITES=\{suite.summary.suites}\n")
sb.write_string("\{name}_PASSED_SUITES=\{suite.summary.passed_suites}\n")
sb.write_string("\{name}_FAILED_SUITES=\{suite.summary.failed_suites}\n")
sb.write_string("\{name}_TOTAL=\{suite.summary.total}\n")
sb.write_string("\{name}_PASSED=\{suite.summary.passed}\n")
sb.write_string("\{name}_FAILED=\{suite.summary.failed}\n")
sb.write_string("\{name}_ISSUES=\{suite.summary.issues}\n")
sb.write_string("\{name}_SCORE=\{suite_health_score(suite)}\n")
sb.to_string()
}
///|
/// Render release-note bullets from a suite.
pub fn suite_to_release_notes(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string("Test suite `")
sb.write_string(suite.name)
sb.write_string("` ")
sb.write_string(if suite.ok { "passed" } else { "failed" })
sb.write_string(
" with \{suite.summary.passed}/\{suite.summary.total} passing tests.",
)
if suite.summary.skipped > 0 {
sb.write_string(" Skipped: \{suite.summary.skipped}.")
}
if suite.summary.todo > 0 {
sb.write_string(" TODO: \{suite.summary.todo}.")
}
if suite.summary.issues > 0 {
sb.write_string(" Issues: \{suite.summary.issues}.")
}
sb.to_string()
}
///|
/// Render a Markdown checklist useful for manual release reviews.
pub fn suite_to_checklist(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string("# Release Test Checklist\n\n")
for item in suite.reports {
sb.write_string("- [")
sb.write_string(if item.report.ok { "x" } else { " " })
sb.write_string("] ")
sb.write_string(item.name)
sb.write_string(" (")
sb.write_string(if item.report.ok { "ok" } else { "failed" })
sb.write_string(
", \{item.report.summary.passed}/\{item.report.summary.total} passed",
)
if item.report.issues.length() > 0 {
sb.write_string(", \{item.report.issues.length()} issues")
}
sb.write_string(")\n")
}
sb.to_string()
}
///|
/// Render issue details as a Markdown table.
pub fn suite_issues_to_markdown(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string("| Suite | Severity | Code | Line | Message |\n")
sb.write_string("| --- | --- | --- | ---: | --- |\n")
for item in suite.reports {
for issue_item in item.report.issues {
let meta = issue_meta(issue_item.code)
sb.write_string("| ")
sb.write_string(markdown_cell(item.name))
sb.write_string(" | ")
sb.write_string(severity_label(meta.severity))
sb.write_string(" | `")
sb.write_string(issue_item.code)
sb.write_string("` | \{issue_item.line} | ")
sb.write_string(markdown_cell(issue_item.message))
sb.write_string(" |\n")
}
}
sb.to_string()
}
///|
/// Find the report with the most issues.
pub fn report_with_most_issues(suite : TapSuiteReport) -> TapNamedReport? {
let mut best_index = -1
let mut best_count = -1
for i in 0.. best_count {
best_count = issue_count
best_index = i
}
}
if best_index >= 0 {
Some(suite.reports[best_index])
} else {
None
}
}
///|
fn suite_point_name(point : TapPoint) -> String {
if point.name.length() > 0 {
point.name
} else if point.number > 0 {
"test \{point.number}"
} else {
"unnumbered at line \{point.line}"
}
}
///|
fn markdown_cell(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'|' => sb.write_string("\\|")
'\n' => sb.write_string("
")
'\r' => ()
_ => sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn csv_escape(text : String) -> String {
let must_quote = text.contains(",") ||
text.contains("\"") ||
text.contains("\n")
if !must_quote {
return text
}
let sb = StringBuilder()
sb.write_string("\"")
for ch in text {
match ch {
'"' => sb.write_string("\"\"")
_ => sb.write_char(ch)
}
}
sb.write_string("\"")
sb.to_string()
}
///|
fn tsv_escape(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'\t' => sb.write_string(" ")
'\n' => sb.write_string(" ")
'\r' => ()
_ => sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn clamp_suite_score(score : Int) -> Int {
if score < 0 {
0
} else if score > 100 {
100
} else {
score
}
}