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 30365d32f0 New Feature: Persist dated test-run history for gradlew
test and testIntegration, opt-in via test.history flag (#1677)
30365d32f0 is described below
commit 30365d32f0003f0012056a3bd771fbf8531d412d
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Tue Aug 18 21:04:17 2026 +0530
New Feature: Persist dated test-run history for gradlew test and
testIntegration, opt-in via test.history flag (#1677)
---
.gitignore | 1 +
build.gradle | 42 +++++
framework/testtools/config/testtools.properties | 46 ++++++
.../data/TestReportsScheduledServiceData.xml | 46 ++++++
framework/testtools/ofbiz-component.xml | 3 +
framework/testtools/servicedef/services.xml | 14 ++
.../org/apache/ofbiz/testtools/report/GitInfo.java | 86 ++++++++++
.../ofbiz/testtools/report/JUnitXmlCounter.java | 116 ++++++++++++++
.../ofbiz/testtools/report/TestReportArchiver.java | 178 +++++++++++++++++++++
.../testtools/report/TestReportArchiverCli.java | 80 +++++++++
.../testtools/report/TestReportPurgePlanner.java | 154 ++++++++++++++++++
.../testtools/report/TestReportPurgeService.java | 175 ++++++++++++++++++++
.../ofbiz/testtools/report/TestRunManifest.java | 171 ++++++++++++++++++++
.../apache/ofbiz/testtools/report/GitInfoTest.java | 48 ++++++
.../testtools/report/JUnitXmlCounterTest.java | 77 +++++++++
.../report/TestReportArchiverCliTest.java | 78 +++++++++
.../testtools/report/TestReportArchiverTest.java | 105 ++++++++++++
.../report/TestReportPurgePlannerTest.java | 140 ++++++++++++++++
.../report/TestReportPurgeServiceTest.java | 55 +++++++
framework/testtools/test-report-archive.gradle | 111 +++++++++++++
20 files changed, 1726 insertions(+)
diff --git a/.gitignore b/.gitignore
index 8693c5d3bb..ee7f950633 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ runtime/logs/access_log.*
runtime/logs/*.log*
runtime/logs/*.html*
runtime/logs/test-results/*
+runtime/logs/test-reports-history/
runtime/logs/birt
runtime/data/h2/*
runtime/data/utilcache.*
diff --git a/build.gradle b/build.gradle
index 1b191128c0..860c12764a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,6 +113,7 @@ useLatestVersions {
apply from: 'common.gradle'
apply from: 'dependencies.gradle'
apply from: 'test-reports.gradle'
+apply from: 'framework/testtools/test-report-archive.gradle'
// global properties
ext.os = System.getProperty('os.name').toLowerCase()
@@ -411,6 +412,35 @@ test {
finalizedBy(reskinGradleTestReport)
}
+// The archiveUnitTestReport provider is looked up and configured here,
outside the test task's
+// own configuration closure below: calling tasks.named(String, Action) while
already inside
+// another task's configuration closure fails at evaluation time with
"DefaultTaskContainer#named
+// (String, Action) on task set cannot be executed in the current context"
(Gradle disallows that
+// reentrant container mutation), so the two lookups have to stay separate
statements.
+def testTaskProvider = tasks.named('test')
+def archiveUnitTestReportProvider = tasks.named('archiveUnitTestReport')
+archiveUnitTestReportProvider.configure {
+ // Run after reskinGradleTestReport so the copied html-report/ includes
the Bootstrap-style
+ // reskin, not Gradle's bare default HTML.
+ mustRunAfter(reskinGradleTestReport)
+ // Skip archiving when 'test' was UP-TO-DATE (or skipped): finalizedBy
still runs this task
+ // in that case (see test-reports.gradle's "Idempotency guard" comment for
the same Gradle
+ // behavior elsewhere), which would otherwise re-archive the previous run's
+ // build/test-results/test output under a manifest stamped with the
*current* git commit.
+ onlyIf { testTaskProvider.get().didWork }
+ doFirst {
+ systemProperty 'test.report.suite.name', 'unit'
+ systemProperty 'test.report.source.task', 'test'
+ systemProperty 'test.report.task.outcome',
+ testTaskProvider.get().state.failure != null ? 'FAILED' :
'PASSED'
+ systemProperty 'test.report.results.dir',
file("$buildDir/test-results/test").absolutePath
+ systemProperty 'test.report.html.dir',
file("$buildDir/reports/tests/test").absolutePath
+ }
+}
+testTaskProvider.configure {
+ finalizedBy(archiveUnitTestReportProvider)
+}
+
// 'gradlew test --tests <ClassName>' against a @JunitJupiterTest class fails
with Gradle's generic
// "No tests found for given includes" rather than pointing at
testIntegration: the excludeTags
// filter above excludes the class from discovery entirely before --tests
filtering ever runs, so
@@ -1419,6 +1449,18 @@ def createOfbizCommandTask(taskName, arguments) {
}
finalizedBy(createTestReport)
finalizedBy(createFramedTestReport)
+ finalizedBy(tasks.named('archiveIntegrationTestReport') {
+ // Run after both report tasks so the copied results/ includes
test-report.html
+ // and the framed html/ report, not just the raw JUnit XML.
+ mustRunAfter(createTestReport, createFramedTestReport)
+ doFirst {
+ systemProperty 'test.report.suite.name', 'testIntegration'
+ systemProperty 'test.report.source.task', taskName
+ systemProperty 'test.report.task.outcome',
+ tasks.named(taskName).get().state.failure != null
? 'FAILED' : 'PASSED'
+ systemProperty 'test.report.results.dir',
file('./runtime/logs/test-results').absolutePath
+ }
+ })
}
mainClass = application.mainClass
args arguments
diff --git a/framework/testtools/config/testtools.properties
b/framework/testtools/config/testtools.properties
new file mode 100644
index 0000000000..c2e9783185
--- /dev/null
+++ b/framework/testtools/config/testtools.properties
@@ -0,0 +1,46 @@
+###############################################################################
+# 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.
+###############################################################################
+
+# -- Persist dated test-run history (manifest.json + copied XML/HTML)
alongside the default
+# gradlew test / gradlew testIntegration output, so results survive being
overwritten by the
+# next run. Default: false - current behavior (no history; each run's
output is simply
+# overwritten in place, as it always has been). Same gradlew commands
either way; only this
+# setting changes.
+test.history=false
+
+# -- Days to retain archived test-run history before the daily purge job
removes it. Only read
+# when test.history=true above. Uncomment and set an actual value (e.g. 7)
to enable
+# retention/purge; if test.history=true but this stays commented, history
accumulates
+# indefinitely until you set a number.
+#test.history.days=7
+
+# -- Directory gradlew test's history is archived under, when
test.history=true above. Relative
+# paths resolve against the project root. Default (commented):
build/test-reports-history - a
+# sibling of build/test-results/test, chosen so history survives `gradle
clean` (see
+# test-report-archive.gradle's clean task exclusion, which is derived from
this same value)
+# without sitting inside a directory other existing tooling already globs
or deletes. Only
+# uncomment if you want history stored somewhere else, e.g. under runtime/
- if you point it
+# outside build/, the gradle clean exclusion becomes unnecessary and is
skipped automatically.
+#test.history.unit.dir=build/test-reports-history
+
+# -- Directory gradlew testIntegration's history is archived under, when
test.history=true above.
+# Default (commented): runtime/logs/test-reports-history - a sibling of
+# runtime/logs/test-results, so it's never nested inside the directory the
pre-run cleanup step
+# and report generators already glob. Only uncomment if you want history
stored somewhere else.
+#test.history.integration.dir=runtime/logs/test-reports-history
diff --git a/framework/testtools/data/TestReportsScheduledServiceData.xml
b/framework/testtools/data/TestReportsScheduledServiceData.xml
new file mode 100644
index 0000000000..76b457bfa0
--- /dev/null
+++ b/framework/testtools/data/TestReportsScheduledServiceData.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+
+<entity-engine-xml>
+ <!-- Daily purge of dated test-run history folders
(build/test-reports-history/ and
+ runtime/logs/test-reports-history/) - keeps disk usage bounded while
always retaining
+ each suite's last few green runs as a safety net. Runs at 11:30 PM
daily via its own
+ TESTREPORT_2330 TemporalExpression below, rather than the shared
MIDNIGHT_DAILY
+ one (framework/service/data/ServiceSeedData.xml, reused by several
other unrelated daily
+ jobs - see e.g. framework/entityext/data/EntityScheduledServices.xml)
- a
+ TemporalExpression's date1 timestamp carries its time-of-day into
every computed
+ occurrence (TemporalExpressions.Frequency#prepareCal sets the
calendar directly from
+ date1, then only ever adds whole days), so a dedicated expression is
what actually
+ controls the time-of-day; changing JobSandbox.runTime's own clock
time would not.
+
+ Seeded unconditionally so it's visible/manageable from the admin
Scheduler screen, but
+ purgeOldTestReports itself no-ops unless test.history=true is set in
+ framework/testtools/config/testtools.properties (and skips purging,
without erroring, if
+ test.history.days is left unset) - mirroring how
autoSyncRotatedSecrets is seeded but
+ no-ops unless secret.rotation.autosync.enabled=true (see
+ SecretManagerScheduledServiceData.xml). Config lives in
testtools.properties, not a
+ SystemProperty row here - see that file's test.history /
test.history.days. -->
+ <TemporalExpression tempExprId="TESTREPORT_2330" tempExprTypeId="FREQUENCY"
+ description="Every day at 11:30 PM"
+ date1="2020-01-01 23:30:00.000" integer1="5" integer2="1"/>
+ <JobSandbox jobId="TESTREPORT_PURGE" jobName="Purge old test report runs"
+ runTime="2020-01-01 23:30:00.000" serviceName="purgeOldTestReports"
+ poolId="pool" runAsUser="system" tempExprId="TESTREPORT_2330"
maxRecurrenceCount="-1"/>
+</entity-engine-xml>
diff --git a/framework/testtools/ofbiz-component.xml
b/framework/testtools/ofbiz-component.xml
index 93283e52f6..ffb7f32cc5 100644
--- a/framework/testtools/ofbiz-component.xml
+++ b/framework/testtools/ofbiz-component.xml
@@ -24,6 +24,9 @@
<!-- define resource loaders; most common is to use the component resource
loader -->
<resource-loader name="main" type="component"/>
+ <!-- seed data for the daily test-report purge job's
JobSandbox/TemporalExpression rows -->
+ <entity-resource type="data" reader-name="seed" loader="main"
location="data/TestReportsScheduledServiceData.xml"/>
+
<!-- service resources: model(s), eca(s) and group definitions -->
<service-resource type="model" loader="main"
location="servicedef/services.xml"/>
diff --git a/framework/testtools/servicedef/services.xml
b/framework/testtools/servicedef/services.xml
index f12483c40e..a15f5bd606 100644
--- a/framework/testtools/servicedef/services.xml
+++ b/framework/testtools/servicedef/services.xml
@@ -34,4 +34,18 @@ under the License.
<attribute name="test" type="junit.framework.Test" mode="IN"
optional="false"/>
<attribute name="testResult" type="junit.framework.TestResult"
mode="IN" optional="false"/>
</service>
+
+ <service name="purgeOldTestReports" engine="java"
+
location="org.apache.ofbiz.testtools.report.TestReportPurgeService"
invoke="purgeOldTestReports"
+ auth="true" use-transaction="false">
+ <description>No-ops unless test.history=true is set in
+ framework/testtools/config/testtools.properties. When enabled,
deletes dated run
+ folders under build/test-reports-history/ and
runtime/logs/test-reports-history/ (or
+ their testtools.properties
test.history.unit.dir/test.history.integration.dir
+ overrides, if set) older than the testtools.properties
test.history.days setting
+ (skips purging, without erroring, if that setting is left
unset/commented), always
+ keeping the last 5 fully-passing runs per suite. Scheduled daily
by the
+ TESTREPORT_PURGE JobSandbox entry seeded in
TestReportsScheduledServiceData.xml.</description>
+ <attribute name="deletedCount" type="Long" mode="OUT" optional="true"/>
+ </service>
</services>
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/GitInfo.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/GitInfo.java
new file mode 100644
index 0000000000..4d810b960c
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/GitInfo.java
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.ofbiz.base.util.Debug;
+
+/** Looks up the current git commit and branch via the {@code git} CLI. Never
throws. */
+public final class GitInfo {
+
+ private static final String MODULE = GitInfo.class.getName();
+ private static final String UNKNOWN = "unknown";
+ private static final long TIMEOUT_SECONDS = 5;
+
+ private GitInfo() {
+ }
+
+ public static String currentCommit(File workingDir) {
+ return run(workingDir, "rev-parse", "--short", "HEAD");
+ }
+
+ public static String currentBranch(File workingDir) {
+ return run(workingDir, "rev-parse", "--abbrev-ref", "HEAD");
+ }
+
+ private static String run(File workingDir, String... gitArgs) {
+ try {
+ String[] command = new String[gitArgs.length + 1];
+ command[0] = "git";
+ System.arraycopy(gitArgs, 0, command, 1, gitArgs.length);
+ Process process = new ProcessBuilder(command)
+ .directory(workingDir)
+ .redirectErrorStream(true)
+ .start();
+ AtomicReference<String> outputRef = new AtomicReference<>("");
+ Thread reader = new Thread(() -> {
+ try {
+ outputRef.set(new
String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ Debug.logInfo(e, "GitInfo: error reading git output, using
'" + UNKNOWN + "'", MODULE);
+ }
+ }, "GitInfo-stdout-reader");
+ reader.setDaemon(true);
+ reader.start();
+ boolean finished = process.waitFor(TIMEOUT_SECONDS,
TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ return UNKNOWN;
+ }
+ reader.join(1000);
+ String output = outputRef.get();
+ if (process.exitValue() != 0 || output.trim().isEmpty()) {
+ return UNKNOWN;
+ }
+ return output.trim();
+ } catch (IOException e) {
+ Debug.logWarning(e, "GitInfo: git lookup failed, using '" +
UNKNOWN + "'", MODULE);
+ return UNKNOWN;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ Debug.logWarning(e, "GitInfo: git lookup interrupted, using '" +
UNKNOWN + "'", MODULE);
+ return UNKNOWN;
+ }
+ }
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/JUnitXmlCounter.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/JUnitXmlCounter.java
new file mode 100644
index 0000000000..4bffcf3b25
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/JUnitXmlCounter.java
@@ -0,0 +1,116 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+/**
+ * Sums pass/fail/skip counts across every {@code <testsuite>} JUnit XML file
under a directory,
+ * matching the {@code tests}/{@code failures}/{@code errors}/{@code skipped}
attributes
+ * {@code org.apache.ofbiz.testtools.SuiteXmlReportWriter} writes for {@code
testIntegration}
+ * runs, and that Gradle's own JUnit Platform listener writes for the plain
{@code test} task.
+ */
+public final class JUnitXmlCounter {
+
+ private static final String MODULE = JUnitXmlCounter.class.getName();
+
+ private JUnitXmlCounter() {
+ }
+
+ /** Recursively walks {@code resultsDir} for {@code *.xml} files and sums
their testsuite counts. */
+ public static TestRunManifest.Counts count(File resultsDir) {
+ int total = 0;
+ int failed = 0;
+ int skipped = 0;
+ if (resultsDir != null && resultsDir.isDirectory()) {
+ for (File xmlFile : listXmlFilesRecursively(resultsDir)) {
+ try {
+ TestRunManifest.Counts fileCounts = countOneFile(xmlFile);
+ total += fileCounts.getTotal();
+ failed += fileCounts.getFailed();
+ skipped += fileCounts.getSkipped();
+ } catch (Exception e) {
+ // Malformed/partial XML from an interrupted run - skip
it, don't fail archiving.
+ Debug.logWarning(e, "JUnitXmlCounter: skipping unparsable
file " + xmlFile, MODULE);
+ }
+ }
+ }
+ return new TestRunManifest.Counts(total, total - failed - skipped,
failed, skipped);
+ }
+
+ private static TestRunManifest.Counts countOneFile(File xmlFile)
+ throws ParserConfigurationException, IOException, SAXException {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",
true);
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ Document doc = builder.parse(xmlFile);
+ NodeList suites = doc.getElementsByTagName("testsuite");
+ int total = 0;
+ int failed = 0;
+ int skipped = 0;
+ for (int i = 0; i < suites.getLength(); i++) {
+ Element suite = (Element) suites.item(i);
+ total += parseIntAttribute(suite, "tests");
+ failed += parseIntAttribute(suite, "failures") +
parseIntAttribute(suite, "errors");
+ skipped += parseIntAttribute(suite, "skipped");
+ }
+ return new TestRunManifest.Counts(total, total - failed - skipped,
failed, skipped);
+ }
+
+ private static int parseIntAttribute(Element element, String
attributeName) {
+ String value = element.getAttribute(attributeName);
+ if (value == null || value.isEmpty()) {
+ return 0;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ private static List<File> listXmlFilesRecursively(File dir) {
+ List<File> result = new ArrayList<>();
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return result;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ result.addAll(listXmlFilesRecursively(child));
+ } else if (child.getName().endsWith(".xml")) {
+ result.add(child);
+ }
+ }
+ return result;
+ }
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportArchiver.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportArchiver.java
new file mode 100644
index 0000000000..3877c272ed
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportArchiver.java
@@ -0,0 +1,178 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.ofbiz.base.lang.JSON;
+
+/**
+ * Archives one test run's output into a dated folder under {@code
runtime/test-reports/}, and
+ * writes a {@code manifest.json} describing it. Both {@code test} and {@code
testIntegration}
+ * runs are handled by copying whatever result/report directories the caller
resolved (see
+ * {@link ArchiveRequest}) - copying, not just recording a path, because both
real source
+ * locations ({@code build/test-results/test} and {@code
runtime/logs/test-results}) are
+ * overwritten by the very next run.
+ */
+public final class TestReportArchiver {
+
+ private static final DateTimeFormatter DATE_FOLDER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC);
+ // Unit-suffixed (04h16m23s) rather than plain-hyphenated (04-16-23) so
the segment reads as
+ // a time-of-day on its own, without relying on its position under the
date folder for
+ // context. Still filesystem-safe (no colons) and sorts identically to the
hyphenated form,
+ // since every field stays zero-padded and the literal h/m/s separators
are constant.
+ private static final DateTimeFormatter TIME_FOLDER =
+
DateTimeFormatter.ofPattern("HH'h'mm'm'ss's'").withZone(ZoneOffset.UTC);
+
+ private TestReportArchiver() {
+ }
+
+ /**
+ * Performs the archive: copies result/report directories into a new dated
run folder and
+ * writes manifest.json there. Returns the manifest that was written.
+ */
+ public static TestRunManifest archive(ArchiveRequest request) throws
IOException {
+ Instant now = Instant.now();
+ String dateFolder = DATE_FOLDER.format(now);
+ String timeFolder = TIME_FOLDER.format(now);
+ File runFolder = new File(request.getBaseDir(),
+ dateFolder + File.separator + timeFolder + "_" +
request.getSuiteName());
+ Files.createDirectories(runFolder.toPath());
+
+ TestRunManifest.Counts counts =
JUnitXmlCounter.count(request.getResultsDir());
+
+ Map<String, String> artifacts = new LinkedHashMap<>();
+ File resultsDest = new File(runFolder, "results");
+ if (copyIfExists(request.getResultsDir(), resultsDest)) {
+ artifacts.put("junitXml", "results/");
+ File innerHtmlReport = new File(resultsDest, "test-report.html");
+ if (innerHtmlReport.exists()) {
+ artifacts.put("htmlReport", "results/test-report.html");
+ }
+ }
+ if (request.getHtmlReportDir() != null) {
+ File htmlDest = new File(runFolder, "html-report");
+ if (copyIfExists(request.getHtmlReportDir(), htmlDest)) {
+ artifacts.put("htmlReport", "html-report/index.html");
+ }
+ }
+
+ TestRunManifest manifest = new TestRunManifest();
+ manifest.setRunId(dateFolder + "_" + timeFolder + "_" +
request.getSuiteName());
+ manifest.setSuiteName(request.getSuiteName());
+ manifest.setArchivedAt(DateTimeFormatter.ISO_INSTANT.format(now));
+ manifest.setGradleTask(request.getSourceTask());
+ manifest.setOutcome(request.getOutcome());
+ manifest.setGitCommit(GitInfo.currentCommit(request.getProjectDir()));
+ manifest.setGitBranch(GitInfo.currentBranch(request.getProjectDir()));
+ manifest.setCounts(counts);
+ manifest.setResultsLocation(runFolder.getAbsolutePath());
+ manifest.setArtifacts(artifacts);
+
+ writeManifest(runFolder, manifest);
+ return manifest;
+ }
+
+ private static void writeManifest(File runFolder, TestRunManifest
manifest) throws IOException {
+ String json = JSON.from(manifest).toString();
+ Files.writeString(new File(runFolder, "manifest.json").toPath(), json);
+ }
+
+ private static boolean copyIfExists(File source, File dest) throws
IOException {
+ if (source == null || !source.exists()) {
+ return false;
+ }
+ copyRecursive(source.toPath(), dest.toPath());
+ return true;
+ }
+
+ private static void copyRecursive(Path source, Path dest) throws
IOException {
+ if (Files.isDirectory(source)) {
+ Files.createDirectories(dest);
+ try (var children = Files.list(source)) {
+ for (Path child : (Iterable<Path>) children::iterator) {
+ copyRecursive(child,
dest.resolve(child.getFileName().toString()));
+ }
+ }
+ } else {
+ Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ /** Immutable parameters for one {@link TestReportArchiver#archive} call.
*/
+ public static final class ArchiveRequest {
+
+ private final File baseDir;
+ private final File projectDir;
+ private final String suiteName;
+ private final String sourceTask;
+ private final String outcome;
+ private final File resultsDir;
+ private final File htmlReportDir;
+
+ public ArchiveRequest(File baseDir, File projectDir, String suiteName,
String sourceTask, String outcome,
+ File resultsDir, File htmlReportDir) {
+ this.baseDir = baseDir;
+ this.projectDir = projectDir;
+ this.suiteName = suiteName;
+ this.sourceTask = sourceTask;
+ this.outcome = outcome;
+ this.resultsDir = resultsDir;
+ this.htmlReportDir = htmlReportDir;
+ }
+
+ public File getBaseDir() {
+ return baseDir;
+ }
+
+ public File getProjectDir() {
+ return projectDir;
+ }
+
+ public String getSuiteName() {
+ return suiteName;
+ }
+
+ public String getSourceTask() {
+ return sourceTask;
+ }
+
+ public String getOutcome() {
+ return outcome;
+ }
+
+ public File getResultsDir() {
+ return resultsDir;
+ }
+
+ public File getHtmlReportDir() {
+ return htmlReportDir;
+ }
+ }
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportArchiverCli.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportArchiverCli.java
new file mode 100644
index 0000000000..ad1bcf1419
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportArchiverCli.java
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+
+/**
+ * Entry point invoked by the Gradle {@code archiveUnitTestReport} / {@code
+ * archiveIntegrationTestReport} tasks (see {@code
test-report-archive.gradle}) via {@code
+ * finalizedBy} on {@code test} and on {@code ofbiz --test}/{@code
testIntegration}. Archiving
+ * must never fail the build - every exception is caught, logged to stderr,
and swallowed.
+ *
+ * <p>System properties consumed:
+ * <ul>
+ * <li>{@code test.report.base.dir} - runtime/test-reports (or configured
override), required</li>
+ * <li>{@code test.report.project.dir} - project rootDir, used for git
commit/branch lookup</li>
+ * <li>{@code test.report.suite.name} - e.g. "unit", "testIntegration",
required</li>
+ * <li>{@code test.report.source.task} - gradle task name that just ran</li>
+ * <li>{@code test.report.task.outcome} - "PASSED" | "FAILED"</li>
+ * <li>{@code test.report.results.dir} - directory holding this run's JUnit
XML, required</li>
+ * <li>{@code test.report.html.dir} - separate HTML report directory, only
set when the HTML
+ * report does not already live inside test.report.results.dir</li>
+ * </ul>
+ */
+public final class TestReportArchiverCli {
+
+ private TestReportArchiverCli() {
+ }
+
+ public static void main(String[] args) {
+ try {
+ String baseDir = require("test.report.base.dir");
+ String resultsDir = require("test.report.results.dir");
+ String suiteName = require("test.report.suite.name");
+
+ String htmlDirProperty =
System.getProperty("test.report.html.dir");
+ File htmlDir = (htmlDirProperty == null ||
htmlDirProperty.isBlank())
+ ? null : new File(htmlDirProperty);
+
+ TestReportArchiver.ArchiveRequest request = new
TestReportArchiver.ArchiveRequest(
+ new File(baseDir),
+ new File(System.getProperty("test.report.project.dir",
".")),
+ suiteName,
+ System.getProperty("test.report.source.task", "unknown"),
+ System.getProperty("test.report.task.outcome", "UNKNOWN"),
+ new File(resultsDir),
+ htmlDir);
+
+ TestRunManifest manifest = TestReportArchiver.archive(request);
+ System.out.println("TestReportArchiverCli: archived '" + suiteName
+ "' run to "
+ + manifest.getResultsLocation());
+ } catch (Exception e) {
+ System.err.println("TestReportArchiverCli: failed to archive test
report: " + e.getMessage());
+ }
+ }
+
+ private static String require(String propertyName) {
+ String value = System.getProperty(propertyName);
+ if (value == null || value.isBlank()) {
+ throw new IllegalStateException("missing required system property:
" + propertyName);
+ }
+ return value;
+ }
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportPurgePlanner.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportPurgePlanner.java
new file mode 100644
index 0000000000..582bb9ac84
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportPurgePlanner.java
@@ -0,0 +1,154 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.ofbiz.base.lang.JSON;
+import org.apache.ofbiz.base.util.Debug;
+
+/**
+ * Pure decision logic for {@link TestReportPurgeService}: given the run
folders under {@code
+ * runtime/test-reports/<date>/}, decides which ones are older than the
retention window and safe
+ * to delete - "safe" meaning not one of the last N fully-passing runs for its
suite, which stay
+ * protected regardless of age so a known-good baseline is never lost.
+ */
+public final class TestReportPurgePlanner {
+
+ private static final String MODULE =
TestReportPurgePlanner.class.getName();
+ private static final DateTimeFormatter DATE_FOLDER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ private TestReportPurgePlanner() {
+ }
+
+ /** One discovered run folder and the manifest fields the planner needs. */
+ public static final class RunFolder {
+ private final File dir;
+ private final LocalDate date;
+ private final String suiteName;
+ private final boolean green;
+
+ public RunFolder(File dir, LocalDate date, String suiteName, boolean
green) {
+ this.dir = dir;
+ this.date = date;
+ this.suiteName = suiteName;
+ this.green = green;
+ }
+
+ public File getDir() {
+ return dir;
+ }
+
+ public LocalDate getDate() {
+ return date;
+ }
+
+ public String getSuiteName() {
+ return suiteName;
+ }
+
+ public boolean isGreen() {
+ return green;
+ }
+ }
+
+ /** Walks {@code baseDir/<yyyy-MM-dd>/<run>/manifest.json} and builds the
RunFolder list. */
+ public static List<RunFolder> discoverRunFolders(File baseDir) {
+ List<RunFolder> runFolders = new ArrayList<>();
+ File[] dateDirs = baseDir.listFiles(File::isDirectory);
+ if (dateDirs == null) {
+ return runFolders;
+ }
+ for (File dateDir : dateDirs) {
+ LocalDate date;
+ try {
+ date = LocalDate.parse(dateDir.getName(), DATE_FOLDER);
+ } catch (DateTimeParseException e) {
+ continue; // not a date-named folder, skip
+ }
+ File[] runDirs = dateDir.listFiles(File::isDirectory);
+ if (runDirs == null) {
+ continue;
+ }
+ for (File runDir : runDirs) {
+ File manifestFile = new File(runDir, "manifest.json");
+ if (!manifestFile.isFile()) {
+ continue;
+ }
+ try (FileInputStream in = new FileInputStream(manifestFile)) {
+ TestRunManifest manifest =
JSON.from(in).toObject(TestRunManifest.class);
+ // A run only counts as green when it actually tested
something (total > 0) and
+ // both the counts and the recorded outcome agree it
passed - otherwise an empty
+ // or failed-before-any-XML-was-produced run (failed == 0
by default) would be
+ // wrongly treated as a protected baseline.
+ boolean green = manifest.getCounts() != null &&
manifest.getCounts().getTotal() > 0
+ && manifest.getCounts().getFailed() == 0 &&
"PASSED".equals(manifest.getOutcome());
+ runFolders.add(new RunFolder(runDir, date,
manifest.getSuiteName(), green));
+ } catch (IOException e) {
+ // Unreadable/corrupt manifest - leave the folder alone,
don't guess.
+ Debug.logWarning(e, "TestReportPurgePlanner: skipping
unreadable " + manifestFile, MODULE);
+ }
+ }
+ }
+ return runFolders;
+ }
+
+ /**
+ * Returns the subset of {@code runFolders} that should be deleted: older
than {@code
+ * retentionDays} relative to {@code today}, excluding each suite's last
{@code
+ * keepLastGreenPerSuite} green runs.
+ */
+ public static List<File> planDeletions(List<RunFolder> runFolders, int
retentionDays,
+ int keepLastGreenPerSuite, LocalDate today) {
+ LocalDate cutoff = today.minusDays(retentionDays);
+
+ Map<String, List<RunFolder>> bySuite = new HashMap<>();
+ for (RunFolder runFolder : runFolders) {
+ bySuite.computeIfAbsent(runFolder.getSuiteName(), key -> new
ArrayList<>()).add(runFolder);
+ }
+
+ Set<File> protectedDirs = new LinkedHashSet<>();
+ for (List<RunFolder> suiteRuns : bySuite.values()) {
+ suiteRuns.stream()
+ .filter(RunFolder::isGreen)
+ .sorted((a, b) -> b.getDate().compareTo(a.getDate()))
+ .limit(keepLastGreenPerSuite)
+ .forEach(runFolder ->
protectedDirs.add(runFolder.getDir()));
+ }
+
+ List<File> toDelete = new ArrayList<>();
+ for (RunFolder runFolder : runFolders) {
+ if (runFolder.getDate().isBefore(cutoff) &&
!protectedDirs.contains(runFolder.getDir())) {
+ toDelete.add(runFolder.getDir());
+ }
+ }
+ return toDelete;
+ }
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportPurgeService.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportPurgeService.java
new file mode 100644
index 0000000000..f710384f12
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestReportPurgeService.java
@@ -0,0 +1,175 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.util.EntityUtilProperties;
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.ServiceUtil;
+
+/**
+ * Deletes dated test-run history folders older than the configured retention
window, always
+ * keeping each suite's last N green (fully-passing) runs regardless of age.
Covers both history
+ * roots the archiver writes (see {@code test-report-archive.gradle}),
defaulting to
+ * {@code build/test-reports-history/} (unit) and {@code
runtime/logs/test-reports-history/}
+ * (integration) but following {@code testtools.properties}' {@code
test.history.unit.dir}/
+ * {@code test.history.integration.dir} overrides if either is set, so purge
always targets
+ * wherever the archiver is actually writing.
+ *
+ * <p>Entirely opt-in, driven by {@code
framework/testtools/config/testtools.properties}: no-ops
+ * unless {@code test.history=true} is set there, and skips purging (but not
archiving) if
+ * {@code test.history=true} but {@code test.history.days} is left
unset/commented - history then
+ * just accumulates until a retention window is explicitly configured.
Scheduled daily regardless
+ * (visible/manageable from the admin Scheduler screen) via the
TESTREPORT_PURGE JobSandbox entry
+ * seeded in TestReportsScheduledServiceData.xml, mirroring how
autoSyncRotatedSecrets is seeded
+ * but no-ops unless secret.rotation.autosync.enabled=true (see
+ * SecretManagerScheduledServiceData.xml).
+ */
+public final class TestReportPurgeService {
+
+ private static final String MODULE =
TestReportPurgeService.class.getName();
+ private static final String RESOURCE = "testtools";
+ private static final String DEFAULT_UNIT_HISTORY_PATH =
"build/test-reports-history";
+ private static final String DEFAULT_INTEGRATION_HISTORY_PATH =
"runtime/logs/test-reports-history";
+ private static final int DEFAULT_KEEP_LAST_GREEN = 5;
+
+ private TestReportPurgeService() {
+ }
+
+ public static Map<String, Object> purgeOldTestReports(DispatchContext
dctx, Map<String, ? extends Object> context) {
+ Delegator delegator = dctx.getDelegator();
+
+ String testHistory = readStringProperty(delegator, "test.history",
"false");
+ if (!"true".equalsIgnoreCase(testHistory)) {
+ return ServiceUtil.returnSuccess("test.history is not enabled in
general.properties, nothing to purge");
+ }
+
+ String retentionDaysValue = readStringProperty(delegator,
"test.history.days", null);
+ if (UtilValidate.isEmpty(retentionDaysValue)) {
+ return ServiceUtil.returnSuccess(
+ "test.history.days is not configured in
general.properties, skipping purge (history retained indefinitely)");
+ }
+ int retentionDays;
+ try {
+ retentionDays = Integer.parseInt(retentionDaysValue.trim());
+ } catch (NumberFormatException e) {
+ return ServiceUtil.returnError("test.history.days is not a valid
integer: '" + retentionDaysValue + "'");
+ }
+
+ String unitHistoryPath = readStringProperty(delegator,
"test.history.unit.dir", DEFAULT_UNIT_HISTORY_PATH);
+ String integrationHistoryPath = readStringProperty(delegator,
"test.history.integration.dir",
+ DEFAULT_INTEGRATION_HISTORY_PATH);
+
+ String ofbizHome = System.getProperty("ofbiz.home");
+ File unitHistoryDir = resolveBaseDir(unitHistoryPath, ofbizHome);
+ File integrationHistoryDir = resolveBaseDir(integrationHistoryPath,
ofbizHome);
+
+ long deletedCount = purgeOneHistoryDir(unitHistoryDir, retentionDays,
DEFAULT_KEEP_LAST_GREEN)
+ + purgeOneHistoryDir(integrationHistoryDir, retentionDays,
DEFAULT_KEEP_LAST_GREEN);
+
+ String message = "Purged " + deletedCount + " test report run(s) older
than " + retentionDays
+ + " days across " + unitHistoryDir + " and " +
integrationHistoryDir
+ + " (kept last " + DEFAULT_KEEP_LAST_GREEN + " green run(s)
per suite)";
+ Debug.logInfo("purgeOldTestReports: " + message, MODULE);
+ Map<String, Object> result = ServiceUtil.returnSuccess(message);
+ result.put("deletedCount", deletedCount);
+ return result;
+ }
+
+ private static long purgeOneHistoryDir(File baseDir, int retentionDays,
int keepLastGreen) {
+ if (!baseDir.isDirectory()) {
+ return 0;
+ }
+
+ List<TestReportPurgePlanner.RunFolder> runFolders =
TestReportPurgePlanner.discoverRunFolders(baseDir);
+ List<File> toDelete = TestReportPurgePlanner.planDeletions(runFolders,
retentionDays, keepLastGreen, LocalDate.now());
+
+ long deletedCount = 0;
+ for (File dir : toDelete) {
+ try {
+ deleteRecursive(dir.toPath());
+ deletedCount++;
+ } catch (IOException e) {
+ Debug.logError(e, "purgeOldTestReports: failed to delete " +
dir, MODULE);
+ }
+ }
+ deleteEmptyDateFolders(baseDir);
+ return deletedCount;
+ }
+
+ /**
+ * Resolves {@code configuredPath} against {@code ofbizHome} when it's a
relative path, matching
+ * the pattern used elsewhere in this codebase for {@code runtime/...}
paths (see
+ * {@code UtilURL.fromOfbizHomePath} and {@code CatalinaContainer}) rather
than leaving it to
+ * resolve against whatever the JVM's working directory happens to be.
Falls back to resolving
+ * as-is (relative to JVM cwd) when {@code ofbizHome} is unset.
+ */
+ static File resolveBaseDir(String configuredPath, String ofbizHome) {
+ File configured = new File(configuredPath);
+ if (configured.isAbsolute() || UtilValidate.isEmpty(ofbizHome)) {
+ return configured;
+ }
+ return new File(ofbizHome, configuredPath);
+ }
+
+ private static String readStringProperty(Delegator delegator, String
propertyName, String defaultValue) {
+ try {
+ String value = EntityUtilProperties.getPropertyValue(RESOURCE,
propertyName, delegator);
+ return UtilValidate.isNotEmpty(value) ? value.trim() :
defaultValue;
+ } catch (Exception e) {
+ Debug.logWarning(e, "purgeOldTestReports: could not read " +
propertyName + ", using default '"
+ + defaultValue + "'", MODULE);
+ return defaultValue;
+ }
+ }
+
+ private static void deleteRecursive(Path path) throws IOException {
+ if (Files.isDirectory(path)) {
+ try (var children = Files.list(path)) {
+ for (Path child : (Iterable<Path>) children::iterator) {
+ deleteRecursive(child);
+ }
+ }
+ }
+ Files.deleteIfExists(path);
+ }
+
+ private static void deleteEmptyDateFolders(File baseDir) {
+ File[] dateDirs = baseDir.listFiles(File::isDirectory);
+ if (dateDirs == null) {
+ return;
+ }
+ for (File dateDir : dateDirs) {
+ File[] children = dateDir.listFiles();
+ if (children != null && children.length == 0) {
+ dateDir.delete();
+ }
+ }
+ }
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestRunManifest.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestRunManifest.java
new file mode 100644
index 0000000000..08e4c6f05c
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/report/TestRunManifest.java
@@ -0,0 +1,171 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Plain data holder serialized to {@code manifest.json} by {@link
TestReportArchiver} and read
+ * back by {@link TestReportPurgePlanner}, via {@link
org.apache.ofbiz.base.lang.JSON}'s
+ * Jackson-backed bean (de)serialization. Field/getter names form the manifest
schema.
+ */
+public final class TestRunManifest {
+
+ private String runId;
+ private String suiteName;
+ private String archivedAt;
+ private String gradleTask;
+ private String outcome;
+ private String gitCommit;
+ private String gitBranch;
+ private Counts counts;
+ private String resultsLocation;
+ private Map<String, String> artifacts = new LinkedHashMap<>();
+
+ public String getRunId() {
+ return runId;
+ }
+
+ public void setRunId(String runId) {
+ this.runId = runId;
+ }
+
+ public String getSuiteName() {
+ return suiteName;
+ }
+
+ public void setSuiteName(String suiteName) {
+ this.suiteName = suiteName;
+ }
+
+ public String getArchivedAt() {
+ return archivedAt;
+ }
+
+ public void setArchivedAt(String archivedAt) {
+ this.archivedAt = archivedAt;
+ }
+
+ public String getGradleTask() {
+ return gradleTask;
+ }
+
+ public void setGradleTask(String gradleTask) {
+ this.gradleTask = gradleTask;
+ }
+
+ public String getOutcome() {
+ return outcome;
+ }
+
+ public void setOutcome(String outcome) {
+ this.outcome = outcome;
+ }
+
+ public String getGitCommit() {
+ return gitCommit;
+ }
+
+ public void setGitCommit(String gitCommit) {
+ this.gitCommit = gitCommit;
+ }
+
+ public String getGitBranch() {
+ return gitBranch;
+ }
+
+ public void setGitBranch(String gitBranch) {
+ this.gitBranch = gitBranch;
+ }
+
+ public Counts getCounts() {
+ return counts;
+ }
+
+ public void setCounts(Counts counts) {
+ this.counts = counts;
+ }
+
+ public String getResultsLocation() {
+ return resultsLocation;
+ }
+
+ public void setResultsLocation(String resultsLocation) {
+ this.resultsLocation = resultsLocation;
+ }
+
+ public Map<String, String> getArtifacts() {
+ return artifacts;
+ }
+
+ public void setArtifacts(Map<String, String> artifacts) {
+ this.artifacts = artifacts;
+ }
+
+ /** Pass/fail/skip totals for one archived run. */
+ public static final class Counts {
+ private int total;
+ private int passed;
+ private int failed;
+ private int skipped;
+
+ public Counts() {
+ }
+
+ public Counts(int total, int passed, int failed, int skipped) {
+ this.total = total;
+ this.passed = passed;
+ this.failed = failed;
+ this.skipped = skipped;
+ }
+
+ public int getTotal() {
+ return total;
+ }
+
+ public void setTotal(int total) {
+ this.total = total;
+ }
+
+ public int getPassed() {
+ return passed;
+ }
+
+ public void setPassed(int passed) {
+ this.passed = passed;
+ }
+
+ public int getFailed() {
+ return failed;
+ }
+
+ public void setFailed(int failed) {
+ this.failed = failed;
+ }
+
+ public int getSkipped() {
+ return skipped;
+ }
+
+ public void setSkipped(int skipped) {
+ this.skipped = skipped;
+ }
+ }
+}
diff --git
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/GitInfoTest.java
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/GitInfoTest.java
new file mode 100644
index 0000000000..8dc0aead6d
--- /dev/null
+++
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/GitInfoTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.matchesPattern;
+import static org.hamcrest.Matchers.not;
+
+class GitInfoTest {
+
+ @Test
+ void returnsUnknownForADirectoryWithNoGitRepo(@TempDir File notARepo) {
+ assertThat(GitInfo.currentCommit(notARepo), is("unknown"));
+ assertThat(GitInfo.currentBranch(notARepo), is("unknown"));
+ }
+
+ @Test
+ void returnsARealShortCommitHashInsideThisRepo() {
+ // build.gradle's own workingDir default for JavaExec is the project
root, which is
+ // exactly what test.report.project.dir will be set to in production -
use it here too.
+ String commit = GitInfo.currentCommit(new File("."));
+
+ assertThat(commit, not(is("unknown")));
+ assertThat(commit, matchesPattern("[0-9a-f]{7,40}"));
+ }
+}
diff --git
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/JUnitXmlCounterTest.java
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/JUnitXmlCounterTest.java
new file mode 100644
index 0000000000..6cb4d97e8e
--- /dev/null
+++
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/JUnitXmlCounterTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+class JUnitXmlCounterTest {
+
+ @Test
+ void sumsCountsAcrossMultipleXmlFilesRecursively(@TempDir File resultsDir)
throws IOException {
+ // Matches the <testsuite errors= failures= skipped= tests=> shape
+ // org.apache.ofbiz.testtools.SuiteXmlReportWriter and Gradle's own
JUnit Platform
+ // listener both produce.
+ writeSuiteXml(new File(resultsDir, "SuiteA.xml"), 10, 1, 1, 0);
+ File nested = new File(resultsDir, "sub");
+ nested.mkdirs();
+ writeSuiteXml(new File(nested, "SuiteB.xml"), 5, 0, 0, 2);
+
+ TestRunManifest.Counts counts = JUnitXmlCounter.count(resultsDir);
+
+ assertThat(counts.getTotal(), is(15));
+ assertThat(counts.getFailed(), is(2));
+ assertThat(counts.getSkipped(), is(2));
+ assertThat(counts.getPassed(), is(11));
+ }
+
+ @Test
+ void skipsUnparsableFilesInsteadOfThrowing(@TempDir File resultsDir)
throws IOException {
+ Files.writeString(new File(resultsDir, "broken.xml").toPath(),
"<not-xml");
+ writeSuiteXml(new File(resultsDir, "Good.xml"), 3, 0, 0, 0);
+
+ TestRunManifest.Counts counts = JUnitXmlCounter.count(resultsDir);
+
+ assertThat(counts.getTotal(), is(3));
+ assertThat(counts.getFailed(), is(0));
+ }
+
+ @Test
+ void returnsAllZeroesWhenDirectoryDoesNotExist() {
+ TestRunManifest.Counts counts = JUnitXmlCounter.count(new
File("/no/such/dir"));
+
+ assertThat(counts.getTotal(), is(0));
+ assertThat(counts.getPassed(), is(0));
+ assertThat(counts.getFailed(), is(0));
+ assertThat(counts.getSkipped(), is(0));
+ }
+
+ private static void writeSuiteXml(File file, int tests, int failures, int
errors, int skipped) throws IOException {
+ String xml = "<testsuite name=\"x\" tests=\"" + tests + "\"
failures=\"" + failures
+ + "\" errors=\"" + errors + "\" skipped=\"" + skipped +
"\"></testsuite>";
+ Files.writeString(file.toPath(), xml);
+ }
+}
diff --git
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportArchiverCliTest.java
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportArchiverCliTest.java
new file mode 100644
index 0000000000..7c702d813a
--- /dev/null
+++
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportArchiverCliTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+class TestReportArchiverCliTest {
+
+ private static final String[] PROPERTY_NAMES = {
+ "test.report.base.dir", "test.report.project.dir",
"test.report.suite.name",
+ "test.report.source.task", "test.report.task.outcome",
"test.report.results.dir",
+ "test.report.html.dir"
+ };
+
+ @AfterEach
+ void clearSystemProperties() {
+ for (String name : PROPERTY_NAMES) {
+ System.clearProperty(name);
+ }
+ }
+
+ @Test
+ void writesAManifestWhenAllRequiredPropertiesAreSet(@TempDir File tmp)
throws IOException {
+ File baseDir = new File(tmp, "runtime/test-reports");
+ File resultsDir = new File(tmp, "results");
+ resultsDir.mkdirs();
+ Files.writeString(new File(resultsDir, "S.xml").toPath(),
+ "<testsuite name=\"x\" tests=\"1\" failures=\"0\" errors=\"0\"
skipped=\"0\"></testsuite>");
+
+ System.setProperty("test.report.base.dir", baseDir.getAbsolutePath());
+ System.setProperty("test.report.project.dir", tmp.getAbsolutePath());
+ System.setProperty("test.report.suite.name", "unit");
+ System.setProperty("test.report.source.task", "test");
+ System.setProperty("test.report.task.outcome", "PASSED");
+ System.setProperty("test.report.results.dir",
resultsDir.getAbsolutePath());
+
+ TestReportArchiverCli.main(new String[0]);
+
+ File[] dateDirs = baseDir.listFiles();
+ assertThat(dateDirs != null && dateDirs.length == 1, is(true));
+ File[] runDirs = dateDirs[0].listFiles();
+ assertThat(runDirs != null && runDirs.length == 1, is(true));
+ assertThat(new File(runDirs[0], "manifest.json").exists(), is(true));
+ }
+
+ @Test
+ void doesNotThrowWhenARequiredPropertyIsMissing() {
+ System.setProperty("test.report.base.dir", "/tmp/whatever");
+ // test.report.results.dir and test.report.suite.name intentionally
left unset.
+
+ TestReportArchiverCli.main(new String[0]); // must not throw
+ }
+}
diff --git
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportArchiverTest.java
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportArchiverTest.java
new file mode 100644
index 0000000000..41b96216c0
--- /dev/null
+++
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportArchiverTest.java
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.apache.ofbiz.base.lang.JSON;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.matchesPattern;
+import static org.hamcrest.Matchers.notNullValue;
+
+class TestReportArchiverTest {
+
+ @Test
+ void archivesASeparateResultsAndHtmlDirLikeThePlainTestTask(@TempDir File
tmp) throws IOException {
+ File baseDir = new File(tmp, "runtime/test-reports");
+ File resultsDir = new File(tmp, "build/test-results/test");
+ File htmlDir = new File(tmp, "build/reports/tests/test");
+ resultsDir.mkdirs();
+ htmlDir.mkdirs();
+ Files.writeString(new File(resultsDir,
"org.example.SomeTest.xml").toPath(),
+ "<testsuite name=\"x\" tests=\"2\" failures=\"1\" errors=\"0\"
skipped=\"0\"></testsuite>");
+ Files.writeString(new File(htmlDir, "index.html").toPath(),
"<html>report</html>");
+
+ TestReportArchiver.ArchiveRequest request = new
TestReportArchiver.ArchiveRequest(
+ baseDir, tmp, "unit", "test", "FAILED", resultsDir, htmlDir);
+ TestRunManifest manifest = TestReportArchiver.archive(request);
+
+ assertThat(manifest.getSuiteName(), is("unit"));
+ assertThat(manifest.getOutcome(), is("FAILED"));
+ assertThat(manifest.getCounts().getTotal(), is(2));
+ assertThat(manifest.getCounts().getFailed(), is(1));
+ assertThat(manifest.getArtifacts().get("junitXml"), is("results/"));
+ assertThat(manifest.getArtifacts().get("htmlReport"),
is("html-report/index.html"));
+
+ File runFolder = new File(manifest.getResultsLocation());
+ assertThat(new File(runFolder,
"results/org.example.SomeTest.xml").exists(), is(true));
+ assertThat(new File(runFolder, "html-report/index.html").exists(),
is(true));
+
+ File manifestFile = new File(runFolder, "manifest.json");
+ assertThat(manifestFile.exists(), is(true));
+ TestRunManifest roundTripped =
JSON.from(Files.newInputStream(manifestFile.toPath())).toObject(TestRunManifest.class);
+ assertThat(roundTripped.getSuiteName(), is("unit"));
+ assertThat(roundTripped.getCounts().getFailed(), is(1));
+ }
+
+ @Test
+ void archivesACombinedResultsDirLikeTestIntegration(@TempDir File tmp)
throws IOException {
+ File baseDir = new File(tmp, "runtime/test-reports");
+ File resultsDir = new File(tmp, "runtime/logs/test-results");
+ resultsDir.mkdirs();
+ Files.writeString(new File(resultsDir, "SomeSuite.xml").toPath(),
+ "<testsuite name=\"x\" tests=\"3\" failures=\"0\" errors=\"0\"
skipped=\"1\"></testsuite>");
+ Files.writeString(new File(resultsDir, "test-report.html").toPath(),
"<html>combined</html>");
+
+ TestReportArchiver.ArchiveRequest request = new
TestReportArchiver.ArchiveRequest(
+ baseDir, tmp, "testIntegration", "testIntegration", "PASSED",
resultsDir, null);
+ TestRunManifest manifest = TestReportArchiver.archive(request);
+
+ assertThat(manifest.getArtifacts().get("junitXml"), is("results/"));
+ assertThat(manifest.getArtifacts().get("htmlReport"),
is("results/test-report.html"));
+
+ File runFolder = new File(manifest.getResultsLocation());
+ assertThat(new File(runFolder, "results/test-report.html").exists(),
is(true));
+ }
+
+ @Test
+ void runFolderNameFollowsDateTimeSuiteConvention(@TempDir File tmp) throws
IOException {
+ File baseDir = new File(tmp, "runtime/test-reports");
+ File resultsDir = new File(tmp, "empty-results");
+ resultsDir.mkdirs();
+
+ TestReportArchiver.ArchiveRequest request = new
TestReportArchiver.ArchiveRequest(baseDir, tmp, "unit", "test", "PASSED",
resultsDir, null);
+ TestRunManifest manifest = TestReportArchiver.archive(request);
+
+ assertThat(manifest.getRunId(), notNullValue());
+ File runFolder = new File(manifest.getResultsLocation());
+ assertThat(runFolder.getParentFile().getParentFile(),
equalTo(baseDir));
+ assertThat(runFolder.getName(),
matchesPattern("\\d{2}h\\d{2}m\\d{2}s_unit"));
+ assertThat(runFolder.getParentFile().getName(),
matchesPattern("\\d{4}-\\d{2}-\\d{2}"));
+ }
+}
diff --git
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportPurgePlannerTest.java
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportPurgePlannerTest.java
new file mode 100644
index 0000000000..cf0d84aecc
--- /dev/null
+++
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportPurgePlannerTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.time.LocalDate;
+import java.util.List;
+
+import org.apache.ofbiz.base.lang.JSON;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+
+class TestReportPurgePlannerTest {
+
+ private static TestReportPurgePlanner.RunFolder runFolder(String path,
String date, String suite, boolean green) {
+ return new TestReportPurgePlanner.RunFolder(new File(path),
LocalDate.parse(date), suite, green);
+ }
+
+ private static void writeManifest(File runDir, String suiteName, String
outcome, int total, int failed)
+ throws IOException {
+ Files.createDirectories(runDir.toPath());
+ TestRunManifest manifest = new TestRunManifest();
+ manifest.setRunId(runDir.getName());
+ manifest.setSuiteName(suiteName);
+ manifest.setOutcome(outcome);
+ manifest.setCounts(new TestRunManifest.Counts(total, total - failed,
failed, 0));
+ Files.writeString(new File(runDir, "manifest.json").toPath(),
JSON.from(manifest).toString());
+ }
+
+ @Test
+ void deletesOnlyRunsOlderThanTheRetentionWindow() {
+ List<TestReportPurgePlanner.RunFolder> runFolders = List.of(
+ runFolder("/base/2026-01-01/a_unit", "2026-01-01", "unit",
true),
+ runFolder("/base/2026-08-01/b_unit", "2026-08-01", "unit",
true));
+ LocalDate today = LocalDate.parse("2026-08-17");
+
+ List<File> toDelete = TestReportPurgePlanner.planDeletions(runFolders,
30, 0, today);
+
+ assertThat(toDelete, contains(new File("/base/2026-01-01/a_unit")));
+ }
+
+ @Test
+ void protectsTheLastNGreenRunsPerSuiteEvenIfOld() {
+ List<TestReportPurgePlanner.RunFolder> runFolders = List.of(
+ runFolder("/base/2026-01-01/a_unit", "2026-01-01", "unit",
true),
+ runFolder("/base/2026-01-02/b_unit", "2026-01-02", "unit",
true),
+ runFolder("/base/2026-01-03/c_unit", "2026-01-03", "unit",
false));
+ LocalDate today = LocalDate.parse("2026-08-17");
+
+ List<File> toDelete = TestReportPurgePlanner.planDeletions(runFolders,
30, 1, today);
+
+ // b_unit (2026-01-02) is the most recent green run for "unit" -
protected.
+ // a_unit (older green) and c_unit (red, never protected) are both
fair game.
+ assertThat(toDelete, containsInAnyOrder(new
File("/base/2026-01-01/a_unit"), new File("/base/2026-01-03/c_unit")));
+ }
+
+ @Test
+ void protectionIsPerSuiteNotGlobal() {
+ List<TestReportPurgePlanner.RunFolder> runFolders = List.of(
+ runFolder("/base/2026-01-01/a_unit", "2026-01-01", "unit",
true),
+ runFolder("/base/2026-01-01/a_testIntegration", "2026-01-01",
"testIntegration", true));
+ LocalDate today = LocalDate.parse("2026-08-17");
+
+ List<File> toDelete = TestReportPurgePlanner.planDeletions(runFolders,
30, 1, today);
+
+ assertThat(toDelete, empty());
+ }
+
+ @Test
+ void keepsRunsWithinTheRetentionWindowRegardlessOfGreen() {
+ List<TestReportPurgePlanner.RunFolder> runFolders = List.of(
+ runFolder("/base/2026-08-16/a_unit", "2026-08-16", "unit",
false));
+ LocalDate today = LocalDate.parse("2026-08-17");
+
+ List<File> toDelete = TestReportPurgePlanner.planDeletions(runFolders,
30, 0, today);
+
+ assertThat(toDelete, hasSize(0));
+ }
+
+ @Test
+ void
anEmptyRunWithZeroTotalIsNotTreatedAsGreenEvenThoughFailedIsZero(@TempDir File
tmp) throws IOException {
+ File dateDir = new File(tmp, "2026-01-01");
+ File emptyRunDir = new File(dateDir, "00-00-00_unit");
+ writeManifest(emptyRunDir, "unit", "PASSED", 0, 0);
+
+ List<TestReportPurgePlanner.RunFolder> runFolders =
TestReportPurgePlanner.discoverRunFolders(tmp);
+
+ assertThat(runFolders, hasSize(1));
+ assertThat(runFolders.get(0).isGreen(), is(false));
+ }
+
+ @Test
+ void
aRunWithOutcomeFailedIsNotTreatedAsGreenEvenThoughFailedCountIsZero(@TempDir
File tmp) throws IOException {
+ File dateDir = new File(tmp, "2026-01-01");
+ File failedRunDir = new File(dateDir, "00-00-00_unit");
+ writeManifest(failedRunDir, "unit", "FAILED", 5, 0);
+
+ List<TestReportPurgePlanner.RunFolder> runFolders =
TestReportPurgePlanner.discoverRunFolders(tmp);
+
+ assertThat(runFolders, hasSize(1));
+ assertThat(runFolders.get(0).isGreen(), is(false));
+ }
+
+ @Test
+ void aGenuinePassingRunIsStillTreatedAsGreen(@TempDir File tmp) throws
IOException {
+ File dateDir = new File(tmp, "2026-01-01");
+ File passingRunDir = new File(dateDir, "00-00-00_unit");
+ writeManifest(passingRunDir, "unit", "PASSED", 5, 0);
+
+ List<TestReportPurgePlanner.RunFolder> runFolders =
TestReportPurgePlanner.discoverRunFolders(tmp);
+
+ assertThat(runFolders, hasSize(1));
+ assertThat(runFolders.get(0).isGreen(), is(true));
+ }
+}
diff --git
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportPurgeServiceTest.java
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportPurgeServiceTest.java
new file mode 100644
index 0000000000..018aa2cc93
--- /dev/null
+++
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/report/TestReportPurgeServiceTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.
+ */
+package org.apache.ofbiz.testtools.report;
+
+import java.io.File;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Covers just {@link TestReportPurgeService#resolveBaseDir(String, String)}:
the rest of
+ * {@code purgeOldTestReports} needs a running DispatchContext/Delegator, so
it's out of scope here
+ * by design.
+ */
+class TestReportPurgeServiceTest {
+
+ @Test
+ void resolvesARelativePathAgainstOfbizHome() {
+ File resolved =
TestReportPurgeService.resolveBaseDir("runtime/test-reports", "/opt/ofbiz");
+
+ assertThat(resolved, is(new File("/opt/ofbiz",
"runtime/test-reports")));
+ }
+
+ @Test
+ void leavesAnAbsolutePathAlone() {
+ File resolved =
TestReportPurgeService.resolveBaseDir("/var/ofbiz-reports", "/opt/ofbiz");
+
+ assertThat(resolved, is(new File("/var/ofbiz-reports")));
+ }
+
+ @Test
+ void fallsBackToTheConfiguredPathAsIsWhenOfbizHomeIsUnset() {
+ File resolved =
TestReportPurgeService.resolveBaseDir("runtime/test-reports", null);
+
+ assertThat(resolved, is(new File("runtime/test-reports")));
+ }
+}
diff --git a/framework/testtools/test-report-archive.gradle
b/framework/testtools/test-report-archive.gradle
new file mode 100644
index 0000000000..a0abacd688
--- /dev/null
+++ b/framework/testtools/test-report-archive.gradle
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ */
+
+// Test report persistence: archives test/testIntegration output into a dated
history folder, by
+// default sitting right next to each task's own default output, so it
survives gradle clean and
+// multiple runs/day - both build/test-results/test and
runtime/logs/test-results are overwritten
+// on every run (see TestReportArchiver's class javadoc). Split into its own
file for the same
+// reason test-reports.gradle is: self-contained and easiest to find as a
unit; applied from
+// build.gradle so it behaves exactly as if declared inline there.
+//
+// Everything here is opt-in and configured via the plain testtools.properties
config file
+// (test.history / test.history.unit.dir / test.history.integration.dir), not
a gradle command
+// line flag - same `gradlew test`/`gradlew testIntegration` invocations
either way. Read directly
+// as a flat properties file here (no OFBiz runtime/container needed for that)
so the archiver
+// tasks' onlyIf can gate on it at configuration time.
+def testtoolsPropertiesFile =
file("$rootDir/framework/testtools/config/testtools.properties")
+def testtoolsProperties = new Properties()
+if (testtoolsPropertiesFile.exists()) {
+ testtoolsPropertiesFile.withInputStream { testtoolsProperties.load(it) }
+}
+def testHistoryEnabled =
Boolean.parseBoolean(testtoolsProperties.getProperty('test.history',
'false').trim())
+
+// Resolves a testtools.properties path value (relative paths resolve against
the project root) to
+// a File. Shared by both history directories below.
+def resolveConfiguredDir = { String configuredPath ->
+ File configured = file(configuredPath)
+ configured.isAbsolute() ? configured : file("$rootDir/$configuredPath")
+}
+
+// Defaults are siblings of each task's own default output directory, not
nested inside it:
+// build/test-reports-history/ next to build/test-results/test/ +
build/reports/tests/test/
+// runtime/logs/test-reports-history/ next to runtime/logs/test-results/
+// Deliberately NOT nested inside runtime/logs/test-results/ or
build/test-results/test/ by
+// default - both of those are targets of pre-existing, unrelated `include
'*.xml'` glob patterns
+// (the pre-run `delete fileTree(...)` step and the
createTestReport/createFramedTestReport report
+// generators in test-reports.gradle) that match at any depth by default;
nesting history there
+// would get it deleted before the next run and/or blended into every future
report as stale
+// duplicate data. Sibling placement also keeps history outside `test`'s own
declared
+// @OutputDirectory, so Gradle's own stale-output cleanup for that task has no
reason to touch it.
+// Both are overridable via testtools.properties if you want history stored
elsewhere instead
+// (e.g. entirely under runtime/) - see that file's test.history.unit.dir/
+// test.history.integration.dir comments for the trade-offs of moving the
unit-side one
+// specifically.
+def unitHistoryBaseDir =
resolveConfiguredDir(testtoolsProperties.getProperty('test.history.unit.dir',
'build/test-reports-history').trim())
+def integrationHistoryBaseDir =
resolveConfiguredDir(testtoolsProperties.getProperty('test.history.integration.dir',
'runtime/logs/test-reports-history').trim())
+
+// baseDir is passed in explicitly (rather than read from the enclosing script
scope) because
+// top-level `def` variables in a Gradle script are local to the script's own
run() method - a
+// separately-declared method like this one can't see them, and referencing
them directly here
+// fails at task-execution time with "unknown property".
+def registerArchiveTestReportTask(String taskName, File baseDir, boolean
historyEnabled) {
+ tasks.register(taskName, JavaExec) {
+ group = 'verification'
+ description = 'Archives a test run into a dated history folder
(manifest.json + copied output)'
+
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'org.apache.ofbiz.testtools.report.TestReportArchiverCli'
+
+ systemProperty 'test.report.base.dir', baseDir.absolutePath
+ systemProperty 'test.report.project.dir', rootDir.absolutePath
+
+ // Opt-in gate: testtools.properties' test.history flag, read once
above. Off by default -
+ // current behavior (no history, each run's output is simply
overwritten in place, as it
+ // always has been).
+ onlyIf { historyEnabled }
+
+ // TestReportArchiverCli catches everything itself; ignoreExitValue
covers the
+ // (should-never-happen) case of the JVM failing to start at all.
+ ignoreExitValue = true
+ }
+}
+
+registerArchiveTestReportTask('archiveUnitTestReport', unitHistoryBaseDir,
testHistoryEnabled)
+registerArchiveTestReportTask('archiveIntegrationTestReport',
integrationHistoryBaseDir, testHistoryEnabled)
+
+// Preserve archived unit-test history across `gradle clean`, but only when
the configured history
+// directory actually sits inside buildDir (true for the default; if
test.history.unit.dir has
+// been redirected outside build/ - e.g. under runtime/ - there's nothing
under buildDir to
+// protect, so clean's normal full-wipe behavior is left alone). The default
`clean` task deletes
+// all of buildDir wholesale, which would otherwise wipe the history folder
along with it -
+// exactly the "wiped on gradle clean" problem this feature exists to solve,
just one level
+// deeper. Exclusion path is derived from the actual configured directory, not
hardcoded, so a
+// redirect to another build/-relative subfolder still gets protected
correctly.
+// (No equivalent change is ever needed for the integration side: nothing in
this project deletes
+// all of runtime/logs/ by default, so its default location is already safe
as-is regardless.)
+def buildDirPath = buildDir.toPath().normalize()
+def unitHistoryPath = unitHistoryBaseDir.toPath().normalize()
+if (unitHistoryPath.startsWith(buildDirPath)) {
+ def excludePattern =
buildDirPath.relativize(unitHistoryPath).toString().replace(File.separator,
'/') + '/**'
+ tasks.named('clean') {
+ delete = fileTree(buildDir) {
+ exclude excludePattern
+ }
+ }
+}