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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
+}
+
+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 &mdash; 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"
+    }
+}

Reply via email to