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 82db69805a Retiring the legacy Ant-based JUnit Integration Tests 
reports (#1603)
82db69805a is described below

commit 82db69805ae8623da9bb6f9af59808b05d175a4a
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sat Aug 8 17:17:59 2026 +0530

    Retiring the legacy Ant-based JUnit Integration Tests reports (#1603)
    
    Retiring the legacy Ant-based createTestReports task and replacing it
    with the new framed test report as the single source of truth for unit
    test results.
    
    Renamed the report tasks and output paths for clarity, and brought the
    new report to full parity with the old one by adding global
    fails/errors/skipped pages plus per-suite fails/errors pages.
    
    Fixed stat cards on the per-suite status pages and tidied up leftover
    report task cleanup logic.
    
    Updated unit-tests.adoc to reflect the renamed test report paths.
    
    Verified JUnit3 passthrough still works correctly alongside the new
    report generation.
---
 build.gradle                                       |  13 +-
 .../testtools/src/docs/asciidoc/unit-tests.adoc    |  10 +-
 test-reports.gradle                                | 216 +++++++++++++++------
 3 files changed, 161 insertions(+), 78 deletions(-)

diff --git a/build.gradle b/build.gradle
index 55cf7fb0c9..9142a731e5 100644
--- a/build.gradle
+++ b/build.gradle
@@ -253,9 +253,6 @@ subprojects {
 }
 
 configurations {
-    junitReport {
-        description = 'libraries needed to run junitreport for OFBiz unit 
tests'
-    }
     ofbizPlugins {
         description = 'ofbiz plugin dependencies configuration'
         transitive = true
@@ -281,9 +278,6 @@ dependencies {
         compileOnly project(path: subProject.path, configuration: 
'pluginLibsCompileOnly')
     }
 
-    junitReport 'junit:junit:4.13.2'
-    junitReport 'org.apache.ant:ant-junit:1.10.15'
-
     // Libraries downloaded manually
     implementation fileTree(dir: file("${rootDir}/lib"), include: '**/*.jar')
     getDirectoryInActiveComponentsIfExists('lib').each { libDir ->
@@ -925,7 +919,7 @@ task generateAllPluginsDocumentation(group: docsGroup,
 
 
 // ========== System Administration tasks ==========
-// createTestReports/createModernTestReport/createFramedTestReport moved to 
test-reports.gradle
+// createTestReport/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') {
@@ -1269,7 +1263,7 @@ def createOfbizCommandTask(taskName, arguments) {
             // 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
+            // whenever they last ran, and the two 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
@@ -1277,8 +1271,7 @@ def createOfbizCommandTask(taskName, arguments) {
             doFirst {
                 delete fileTree('./runtime/logs/test-results') { include 
'*.xml' }
             }
-            finalizedBy(createTestReports)
-            finalizedBy(createModernTestReport)
+            finalizedBy(createTestReport)
             finalizedBy(createFramedTestReport)
         } else {
             classpath = sourceSets.main.runtimeClasspath
diff --git a/framework/testtools/src/docs/asciidoc/unit-tests.adoc 
b/framework/testtools/src/docs/asciidoc/unit-tests.adoc
index 5977c897f1..803276673c 100644
--- a/framework/testtools/src/docs/asciidoc/unit-tests.adoc
+++ b/framework/testtools/src/docs/asciidoc/unit-tests.adoc
@@ -194,7 +194,9 @@ This error message will be in between starting and finished 
test cass line like
 ----
 
 == Test result
-After you run unit test, you can see the result of the testing. +
-If you use run-tests target, you can see test result web page by view 
runtime/logs/test-results/html/index.html
-file in web browser and see JUnit Test Result files for each test suite in 
runtime/logs/test-results directory. +
-If you use other target you only see JUnit Test Result file in 
runtime/logs/test-results.
+After you run the tests, you can see the results in 
`runtime/logs/test-results`: +
+Open `runtime/logs/test-results/html/index.html` in a web browser for a 
navigable, multi-page
+report, or `runtime/logs/test-results/test-report.html` for the same results 
as a single
+portable file. +
+The raw JUnit Test Result XML file for each test suite is also available 
directly in
+`runtime/logs/test-results`.
diff --git a/test-reports.gradle b/test-reports.gradle
index 8c0ff004c1..c8b874c2fa 100644
--- a/test-reports.gradle
+++ b/test-reports.gradle
@@ -19,37 +19,15 @@
 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.
+// createTestReport (single-page) and createFramedTestReport (navigable 
multi-page) 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
+    // Used two ways: inlined into test-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).
@@ -145,6 +123,7 @@ table.bs-table tbody tr.row-skipped { color: 
var(--bs-secondary); }
 
 pre { white-space: pre-wrap; margin: 0; font-size: 0.85em; }
 .parse-error { color: var(--bs-danger); font-weight: bold; }
+.suite-status-links { margin: 0 0 1rem; font-size: 0.9rem; }
 
 .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; }
@@ -229,22 +208,22 @@ def renderStatCards(Map counts, Closure statusHrefFn, 
List extraCards = [], Stri
     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')
+        // that status, in-page or on a dedicated page depending on 
statusHrefFn. A null
+        // statusHrefFn means "this page can't be filtered" - it renders the 
same four cards as
+        // plain values instead, for the already-pre-filtered static status 
pages, which carry
+        // neither the #filters block nor junit-report.js a "#status-*" href 
would need.
+        [[status: 'tests', label: 'Tests', value: counts.tests, title: 'Show 
all tests'],
+         [status: 'failures', label: 'Failures', value: counts.failures, 
title: 'Show only failures'],
+         [status: 'errors', label: 'Errors', value: counts.errors, title: 
'Show only errors'],
+         [status: 'skipped', label: 'Skipped', value: counts.skipped, title: 
'Show only skipped']].each { card ->
+            div('class': 'stat-card') {
+                if (statusHrefFn) {
+                    a('class': 'stat-value', href: statusHrefFn(card.status), 
title: card.title, "${card.value}")
+                } else {
+                    div('class': 'stat-value', "${card.value}")
+                }
+                div('class': 'stat-label', card.label)
+            }
         }
         div('class': 'stat-card') {
             div('class': 'stat-value', counts.successRate)
@@ -269,7 +248,7 @@ def buildOverallSummaryTable(List suites, Closure 
statusHrefFn) {
     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
+    // then failed before any suite ran, leaving 
createTestReport/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
@@ -313,7 +292,13 @@ def buildSummaryTable(List suites, Closure hrefFn) {
     writer.toString()
 }
 
-def buildSuiteDetailFragment(Map suite) {
+def buildSuiteDetailFragment(Map suite, boolean linked = true) {
+    // linked = true (the default) is the live, JS-filterable suite page: stat 
cards link to
+    // "#status-*" and report the suite's own totals. linked = false is a 
static status page
+    // (renderFramedSuiteStatusPage), whose `suite` has already had its 
testcases filtered down to
+    // one status and which loads neither the #filters block nor 
junit-report.js - there the cards
+    // must be plain values (a "#status-*" href would be a dead link) counted 
off the filtered
+    // testcases actually rendered in the table below, not off the whole 
suite's attributes.
     def writer = new StringWriter()
     def html = new MarkupBuilder(writer)
     html.div {
@@ -326,16 +311,24 @@ def buildSuiteDetailFragment(Map suite) {
             // 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 toSeconds = { String s -> (s ==~ /-?[0-9]*\.?[0-9]+/) ? 
s.toDouble() : 0.0 }
+            def tests = linked ? toInt(suite.tests) : suite.testcases.size()
+            def failures = linked ? toInt(suite.failures) : 
suite.testcases.count { it.status == 'fail' }
+            def errors = linked ? toInt(suite.errors) : suite.testcases.count 
{ it.status == 'error' }
             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}" },
+            // Success rate and Time are derived from the same set of 
testcases as the four counts
+            // above, so a filtered page's cards stay internally consistent 
with its own table
+            // rather than mixing filtered counts with whole-suite figures.
+            def counts = linked
+                    ? [tests: suite.tests, failures: suite.failures, errors: 
suite.errors,
+                       skipped: suite.skipped, successRate: successRate, time: 
suite.time]
+                    : [tests: tests, failures: failures, errors: errors,
+                       skipped: suite.testcases.count { it.status == 'skipped' 
}, successRate: successRate,
+                       time: String.format('%.3f', (suite.testcases.sum { 
toSeconds(it.time) } ?: 0.0) as double)]
+            def statusHrefFn = linked ? { String status -> "#status-${status}" 
} : null
+            mkp.yieldUnescaped(renderStatCards(counts, statusHrefFn,
                     [[label: 'Time Stamp', value: suite.timestamp ?: 'N/A'],
                      [label: 'Host', value: suite.hostname ?: 'N/A']]))
             table('class': 'detail bs-table') {
@@ -411,7 +404,7 @@ def renderFramedIndexPage() {
 <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>
+<a href="../test-report.html">test-report.html</a> instead.</p>
 </body>
 </noframes>
 </frameset>
@@ -432,6 +425,9 @@ def renderFramedNavPage(List suites) {
             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') }
+                li { a('class': 'nav-link', href: 'all-tests-fails.html', 
target: 'detail', 'All Failures') }
+                li { a('class': 'nav-link', href: 'all-tests-errors.html', 
target: 'detail', 'All Errors') }
+                li { a('class': 'nav-link', href: 'all-tests-skipped.html', 
target: 'detail', 'All Skipped') }
             }
             div('class': 'nav-section-label', 'Suites')
             ul('class': 'nav-list') {
@@ -478,9 +474,21 @@ def filterControlsHtml() {
 </div>'''
 }
 
-def renderFramedSuitePage(Map suite) {
+def renderFramedSuiteStatusLinksHtml(boolean hasFailsPage, boolean 
hasErrorsPage, String slug) {
+    def links = []
+    if (hasFailsPage) {
+        links << "<a href=\"suite-${slug}-fails.html\">Failures only</a>"
+    }
+    if (hasErrorsPage) {
+        links << "<a href=\"suite-${slug}-errors.html\">Errors only</a>"
+    }
+    links ? "<p class=\"suite-status-links\">${links.join(' | ')}</p>" : ''
+}
+
+def renderFramedSuitePage(Map suite, boolean hasFailsPage, boolean 
hasErrorsPage) {
     def detail = buildSuiteDetailFragment(suite)
     def suiteNameHtml = escapeHtmlText(suite.name)
+    def statusLinks = renderFramedSuiteStatusLinksHtml(hasFailsPage, 
hasErrorsPage, suiteSlug(suite.name))
     """<!DOCTYPE html>
 <html>
 <head>
@@ -491,6 +499,7 @@ def renderFramedSuitePage(Map suite) {
 <body>
 <div class="navbar-report"><h1>${suiteNameHtml}</h1></div>
 <div class="container">
+${statusLinks}
 ${filterControlsHtml()}
 ${detail}
 </div>
@@ -500,6 +509,33 @@ ${detail}
 """
 }
 
+def renderFramedSuiteStatusPage(Map suite, String status, String statusLabel) {
+    // Per-suite equivalent of renderFramedStatusPage, generated only when the 
suite actually has
+    // a test in that status - mirrors the old Ant report's per-class 
-fails/-errors pages, which
+    // only existed for classes with a failure/error.
+    def filteredSuite = suite + [testcases: suite.testcases.findAll { 
it.status == status }]
+    // linked = false: this page is already filtered and has no filter 
controls or JS, so its stat
+    // cards render as plain values counted off filteredSuite.testcases - see 
buildSuiteDetailFragment.
+    def detail = buildSuiteDetailFragment(filteredSuite, false)
+    def suiteNameHtml = escapeHtmlText(suite.name)
+    """<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Apache OFBiz Test Report: ${suiteNameHtml} - ${statusLabel}</title>
+<link rel="stylesheet" href="junit-report.css">
+</head>
+<body>
+<div class="navbar-report"><h1>${suiteNameHtml} - ${statusLabel}</h1></div>
+<div class="container">
+<p class="suite-status-links"><a 
href="suite-${suiteSlug(suite.name)}.html">&larr; Back to suite</a></p>
+${detail}
+</div>
+</body>
+</html>
+"""
+}
+
 def buildAllTestsFragment(List suites) {
     def writer = new StringWriter()
     def html = new MarkupBuilder(writer)
@@ -524,6 +560,36 @@ def buildAllTestsFragment(List suites) {
     writer.toString()
 }
 
+def suitesFilteredByStatus(List suites, String status) {
+    // Keeps every suite (even ones with zero matches) so 
buildAllTestsFragment's normal
+    // iteration just emits no rows for them - simpler than special-casing an 
empty suite list,
+    // and matches how the unfiltered all-tests.html already handles a suite 
with a parseError
+    // (skipped, not removed from the list).
+    suites.collect { s -> s.parseError ? s : s + [testcases: 
s.testcases.findAll { it.status == status }] }
+}
+
+def renderFramedStatusPage(String status, String title, List suites) {
+    // Pre-filtered, static equivalent of all-tests.html's JS checkbox filter 
- unlike that page,
+    // this has a stable URL and needs no JavaScript, matching the old Ant 
report's
+    // alltests-fails.html/alltests-errors.html/alltests-skipped.html.
+    def detail = buildAllTestsFragment(suitesFilteredByStatus(suites, status))
+    """<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Apache OFBiz Test Report: ${title}</title>
+<link rel="stylesheet" href="junit-report.css">
+</head>
+<body>
+<div class="navbar-report"><h1>${title}</h1></div>
+<div class="container">
+${detail}
+</div>
+</body>
+</html>
+"""
+}
+
 def renderFramedAllTestsPage(List suites) {
     def detail = buildAllTestsFragment(suites)
     """<!DOCTYPE html>
@@ -549,16 +615,30 @@ 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
+            // comment. test-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)
+            'all-tests.html': renderFramedAllTestsPage(suites),
+            'all-tests-fails.html': renderFramedStatusPage('fail', 'All 
Failures', suites),
+            'all-tests-errors.html': renderFramedStatusPage('error', 'All 
Errors', suites),
+            'all-tests-skipped.html': renderFramedStatusPage('skipped', 'All 
Skipped', suites)
     ]
-    suites.each { s -> files["suite-${suiteSlug(s.name)}.html"] = 
renderFramedSuitePage(s) }
+    suites.each { s ->
+        def slug = suiteSlug(s.name)
+        def hasFails = !s.parseError && s.testcases.any { it.status == 'fail' }
+        def hasErrors = !s.parseError && s.testcases.any { it.status == 
'error' }
+        files["suite-${slug}.html"] = renderFramedSuitePage(s, hasFails, 
hasErrors)
+        if (hasFails) {
+            files["suite-${slug}-fails.html"] = renderFramedSuiteStatusPage(s, 
'fail', 'Failures')
+        }
+        if (hasErrors) {
+            files["suite-${slug}-errors.html"] = 
renderFramedSuiteStatusPage(s, 'error', 'Errors')
+        }
+    }
     files
 }
 
@@ -608,8 +688,8 @@ def parseSuiteXml(File xmlFile) {
     }
 }
 
-task createModernTestReport(group: sysadminGroup,
-        description: 'Generate a single-page modern HTML test report alongside 
the classic Ant-based one') {
+task createTestReport(group: sysadminGroup,
+        description: 'Generate a single-page HTML test report') {
     doLast {
         def suiteFiles = fileTree('./runtime/logs/test-results') {
             include '*.xml'
@@ -624,28 +704,36 @@ task createModernTestReport(group: sysadminGroup,
         def suites = suiteFiles.collect { xmlFile -> parseSuiteXml(xmlFile) }
                 .sort { it.name?.toLowerCase() }
 
-        def reportFile = file('./runtime/logs/test-results/modern-report.html')
+        def reportFile = file('./runtime/logs/test-results/test-report.html')
         reportFile.parentFile.mkdirs()
+        // A checkout upgrading from before the rename still has the previous 
run's
+        // modern-report.html sitting here with nothing to mark it stale - 
drop it so only the
+        // report this run just generated is left behind (the framed task 
self-cleans its whole
+        // output directory, and clears the old modern-report-framed/ the same 
way).
+        delete file('./runtime/logs/test-results/modern-report.html')
         reportFile.text = renderModernReport(suites)
-        println "Modern test report written to ${reportFile}"
+        println "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') {
+        description: 'Generate a navigable, frameset-based HTML test report') {
     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.
+        // See createTestReport'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')
+        def outDir = file('./runtime/logs/test-results/html')
         if (outDir.exists()) {
             outDir.deleteDir()
         }
+        // Same stale-output cleanup createTestReport does for 
modern-report.html: a checkout
+        // upgrading from before the rename still has the pre-rename framed 
output directory here.
+        delete file('./runtime/logs/test-results/modern-report-framed')
         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