This is an automated email from the ASF dual-hosted git repository.
ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git
The following commit(s) were added to refs/heads/trunk by this push:
new a932d3810e Add modern JUnit5 test reports (single-page and framed) and
fix a testdef suite-name casing mismatch (#1584)
a932d3810e is described below
commit a932d3810eb181825031271fdccc979bcd80c068
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Fri Aug 7 12:41:34 2026 +0530
Add modern JUnit5 test reports (single-page and framed) and fix a testdef
suite-name casing mismatch (#1584)
Add modern JUnit5 test reports (single-page and framed) and fix a testdef
suite-name casing mismatch(just following the naming convention in one test
suite name)
1) Add a createModernTestReport Gradle task that runs alongside the
existing createTestReports (Ant-style) task
2) Parse the JUnit suite XML output into structured data shared by both new
report renderers
3) Render a single filterable HTML page summarizing all suites and their
test cases
4) Extract shared summary/detail rendering and add a URL-safe suite slug
for reuse across report pages
5) Add a navigable framed report (suite list plus per-suite detail pages)
alongside the single-page and Ant reports
6) Clear the framed report output directory before each run so stale
suite-*.html pages cannot linger from earlier runs
7) Add per-suite summary cards
(tests/failures/errors/skipped/time/timestamp/host) to both new reports,
matching the classic report's per-class summary
8) Purge stale suite XML before each test run so reports never blend
results from an earlier run with the current one
9) Share CSS/JS across the framed report's pages via
junit-report.css/junit-report.js instead of duplicating them on every page
10) Escape suite names before writing them into the framed report's
title/heading
11) Sort suites by their reported name instead of by filename, and guard
the summary totals against an empty suite list
12) Lowercase MinilangTests' suite-name/case-name for consistency with the
naming used by the other test suites, and rename the testdef file to match
13) Extract the modern/framed test report tasks and their helpers out of
build.gradle into test-reports.gradle, applied from build.gradle so it still
runs on every Gradle invocation
---
build.gradle | 39 +-
.../{MinilangTests.xml => minilangtests.xml} | 4 +-
test-reports.gradle | 653 +++++++++++++++++++++
3 files changed, 674 insertions(+), 22 deletions(-)
diff --git a/build.gradle b/build.gradle
index c68f8f4187..8a80411f6f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+import groovy.xml.MarkupBuilder
+import groovy.xml.XmlSlurper
import org.apache.tools.ant.filters.ReplaceTokens
import org.asciidoctor.gradle.jvm.AsciidoctorTask
@@ -110,6 +112,7 @@ useLatestVersions {
apply from: 'common.gradle'
apply from: 'dependencies.gradle'
+apply from: 'test-reports.gradle'
// global properties
ext.os = System.getProperty('os.name').toLowerCase()
@@ -915,26 +918,8 @@ task generateAllPluginsDocumentation(group: docsGroup,
// ========== System Administration tasks ==========
-task createTestReports(group: sysadminGroup, description: 'Generate HTML
reports from junit XML output') {
- doLast {
- ant.taskdef(name: 'junitreport',
- classname:
'org.apache.tools.ant.taskdefs.optional.junit.XMLResultAggregator',
- classpath: configurations.junitReport.asPath)
- ant.junitreport(todir: './runtime/logs/test-results') {
- fileset(dir: './runtime/logs/test-results') {
- include(name: '*.xml')
- }
- report(format:'frames', todir:'./runtime/logs/test-results/html')
- report(format:'noframes', todir:'./runtime/logs/test-results/html')
- }
- // Ant's stock junit-frames.xsl/junit-noframes.xsl label this column
"Type", but it actually
- // renders the failure/error message and stack trace, not the bare
exception type - relabel
- // it post-generation rather than vendoring and maintaining a forked
copy of Ant's templates.
- fileTree('./runtime/logs/test-results/html') {
- include '**/*.html'
- }.each { file -> file.text = file.text.replace('>Type</th>', '>Failure
Reason</th>') }
- }
-}
+// createTestReports/createModernTestReport/createFramedTestReport moved to
test-reports.gradle
+// (applied above) - kept out of this file since they're self-contained and
sizable.
task gitInfoFooter(group: sysadminGroup, description: 'Update the Git
Branch-revision info in the footer if Git is used') {
doLast {
@@ -1273,7 +1258,21 @@ def createOfbizCommandTask(taskName, arguments) {
jvmArgs(application.applicationDefaultJvmArgs)
if (taskName ==~ /^ofbiz.*(--test|-t).*/) {
classpath = sourceSets.main.runtimeClasspath +
sourceSets.test.runtimeClasspath
+ // TestRunContainer.java writes one XML per suite as
"<suite.getName()>.xml" and only
+ // ever overwrites the suites a run actually re-executes - nothing
here deletes the
+ // rest first. Left alone, a narrower run (suitename=... filtering
to one component) or
+ // a renamed/removed suite leaves other suites' XML sitting in
this directory from
+ // whenever they last ran, and all three report tasks below just
glob whatever *.xml
+ // files exist at generation time - so the report would silently
blend fresh results
+ // with stale ones from an earlier run into what looks like one
coherent run. Clearing
+ // the directory before the run starts guarantees every report
reflects exactly this
+ // run's suites, nothing left over from before.
+ doFirst {
+ delete fileTree('./runtime/logs/test-results') { include
'*.xml' }
+ }
finalizedBy(createTestReports)
+ finalizedBy(createModernTestReport)
+ finalizedBy(createFramedTestReport)
} else {
classpath = sourceSets.main.runtimeClasspath
}
diff --git a/framework/minilang/testdef/MinilangTests.xml
b/framework/minilang/testdef/minilangtests.xml
similarity index 92%
rename from framework/minilang/testdef/MinilangTests.xml
rename to framework/minilang/testdef/minilangtests.xml
index 4853faa534..3529b72e1c 100644
--- a/framework/minilang/testdef/MinilangTests.xml
+++ b/framework/minilang/testdef/minilangtests.xml
@@ -18,11 +18,11 @@
under the License.
-->
-<test-suite suite-name="MinilangTests"
+<test-suite suite-name="minilangtests"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/test-suite.xsd">
- <test-case case-name="MiniLangUnitTests">
+ <test-case case-name="minilangunittests">
<jupiter-test-suite
class-name="org.apache.ofbiz.minilang.test.MiniLangTests"/>
</test-case>
diff --git a/test-reports.gradle b/test-reports.gradle
new file mode 100644
index 0000000000..8c0ff004c1
--- /dev/null
+++ b/test-reports.gradle
@@ -0,0 +1,653 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+import groovy.xml.MarkupBuilder
+import groovy.xml.XmlSlurper
+
+// createTestReports (classic Ant-based), createModernTestReport (single-page)
and
+// createFramedTestReport (navigable multi-page) all turn the JUnit suite XML
written to
+// runtime/logs/test-results into an HTML report - split out of build.gradle
into its own file
+// since it's self-contained and sizable, applied below so it still runs as
part of every Gradle
+// invocation exactly as if it were declared inline.
+def sysadminGroup = 'System Administration'
+
+task createTestReports(group: sysadminGroup, description: 'Generate HTML
reports from junit XML output') {
+ doLast {
+ ant.taskdef(name: 'junitreport',
+ classname:
'org.apache.tools.ant.taskdefs.optional.junit.XMLResultAggregator',
+ classpath: configurations.junitReport.asPath)
+ ant.junitreport(todir: './runtime/logs/test-results') {
+ fileset(dir: './runtime/logs/test-results') {
+ include(name: '*.xml')
+ }
+ report(format:'frames', todir:'./runtime/logs/test-results/html')
+ report(format:'noframes', todir:'./runtime/logs/test-results/html')
+ }
+ // Ant's stock junit-frames.xsl/junit-noframes.xsl label this column
"Type", but it actually
+ // renders the failure/error message and stack trace, not the bare
exception type - relabel
+ // it post-generation rather than vendoring and maintaining a forked
copy of Ant's templates.
+ fileTree('./runtime/logs/test-results/html') {
+ include '**/*.html'
+ }.each { file -> file.text = file.text.replace('>Type</th>', '>Failure
Reason</th>') }
+ }
+}
+
+def modernReportCss() {
+ // Hand-rolled, not a real Bootstrap import: same default
palette/spacing/component shapes.
+ // Used two ways: inlined into modern-report.html so that report stays one
portable file, and
+ // written once as a shared junit-report.css alongside the framed report's
many pages (referenced via
+ // a same-directory relative <link>, so it's still a
zero-external-network-request file:// doc -
+ // see the 2026-08-07 "CSS/JS duplication" note in
junit5-improvements-august2026.md).
+ '''
+:root {
+ --bs-primary: #0d6efd;
+ --bs-success: #198754;
+ --bs-danger: #dc3545;
+ --bs-warning: #fd7e14;
+ --bs-secondary: #6c757d;
+ --bs-body-bg: #f8f9fa;
+ --bs-border-color: #dee2e6;
+}
+body {
+ font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
+ margin: 0;
+ color: #212529;
+ background: var(--bs-body-bg);
+ line-height: 1.5;
+}
+.navbar-report { background: var(--bs-primary); color: #fff; padding: 0.75rem
1.5rem; }
+.navbar-report h1 { margin: 0; font-size: 1.4rem; }
+.container { max-width: 1140px; margin: 0 auto; padding: 1.5rem; }
+h2 { font-size: 1.25rem; margin: 1.5rem 0 0.75rem; }
+
+.stat-cards { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom:
1.5rem; }
+.stat-card {
+ flex: 1 1 8rem;
+ background: #fff;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.375rem;
+ padding: 0.9rem;
+ text-align: center;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+.stat-value { display: block; font-size: 1.6rem; font-weight: 600; color:
#212529; text-decoration: none; }
+a.stat-value:hover { color: var(--bs-primary); text-decoration: underline; }
+.stat-label { font-size: 0.75rem; color: var(--bs-secondary); text-transform:
uppercase; letter-spacing: 0.03em; }
+
+.filter-group { margin-bottom: 1rem; }
+.btn-check { position: absolute; opacity: 0; pointer-events: none; }
+.btn-toggle {
+ display: inline-block;
+ padding: 0.375rem 0.9rem;
+ margin-right: 0.4rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.375rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+ color: #444;
+ background: #fff;
+ user-select: none;
+}
+.btn-check:checked + .btn-toggle-pass { background: var(--bs-success);
border-color: var(--bs-success); color: #fff; }
+.btn-check:checked + .btn-toggle-fail { background: var(--bs-danger);
border-color: var(--bs-danger); color: #fff; }
+.btn-check:checked + .btn-toggle-error { background: var(--bs-warning);
border-color: var(--bs-warning); color: #fff; }
+.btn-check:checked + .btn-toggle-skipped { background: var(--bs-secondary);
border-color: var(--bs-secondary); color: #fff; }
+.btn-check:focus-visible + .btn-toggle { outline: 2px solid var(--bs-primary);
outline-offset: 1px; }
+
+table.bs-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-bottom: 1.5rem;
+ background: #fff;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+table.bs-table th, table.bs-table td {
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ vertical-align: top;
+ border-top: 1px solid var(--bs-border-color);
+}
+table.bs-table thead th { background: #f1f3f5; border-top: none;
border-bottom: 2px solid var(--bs-border-color); }
+table.bs-table tbody tr:nth-child(odd) { background: rgba(0, 0, 0, 0.02); }
+table.bs-table tbody tr:hover { background: rgba(13, 110, 253, 0.06); }
+table.bs-table tbody tr.row-fail, table.bs-table tbody tr.row-error {
background: rgba(220, 53, 69, 0.07); }
+table.bs-table tbody tr.row-skipped { color: var(--bs-secondary); }
+
+.badge {
+ display: inline-block;
+ padding: 0.3em 0.65em;
+ border-radius: 10rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #fff;
+}
+.badge-pass { background: var(--bs-success); }
+.badge-fail { background: var(--bs-danger); }
+.badge-error { background: var(--bs-warning); }
+.badge-skipped { background: var(--bs-secondary); }
+
+pre { white-space: pre-wrap; margin: 0; font-size: 0.85em; }
+.parse-error { color: var(--bs-danger); font-weight: bold; }
+
+.nav-pane { padding: 0.75rem; }
+.nav-section-label { font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.03em; color: var(--bs-secondary); margin: 1rem 0 0.4rem; }
+.nav-list { list-style: none; margin: 0; padding: 0; border: 1px solid
var(--bs-border-color); border-radius: 0.375rem; overflow: hidden; }
+.nav-list li { border-top: 1px solid var(--bs-border-color); }
+.nav-list li:first-child { border-top: none; }
+.nav-link { display: block; padding: 0.5rem 0.9rem; color: #212529;
text-decoration: none; }
+.nav-link:hover { background: rgba(13, 110, 253, 0.08); }
+'''
+}
+
+def modernReportJs() {
+ '''
+function applyFilters() {
+ var showPass = document.getElementById('filter-pass').checked;
+ var showFail = document.getElementById('filter-fail').checked;
+ var showError = document.getElementById('filter-error').checked;
+ var showSkipped = document.getElementById('filter-skipped').checked;
+ var rows = document.querySelectorAll('table.detail tbody tr');
+ rows.forEach(function(row) {
+ var visible = (row.classList.contains('row-pass') && showPass)
+ || (row.classList.contains('row-fail') && showFail)
+ || (row.classList.contains('row-error') && showError)
+ || (row.classList.contains('row-skipped') && showSkipped);
+ row.style.display = visible ? '' : 'none';
+ });
+}
+['filter-pass', 'filter-fail', 'filter-error',
'filter-skipped'].forEach(function(id) {
+ document.getElementById(id).addEventListener('change', applyFilters);
+});
+applyFilters();
+
+// The Summary section's Tests/Failures/Errors/Skipped counts link here as
"#status-<name>"
+// (same-page) or "<page>.html#status-<name>" (cross-page, e.g. the framed
report's overview
+// linking to all-tests.html) - on arrival, set the checkboxes to match and
jump to the results.
+var STATUS_HASH_CHECKS = {
+ 'status-tests': ['filter-pass', 'filter-fail', 'filter-error',
'filter-skipped'],
+ 'status-failures': ['filter-fail'],
+ 'status-errors': ['filter-error'],
+ 'status-skipped': ['filter-skipped']
+};
+function applyStatusHash() {
+ var checksToEnable = STATUS_HASH_CHECKS[window.location.hash.replace('#',
'')];
+ if (!checksToEnable) {
+ return;
+ }
+ ['filter-pass', 'filter-fail', 'filter-error',
'filter-skipped'].forEach(function(id) {
+ document.getElementById(id).checked = checksToEnable.indexOf(id) !==
-1;
+ });
+ applyFilters();
+ var filters = document.getElementById('filters');
+ if (filters) {
+ filters.scrollIntoView();
+ }
+}
+window.addEventListener('hashchange', applyStatusHash);
+applyStatusHash();
+'''
+}
+
+def suiteSlug(String name) {
+ (name ?: '').replaceAll('[^A-Za-z0-9_-]', '_')
+}
+
+def escapeHtmlText(String s) {
+ // Everywhere else a suite/test name reaches these reports it's passed as
a MarkupBuilder
+ // element-content argument, which escapes it automatically.
renderFramedSuitePage's
+ // <title>/<h1> are the one spot that interpolates a raw value straight
into a GString HTML
+ // shell instead - suite.name is developer-authored (testdef suite-name
attribute), not user
+ // input, but an "&" or "<" in it would still produce broken HTML on that
one page unescaped.
+ (s ?: '').replace('&', '&').replace('<', '<').replace('>', '>')
+}
+
+def renderStatCards(Map counts, Closure statusHrefFn, List extraCards = [],
String wrapperId = null) {
+ // Shared by buildOverallSummaryTable
(Tests/Failures/Errors/Skipped/Success rate/Time summed
+ // across every suite) and buildSuiteDetailFragment (the same six, read
off one suite instead
+ // of summed, plus that suite's own Time Stamp/Host as extraCards) - the
two callers differ in
+ // where the numbers come from, not in how a stat card is rendered.
+ def writer = new StringWriter()
+ def html = new MarkupBuilder(writer)
+ def wrapperAttrs = wrapperId ? [id: wrapperId, 'class': 'stat-cards'] :
['class': 'stat-cards']
+ html.div(wrapperAttrs) {
+ // Tests/Failures/Errors/Skipped are links (matching the classic
report's
+ // all-tests.html/alltests-fails.html/etc.) that filter the results
below down to just
+ // that status, in-page or on a dedicated page depending on
statusHrefFn.
+ div('class': 'stat-card') {
+ a('class': 'stat-value', href: statusHrefFn('tests'), title: 'Show
all tests', "${counts.tests}")
+ div('class': 'stat-label', 'Tests')
+ }
+ div('class': 'stat-card') {
+ a('class': 'stat-value', href: statusHrefFn('failures'), title:
'Show only failures', "${counts.failures}")
+ div('class': 'stat-label', 'Failures')
+ }
+ div('class': 'stat-card') {
+ a('class': 'stat-value', href: statusHrefFn('errors'), title:
'Show only errors', "${counts.errors}")
+ div('class': 'stat-label', 'Errors')
+ }
+ div('class': 'stat-card') {
+ a('class': 'stat-value', href: statusHrefFn('skipped'), title:
'Show only skipped', "${counts.skipped}")
+ div('class': 'stat-label', 'Skipped')
+ }
+ div('class': 'stat-card') {
+ div('class': 'stat-value', counts.successRate)
+ div('class': 'stat-label', 'Success rate')
+ }
+ div('class': 'stat-card') {
+ div('class': 'stat-value', counts.time)
+ div('class': 'stat-label', 'Time (s)')
+ }
+ extraCards.each { card ->
+ div('class': 'stat-card') {
+ div('class': 'stat-value', card.value)
+ div('class': 'stat-label', card.label)
+ }
+ }
+ }
+ writer.toString()
+}
+
+def buildOverallSummaryTable(List suites, Closure statusHrefFn) {
+ def toInt = { String s -> (s ==~ /\d+/) ? s.toInteger() : 0 }
+ def toSeconds = { String s -> (s ==~ /-?[0-9]*\.?[0-9]+/) ? s.toDouble() :
0.0 }
+ // List.sum(Closure) returns null (not 0) on an empty list, unlike the
no-arg sum() - discovered
+ // live when a malformed CLI invocation purged runtime/logs/test-results/
(item 3's own fix) and
+ // then failed before any suite ran, leaving
createModernTestReport/createFramedTestReport to
+ // parse zero suites and crash on "null as int" instead of just rendering
"0 tests".
+ def totalTests = (suites.sum { toInt(it.tests) } ?: 0) as int
+ def totalFailures = (suites.sum { toInt(it.failures) } ?: 0) as int
+ def totalErrors = (suites.sum { toInt(it.errors) } ?: 0) as int
+ def totalSkipped = (suites.sum { toInt(it.skipped) } ?: 0) as int
+ def totalTime = (suites.sum { toSeconds(it.time) } ?: 0.0) as double
+ def successRate = totalTests > 0
+ ? String.format('%.2f%%', ((totalTests - totalFailures -
totalErrors) / (double) totalTests) * 100)
+ : 'N/A'
+ renderStatCards([tests: totalTests, failures: totalFailures, errors:
totalErrors,
+ skipped: totalSkipped, successRate: successRate,
+ time: String.format('%.3f', totalTime)],
+ statusHrefFn, [], 'overall-summary')
+}
+
+def buildSummaryTable(List suites, Closure hrefFn) {
+ def writer = new StringWriter()
+ def html = new MarkupBuilder(writer)
+ html.table(id: 'summary', 'class': 'bs-table') {
+ thead {
+ tr {
+ th('Suite'); th('Tests'); th('Failures'); th('Errors');
th('Skipped')
+ th('Time (s)'); th('Time Stamp'); th('Host')
+ }
+ }
+ tbody {
+ suites.each { s ->
+ tr {
+ td { a(href: hrefFn(s), s.name) }
+ td(s.tests)
+ td(s.failures)
+ td(s.errors)
+ td(s.skipped)
+ td(s.time)
+ td(s.timestamp ?: '')
+ td(s.hostname ?: '')
+ }
+ }
+ }
+ }
+ writer.toString()
+}
+
+def buildSuiteDetailFragment(Map suite) {
+ def writer = new StringWriter()
+ def html = new MarkupBuilder(writer)
+ html.div {
+ h2(id: "suite-${suiteSlug(suite.name)}", suite.name)
+ if (suite.parseError) {
+ p('class': 'parse-error', "Could not parse this suite's results:
${suite.parseError}")
+ } else {
+ // Suite-scoped counterpart to the report-wide Summary block
(buildOverallSummaryTable)
+ // - shares renderStatCards with it, fed this one suite's own
numbers directly (no
+ // summing needed) plus Time Stamp/Host as extraCards, meaningful
for one suite but not
+ // for a sum across many.
+ def toInt = { String s -> (s ==~ /\d+/) ? s.toInteger() : 0 }
+ def tests = toInt(suite.tests)
+ def failures = toInt(suite.failures)
+ def errors = toInt(suite.errors)
+ def successRate = tests > 0
+ ? String.format('%.2f%%', ((tests - failures - errors) /
(double) tests) * 100)
+ : 'N/A'
+ mkp.yieldUnescaped(renderStatCards(
+ [tests: suite.tests, failures: suite.failures, errors:
suite.errors,
+ skipped: suite.skipped, successRate: successRate, time:
suite.time],
+ { status -> "#status-${status}" },
+ [[label: 'Time Stamp', value: suite.timestamp ?: 'N/A'],
+ [label: 'Host', value: suite.hostname ?: 'N/A']]))
+ table('class': 'detail bs-table') {
+ thead { tr { th('Test'); th('Status'); th('Time (s)');
th('Failure Reason') } }
+ tbody {
+ suite.testcases.each { tc ->
+ tr('class': "row-${tc.status}") {
+ // tc.name is the report-facing test identity
JupiterTestSuite.reportingName()
+ // already builds as "ClassName.methodName -
Display Name" (or the bare method
+ // name for plain JUnit 3 suites) - tc.classname
is a synthetic bridge class
+ // (e.g.
JupiterTestExtension$JupiterTestSuite$JupiterLeafTest for Jupiter-backed
+ // suites) that's meaningless to a reader, so it's
deliberately not shown here.
+ td(tc.name)
+ td { span('class': "badge badge-${tc.status}",
tc.status) }
+ td(tc.time)
+ td { pre(tc.failureReason ?: '') }
+ }
+ }
+ }
+ }
+ }
+ }
+ writer.toString()
+}
+
+def buildBodyFragment(List suites) {
+ def summary = buildSummaryTable(suites) { s ->
"#suite-${suiteSlug(s.name)}" }
+ def details = suites.collect { s -> buildSuiteDetailFragment(s) }.join()
+ "<div>${summary}${details}</div>"
+}
+
+def renderModernReport(List suites) {
+ def overallSummary = buildOverallSummaryTable(suites) { status ->
"#status-${status}" }
+ def bodyFragment = buildBodyFragment(suites)
+ def css = modernReportCss()
+ def js = modernReportJs()
+ """<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<title>Apache OFBiz Test Report</title>
+<style>
+${css}
+</style>
+</head>
+<body>
+<div class="navbar-report"><h1>Apache OFBiz Test Report</h1></div>
+<div class="container">
+<h2>Summary</h2>
+${overallSummary}
+${filterControlsHtml()}
+${bodyFragment}
+</div>
+<script>
+${js}
+</script>
+</body>
+</html>
+"""
+}
+
+def renderFramedIndexPage() {
+ '''<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Apache OFBiz Test Report</title>
+</head>
+<frameset cols="20%,80%">
+<frame src="nav.html" name="nav">
+<frame src="overview.html" name="detail">
+<noframes>
+<body>
+<p>This report is designed to be viewed using the frames feature. If you see
this
+message, you are using a non-frame-capable web client — use
+<a href="../modern-report.html">modern-report.html</a> instead.</p>
+</body>
+</noframes>
+</frameset>
+</html>
+'''
+}
+
+def renderFramedNavPage(List suites) {
+ def writer = new StringWriter()
+ def html = new MarkupBuilder(writer)
+ html.html {
+ head {
+ meta(charset: 'UTF-8')
+ title('Apache OFBiz Test Report: Suites')
+ link(rel: 'stylesheet', href: 'junit-report.css')
+ }
+ body('class': 'nav-pane') {
+ ul('class': 'nav-list') {
+ li { a('class': 'nav-link', href: 'overview.html', target:
'detail', 'Overview') }
+ li { a('class': 'nav-link', href: 'all-tests.html', target:
'detail', 'All Tests') }
+ }
+ div('class': 'nav-section-label', 'Suites')
+ ul('class': 'nav-list') {
+ suites.each { s ->
+ li { a('class': 'nav-link', href:
"suite-${suiteSlug(s.name)}.html", target: 'detail', s.name) }
+ }
+ }
+ }
+ }
+ "<!DOCTYPE html>\n${writer}"
+}
+
+def renderFramedOverviewPage(List suites) {
+ def overallSummary = buildOverallSummaryTable(suites) { status ->
"all-tests.html#status-${status}" }
+ def summary = buildSummaryTable(suites) { s ->
"suite-${suiteSlug(s.name)}.html" }
+ """<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Apache OFBiz Test Report: Overview</title>
+<link rel="stylesheet" href="junit-report.css">
+</head>
+<body>
+<div class="navbar-report"><h1>Apache OFBiz Test Report</h1></div>
+<div class="container">
+<h2>Summary</h2>
+${overallSummary}
+${summary}
+</div>
+</body>
+</html>
+"""
+}
+
+def filterControlsHtml() {
+ // Same #filter-pass/-fail/-error/-skipped checkbox elements and change
listeners the JS
+ // already targets - only the visual presentation changes (hidden checkbox
+ a same-colored
+ // adjacent label styled as a toggle button, via the .btn-check:checked +
.btn-toggle-* CSS).
+ '''<div id="filters" class="filter-group">
+<input type="checkbox" id="filter-pass" class="btn-check" checked><label
class="btn-toggle btn-toggle-pass" for="filter-pass">Pass</label>
+<input type="checkbox" id="filter-fail" class="btn-check" checked><label
class="btn-toggle btn-toggle-fail" for="filter-fail">Fail</label>
+<input type="checkbox" id="filter-error" class="btn-check" checked><label
class="btn-toggle btn-toggle-error" for="filter-error">Error</label>
+<input type="checkbox" id="filter-skipped" class="btn-check" checked><label
class="btn-toggle btn-toggle-skipped" for="filter-skipped">Skipped</label>
+</div>'''
+}
+
+def renderFramedSuitePage(Map suite) {
+ def detail = buildSuiteDetailFragment(suite)
+ def suiteNameHtml = escapeHtmlText(suite.name)
+ """<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Apache OFBiz Test Report: ${suiteNameHtml}</title>
+<link rel="stylesheet" href="junit-report.css">
+</head>
+<body>
+<div class="navbar-report"><h1>${suiteNameHtml}</h1></div>
+<div class="container">
+${filterControlsHtml()}
+${detail}
+</div>
+<script src="junit-report.js"></script>
+</body>
+</html>
+"""
+}
+
+def buildAllTestsFragment(List suites) {
+ def writer = new StringWriter()
+ def html = new MarkupBuilder(writer)
+ html.table('class': 'detail bs-table') {
+ thead { tr { th('Suite'); th('Test'); th('Status'); th('Time (s)');
th('Failure Reason') } }
+ tbody {
+ suites.each { s ->
+ if (!s.parseError) {
+ s.testcases.each { tc ->
+ tr('class': "row-${tc.status}") {
+ td { a(href: "suite-${suiteSlug(s.name)}.html",
s.name) }
+ td(tc.name)
+ td { span('class': "badge badge-${tc.status}",
tc.status) }
+ td(tc.time)
+ td { pre(tc.failureReason ?: '') }
+ }
+ }
+ }
+ }
+ }
+ }
+ writer.toString()
+}
+
+def renderFramedAllTestsPage(List suites) {
+ def detail = buildAllTestsFragment(suites)
+ """<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Apache OFBiz Test Report: All Tests</title>
+<link rel="stylesheet" href="junit-report.css">
+</head>
+<body>
+<div class="navbar-report"><h1>All Tests</h1></div>
+<div class="container">
+${filterControlsHtml()}
+${detail}
+</div>
+<script src="junit-report.js"></script>
+</body>
+</html>
+"""
+}
+
+def buildFramedReportFiles(List suites) {
+ def files = [
+ // Written once and referenced by every page below via a
same-directory relative
+ // <link>/<script src> instead of each page inlining its own copy
- see modernReportCss()'s
+ // comment. modern-report.html (the single-page report) still
inlines both directly, since
+ // staying one portable file is the point there.
+ 'junit-report.css': modernReportCss(),
+ 'junit-report.js': modernReportJs(),
+ 'index.html': renderFramedIndexPage(),
+ 'nav.html': renderFramedNavPage(suites),
+ 'overview.html': renderFramedOverviewPage(suites),
+ 'all-tests.html': renderFramedAllTestsPage(suites)
+ ]
+ suites.each { s -> files["suite-${suiteSlug(s.name)}.html"] =
renderFramedSuitePage(s) }
+ files
+}
+
+def parseSuiteXml(File xmlFile) {
+ try {
+ def suite = new XmlSlurper().parse(xmlFile)
+ def testcases = suite.testcase.collect { tc ->
+ def status = 'pass'
+ def failureReason = null
+ if (tc.failure.size() > 0) {
+ status = 'fail'
+ failureReason = tc.failure[0].text().trim() ?:
(tc.failure[0].@message as String)
+ } else if (tc.error.size() > 0) {
+ status = 'error'
+ failureReason = tc.error[0].text().trim() ?:
(tc.error[0].@message as String)
+ } else if (tc.skipped.size() > 0) {
+ status = 'skipped'
+ }
+ [
+ classname: tc.@classname as String,
+ name: tc.@name as String,
+ time: (tc.@time as String) ?: '0',
+ status: status,
+ failureReason: failureReason
+ ]
+ }
+ [
+ name: suite.@name as String,
+ tests: (suite.@tests as String) ?: '0',
+ failures: (suite.@failures as String) ?: '0',
+ errors: (suite.@errors as String) ?: '0',
+ skipped: (suite.@skipped as String) ?: '0',
+ time: (suite.@time as String) ?: '0',
+ timestamp: (suite.@timestamp as String) ?: '',
+ hostname: (suite.@hostname as String) ?: '',
+ testcases: testcases,
+ parseError: null
+ ]
+ } catch (Exception e) {
+ [
+ name: xmlFile.name,
+ tests: '?', failures: '?', errors: '?', skipped: '?', time:
'?',
+ timestamp: '', hostname: '',
+ testcases: [],
+ parseError: e.message
+ ]
+ }
+}
+
+task createModernTestReport(group: sysadminGroup,
+ description: 'Generate a single-page modern HTML test report alongside
the classic Ant-based one') {
+ doLast {
+ def suiteFiles = fileTree('./runtime/logs/test-results') {
+ include '*.xml'
+ exclude 'TESTS-TestSuites.xml'
+ }
+ // Sort by the suite's own reported name, not the XML file's on-disk
name: testdef
+ // suite-name and the output filename (TestRunContainer writes
"<suite.getName()>.xml")
+ // are supposed to track each other, but a case-only rename can leave
them out of sync on
+ // a case-insensitive-but-preserving filesystem (macOS default) -
sorting on the parsed
+ // name keeps ordering correct regardless, and case-insensitively so a
capitalization
+ // quirk in either one can't bump a suite out of alphabetical order.
+ def suites = suiteFiles.collect { xmlFile -> parseSuiteXml(xmlFile) }
+ .sort { it.name?.toLowerCase() }
+
+ def reportFile = file('./runtime/logs/test-results/modern-report.html')
+ reportFile.parentFile.mkdirs()
+ reportFile.text = renderModernReport(suites)
+ println "Modern test report written to ${reportFile}"
+ }
+}
+
+task createFramedTestReport(group: sysadminGroup,
+ description: 'Generate a navigable, frameset-based HTML test report
alongside the classic Ant-based and single-page modern reports') {
+ doLast {
+ def suiteFiles = fileTree('./runtime/logs/test-results') {
+ include '*.xml'
+ exclude 'TESTS-TestSuites.xml'
+ }
+ // See createModernTestReport's matching comment: sort by parsed suite
name, not filename.
+ def suites = suiteFiles.collect { xmlFile -> parseSuiteXml(xmlFile) }
+ .sort { it.name?.toLowerCase() }
+
+ def outDir = file('./runtime/logs/test-results/modern-report-framed')
+ if (outDir.exists()) {
+ outDir.deleteDir()
+ }
+ outDir.mkdirs()
+ buildFramedReportFiles(suites).each { name, content ->
file("${outDir}/${name}").text = content }
+ println "Framed test report written to ${outDir}/index.html"
+ }
+}