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
+        }
+    }
+}

Reply via email to