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 fac1ba4911 REST API Support for Running and Monitoring OFBiz Test 
Cases (#1690)
fac1ba4911 is described below

commit fac1ba4911c16d84cf25424318af126e5e78b8d2
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Thu Aug 20 16:31:51 2026 +0530

    REST API Support for Running and Monitoring OFBiz Test Cases (#1690)
    
    Today, running OFBiz's tests means logging into the server and typing a
    command by hand. This feature lets tests be started and checked over a
    simple web request instead, so tools like CI pipelines can run them
    automatically, and no one needs direct server access just to run a test.
    
    I took care of the following work items in this pass:
    
    1. Adds TestRunServices.runTestSuite/getTestRunStatus, a
    REST/service-triggered way to kick off an existing testdef test-suite
    (optionally one case-name within it) asynchronously and poll it via a
    runId, reusing the exact same JunitSuiteWrapper/TestRunContainer engine
    that ofbiz --test and gradlew testIntegration already use.
    
    2. Gates that API behind two independent fail-closed checks enforced
    inside the service itself, not just at the REST layer: the
    test.api.enabled config flag (off by default) and a new TESTEXEC_ADMIN
    permission.
    
    3. Tracks each run's lifecycle (QUEUED -> RUNNING ->
    PASSED/FAILED/ERROR) in an in-memory TestRunTracker so getTestRunStatus
    can be polled while the run executes on a dedicated single-threaded
    executor, and archives finished API-triggered runs into the same
    manifest.json test-history store gradlew test/testIntegration already
    write to, tagged trigger="api".
    
    4. Adds runScopedTestSuite/getScopedTestRunStatus, which force-lock
    componentName server-side so a component-branded REST endpoint can never
    trigger or poll another component's tests regardless of what a caller
    supplies; plugins/example's ExampleTestRunServices (a Groovy service
    script) is the reference implementation any other component can copy.
    
    5. Adds a testParams map that lets a caller override field values inside
    the Jupiter test methods for that one run, delivered through a
    ThreadLocal bridge and JupiterTestHelper.getTestParams(), later extended
    to be namespaced per test method so two different methods in the same
    run can each receive their own value for the same field name.
    
    6. Adds ofbiz --test method= on the CLI and the equivalent
    testMethodName on runTestSuite/runExampleTestSuite, both scoping a
    resolved case's class down to one @Test or @ParameterizedTest method
    instead of running the whole class, sharing the identical fail-closed
    validators and reflection-based method discovery (needed because JUnit
    Platform's plain selectMethod(Class,String) cannot match a method that
    takes parameters).
    
    7. Documents and partially mitigates a POC-scope dispatcher/delegator
    resource leak from running tests inside a long-lived server process,
    deliberately using ServiceContainer.removeFromCache rather than
    deregister after a fix for a critical regression where deregister was
    found to shut down the live server's real JMS listeners.
    
    8. Adds substantial new unit-test coverage across both repos
    (TestRunServicesTest, TestRunContainerTest, JupiterClassRunnerTest,
    TestRunTrackerTest, and more) and grows plugins/example's
    ExampleJupiterTests with testParams-driven and parameterized test
    methods used to exercise every feature above end-to-end.
    
    9. A final round of live testing against a running dev server -- unit
    tests, checkstyle, and real REST/CLI calls covering valid runs,
    parameterized methods, every validation-failure path, permission/auth
    checks, and componentName-tampering attempts -- found zero regressions
    across both the test-run-triggering feature and the
    method=/testMethodName feature.
---
 README.md                                          |  13 +
 .../ofbiz/base/start/StartupCommandUtil.java       |   8 +-
 framework/testtools/config/testtools.properties    |  13 +
 .../data/TestToolsSecurityGroupDemoData.xml        |  23 +
 .../data/TestToolsSecurityPermissionSeedData.xml   |  24 +
 framework/testtools/ofbiz-component.xml            |   4 +
 framework/testtools/servicedef/services.xml        |  33 ++
 .../apache/ofbiz/testtools/JunitSuiteWrapper.java  |  21 +
 .../ofbiz/testtools/JupiterTestExtension.java      |  89 +++-
 .../apache/ofbiz/testtools/JupiterTestHelper.java  |  48 ++
 .../apache/ofbiz/testtools/TestRunContainer.java   | 118 ++++-
 .../org/apache/ofbiz/testtools/TestRunRecord.java  | 125 +++++
 .../apache/ofbiz/testtools/TestRunServices.java    | 519 +++++++++++++++++++++
 .../org/apache/ofbiz/testtools/TestRunTracker.java |  70 +++
 .../ofbiz/testtools/report/TestReportArchiver.java |  19 +
 .../ofbiz/testtools/report/TestRunManifest.java    |  18 +
 .../ofbiz/testtools/JupiterClassRunnerTest.java    | 243 ++++++++++
 .../ofbiz/testtools/TestRunContainerTest.java      | 111 +++++
 .../ofbiz/testtools/TestRunServicesTest.java       | 296 ++++++++++++
 .../apache/ofbiz/testtools/TestRunTrackerTest.java | 139 ++++++
 .../testtools/report/TestReportArchiverTest.java   |  32 ++
 21 files changed, 1960 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md
index 62f3fca50a..fbb8b8a201 100644
--- a/README.md
+++ b/README.md
@@ -545,6 +545,19 @@ Listens on port **5005**
 
 `gradlew "ofbiz --test component=entity --test loglevel=verbose" --debug-jvm`
 
+#### Execute a single test method within an integration test case
+
+Requires `case=` (method= narrows that case's class down to one method, so 
case= is
+needed to identify which class that is), and only applies when `case=` 
resolves to a
+`jupiter-test-suite` (JUnit 5) class.
+
+> **Warning** -
+> A method run alone this way can behave differently than it does as part of 
the whole
+> class, if that class has methods that implicitly depend on declaration order 
or on a
+> sibling method's side effects.
+
+`gradlew "ofbiz --test component=entity --test suitename=entitytests --test 
case=entity-query-tests --test method=testSpecificMethod"`
+
 #### Execute an integration test suite
 
 `gradlew "ofbiz --test component=entity --test suitename=entitytests"`
diff --git 
a/framework/start/src/main/java/org/apache/ofbiz/base/start/StartupCommandUtil.java
 
b/framework/start/src/main/java/org/apache/ofbiz/base/start/StartupCommandUtil.java
index 22025caebb..dbd556f913 100644
--- 
a/framework/start/src/main/java/org/apache/ofbiz/base/start/StartupCommandUtil.java
+++ 
b/framework/start/src/main/java/org/apache/ofbiz/base/start/StartupCommandUtil.java
@@ -152,7 +152,13 @@ public final class StartupCommandUtil {
                     + System.lineSeparator()
                     + "--test case=entity-query-tests"
                     + System.lineSeparator()
-                    + "--test loglevel=warning")
+                    + "--test loglevel=warning"
+                    + System.lineSeparator()
+                    + "--test case=entity-query-tests --test 
method=testSpecificMethod (requires case=; only "
+                    + "applies when case= resolves to a jupiter-test-suite - 
scopes the run to that one "
+                    + "@Test method instead of the whole class. Caution: a 
method run alone may behave "
+                    + "differently than as part of the full class if it 
implicitly depends on a sibling "
+                    + "method - see JupiterClassRunner's javadoc)")
             .numberOfArgs(2)
             .valueSeparator('=')
             .optionalArg(true)
diff --git a/framework/testtools/config/testtools.properties 
b/framework/testtools/config/testtools.properties
index eab279d530..e2791ba466 100644
--- a/framework/testtools/config/testtools.properties
+++ b/framework/testtools/config/testtools.properties
@@ -44,3 +44,16 @@ test.history.days=7
 #    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
+
+# -- Enables the runTestSuite/getTestRunStatus REST-triggered test execution 
API (see
+#    TestRunServices). Off by default: this is remote-triggered code 
execution, so it must be
+#    explicitly opted into per environment. Checked via EntityUtilProperties 
(delegator-aware), so
+#    it can also be flipped live via a SystemProperty row without a restart. 
Known POC limitation:
+#    each triggered run creates a new test Delegator/LocalDispatcher, and 
while its
+#    ServiceContainer-level cache entry is now removed after each run, the 
heavier ServiceDispatcher
+#    instance behind it - including that test Delegator, which it keeps pinned 
- still accumulates
+#    permanently (no public API to remove it) and startup services still 
re-run on every call; it
+#    also runs against the live server's database with only best-effort 
rollback, not a true
+#    isolated sandbox - see TestRunServices' class javadoc before enabling 
this in any
+#    shared/long-lived environment.
+test.api.enabled=false
diff --git a/framework/testtools/data/TestToolsSecurityGroupDemoData.xml 
b/framework/testtools/data/TestToolsSecurityGroupDemoData.xml
new file mode 100644
index 0000000000..8f71a5c963
--- /dev/null
+++ b/framework/testtools/data/TestToolsSecurityGroupDemoData.xml
@@ -0,0 +1,23 @@
+<?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>
+    <!-- Test execution API security -->
+    <SecurityGroupPermission fromDate="2001-05-13 12:00:00.0" 
groupId="FULLADMIN" permissionId="TESTEXEC_ADMIN"/>
+</entity-engine-xml>
diff --git a/framework/testtools/data/TestToolsSecurityPermissionSeedData.xml 
b/framework/testtools/data/TestToolsSecurityPermissionSeedData.xml
new file mode 100644
index 0000000000..433d5c2709
--- /dev/null
+++ b/framework/testtools/data/TestToolsSecurityPermissionSeedData.xml
@@ -0,0 +1,24 @@
+<?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>
+    <!-- Test execution API security -->
+    <SecurityPermission permissionId="TESTEXEC_ADMIN"
+            description="Permission to trigger and view OFBiz test runs via 
the runTestSuite/getTestRunStatus REST/service API"/>
+</entity-engine-xml>
diff --git a/framework/testtools/ofbiz-component.xml 
b/framework/testtools/ofbiz-component.xml
index ffb7f32cc5..8674a90836 100644
--- a/framework/testtools/ofbiz-component.xml
+++ b/framework/testtools/ofbiz-component.xml
@@ -26,6 +26,10 @@
 
     <!-- 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"/>
+    <!-- declares the TESTEXEC_ADMIN permission ID 
(runTestSuite/getTestRunStatus) -->
+    <entity-resource type="data" reader-name="seed" loader="main" 
location="data/TestToolsSecurityPermissionSeedData.xml"/>
+    <!-- grants TESTEXEC_ADMIN (runTestSuite/getTestRunStatus) to FULLADMIN -->
+    <entity-resource type="data" reader-name="demo" loader="main" 
location="data/TestToolsSecurityGroupDemoData.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 a15f5bd606..b21535ad7f 100644
--- a/framework/testtools/servicedef/services.xml
+++ b/framework/testtools/servicedef/services.xml
@@ -48,4 +48,37 @@ under the License.
             TESTREPORT_PURGE JobSandbox entry seeded in 
TestReportsScheduledServiceData.xml.</description>
         <attribute name="deletedCount" type="Long" mode="OUT" optional="true"/>
     </service>
+
+    <service name="runTestSuite" engine="java" auth="true"
+             location="org.apache.ofbiz.testtools.TestRunServices" 
invoke="runTestSuite">
+        <description>Kicks off a testdef test-suite run asynchronously via the 
in-JVM Jupiter test
+            engine and returns a runId immediately; poll getTestRunStatus for 
progress/results.
+            Gated by the test.api.enabled config flag and the TESTEXEC_ADMIN 
permission. Not
+            intended for direct *.rest.xml exposure - a component-branded REST 
endpoint must wrap
+            this with a component-scoped service (see 
TestRunServices.runScopedTestSuite and
+            plugins/example's ExampleTestRunServices for the pattern), or the 
endpoint can trigger
+            any component's tests. testMethodName optionally scopes the run to 
one
+            @Test/@ParameterizedTest method within the class testCaseName 
resolves to - requires
+            testCaseName, and only applies when it resolves to a 
jupiter-test-suite.</description>
+        <attribute name="componentName" type="String" mode="IN" 
optional="true"/>
+        <attribute name="suiteName" type="String" mode="IN" optional="false"/>
+        <attribute name="testCaseName" type="String" mode="IN" 
optional="true"/>
+        <attribute name="testMethodName" type="String" mode="IN" 
optional="true"/>
+        <attribute name="testParams" type="Map" mode="IN" optional="true"/>
+        <attribute name="runId" type="String" mode="OUT" optional="true"/>
+    </service>
+
+    <service name="getTestRunStatus" engine="java" auth="true"
+             location="org.apache.ofbiz.testtools.TestRunServices" 
invoke="getTestRunStatus">
+        <description>Reads a runTestSuite-triggered run's current status 
(QUEUED/RUNNING/PASSED/
+            FAILED/ERROR) and result summary from the in-memory 
TestRunTracker. Not intended for
+            direct *.rest.xml exposure - a component-branded REST endpoint 
must wrap this with a
+            component-scoped service (see 
TestRunServices.getScopedTestRunStatus and
+            plugins/example's ExampleTestRunServices for the pattern), or the 
endpoint can poll any
+            component's tests.</description>
+        <attribute name="runId" type="String" mode="IN" optional="false"/>
+        <attribute name="status" type="String" mode="OUT" optional="true"/>
+        <attribute name="componentName" type="String" mode="OUT" 
optional="true"/>
+        <attribute name="resultSummary" type="Map" mode="OUT" optional="true"/>
+    </service>
 </services>
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JunitSuiteWrapper.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JunitSuiteWrapper.java
index 2017769ec4..1d854a03a4 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JunitSuiteWrapper.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JunitSuiteWrapper.java
@@ -35,6 +35,12 @@ public class JunitSuiteWrapper {
 
     private static final String MODULE = JunitSuiteWrapper.class.getName();
     private List<ModelTestSuite> modelTestSuiteList = new LinkedList<>();
+    // Every ModelTestSuite the constructor builds but then discards below 
(empty test list, so it
+    // never makes it into modelTestSuiteList / getAllTestList()). The 
constructor still creates a
+    // real test Delegator/LocalDispatcher for each of these - see 
getDiscardedModelTestSuites() -
+    // so a caller that treats "no tests found" as a plain error must still 
deregister these, or
+    // that dispatcher leaks with no other path ever reaching it.
+    private List<ModelTestSuite> discardedModelTestSuiteList = new 
LinkedList<>();
 
     public JunitSuiteWrapper(String componentName, String suiteName, String 
testCase) {
         for (ComponentConfig.TestSuiteInfo testSuiteInfo: 
ComponentConfig.getAllTestSuiteInfos(componentName)) {
@@ -58,6 +64,8 @@ public class JunitSuiteWrapper {
                 ModelTestSuite modelTestSuite = new 
ModelTestSuite(documentElement, testCase);
                 if (modelTestSuite.getTestList().size() > 0) {
                     this.modelTestSuiteList.add(modelTestSuite);
+                } else {
+                    this.discardedModelTestSuiteList.add(modelTestSuite);
                 }
             } catch (GenericConfigException e) {
                 String errMsg = "Error reading XML document from 
ResourceHandler for loader [" + testSuiteResource.getLoaderName()
@@ -88,4 +96,17 @@ public class JunitSuiteWrapper {
 
         return allTestList;
     }
+
+    /**
+     * Gets the ModelTestSuites the constructor built but discarded because 
they had no matching
+     * test cases (e.g. a testCaseName/suiteName that matched a {@code 
<test-suite>} element with
+     * zero resulting entries). These are not reachable via {@link 
#getModelTestSuites()} or
+     * {@link #getAllTestList()}, but each one still holds a real 
dispatcher/delegator pair the
+     * constructor created - callers that reject this wrapper outright (e.g. 
"no tests found")
+     * still need this list to avoid leaking those.
+     * @return the discarded model test suites
+     */
+    List<ModelTestSuite> getDiscardedModelTestSuites() {
+        return this.discardedModelTestSuiteList;
+    }
 }
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
index 39ccfc814d..de4c7366a4 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
@@ -19,6 +19,7 @@
 package org.apache.ofbiz.testtools;
 
 import java.lang.reflect.Field;
+import java.lang.reflect.Method;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -37,6 +38,9 @@ import org.junit.jupiter.api.extension.ParameterContext;
 import org.junit.jupiter.api.extension.ParameterResolutionException;
 import org.junit.jupiter.api.extension.ParameterResolver;
 import org.junit.jupiter.api.extension.TestInstancePostProcessor;
+import org.junit.platform.commons.support.HierarchyTraversalMode;
+import org.junit.platform.commons.support.ReflectionSupport;
+import org.junit.platform.engine.DiscoverySelector;
 import org.junit.platform.engine.TestExecutionResult;
 import org.junit.platform.launcher.Launcher;
 import org.junit.platform.launcher.LauncherDiscoveryRequest;
@@ -46,6 +50,7 @@ import 
org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
 import org.junit.platform.launcher.core.LauncherFactory;
 
 import static 
org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static 
org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod;
 
 /**
  * Injects the per-suite Delegator/LocalDispatcher that ModelTestSuite already 
builds for JUnit 3
@@ -136,6 +141,19 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
 
     static final ThreadLocal<Delegator> CURRENT_DELEGATOR = new 
ThreadLocal<>();
     static final ThreadLocal<LocalDispatcher> CURRENT_DISPATCHER = new 
ThreadLocal<>();
+    static final ThreadLocal<Map<String, Object>> CURRENT_TEST_PARAMS = new 
ThreadLocal<>();
+
+    /**
+     * The bare, undecorated method name of the Jupiter {@literal @}Test 
currently executing on this
+     * thread (e.g. "shouldCreateExample") - armed/cleared in 
JupiterClassRunner's
+     * executionStarted()/executionFinished() listener callbacks, the same 
lifecycle already used for
+     * TEST_CASE_MDC_KEY. Lets JupiterTestHelper.getTestParams() look up a 
namespaced per-method
+     * override in CURRENT_TEST_PARAMS without every test method having to 
identify itself. For a
+     * parameterized test, reportingName() returns a decorated name (e.g.
+     * "methodName[exampleTypeId=CONTRIVED]"), so namespacing only cleanly 
targets plain,
+     * non-parameterized @Test methods - see 
JupiterTestHelper.getTestParams()'s javadoc.
+     */
+    static final ThreadLocal<String> CURRENT_TEST_METHOD_NAME = new 
ThreadLocal<>();
 
     /**
      * Disables classes/methods run outside the ofbiz --test container instead 
of letting them reach
@@ -278,18 +296,50 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
         private final Class<?> testClass;
         private final Delegator delegator;
         private final LocalDispatcher dispatcher;
+        private final Map<String, Object> testParams;
         private final List<SuiteReportSink> sinks;
         private final Launcher launcher;
         private final LauncherDiscoveryRequest request;
 
         JupiterClassRunner(Class<?> testClass, Delegator delegator, 
LocalDispatcher dispatcher, SuiteReportSink... sinks) {
+            this(testClass, delegator, dispatcher, Map.of(), null, sinks);
+        }
+
+        JupiterClassRunner(Class<?> testClass, Delegator delegator, 
LocalDispatcher dispatcher,
+                Map<String, Object> testParams, SuiteReportSink... sinks) {
+            this(testClass, delegator, dispatcher, testParams, null, sinks);
+        }
+
+        /**
+         * @param methodName when non-null, scopes discovery to exactly this 
{@literal @}Test/
+         *     {@literal @}ParameterizedTest method ({@code selectMethod}) 
instead of the whole class
+         *     ({@code selectClass}) - the {@code ofbiz --test method=} CLI 
path
+         *     (TestRunContainer.start()) supplies this; every other caller 
passes null and gets
+         *     today's whole-class behavior unchanged. Naming a {@literal 
@}ParameterizedTest method
+         *     here selects every invocation of that method, not one specific 
input row - JUnit
+         *     Platform's selectMethod() has no finer granularity than that.
+         *
+         *     <p><b>Caution:</b> a method run alone this way can behave 
differently than it does as
+         *     part of the whole class - flagUnorderedJupiterTests 
(build.gradle) exists precisely
+         *     because several classes in this codebase were found to have 
methods that implicitly
+         *     depend on declaration order or on a sibling method's side 
effects. A method scoped
+         *     this way may pass alone but fail as part of the full class, or 
the reverse - that is
+         *     not a bug in this parameter, it reflects a pre-existing lack of 
independence between
+         *     methods in the target class.
+         */
+        JupiterClassRunner(Class<?> testClass, Delegator delegator, 
LocalDispatcher dispatcher,
+                Map<String, Object> testParams, String methodName, 
SuiteReportSink... sinks) {
             this.testClass = testClass;
             this.delegator = delegator;
             this.dispatcher = dispatcher;
+            this.testParams = testParams;
             this.sinks = List.of(sinks);
             this.launcher = LauncherFactory.create();
+            DiscoverySelector[] selectors = methodName != null
+                    ? selectMethodByName(testClass, methodName)
+                    : new DiscoverySelector[] {selectClass(testClass)};
             this.request = LauncherDiscoveryRequestBuilder.request()
-                    .selectors(selectClass(testClass))
+                    .selectors(selectors)
                     .configurationParameter(
                             "junit.jupiter.testmethod.order.default",
                             
"org.junit.jupiter.api.MethodOrderer$OrderAnnotation")
@@ -297,6 +347,38 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
                     .build();
         }
 
+        /**
+         * Resolves {@code methodName} against every declared/inherited method 
of that name on
+         * {@code testClass} and builds one {@code selectMethod} selector per 
match, instead of relying
+         * on {@code DiscoverySelectors.selectMethod(Class, String)} alone.
+         *
+         * <p>That two-argument overload only matches a zero-parameter method 
- it delegates to the
+         * three-argument overload with an empty parameter-type list, so it 
can never resolve a
+         * {@literal @}ParameterizedTest method (which always declares at 
least one parameter) or any
+         * {@literal @}Test method taking a JupiterTestExtension-resolved 
Delegator/LocalDispatcher
+         * parameter; both fail with the same "could not find method" 
discovery error a genuine typo
+         * produces, silently misreporting a real method as nonexistent. 
Resolving by reflection first
+         * and selecting by {@code Method} instead of by name alone sidesteps 
that restriction.
+         *
+         * <p>When no method of that name exists at all, falls back to the 
plain by-name selector so
+         * the same clean "could not find method" discovery failure (routed 
through
+         * reportContainerFailure() below as this class's initializationError) 
still fires for a
+         * genuine typo - this method never throws for an unresolved name 
itself.
+         * @param testClass the class methodName is resolved against
+         * @param methodName the requested method name
+         * @return one selector per overload/match found, or a single by-name 
selector if none matched
+         */
+        private static DiscoverySelector[] selectMethodByName(Class<?> 
testClass, String methodName) {
+            List<Method> matches = ReflectionSupport.findMethods(testClass,
+                    method -> method.getName().equals(methodName), 
HierarchyTraversalMode.TOP_DOWN);
+            if (matches.isEmpty()) {
+                return new DiscoverySelector[] {selectMethod(testClass, 
methodName)};
+            }
+            return matches.stream()
+                    .map(method -> (DiscoverySelector) selectMethod(testClass, 
method))
+                    .toArray(DiscoverySelector[]::new);
+        }
+
         /**
          * Runs this class's tests, reporting to every configured sink as 
JUnit 5 execution events
          * arrive. Isolated per class (tightens item 14 of the JUnit5 
improvements catalog): an
@@ -310,6 +392,7 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
         void run() {
             JupiterTestExtension.CURRENT_DELEGATOR.set(delegator);
             JupiterTestExtension.CURRENT_DISPATCHER.set(dispatcher);
+            JupiterTestExtension.CURRENT_TEST_PARAMS.set(testParams);
             Map<String, Long> startTimes = new HashMap<>();
             try {
                 launcher.execute(request, new TestExecutionListener() {
@@ -318,6 +401,7 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
                         if (testIdentifier.isTest()) {
                             startTimes.put(testIdentifier.getUniqueId(), 
System.currentTimeMillis());
                             ThreadContext.put(TEST_CASE_MDC_KEY, 
testClass.getSimpleName() + "#" + reportingName(testIdentifier));
+                            
JupiterTestExtension.CURRENT_TEST_METHOD_NAME.set(reportingName(testIdentifier));
                             ReportingSupport.dispatch(sinks, sink -> 
sink.testStarted(testClass.getName(), reportingName(testIdentifier)));
                         }
                     }
@@ -361,6 +445,7 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
                             ReportingSupport.dispatch(sinks, sink -> 
sink.testFinished(testClass.getName(), name, elapsed, outcome));
                         } finally {
                             ThreadContext.remove(TEST_CASE_MDC_KEY);
+                            
JupiterTestExtension.CURRENT_TEST_METHOD_NAME.remove();
                         }
                     }
                 });
@@ -370,6 +455,8 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
                 ThreadContext.remove(TEST_CASE_MDC_KEY);
                 JupiterTestExtension.CURRENT_DELEGATOR.remove();
                 JupiterTestExtension.CURRENT_DISPATCHER.remove();
+                JupiterTestExtension.CURRENT_TEST_PARAMS.remove();
+                JupiterTestExtension.CURRENT_TEST_METHOD_NAME.remove();
             }
         }
 
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestHelper.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestHelper.java
index 96e23cade3..bcdb7c09e6 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestHelper.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestHelper.java
@@ -18,6 +18,8 @@
  
*******************************************************************************/
 package org.apache.ofbiz.testtools;
 
+import java.util.LinkedHashMap;
+import java.util.Map;
 import java.util.Set;
 
 import org.apache.ofbiz.base.util.Debug;
@@ -85,6 +87,52 @@ public interface JupiterTestHelper {
         return JupiterTestExtension.CURRENT_DISPATCHER.get();
     }
 
+    /**
+     * Returns this run's caller-supplied test parameters, merged for the test 
method currently
+     * executing on this thread - the exact Map a runTestSuite REST/service 
call's testParams
+     * argument carried in, or an empty map for a plain gradlew 
test/testIntegration run (which
+     * never supplies one) or an API-triggered run with no overrides. Never 
null, so every call site
+     * can write testParams.someKey ?: someDefault (Groovy) without a separate 
null check on the map
+     * itself. Each test decides its own per-field defaulting this way, in the 
test method itself,
+     * rather than through a second lookup layer - see JupiterTestExtension's 
CURRENT_TEST_PARAMS
+     * javadoc for why an intermediate properties-file fallback tier was tried 
and dropped. Named to
+     * match the wire-level runTestSuite service attribute (testParams), not 
testParameters - one
+     * name for the concept everywhere.
+     *
+     * <p>The caller's testParams map can carry both flat/common keys and 
nested per-test-method
+     * override objects, keyed by the exact test method name (e.g.
+     * {@code {"exampleTypeId": "CONTRIVED", "shouldUpdateExample": 
{"exampleTypeId": "INSPIRED"}}}).
+     * This method resolves that down to one flat map for the 
currently-running method: start from
+     * every top-level entry whose value is not itself a Map (nested objects 
are namespace
+     * containers, never a real field value in their own right, so none of 
them - not just the
+     * current method's own - belong in this common base); if the raw map has 
an entry for the
+     * current method name whose value is a Map, merge it on top (its keys win 
on conflict). A
+     * missing or malformed (non-Map-valued) namespaced entry simply falls 
back to the common base.
+     * For a parameterized test method, the current-method name is decorated 
(e.g.
+     * "methodName[exampleTypeId=CONTRIVED]") and so will never match a plain 
method-name key -
+     * namespacing only cleanly targets plain, non-parameterized @Test methods.
+     * @return the current run's merged test parameters, never null
+     */
+    default Map<String, Object> getTestParams() {
+        Map<String, Object> params = 
JupiterTestExtension.CURRENT_TEST_PARAMS.get();
+        if (params == null) {
+            return Map.of();
+        }
+        Map<String, Object> commonBase = new LinkedHashMap<>();
+        for (Map.Entry<String, Object> entry : params.entrySet()) {
+            if (!(entry.getValue() instanceof Map)) {
+                commonBase.put(entry.getKey(), entry.getValue());
+            }
+        }
+        String currentMethodName = 
JupiterTestExtension.CURRENT_TEST_METHOD_NAME.get();
+        if (currentMethodName != null && params.get(currentMethodName) 
instanceof Map<?, ?> override) {
+            for (Map.Entry<?, ?> entry : override.entrySet()) {
+                commonBase.put((String) entry.getKey(), entry.getValue());
+            }
+        }
+        return commonBase;
+    }
+
     /**
      * Gets user login.
      * @param userLoginId the user login id
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
index 055daf4af0..fe31b4d0f5 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
@@ -48,6 +48,7 @@ public class TestRunContainer implements Container {
 
     private String name;
     private JunitSuiteWrapper jsWrapper;
+    private String methodName;
 
     @Override
     public void init(List<StartupCommand> ofbizCommands, String name, String 
configFile) throws ContainerException {
@@ -63,22 +64,43 @@ public class TestRunContainer implements Container {
         // set selected log level if passed by user
         setLoggerLevel(testProps.get("loglevel"));
 
+        this.methodName = normalizeMethodName(testProps.get("method"));
+        validateMethodRequiresCase(this.methodName, testProps.get("case"));
+
         this.jsWrapper = prepareJunitSuiteWrapper(testProps);
     }
 
     @Override
     public boolean start() throws ContainerException {
+        // Validated for every resolved suite up front, before any of them run 
- case= given without
+        // suitename= can resolve to suites in more than one testdef document; 
without this pre-pass,
+        // a validation failure on the second (or later) suite would only 
surface after the first has
+        // already executed and written its report, undermining the "method= 
without a valid target
+        // fails before anything runs" guarantee for that (rare, but real) 
multi-suite case. Guarded on
+        // methodName != null so a plain gradlew test/testIntegration run (no 
method= at all) doesn't
+        // pay for this pass or risk it changing behavior - 
getPreparedTestList() is idempotent to call
+        // again in the loop below, but a prepare-time throw from it would 
otherwise now abort the
+        // whole run before any suite executes, instead of after however many 
already had, which is a
+        // real (if narrow) behavior change for a run that never touched 
method= in the first place.
+        if (methodName != null) {
+            for (ModelTestSuite modelSuite: jsWrapper.getModelTestSuites()) {
+                validateMethodAppliesToSuite(methodName, 
modelSuite.getSuiteName(), modelSuite.getPreparedTestList());
+            }
+        }
+
         boolean failedRun = false;
         for (ModelTestSuite modelSuite: jsWrapper.getModelTestSuites()) {
             String suiteName = modelSuite.getSuiteName();
+            List<SuiteEntry> preparedTestList = 
modelSuite.getPreparedTestList();
+
             SuiteXmlReportWriter xmlSink = createXmlReportWriter(suiteName);
             SuiteReportLogger logSink = new SuiteReportLogger();
             xmlSink.startSuite(suiteName);
             logSink.startSuite(suiteName);
 
             try {
-                runSuiteEntries(modelSuite.getPreparedTestList(), 
modelSuite.getDelegator(),
-                        modelSuite.getDispatcher(), xmlSink, logSink);
+                runSuiteEntries(preparedTestList, modelSuite.getDelegator(),
+                        modelSuite.getDispatcher(), Map.of(), methodName, 
xmlSink, logSink);
             } catch (Throwable t) {
                 // Everything inside runSuiteEntries() is per-entry isolated 
already: a JUnit 3 test's
                 // own exception is always caught by 
TestCase.runBare()/TestResult's own
@@ -111,6 +133,21 @@ public class TestRunContainer implements Container {
         return name;
     }
 
+    static void runSuiteEntries(List<SuiteEntry> entries, Delegator delegator, 
LocalDispatcher dispatcher,
+            SuiteReportSink... sinks) {
+        runSuiteEntries(entries, delegator, dispatcher, Map.of(), sinks);
+    }
+
+    /**
+     * @param testParams caller-supplied parameter overrides for Jupiter 
entries (empty for a plain
+     *     {@code gradlew test}/{@code testIntegration} run - see 
TestRunServices for the API-triggered path)
+     * @param sinks where to report results
+     */
+    static void runSuiteEntries(List<SuiteEntry> entries, Delegator delegator, 
LocalDispatcher dispatcher,
+            Map<String, Object> testParams, SuiteReportSink... sinks) {
+        runSuiteEntries(entries, delegator, dispatcher, testParams, null, 
sinks);
+    }
+
     /**
      * Runs one suite's ordered SuiteEntry list, JUnit 3 entries through 
junit.framework.TestResult
      * (translated via Junit3ResultBridge) and Jupiter entries through 
JupiterTestExtension.JupiterClassRunner
@@ -122,10 +159,18 @@ public class TestRunContainer implements Container {
      * @param entries the suite's prepared, ordered test entries
      * @param delegator the suite's Delegator, shared by every entry
      * @param dispatcher the suite's LocalDispatcher, shared by every entry
+     * @param testParams caller-supplied parameter overrides for Jupiter 
entries (empty for a plain
+     *     {@code gradlew test}/{@code testIntegration} run - see 
TestRunServices for the API-triggered path)
+     * @param methodName when non-null, scopes every JupiterEntry in this call 
to exactly this
+     *     {@literal @}Test/{@literal @}ParameterizedTest method instead of 
running the whole class -
+     *     supplied by the {@code ofbiz --test method=} CLI path (see start() 
below) and by
+     *     TestRunServices' {@code testMethodName}-scoped API-triggered path; 
null for a plain
+     *     {@code gradlew test}/{@code testIntegration} run and for an 
API-triggered run that omits
+     *     testMethodName, both of which run whole classes
      * @param sinks where to report results
      */
     static void runSuiteEntries(List<SuiteEntry> entries, Delegator delegator, 
LocalDispatcher dispatcher,
-            SuiteReportSink... sinks) {
+            Map<String, Object> testParams, String methodName, 
SuiteReportSink... sinks) {
         TestResult junit3Result = new TestResult();
         junit3Result.addListener(new Junit3ResultBridge(sinks));
         for (SuiteEntry entry : entries) {
@@ -133,7 +178,8 @@ public class TestRunContainer implements Container {
                 if (entry instanceof Junit3Entry junit3Entry) {
                     junit3Entry.test().run(junit3Result);
                 } else if (entry instanceof JupiterEntry jupiterEntry) {
-                    new 
JupiterTestExtension.JupiterClassRunner(jupiterEntry.testClass(), delegator, 
dispatcher, sinks).run();
+                    new JupiterTestExtension.JupiterClassRunner(
+                            jupiterEntry.testClass(), delegator, dispatcher, 
testParams, methodName, sinks).run();
                 } else {
                     // SuiteEntry is sealed permits Junit3Entry, JupiterEntry, 
so this is unreachable today -
                     // but Java 17 doesn't support exhaustive switch over 
sealed types without preview
@@ -199,6 +245,70 @@ public class TestRunContainer implements Container {
         return jsWrapper;
     }
 
+    /**
+     * Normalizes a blank {@code --test method=} value (e.g. the 
trailing-{@code =} shape
+     * {@code --test method=} produces) to null, so it's indistinguishable 
from method= not having
+     * been given at all - a blank string would otherwise pass both 
validateMethodRequiresCase() and
+     * validateMethodAppliesToSuite() (neither checks for blank, only null) 
and then fail deep inside
+     * JUnit Platform's own precondition check as an opaque 
suiteExecutionError instead of this
+     * feature's own clean ContainerException.
+     *
+     * <p>Package-private and static so TestRunContainerTest can exercise it 
directly without a full
+     * ofbiz --test container bootstrap.
+     * @param rawMethodName the raw --test method= value from the command 
line, or null if not given
+     * @return rawMethodName unchanged if non-null and non-blank, otherwise 
null
+     */
+    static String normalizeMethodName(String rawMethodName) {
+        return (rawMethodName == null || rawMethodName.isBlank()) ? null : 
rawMethodName;
+    }
+
+    /**
+     * Validates that {@code --test method=} was not given without {@code 
--test case=} - method=
+     * scopes a single case's resolved class down to one @Test method, so it's 
meaningless without
+     * case= to say which class that is.
+     *
+     * <p>Package-private and static so TestRunContainerTest can exercise it 
directly without a full
+     * ofbiz --test container bootstrap.
+     * @param methodName the --test method= value, or null if not given
+     * @param caseName the --test case= value, or null if not given
+     * @throws ContainerException if methodName is non-null and caseName is 
null
+     */
+    static void validateMethodRequiresCase(String methodName, String caseName) 
throws ContainerException {
+        if (methodName != null && caseName == null) {
+            throw new ContainerException("--test method=" + methodName + " 
requires --test case=<case-name> to "
+                    + "also be specified - method= scopes a single case's 
class down to one @Test method, so "
+                    + "case= is needed to identify which class that is.");
+        }
+    }
+
+    /**
+     * Validates that {@code --test method=} (when given) has something to 
apply to - a resolved
+     * suite with no JupiterEntry at all (a service-test/entity-xml/JUnit 3 
case) means case= named
+     * something method= can never apply to. The data-load prerequisite
+     * ModelTestSuite.selectTestCaseElements() may have auto-included is 
always a Junit3Entry, so its
+     * presence alone never satisfies this check.
+     *
+     * <p>Package-private and static so TestRunContainerTest can exercise it 
directly without a full
+     * ofbiz --test container bootstrap.
+     * @param methodName the --test method= value, or null if not given
+     * @param suiteName the resolved suite's name, used only for the exception 
message
+     * @param preparedTestList the resolved suite's prepared entries
+     * @throws ContainerException if methodName is non-null and no entry in 
preparedTestList is a JupiterEntry
+     */
+    // This checks "at least one JupiterEntry", not "exactly one": every 
testdef file in this repo
+    // resolves case= to at most one jupiter-test-suite entry today, but 
test-suite.xsd's test-group
+    // element technically allows more than one jupiter-test-suite child - if 
a future testdef file
+    // used that shape, method= would be applied to every one of them via 
runSuiteEntries(), silently
+    // failing whichever one doesn't happen to declare the named method.
+    static void validateMethodAppliesToSuite(String methodName, String 
suiteName, List<SuiteEntry> preparedTestList)
+            throws ContainerException {
+        if (methodName != null && 
preparedTestList.stream().noneMatch(JupiterEntry.class::isInstance)) {
+            throw new ContainerException("--test method=" + methodName + " was 
given, but the resolved case= "
+                    + "did not include a jupiter-test-suite entry in suite '" 
+ suiteName + "' - method= only "
+                    + "applies to Jupiter (JUnit 5) test classes.");
+        }
+    }
+
     private static SuiteXmlReportWriter createXmlReportWriter(String 
suiteName) throws ContainerException {
         try {
             return new SuiteXmlReportWriter(new FileOutputStream(LOG_DIR + 
suiteName + ".xml"));
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunRecord.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunRecord.java
new file mode 100644
index 0000000000..983700e0ef
--- /dev/null
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunRecord.java
@@ -0,0 +1,125 @@
+/*******************************************************************************
+ * 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;
+
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Immutable snapshot of one runTestSuite-triggered run's state, held by 
{@link TestRunTracker}.
+ * Each transition method returns a new instance rather than mutating this 
one, so a
+ * ConcurrentHashMap.put() of the new instance is always a safe, atomic state 
change - no
+ * synchronized block needed on the record itself.
+ */
+final class TestRunRecord {
+
+    enum Status { QUEUED, RUNNING, PASSED, FAILED, ERROR }
+
+    private final String runId;
+    private final String suiteName;
+    private final String componentName;
+    private final Status status;
+    private final Instant startedAt;
+    private final Instant completedAt;
+    private final String triggeredBy;
+    private final Map<String, Object> paramsUsed;
+    private final Map<String, Object> resultSummary;
+    private final String errorMessage;
+
+    private TestRunRecord(String runId, String suiteName, String 
componentName, Status status, Instant startedAt,
+            Instant completedAt, String triggeredBy, Map<String, Object> 
paramsUsed, Map<String, Object> resultSummary,
+            String errorMessage) {
+        this.runId = runId;
+        this.suiteName = suiteName;
+        this.componentName = componentName;
+        this.status = status;
+        this.startedAt = startedAt;
+        this.completedAt = completedAt;
+        this.triggeredBy = triggeredBy;
+        this.paramsUsed = paramsUsed;
+        this.resultSummary = resultSummary;
+        this.errorMessage = errorMessage;
+    }
+
+    static TestRunRecord queued(String runId, String suiteName, String 
componentName, String triggeredBy,
+            Map<String, Object> paramsUsed) {
+        return new TestRunRecord(runId, suiteName, componentName, 
Status.QUEUED, Instant.now(), null, triggeredBy,
+                paramsUsed, null, null);
+    }
+
+    TestRunRecord running() {
+        return new TestRunRecord(runId, suiteName, componentName, 
Status.RUNNING, startedAt, null, triggeredBy,
+                paramsUsed, null, null);
+    }
+
+    TestRunRecord passed(Map<String, Object> resultSummary) {
+        return new TestRunRecord(runId, suiteName, componentName, 
Status.PASSED, startedAt, Instant.now(), triggeredBy,
+                paramsUsed, resultSummary, null);
+    }
+
+    TestRunRecord failed(Map<String, Object> resultSummary) {
+        return new TestRunRecord(runId, suiteName, componentName, 
Status.FAILED, startedAt, Instant.now(), triggeredBy,
+                paramsUsed, resultSummary, null);
+    }
+
+    TestRunRecord error(Throwable throwable) {
+        return new TestRunRecord(runId, suiteName, componentName, 
Status.ERROR, startedAt, Instant.now(), triggeredBy,
+                paramsUsed, null, throwable.getMessage());
+    }
+
+    String runId() {
+        return runId;
+    }
+
+    String suiteName() {
+        return suiteName;
+    }
+
+    String componentName() {
+        return componentName;
+    }
+
+    Status status() {
+        return status;
+    }
+
+    Instant startedAt() {
+        return startedAt;
+    }
+
+    Instant completedAt() {
+        return completedAt;
+    }
+
+    String triggeredBy() {
+        return triggeredBy;
+    }
+
+    Map<String, Object> paramsUsed() {
+        return paramsUsed;
+    }
+
+    Map<String, Object> resultSummary() {
+        return resultSummary;
+    }
+
+    String errorMessage() {
+        return errorMessage;
+    }
+}
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
new file mode 100644
index 0000000000..a3f6ffc8af
--- /dev/null
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunServices.java
@@ -0,0 +1,519 @@
+/*******************************************************************************
+ * 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;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import org.apache.ofbiz.base.container.ContainerException;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilGenerics;
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityUtilProperties;
+import org.apache.ofbiz.service.DispatchContext;
+import org.apache.ofbiz.service.ServiceContainer;
+import org.apache.ofbiz.service.ServiceUtil;
+import org.apache.ofbiz.testtools.report.TestReportArchiver;
+import org.apache.ofbiz.testtools.report.TestRunManifest;
+
+/**
+ * REST-triggered test execution: runTestSuite kicks off a testdef {@code 
<test-suite>} (optionally
+ * one {@code case-name} within it) asynchronously and returns a runId; 
getTestRunStatus polls it.
+ * Reuses the exact same in-JVM engine {@code ofbiz --test} uses - 
JunitSuiteWrapper/ModelTestSuite/
+ * TestRunContainer.runSuiteEntries() - unchanged; the only new execution-side 
behavior is arming
+ * JupiterTestExtension.CURRENT_TEST_PARAMS with the caller's testParams map 
(see
+ * TestRunContainer's new runSuiteEntries() overload).
+ *
+ * <p>runTestSuite also accepts an optional {@code testMethodName}, reusing 
the exact same
+ * fail-closed validators and method-scoped selection the {@code ofbiz --test 
method=} CLI path
+ * built (see {@link TestRunContainer#validateMethodRequiresCase},
+ * {@link TestRunContainer#validateMethodAppliesToSuite}) - both run 
synchronously inside
+ * runTestSuite itself, before a run is ever queued, so a bad 
testMethodName/testCaseName
+ * combination never receives a runId.
+ *
+ * <p>Runs execute one at a time on a dedicated single-threaded executor: a 
second runTestSuite
+ * call while one is in progress queues behind it rather than running 
concurrently.
+ *
+ * <p><b>Known POC limitation - dispatcher/delegator resource leak (one piece 
mitigated; the rest is
+ * not).</b> {@code new JunitSuiteWrapper(...)} reuses {@code 
ModelTestSuite}'s constructor, which
+ * unconditionally creates a fresh test {@code Delegator}/{@code 
LocalDispatcher} via
+ * {@code ServiceContainer.getLocalDispatcher(...)} - the exact same 
construction path the
+ * {@code ofbiz --test} CLI already uses once per process. For the CLI this is 
harmless: the JVM
+ * exits right after. Here, every API-triggered {@code runTestSuite} call goes 
through that same
+ * path inside a long-lived server process, so {@code executeRun} now removes 
each suite's
+ * dispatcher entry from {@code ServiceContainer}'s static dispatcher cache
+ * ({@code ServiceContainer.removeFromCache(name)}) once that suite is done, 
regardless of outcome -
+ * including suites {@code JunitSuiteWrapper} itself discarded for having no 
matching tests (see
+ * {@code JunitSuiteWrapper#getDiscardedModelTestSuites}), which {@code 
runTestSuite} also cleans up
+ * before returning its "no tests found" error. <b>Deliberately {@code 
removeFromCache}, not
+ * {@code ServiceContainer.deregister}</b>: {@code deregister} calls {@code 
LocalDispatcher.deregister()},
+ * which shuts the backing {@code ServiceDispatcher} down as soon as its 
{@code localContext} empties
+ * out - always true immediately for a dispatcher used by exactly one test run 
- and that shutdown
+ * closes JMS listeners through a process-wide singleton ({@code 
JmsListenerFactory}) shared by every
+ * dispatcher in the JVM, including the live server's real one. See
+ * {@code deregisterTestDispatcher}'s own javadoc for the full chain.
+ *
+ * <p>What this fix does <b>not</b> touch, at all: the heavier {@code 
ServiceDispatcher} instance
+ * backing each run - its {@code Security}, {@code JobManager} reference, 
now-empty
+ * {@code localContext} - stays in {@code ServiceDispatcher}'s own separate 
static
+ * {@code dispatchers} cache indefinitely, and so does the test {@code 
Delegator} itself, since
+ * {@code ServiceDispatcher} pins its delegator reference for as long as that 
instance remains
+ * cached. No public API anywhere in the framework removes an entry from that 
map, and adding one
+ * would mean modifying {@code ServiceDispatcher} itself, which is out of 
scope for this fix.
+ * Startup services also still re-run on every single {@code runTestSuite} 
call regardless of this
+ * fix: {@code ModelTestSuite}'s constructor gives each run's delegator a 
unique name, so
+ * {@code ServiceDispatcher.getInstance(delegator)} always misses that cache 
and initializes a new
+ * {@code ServiceDispatcher} - removing the previous run's dispatcher cache 
entry does not change
+ * that, since the next run's key never collided with it in the first place.
+ *
+ * <p><b>Runs execute against the live server's database.</b> An API-triggered 
run is not an isolated
+ * sandbox: {@code modelSuite.getDelegator().rollback()} is called after each 
suite as a best-effort
+ * cleanup, but there is no transactional isolation from the rest of the 
running server, and any
+ * side effect a test performs outside that delegator's own transaction (e.g. 
a service that commits
+ * independently) is not undone. Anyone deciding whether to enable {@code 
test.api.enabled} (see
+ * testtools.properties) should weigh both of the limitations above.
+ *
+ * <p><b>Do not expose {@link #runTestSuite}/{@link #getTestRunStatus} 
directly in a component's own
+ * {@code *.rest.xml}.</b> Both accept/report an arbitrary {@code 
componentName} and so can trigger or
+ * poll any component's tests, not just the exposing component's own - a 
component-branded REST
+ * endpoint must instead wrap {@link #runScopedTestSuite}/{@link 
#getScopedTestRunStatus} with its own
+ * fixed component name, the way {@code plugins/example}'s {@code 
ExampleTestRunServices} does. Writing
+ * {@code <service name="runTestSuite"/>} straight into a {@code *.rest.xml} 
reproduces the exact
+ * cross-component-reach problem the scoped wrappers exist to close.
+ */
+public final class TestRunServices {
+
+    private static final String MODULE = TestRunServices.class.getName();
+    private static final String RESOURCE = "testtools";
+    private static final String TESTEXEC_PERMISSION = "TESTEXEC_ADMIN";
+
+    static final TestRunTracker TRACKER = new TestRunTracker();
+    private static final ExecutorService EXECUTOR = 
Executors.newSingleThreadExecutor(runnable -> {
+        Thread thread = new Thread(runnable, "TestRunServices-worker");
+        thread.setDaemon(true);
+        return thread;
+    });
+
+    private TestRunServices() {
+    }
+
+    public static Map<String, Object> runTestSuite(DispatchContext dctx, 
Map<String, ?> context) {
+        GenericValue userLogin = (GenericValue) context.get("userLogin");
+        String userLoginId = userLogin == null ? "unknown" : 
userLogin.getString("userLoginId");
+        String suiteName = (String) context.get("suiteName");
+        String componentName = (String) context.get("componentName");
+        String testCaseName = (String) context.get("testCaseName");
+        String testMethodName = TestRunContainer.normalizeMethodName((String) 
context.get("testMethodName"));
+        Map<String, Object> testParams = 
UtilGenerics.cast(context.get("testParams"));
+        if (testParams == null) {
+            testParams = Map.of();
+        }
+        // Normalize to an immutable, null-tolerant copy from here on: this 
prevents any mutation
+        // by downstream code (executor/test/archiver) from corrupting the 
caller's map or causing
+        // NPE inside Map.copyOf(...) if a test param value is null. 
LinkedHashMap+unmodifiableMap
+        // tolerates null values, unlike Map.copyOf.
+        testParams = Collections.unmodifiableMap(new 
LinkedHashMap<>(testParams));
+
+        if (!dctx.getSecurity().hasPermission(TESTEXEC_PERMISSION, userLogin)) 
{
+            Debug.logWarning("runTestSuite: DENIED for user '" + userLoginId + 
"', suite '" + suiteName + "'"
+                    + " - missing " + TESTEXEC_PERMISSION, MODULE);
+            return ServiceUtil.returnError("You do not have permission to 
trigger test runs (" + TESTEXEC_PERMISSION + ")");
+        }
+
+        boolean apiEnabled = 
"true".equalsIgnoreCase(readStringProperty(dctx.getDelegator(), 
"test.api.enabled", "false"));
+        if (!apiEnabled) {
+            Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
+                    + " - test.api.enabled is false", MODULE);
+            return ServiceUtil.returnError("The test execution API is disabled 
in this environment (test.api.enabled=false)");
+        }
+
+        // testMethodName reuses the exact same fail-closed validators the 
ofbiz --test method=
+        // CLI path already built (TestRunContainer, same package - see its 
javadoc for the full
+        // rationale). This first check needs only the two raw strings, not 
any resolved suite, so
+        // it runs before JunitSuiteWrapper is even constructed: it fails 
faster (no wasted
+        // dispatcher/delegator construction for a malformed request) and 
mirrors how the CLI's own
+        // init() validates before start() resolves anything. The returned 
error text is REST's own
+        // wording (testMethodName/testCaseName), not e.getMessage()'s CLI 
phrasing (--test
+        // method=/case=) - a REST caller never typed --test, so that 
vocabulary would be confusing
+        // on this surface. The log line keeps e.getMessage() verbatim for 
CLI-consistent debugging.
+        try {
+            TestRunContainer.validateMethodRequiresCase(testMethodName, 
testCaseName);
+        } catch (ContainerException e) {
+            Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
+                    + " - " + e.getMessage(), MODULE);
+            return ServiceUtil.returnError("testMethodName requires 
testCaseName to also be specified - "
+                    + "testMethodName scopes a single case's class down to one 
@Test method, so testCaseName "
+                    + "is needed to identify which class that is.");
+        }
+
+        // Initialized to null (not left blank): the catch (ContainerException 
e) block below reads
+        // wrapper, and per Java's definite-assignment rules a catch block 
never sees an assignment
+        // made inside its own try body - regardless of exception type or 
where in the block that
+        // assignment sits - unless the variable was already assigned before 
the try started. The
+        // pre-existing catch (Exception e) below compiled fine without this 
only because it never
+        // dereferences wrapper.
+        JunitSuiteWrapper wrapper = null;
+        try {
+            wrapper = new JunitSuiteWrapper(componentName, suiteName, 
testCaseName);
+            if (wrapper.getAllTestList().isEmpty()) {
+                // The wrapper's constructor still creates a 
dispatcher/delegator pair for every
+                // <test-suite> element it discarded along the way (see
+                // JunitSuiteWrapper#getDiscardedModelTestSuites) - unlike the 
per-suite loop in
+                // executeRun(), nothing else will ever reach these, so 
deregister them here before
+                // returning the error, or every "no tests found" request 
leaks one dispatcher.
+                for (ModelTestSuite discarded : 
wrapper.getDiscardedModelTestSuites()) {
+                    deregisterTestDispatcher(discarded);
+                }
+                return ServiceUtil.returnError("No tests found (component=" + 
componentName + ", suiteName=" + suiteName
+                        + ", testCaseName=" + testCaseName + ")");
+            }
+            // Second half of the method= validation: this one needs each 
resolved suite's actual
+            // entries, so it can only run once the wrapper above has resolved 
them. Still entirely
+            // synchronous, before EXECUTOR.submit below - a bad 
testMethodName/testCaseName
+            // combination must never reach a queued run, matching the CLI's 
"fails before anything
+            // runs" guarantee. Guarded on testMethodName != null, mirroring 
the identical guard on
+            // TestRunContainer.start()'s own pre-loop pass (CLI side): 
without it, every
+            // testMethodName-less request - the overwhelming majority of 
callers - would still pay
+            // for this pass, and a prepare-time throw from 
getPreparedTestList() would newly abort
+            // the request before any run is queued instead of leaving today's 
behavior unchanged.
+            if (testMethodName != null) {
+                for (ModelTestSuite modelSuite : wrapper.getModelTestSuites()) 
{
+                    
TestRunContainer.validateMethodAppliesToSuite(testMethodName, 
modelSuite.getSuiteName(),
+                            modelSuite.getPreparedTestList());
+                }
+            }
+        } catch (ContainerException e) {
+            // Only validateMethodAppliesToSuite (above) can reach this - 
JunitSuiteWrapper's own
+            // constructor declares no checked exception, so wrapper is always 
assigned by the time
+            // this catch block can run. Both getModelTestSuites() (real 
tests, just not the
+            // requested method) and getDiscardedModelTestSuites() (zero-entry 
suites the wrapper's
+            // constructor still built a dispatcher for) need cleanup here, 
the same as the "no tests
+            // found" branch above gives its own discarded suites.
+            for (ModelTestSuite modelSuite : wrapper.getModelTestSuites()) {
+                deregisterTestDispatcher(modelSuite);
+            }
+            for (ModelTestSuite discarded : 
wrapper.getDiscardedModelTestSuites()) {
+                deregisterTestDispatcher(discarded);
+            }
+            Debug.logWarning("runTestSuite: rejected for user '" + userLoginId 
+ "', suite '" + suiteName + "'"
+                    + " - " + e.getMessage(), MODULE);
+            return ServiceUtil.returnError("testMethodName was given, but the 
resolved testCaseName did not "
+                    + "include a jupiter-test-suite entry in suite '" + 
suiteName + "' - testMethodName only "
+                    + "applies to Jupiter (JUnit 5) test classes.");
+        } catch (Exception e) {
+            // wrapper may or may not be assigned here (a throw from "new 
JunitSuiteWrapper(...)"
+            // itself leaves it null; nothing else in this try block is a 
realistic throw source
+            // today, but the null-check costs nothing and keeps this branch 
correct regardless).
+            if (wrapper != null) {
+                for (ModelTestSuite modelSuite : wrapper.getModelTestSuites()) 
{
+                    deregisterTestDispatcher(modelSuite);
+                }
+                for (ModelTestSuite discarded : 
wrapper.getDiscardedModelTestSuites()) {
+                    deregisterTestDispatcher(discarded);
+                }
+            }
+            Debug.logError(e, "runTestSuite: failed to resolve suite '" + 
suiteName + "' (component=" + componentName
+                    + ", testCaseName=" + testCaseName + ")", MODULE);
+            return ServiceUtil.returnError("Unable to resolve the requested 
test suite (component=" + componentName
+                    + ", suiteName=" + suiteName + ", testCaseName=" + 
testCaseName + "): " + e.getMessage());
+        }
+
+        String runId = UUID.randomUUID().toString();
+        Map<String, Object> finalTestParams = testParams;
+        // wrapper is only ever assigned once for real (the "= null" 
initializer above exists
+        // solely so the catch (ContainerException e) block above compiles - 
see its comment); this
+        // second, single-assignment copy is what the EXECUTOR.submit lambda 
below actually
+        // captures, the same effectively-final-copy pattern finalTestParams 
uses above.
+        JunitSuiteWrapper finalWrapper = wrapper;
+        TRACKER.register(runId, suiteName, componentName, userLoginId, 
testParams);
+        Debug.logInfo("runTestSuite: STARTED runId=" + runId + " user='" + 
userLoginId + "' suite='" + suiteName
+                + "' testCaseName='" + testCaseName + "' testMethodName='" + 
testMethodName + "' testParams=" + testParams,
+                MODULE);
+
+        EXECUTOR.submit(() -> executeRun(runId, suiteName, finalTestParams, 
testMethodName, finalWrapper));
+
+        Map<String, Object> result = ServiceUtil.returnSuccess();
+        result.put("runId", runId);
+        return result;
+    }
+
+    /**
+     * Runs {@link #runTestSuite} with {@code componentName} forced to {@code 
fixedComponentName},
+     * regardless of whatever value (if any) the caller's own context map 
contains - a caller-supplied
+     * componentName is silently overwritten, never honored. Built for 
component-scoped wrapper
+     * services (e.g. plugins/example's runExampleTestSuite): builds a new 
context map rather than
+     * mutating the one it's given, the same defensive-copy discipline 
runTestSuite itself already
+     * applies to testParams - a caller-controlled map must never be assumed 
safe to mutate in place.
+     * @param dctx the dispatch context
+     * @param context the caller's service context - not mutated
+     * @param fixedComponentName the only component this call is allowed to 
resolve suites from
+     * @return the runTestSuite result
+     */
+    public static Map<String, Object> runScopedTestSuite(DispatchContext dctx, 
Map<String, ?> context,
+            String fixedComponentName) {
+        // Fail closed, not open: an empty/null fixedComponentName must never 
reach runTestSuite's
+        // context map. ComponentConfig.matchingComponentName treats a null 
cname as "match every
+        // component" - if this guard were skipped, the entire scoping 
mechanism runScopedTestSuite
+        // exists for would silently degrade to fully unscoped behavior 
instead of erroring out.
+        if (UtilValidate.isEmpty(fixedComponentName)) {
+            return ServiceUtil.returnError("runScopedTestSuite requires a 
fixed componentName");
+        }
+        Map<String, Object> scopedContext = new HashMap<>(context);
+        scopedContext.put("componentName", fixedComponentName);
+        return runTestSuite(dctx, scopedContext);
+    }
+
+    /**
+     * Runs every ModelTestSuite the wrapper resolved (normally exactly one - 
see
+     * JunitSuiteWrapper's suite-name filtering), reporting through a per-run 
SuiteXmlReportWriter
+     * so JUnitXmlCounter/TestReportArchiver see only this run's results, then 
updates the tracker
+     * and - when test.history is enabled - archives into the same 
manifest.json history
+     * gradlew test/testIntegration already write to, tagged trigger="api".
+     *
+     * @param testMethodName when non-null (already validated against every 
suite by
+     *     runTestSuite() before this was ever queued), scopes each suite's 
Jupiter entry to this
+     *     one method - see TestRunContainer's 6-arg runSuiteEntries() overload
+     */
+    private static void executeRun(String runId, String suiteName, Map<String, 
Object> testParams, String testMethodName,
+            JunitSuiteWrapper wrapper) {
+        TRACKER.markRunning(runId);
+        String ofbizHome = System.getProperty("ofbiz.home", ".");
+        File runDir = new File(ofbizHome, 
"runtime/logs/test-results/api-runs/" + runId);
+        runDir.mkdirs();
+
+        try {
+            boolean allPassed = true;
+            List<ModelTestSuite> modelTestSuites = 
wrapper.getModelTestSuites();
+            // Count of suites the loop below has entered (i.e. reached its 
own try/finally, which
+            // deregisters that suite's dispatcher on any outcome). Used by 
the outer finally to
+            // deregister whichever suites the loop never got to reach at all, 
in case some suite's
+            // exception escaped its own try/finally and aborted the loop 
early.
+            int startedCount = 0;
+            try {
+                for (ModelTestSuite modelSuite : modelTestSuites) {
+                    startedCount++;
+                    SuiteXmlReportWriter xmlSink = null;
+                    try {
+                        // FileOutputStream/startSuite are inside this try 
(not before it) so that a
+                        // failure creating/starting the report for this suite 
still reaches the
+                        // finally below and deregisters this suite's 
dispatcher, instead of leaking it
+                        // and aborting the loop with every later suite's 
dispatcher still registered too.
+                        File xmlFile = new File(runDir, 
modelSuite.getSuiteName() + ".xml");
+                        xmlSink = new SuiteXmlReportWriter(new 
FileOutputStream(xmlFile));
+                        xmlSink.startSuite(modelSuite.getSuiteName());
+                        
TestRunContainer.runSuiteEntries(modelSuite.getPreparedTestList(), 
modelSuite.getDelegator(),
+                                modelSuite.getDispatcher(), testParams, 
testMethodName, xmlSink);
+                        modelSuite.getDelegator().rollback();
+                    } finally {
+                        // endSuite() is the only place that actually 
flushes/writes/closes the underlying
+                        // FileOutputStream (see 
SuiteXmlReportWriter#writeAndClose) - without this finally,
+                        // an exception from runSuiteEntries()/rollback() 
would leak the stream (a real
+                        // file-descriptor leak in this long-lived server) and 
leave a 0-byte XML on disk.
+                        if (xmlSink != null) {
+                            xmlSink.endSuite();
+                        }
+                        // Best-effort dispatcher cleanup - see this class's 
javadoc for exactly what this
+                        // does and does not remove. Runs regardless of the 
suite's outcome, same as
+                        // endSuite() above.
+                        deregisterTestDispatcher(modelSuite);
+                    }
+                    allPassed = allPassed && xmlSink.wasSuccessful();
+                }
+            } finally {
+                // If an exception unwound out of the loop above (e.g. 
FileOutputStream/startSuite
+                // failing on a suite past the first), every suite at or after 
startedCount never got
+                // a chance to run its own finally. Deregister those here so 
one bad suite doesn't
+                // leave every suite queued after it permanently registered.
+                for (int i = startedCount; i < modelTestSuites.size(); i++) {
+                    deregisterTestDispatcher(modelTestSuites.get(i));
+                }
+            }
+
+            Map<String, Object> resultSummary = archiveIfEnabled(runId, 
suiteName, testParams, runDir, allPassed);
+            if (allPassed) {
+                TRACKER.markPassed(runId, resultSummary);
+            } else {
+                TRACKER.markFailed(runId, resultSummary);
+            }
+            Debug.logInfo("runTestSuite: " + (allPassed ? "PASSED" : "FAILED") 
+ " runId=" + runId, MODULE);
+        } catch (Throwable t) {
+            // Catches Throwable, not just Exception - matching 
TestRunContainer.start()'s own
+            // last-resort net around runSuiteEntries() - so a test class's 
Error (NoClassDefFoundError,
+            // StackOverflowError, OOM, ...) can't escape uncaught here. Left 
as Exception, such an Error
+            // would bypass TRACKER.markError(...) entirely, leaving this 
run's tracked status stuck at
+            // RUNNING forever - a polling caller would hang indefinitely with 
no timeout to save it.
+            Debug.logError(t, "runTestSuite: ERROR runId=" + runId, MODULE);
+            TRACKER.markError(runId, t);
+        }
+    }
+
+    /**
+     * Removes the {@code ServiceContainer.DISPATCHER_CACHE} entry for the 
test dispatcher
+     * {@code ModelTestSuite}'s constructor created for this suite, via
+     * {@code ServiceContainer.removeFromCache} - <b>not</b> {@code 
ServiceContainer.deregister}. This
+     * distinction matters: {@code deregister} calls {@code 
LocalDispatcher.deregister()}, which (via
+     * {@code ServiceDispatcher.deregister}) shuts the {@code 
ServiceDispatcher} down once its
+     * {@code localContext} map empties out - and for a dispatcher used by 
exactly one test run, that is
+     * always immediately. Shutdown calls {@code 
JmsListenerFactory.closeListeners()}, and
+     * {@code JmsListenerFactory} is a process-wide singleton over a static 
listener map shared by every
+     * dispatcher in the JVM - so {@code deregister} here would close the live 
server's real JMS listeners
+     * on every single test run, not just this run's own (nonexistent) ones. 
{@code removeFromCache} only
+     * removes the cache entry and touches none of that - see this class's 
javadoc for exactly what is and
+     * is not cleaned up as a result.
+     *
+     * <p>Best-effort only: any failure or error here is logged, never 
rethrown - including an
+     * {@code Error}, not just an {@code Exception} - since a cleanup failure 
must never turn an otherwise
+     * passing test run into a reported failure/error.
+     */
+    private static void deregisterTestDispatcher(ModelTestSuite modelSuite) {
+        try {
+            String dispatcherName = modelSuite.getDispatcher().getName();
+            ServiceContainer.removeFromCache(dispatcherName);
+        } catch (Throwable t) {
+            Debug.logWarning(t, "runTestSuite: failed to deregister test 
dispatcher for suite '" + modelSuite.getSuiteName()
+                    + "' (best-effort cleanup only, run result is 
unaffected)", MODULE);
+        }
+    }
+
+    private static Map<String, Object> archiveIfEnabled(String runId, String 
suiteName, Map<String, Object> testParams,
+            File runDir, boolean allPassed) {
+        // Reuses testtools.properties' existing test.history flag (same gate 
TestReportPurgeService
+        // already checks) rather than introducing a second, separate toggle 
for the API-triggered
+        // path - if you haven't opted into persisted history at all, an 
API-triggered run's tracker
+        // entry (in-memory, for polling) is still fully functional, it's just 
not also archived.
+        // Note for the implementer: unlike test.api.enabled above (read via 
EntityUtilProperties,
+        // delegator-aware, so a live SystemProperty override applies), 
test.history here is read with
+        // delegator=null - this method runs on the EXECUTOR's background 
thread, not a request thread
+        // with a live Delegator in hand - so it is file-only: a 
SystemProperty override of test.history
+        // will NOT apply to API-triggered runs. Intentional, but worth 
knowing before relying on it.
+        String testHistory = readStringProperty(null, "test.history", "false");
+        if (!"true".equalsIgnoreCase(testHistory)) {
+            return UtilMisc.toMap("archived", false);
+        }
+        try {
+            String ofbizHome = System.getProperty("ofbiz.home", ".");
+            String integrationHistoryPath = readStringProperty(null, 
"test.history.integration.dir",
+                    "runtime/logs/test-reports-history");
+            File baseDir = new File(integrationHistoryPath);
+            if (!baseDir.isAbsolute()) {
+                baseDir = new File(ofbizHome, integrationHistoryPath);
+            }
+            Map<String, String> paramsUsed = new LinkedHashMap<>();
+            testParams.forEach((key, value) -> paramsUsed.put(key, 
String.valueOf(value)));
+
+            TestRunManifest manifest = TestReportArchiver.archive(new 
TestReportArchiver.ArchiveRequest(
+                    baseDir, new File(ofbizHome), suiteName, "api", allPassed 
? "PASSED" : "FAILED",
+                    runDir, null, "api", paramsUsed));
+
+            return UtilMisc.toMap("archived", true, "total", 
manifest.getCounts().getTotal(),
+                    "passed", manifest.getCounts().getPassed(), "failed", 
manifest.getCounts().getFailed(),
+                    "skipped", manifest.getCounts().getSkipped());
+        } catch (Exception e) {
+            Debug.logWarning(e, "runTestSuite: runId=" + runId + " archiving 
failed (run itself still succeeded/failed"
+                    + " as reported above)", MODULE);
+            return UtilMisc.toMap("archived", false);
+        }
+    }
+
+    public static Map<String, Object> getTestRunStatus(DispatchContext dctx, 
Map<String, ?> context) {
+        GenericValue userLogin = (GenericValue) context.get("userLogin");
+        String userLoginId = userLogin == null ? "unknown" : 
userLogin.getString("userLoginId");
+        if (!dctx.getSecurity().hasPermission(TESTEXEC_PERMISSION, userLogin)) 
{
+            Debug.logWarning("getTestRunStatus: DENIED for user '" + 
userLoginId + "' - missing " + TESTEXEC_PERMISSION, MODULE);
+            return ServiceUtil.returnError("You do not have permission to view 
test run status (" + TESTEXEC_PERMISSION + ")");
+        }
+
+        String runId = (String) context.get("runId");
+        TestRunRecord record = TRACKER.get(runId);
+        if (record == null) {
+            return ServiceUtil.returnError("No such runId: " + runId);
+        }
+
+        Map<String, Object> result = ServiceUtil.returnSuccess();
+        result.put("status", record.status().name());
+        result.put("componentName", record.componentName());
+        Map<String, Object> resultSummary = new LinkedHashMap<>();
+        if (record.resultSummary() != null) {
+            resultSummary.putAll(record.resultSummary());
+        }
+        if (record.errorMessage() != null) {
+            resultSummary.put("errorMessage", record.errorMessage());
+        }
+        result.put("resultSummary", resultSummary);
+        return result;
+    }
+
+    /**
+     * Runs {@link #getTestRunStatus} and, if the run exists, checks its 
recorded componentName
+     * against {@code expectedComponentName} - on a mismatch, returns the 
exact same "No such runId"
+     * error a genuinely unknown runId would produce (never a distinguishable 
"wrong component"
+     * message), so polling can't be used to detect the mere existence of 
another component's runs. A
+     * permission-denied result from the underlying call passes through 
unchanged - the permission
+     * check still runs first, exactly as it does for the unscoped 
getTestRunStatus.
+     * @param dctx the dispatch context
+     * @param context the caller's service context
+     * @param expectedComponentName the only component this call is allowed to 
report on
+     * @return the getTestRunStatus result, or a masked "No such runId" error 
on a component mismatch
+     */
+    public static Map<String, Object> getScopedTestRunStatus(DispatchContext 
dctx, Map<String, ?> context,
+            String expectedComponentName) {
+        // Fail closed, not open: an empty/null expectedComponentName must 
never reach the
+        // equality check below - unlike runScopedTestSuite's 
fixedComponentName (which fails open
+        // by matching every component), a null here would instead throw a raw 
NullPointerException
+        // out of expectedComponentName.equals(...), a different and equally 
unacceptable failure
+        // mode. Guard against both up front so this helper always fails the 
same clean way.
+        if (UtilValidate.isEmpty(expectedComponentName)) {
+            return ServiceUtil.returnError("getScopedTestRunStatus requires an 
expectedComponentName");
+        }
+        Map<String, Object> result = getTestRunStatus(dctx, context);
+        if (ServiceUtil.isError(result)) {
+            return result;
+        }
+        if (!expectedComponentName.equals(result.get("componentName"))) {
+            String runId = (String) context.get("runId");
+            return ServiceUtil.returnError("No such runId: " + runId);
+        }
+        return result;
+    }
+
+    private static String readStringProperty(Delegator delegator, String 
propertyName, String defaultValue) {
+        try {
+            String value = delegator == null
+                    ? UtilProperties.getPropertyValue(RESOURCE, propertyName, 
defaultValue)
+                    : EntityUtilProperties.getPropertyValue(RESOURCE, 
propertyName, delegator);
+            return UtilValidate.isNotEmpty(value) ? value.trim() : 
defaultValue;
+        } catch (Exception e) {
+            Debug.logWarning(e, "TestRunServices: could not read " + 
propertyName + ", using default '"
+                    + defaultValue + "'", MODULE);
+            return defaultValue;
+        }
+    }
+}
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunTracker.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunTracker.java
new file mode 100644
index 0000000000..6bae3f45fd
--- /dev/null
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunTracker.java
@@ -0,0 +1,70 @@
+/*******************************************************************************
+ * 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;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory registry of runTestSuite-triggered runs, for getTestRunStatus 
polling. Deliberately
+ * not persisted - does not survive a server restart. A completed run is also 
archived into the
+ * durable runtime/test-reports/manifest.json history separately (see 
TestRunServices); this
+ * tracker exists only to answer "is it done yet" while a run is in flight or 
shortly after.
+ */
+final class TestRunTracker {
+
+    private final Map<String, TestRunRecord> records = new 
ConcurrentHashMap<>();
+
+    TestRunRecord register(String runId, String suiteName, String 
componentName, String triggeredBy,
+            Map<String, Object> paramsUsed) {
+        // Defensively copies the caller's map: this provides a 
defense-in-depth isolation layer
+        // for the tracker's own stored record. The primary caller 
(TestRunServices.runTestSuite)
+        // already normalizes to an immutable, null-tolerant copy before 
passing here, protecting
+        // both the downstream executor/test (which receives the normalized 
map as finalTestParams
+        // and arms it into JupiterTestExtension.CURRENT_TEST_PARAMS) and the 
archiver. Using
+        // LinkedHashMap+unmodifiableMap instead of Map.copyOf ensures 
compatibility with callers
+        // that may have null param values (Map.copyOf throws 
NullPointerException on null).
+        TestRunRecord record = TestRunRecord.queued(runId, suiteName, 
componentName, triggeredBy,
+                Collections.unmodifiableMap(new LinkedHashMap<>(paramsUsed)));
+        records.put(runId, record);
+        return record;
+    }
+
+    void markRunning(String runId) {
+        records.computeIfPresent(runId, (id, record) -> record.running());
+    }
+
+    void markPassed(String runId, Map<String, Object> resultSummary) {
+        records.computeIfPresent(runId, (id, record) -> 
record.passed(resultSummary));
+    }
+
+    void markFailed(String runId, Map<String, Object> resultSummary) {
+        records.computeIfPresent(runId, (id, record) -> 
record.failed(resultSummary));
+    }
+
+    void markError(String runId, Throwable throwable) {
+        records.computeIfPresent(runId, (id, record) -> 
record.error(throwable));
+    }
+
+    TestRunRecord get(String runId) {
+        return records.get(runId);
+    }
+}
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
index 3877c272ed..d267825fba 100644
--- 
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
@@ -94,6 +94,8 @@ public final class TestReportArchiver {
         manifest.setCounts(counts);
         manifest.setResultsLocation(runFolder.getAbsolutePath());
         manifest.setArtifacts(artifacts);
+        manifest.setTrigger(request.getTrigger());
+        manifest.setParamsUsed(request.getParamsUsed());
 
         writeManifest(runFolder, manifest);
         return manifest;
@@ -135,9 +137,16 @@ public final class TestReportArchiver {
         private final String outcome;
         private final File resultsDir;
         private final File htmlReportDir;
+        private final String trigger;
+        private final Map<String, String> paramsUsed;
 
         public ArchiveRequest(File baseDir, File projectDir, String suiteName, 
String sourceTask, String outcome,
                 File resultsDir, File htmlReportDir) {
+            this(baseDir, projectDir, suiteName, sourceTask, outcome, 
resultsDir, htmlReportDir, "gradle", Map.of());
+        }
+
+        public ArchiveRequest(File baseDir, File projectDir, String suiteName, 
String sourceTask, String outcome,
+                File resultsDir, File htmlReportDir, String trigger, 
Map<String, String> paramsUsed) {
             this.baseDir = baseDir;
             this.projectDir = projectDir;
             this.suiteName = suiteName;
@@ -145,6 +154,8 @@ public final class TestReportArchiver {
             this.outcome = outcome;
             this.resultsDir = resultsDir;
             this.htmlReportDir = htmlReportDir;
+            this.trigger = trigger;
+            this.paramsUsed = paramsUsed;
         }
 
         public File getBaseDir() {
@@ -174,5 +185,13 @@ public final class TestReportArchiver {
         public File getHtmlReportDir() {
             return htmlReportDir;
         }
+
+        public String getTrigger() {
+            return trigger;
+        }
+
+        public Map<String, String> getParamsUsed() {
+            return paramsUsed;
+        }
     }
 }
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
index 08e4c6f05c..f80cccc053 100644
--- 
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
@@ -38,6 +38,8 @@ public final class TestRunManifest {
     private Counts counts;
     private String resultsLocation;
     private Map<String, String> artifacts = new LinkedHashMap<>();
+    private String trigger = "gradle";
+    private Map<String, String> paramsUsed = new LinkedHashMap<>();
 
     public String getRunId() {
         return runId;
@@ -119,6 +121,22 @@ public final class TestRunManifest {
         this.artifacts = artifacts;
     }
 
+    public String getTrigger() {
+        return trigger;
+    }
+
+    public void setTrigger(String trigger) {
+        this.trigger = trigger;
+    }
+
+    public Map<String, String> getParamsUsed() {
+        return paramsUsed;
+    }
+
+    public void setParamsUsed(Map<String, String> paramsUsed) {
+        this.paramsUsed = paramsUsed;
+    }
+
     /** Pass/fail/skip totals for one archived run. */
     public static final class Counts {
         private int total;
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
index 5c0636832c..bb48b786cd 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
@@ -21,6 +21,7 @@ package org.apache.ofbiz.testtools;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 import org.apache.logging.log4j.ThreadContext;
 import org.apache.ofbiz.entity.Delegator;
@@ -33,6 +34,8 @@ import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
@@ -60,6 +63,8 @@ class JupiterClassRunnerTest {
     void clearThreadLocals() {
         JupiterTestExtension.CURRENT_DELEGATOR.remove();
         JupiterTestExtension.CURRENT_DISPATCHER.remove();
+        JupiterTestExtension.CURRENT_TEST_PARAMS.remove();
+        JupiterTestExtension.CURRENT_TEST_METHOD_NAME.remove();
     }
 
     @Test
@@ -181,6 +186,112 @@ class JupiterClassRunnerTest {
         assertThat(error.throwable().getMessage(), is("boom"));
     }
 
+    @Test
+    void testParamsThreadLocalIsArmedDuringExecutionAndClearedAfter() {
+        RecordingSink sink = new RecordingSink();
+        Map<String, Object> testParams = Map.of("greeting", 
"hello-from-caller");
+
+        new JupiterTestExtension.JupiterClassRunner(
+                ParamsRecordingFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), testParams, sink)
+                .run();
+
+        assertThat(ParamsRecordingFixture.seenValue, is("hello-from-caller"));
+        assertThat(JupiterTestExtension.CURRENT_TEST_PARAMS.get(), 
nullValue());
+    }
+
+    @Test
+    void testParamsThreadLocalDefaultsToEmptyMapWhenOmitted() {
+        RecordingSink sink = new RecordingSink();
+
+        new JupiterTestExtension.JupiterClassRunner(
+                ParamsRecordingFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), sink)
+                .run();
+
+        assertThat(ParamsRecordingFixture.paramsWasNull, is(false));
+        assertThat(ParamsRecordingFixture.seenValue, is(nullValue()));
+    }
+
+    @Test
+    void testParamsExposesCallerSuppliedMapVerbatim() {
+        RecordingSink sink = new RecordingSink();
+        Map<String, Object> testParams = Map.of("exampleTypeId", "REAL_WORLD");
+
+        new JupiterTestExtension.JupiterClassRunner(
+                TestParamsFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), testParams, sink)
+                .run();
+
+        assertThat(TestParamsFixture.seenValue, is("REAL_WORLD"));
+    }
+
+    @Test
+    void testParamsIsEmptyMapWhenNoOverridesSupplied() {
+        RecordingSink sink = new RecordingSink();
+
+        new JupiterTestExtension.JupiterClassRunner(
+                TestParamsFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), sink)
+                .run();
+
+        assertThat(TestParamsFixture.seenValue, is(nullValue()));
+    }
+
+    @Test
+    void 
namespacedTestParamOverridesFlatKeyForCurrentMethodOnlyOtherMethodsSeeFlat() {
+        RecordingSink sink = new RecordingSink();
+        Map<String, Object> testParams = Map.of(
+                "color", "red",
+                "shape", "square",
+                "methodOne", Map.of("color", "blue"));
+
+        new JupiterTestExtension.JupiterClassRunner(
+                NamespacedTestParamsFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), testParams, sink)
+                .run();
+
+        assertThat(NamespacedTestParamsFixture.methodOneSeenColor, is("blue"));
+        assertThat(NamespacedTestParamsFixture.methodOneSeenShape, 
is("square"));
+        assertThat(NamespacedTestParamsFixture.methodTwoSeenColor, is("red"));
+    }
+
+    @Test
+    void 
siblingNamespacedEntryIsExcludedFromCommonBaseAndDoesNotLeakAcrossMethods() {
+        RecordingSink sink = new RecordingSink();
+        Map<String, Object> testParams = Map.of(
+                "methodOne", Map.of("color", "blue"),
+                "methodTwo", Map.of("color", "green"));
+
+        new JupiterTestExtension.JupiterClassRunner(
+                NamespacedTestParamsFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), testParams, sink)
+                .run();
+
+        assertThat(NamespacedTestParamsFixture.methodOneSeenColor, is("blue"));
+        assertThat(NamespacedTestParamsFixture.methodOneSeenMethodTwoRawValue, 
is(nullValue()));
+        assertThat(NamespacedTestParamsFixture.methodTwoSeenColor, 
is("green"));
+    }
+
+    @Test
+    void malformedNamespacedEntryFallsBackToCommonBase() {
+        RecordingSink sink = new RecordingSink();
+        Map<String, Object> testParams = Map.of(
+                "color", "red",
+                "methodOne", "not-a-map");
+
+        new JupiterTestExtension.JupiterClassRunner(
+                NamespacedTestParamsFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), testParams, sink)
+                .run();
+
+        assertThat(NamespacedTestParamsFixture.methodOneSeenColor, is("red"));
+    }
+
+    @Test
+    void currentTestMethodNameThreadLocalIsClearedAfterRun() {
+        RecordingSink sink = new RecordingSink();
+
+        new JupiterTestExtension.JupiterClassRunner(
+                NamespacedTestParamsFixture.class, mock(Delegator.class), 
mock(LocalDispatcher.class), sink)
+                .run();
+
+        assertThat(JupiterTestExtension.CURRENT_TEST_METHOD_NAME.get(), 
nullValue());
+    }
+
     @Test
     void 
failedAssertionInsideATestMethodIsReportedAsAFailureWithRealTypeAndStackTrace() 
{
         RecordingSink sink = new RecordingSink();
@@ -214,6 +325,68 @@ class JupiterClassRunnerTest {
         assertThat(error.throwable().getMessage(), is("boom"));
     }
 
+    @Test
+    void methodNameScopesDiscoveryToExactlyThatMethod() {
+        TwoMethodFixture.methodOneRunCount = 0;
+        TwoMethodFixture.methodTwoRunCount = 0;
+        RecordingSink sink = new RecordingSink();
+
+        new JupiterTestExtension.JupiterClassRunner(TwoMethodFixture.class, 
mock(Delegator.class),
+                mock(LocalDispatcher.class), Map.of(), "methodOne", 
sink).run();
+
+        assertThat(TwoMethodFixture.methodOneRunCount, is(1));
+        assertThat(TwoMethodFixture.methodTwoRunCount, is(0));
+        assertThat(sink.testStartedCalls, 
contains(TwoMethodFixture.class.getName() + "#methodOne"));
+    }
+
+    @Test
+    void nullMethodNameStillRunsTheWholeClassUnchanged() {
+        TwoMethodFixture.methodOneRunCount = 0;
+        TwoMethodFixture.methodTwoRunCount = 0;
+        RecordingSink sink = new RecordingSink();
+
+        new JupiterTestExtension.JupiterClassRunner(TwoMethodFixture.class, 
mock(Delegator.class),
+                mock(LocalDispatcher.class), Map.of(), (String) null, 
sink).run();
+
+        assertThat(TwoMethodFixture.methodOneRunCount, is(1));
+        assertThat(TwoMethodFixture.methodTwoRunCount, is(1));
+    }
+
+    @Test
+    void unknownMethodNameIsReportedAsAnInitializationErrorNotASilentNoOp() {
+        // JUnit Platform's selectMethod() validates lazily during 
launcher.execute(), not at
+        // selector-creation time - an unresolvable method surfaces as a 
FAILED container
+        // (isTest() == false), which JupiterClassRunner already routes through
+        // reportContainerFailure() (the same path a throwing @BeforeAll 
takes), reported as
+        // "#initializationError" - confirmed empirically against this 
project's JUnit Platform
+        // version rather than assumed.
+        RecordingSink sink = new RecordingSink();
+
+        new JupiterTestExtension.JupiterClassRunner(TwoMethodFixture.class, 
mock(Delegator.class),
+                mock(LocalDispatcher.class), Map.of(), "noSuchMethod", 
sink).run();
+
+        assertThat(sink.testStartedCalls, 
contains(TwoMethodFixture.class.getName() + "#initializationError"));
+        assertThat(sink.testFinishedCalls, hasSize(1));
+        SuiteReportSink.Outcome.Error error = (SuiteReportSink.Outcome.Error) 
sink.testFinishedCalls.get(0).outcome();
+        assertThat(error.throwable().getMessage(), 
containsString("noSuchMethod"));
+    }
+
+    @Test
+    void methodNameSelectsAllInvocationsOfAParameterizedMethod() {
+        // Regression test for the bug Fix 1 
(JupiterClassRunner.selectMethodByName) resolves:
+        // DiscoverySelectors.selectMethod(Class, String) alone can never 
match a method that
+        // declares a parameter, which every @ParameterizedTest method does by 
definition - it would
+        // fail with the same "could not find method" error a typo produces.
+        ParameterizedMethodFixture.invocationCount = 0;
+        RecordingSink sink = new RecordingSink();
+
+        new 
JupiterTestExtension.JupiterClassRunner(ParameterizedMethodFixture.class, 
mock(Delegator.class),
+                mock(LocalDispatcher.class), Map.of(), "parameterized", 
sink).run();
+
+        assertThat(ParameterizedMethodFixture.invocationCount, is(3));
+        assertThat(sink.testFinishedCalls, hasSize(3));
+    }
+
     //ALLOW PUBLIC FIELDS
     @Tag(JupiterTestExtension.INTEGRATION_TAG)
     static class ThreadRecordingFixture {
@@ -319,5 +492,75 @@ class JupiterClassRunnerTest {
             throw new RuntimeException("boom");
         }
     }
+
+    @Tag(JupiterTestExtension.INTEGRATION_TAG)
+    static class TwoMethodFixture {
+        static int methodOneRunCount;
+        static int methodTwoRunCount;
+
+        @Test
+        void methodOne() {
+            methodOneRunCount++;
+        }
+
+        @Test
+        void methodTwo() {
+            methodTwoRunCount++;
+        }
+    }
+
+    @Tag(JupiterTestExtension.INTEGRATION_TAG)
+    static class ParameterizedMethodFixture {
+        static int invocationCount;
+
+        @ParameterizedTest
+        @CsvSource({"a", "b", "c"})
+        void parameterized(String value) {
+            invocationCount++;
+        }
+    }
+
+    @Tag(JupiterTestExtension.INTEGRATION_TAG)
+    static class ParamsRecordingFixture {
+        static String seenValue;
+        static Boolean paramsWasNull;
+
+        @Test
+        void onlyTest() {
+            Map<String, Object> params = 
JupiterTestExtension.CURRENT_TEST_PARAMS.get();
+            paramsWasNull = (params == null);
+            seenValue = params == null ? null : (String) 
params.get("greeting");
+        }
+    }
+
+    @Tag(JupiterTestExtension.INTEGRATION_TAG)
+    static class TestParamsFixture implements JupiterTestHelper {
+        static Object seenValue;
+
+        @Test
+        void onlyTest() {
+            seenValue = getTestParams().get("exampleTypeId");
+        }
+    }
+
+    @Tag(JupiterTestExtension.INTEGRATION_TAG)
+    static class NamespacedTestParamsFixture implements JupiterTestHelper {
+        static Object methodOneSeenColor;
+        static Object methodOneSeenShape;
+        static Object methodOneSeenMethodTwoRawValue;
+        static Object methodTwoSeenColor;
+
+        @Test
+        void methodOne() {
+            methodOneSeenColor = getTestParams().get("color");
+            methodOneSeenShape = getTestParams().get("shape");
+            methodOneSeenMethodTwoRawValue = getTestParams().get("methodTwo");
+        }
+
+        @Test
+        void methodTwo() {
+            methodTwoSeenColor = getTestParams().get("color");
+        }
+    }
     //FORBID PUBLIC FIELDS
 }
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
index c547a656be..1008b7006e 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
@@ -19,8 +19,10 @@
 package org.apache.ofbiz.testtools;
 
 import java.util.List;
+import java.util.Map;
 
 import org.apache.logging.log4j.ThreadContext;
+import org.apache.ofbiz.base.container.ContainerException;
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.service.LocalDispatcher;
 import org.junit.jupiter.api.Test;
@@ -30,9 +32,11 @@ import junit.framework.TestResult;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.Mockito.mock;
 
@@ -111,6 +115,95 @@ class TestRunContainerTest {
         assertThat(ThreadContext.get(JupiterTestExtension.TEST_CASE_MDC_KEY), 
nullValue());
     }
 
+    @Test
+    void runSuiteEntriesWithAMethodNameOnlyRunsThatOneMethod() {
+        TwoTestFixture.firstRunCount = 0;
+        TwoTestFixture.secondRunCount = 0;
+        RecordingSink sink = new RecordingSink();
+        List<SuiteEntry> entries = List.of(new 
SuiteEntry.JupiterEntry(TwoTestFixture.class));
+
+        TestRunContainer.runSuiteEntries(
+                entries, mock(Delegator.class), mock(LocalDispatcher.class), 
Map.of(), "first", sink);
+
+        assertThat(TwoTestFixture.firstRunCount, is(1));
+        assertThat(TwoTestFixture.secondRunCount, is(0));
+        assertThat(sink.testStartedCalls, 
contains(TwoTestFixture.class.getName() + "#first"));
+    }
+
+    @Test
+    void runSuiteEntriesWithNoMethodNameStillRunsTheWholeClass() {
+        TwoTestFixture.firstRunCount = 0;
+        TwoTestFixture.secondRunCount = 0;
+        RecordingSink sink = new RecordingSink();
+        List<SuiteEntry> entries = List.of(new 
SuiteEntry.JupiterEntry(TwoTestFixture.class));
+
+        TestRunContainer.runSuiteEntries(entries, mock(Delegator.class), 
mock(LocalDispatcher.class), sink);
+
+        assertThat(TwoTestFixture.firstRunCount, is(1));
+        assertThat(TwoTestFixture.secondRunCount, is(1));
+    }
+
+    @Test
+    void normalizeMethodNameTreatsBlankAsAbsent() {
+        assertThat(TestRunContainer.normalizeMethodName(""), nullValue());
+        assertThat(TestRunContainer.normalizeMethodName("   "), nullValue());
+    }
+
+    @Test
+    void normalizeMethodNamePassesThroughNonBlankValue() {
+        
assertThat(TestRunContainer.normalizeMethodName("testFindPartiesById"), 
is("testFindPartiesById"));
+    }
+
+    @Test
+    void normalizeMethodNamePassesThroughNull() {
+        assertThat(TestRunContainer.normalizeMethodName(null), nullValue());
+    }
+
+    @Test
+    void validateMethodRequiresCaseThrowsWhenMethodGivenWithoutCase() {
+        ContainerException thrown = assertThrows(ContainerException.class, () 
->
+                
TestRunContainer.validateMethodRequiresCase("testFindPartiesById", null));
+
+        assertThat(thrown.getMessage(), 
containsString("method=testFindPartiesById"));
+        assertThat(thrown.getMessage(), containsString("case="));
+    }
+
+    @Test
+    void validateMethodRequiresCaseAllowsMethodWithCase() {
+        assertDoesNotThrow(() -> 
TestRunContainer.validateMethodRequiresCase("testFindPartiesById", 
"party-tests"));
+    }
+
+    @Test
+    void validateMethodRequiresCaseAllowsNeitherGiven() {
+        assertDoesNotThrow(() -> 
TestRunContainer.validateMethodRequiresCase(null, null));
+    }
+
+    @Test
+    void validateMethodAppliesToSuiteThrowsWhenNoJupiterEntryResolved() {
+        List<SuiteEntry> entries = List.of(new SuiteEntry.Junit3Entry(new 
NamedCase("dataLoad")));
+
+        ContainerException thrown = assertThrows(ContainerException.class, () 
->
+                
TestRunContainer.validateMethodAppliesToSuite("testFindPartiesById", 
"partytests", entries));
+
+        assertThat(thrown.getMessage(), containsString("partytests"));
+    }
+
+    @Test
+    void validateMethodAppliesToSuiteAllowsAResolvedJupiterEntry() {
+        List<SuiteEntry> entries = List.of(
+                new SuiteEntry.Junit3Entry(new NamedCase("dataLoad")),
+                new SuiteEntry.JupiterEntry(OneTestFixture.class));
+
+        assertDoesNotThrow(() -> 
TestRunContainer.validateMethodAppliesToSuite("onlyTest", "partytests", 
entries));
+    }
+
+    @Test
+    void validateMethodAppliesToSuiteAllowsNullMethodName() {
+        List<SuiteEntry> entries = List.of(new SuiteEntry.Junit3Entry(new 
NamedCase("dataLoad")));
+
+        assertDoesNotThrow(() -> 
TestRunContainer.validateMethodAppliesToSuite(null, "partytests", entries));
+    }
+
     static class NamedCase extends TestCase {
         NamedCase(String name) {
             super(name);
@@ -139,6 +232,24 @@ class TestRunContainerTest {
         }
     }
 
+    @org.junit.jupiter.api.Tag(JupiterTestExtension.INTEGRATION_TAG)
+    static class TwoTestFixture {
+        //ALLOW PUBLIC FIELDS
+        static int firstRunCount;
+        static int secondRunCount;
+        //FORBID PUBLIC FIELDS
+
+        @Test
+        void first() {
+            firstRunCount++;
+        }
+
+        @Test
+        void second() {
+            secondRunCount++;
+        }
+    }
+
     /**
      * Mirrors how ServiceTest/SimpleMethodTest/EntityXmlAssertTest override 
run(TestResult) directly -
      * calling result.startTest(this) (which arms the testCase MDC field via 
Junit3ResultBridge.startTest())
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
new file mode 100644
index 0000000000..28aeee062d
--- /dev/null
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunServicesTest.java
@@ -0,0 +1,296 @@
+/*******************************************************************************
+ * 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;
+
+import java.util.Map;
+
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.security.Security;
+import org.apache.ofbiz.service.DispatchContext;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class TestRunServicesTest {
+
+    @Test
+    void runTestSuiteReturnsErrorWhenPermissionDenied() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("nobody");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
+
+        Map<String, Object> result = TestRunServices.runTestSuite(dctx,
+                Map.of("suiteName", "example-tests", "userLogin", userLogin));
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("runId"), nullValue());
+    }
+
+    @Test
+    void runTestSuiteReturnsErrorWhenApiDisabled() {
+        // No stubbing of testtools.properties overrides: the real classpath 
resource
+        // framework/testtools/config/testtools.properties ships 
test.api.enabled=false (Task 3),
+        // and EntityUtilProperties falls through to it when 
delegator.findOne("SystemProperty", ...)
+        // - unstubbed on this mock - returns null.
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = TestRunServices.runTestSuite(dctx,
+                Map.of("suiteName", "example-tests", "userLogin", userLogin));
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("runId"), nullValue());
+    }
+
+    @Test
+    void getTestRunStatusReturnsErrorWhenPermissionDenied() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("nobody");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
+
+        Map<String, Object> result = TestRunServices.getTestRunStatus(dctx,
+                Map.of("runId", "run-1", "userLogin", userLogin));
+
+        assertThat(result.get("responseMessage"), is("error"));
+    }
+
+    @Test
+    void getTestRunStatusReturnsErrorForAnUnknownRunId() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = TestRunServices.getTestRunStatus(dctx,
+                Map.of("runId", "no-such-run", "userLogin", userLogin));
+
+        assertThat(result.get("responseMessage"), is("error"));
+    }
+
+    @Test
+    void getTestRunStatusIncludesComponentName() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("component-check-run", 
"example-tests", "example", "admin", Map.of());
+
+        Map<String, Object> result = TestRunServices.getTestRunStatus(dctx,
+                Map.of("runId", "component-check-run", "userLogin", 
userLogin));
+
+        assertThat(result.get("componentName"), is("example"));
+    }
+
+    @Test
+    void getScopedTestRunStatusReturnsRealDataWhenComponentMatches() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("scoped-run-match", "example-tests", 
"example", "admin", Map.of());
+
+        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
+                Map.of("runId", "scoped-run-match", "userLogin", userLogin), 
"example");
+
+        assertThat(result.get("responseMessage"), is("success"));
+        assertThat(result.get("componentName"), is("example"));
+    }
+
+    @Test
+    void getScopedTestRunStatusMasksAMismatchedComponentAsUnknownRunId() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("scoped-run-mismatch", 
"content-tests", "content", "admin", Map.of());
+
+        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
+                Map.of("runId", "scoped-run-mismatch", "userLogin", 
userLogin), "example");
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("errorMessage"), is("No such runId: 
scoped-run-mismatch"));
+    }
+
+    @Test
+    void getScopedTestRunStatusPassesThroughPermissionDenialUnchanged() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("nobody");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
+
+        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
+                Map.of("runId", "any-run", "userLogin", userLogin), "example");
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("errorMessage"), is("You do not have permission 
to view test run status (TESTEXEC_ADMIN)"));
+    }
+
+    @Test
+    void runScopedTestSuitePassesThroughPermissionDenialUnchanged() {
+        // Cannot unit-test the componentName-forcing behavior itself in 
isolation - like
+        // runTestSuite's own suite-resolution path, that needs a real 
bootstrapped ComponentConfig
+        // (see this file's existing tests' pattern, and TestRunServices' own 
"Design note on
+        // testability"). This test only confirms the delegation is wired 
correctly: a permission
+        // denial passes straight through, and passing a deliberately 
mismatched componentName
+        // ("content") in the caller's context doesn't cause a crash before 
the permission check
+        // - proving nothing about whether the override happens, only that the 
wrapper doesn't
+        // reject/mangle the call. The override itself is verified by 
manual/live validation (Task 5).
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("nobody");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(false);
+
+        Map<String, Object> result = TestRunServices.runScopedTestSuite(dctx,
+                Map.of("suiteName", "example-tests", "componentName", 
"content", "userLogin", userLogin), "example");
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("runId"), nullValue());
+    }
+
+    @Test
+    void runScopedTestSuiteReturnsErrorForANullFixedComponentName() {
+        // Must fail closed, not open: ComponentConfig.matchingComponentName 
treats a null cname as
+        // "match every component", so skipping this guard would silently turn 
a null
+        // fixedComponentName into fully unscoped behavior instead of an 
error. Uses a permissive
+        // security mock so the guard - not the permission check - is what's 
actually exercised.
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = TestRunServices.runScopedTestSuite(dctx,
+                Map.of("suiteName", "example-tests", "userLogin", userLogin), 
null);
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("errorMessage"), is("runScopedTestSuite requires 
a fixed componentName"));
+        assertThat(result.get("runId"), nullValue());
+    }
+
+    @Test
+    void runScopedTestSuiteReturnsErrorForAnEmptyFixedComponentName() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        Delegator delegator = mock(Delegator.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(dctx.getDelegator()).thenReturn(delegator);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = TestRunServices.runScopedTestSuite(dctx,
+                Map.of("suiteName", "example-tests", "userLogin", userLogin), 
"");
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("errorMessage"), is("runScopedTestSuite requires 
a fixed componentName"));
+        assertThat(result.get("runId"), nullValue());
+    }
+
+    @Test
+    void getScopedTestRunStatusReturnsErrorForANullExpectedComponentName() {
+        // Without this guard, expectedComponentName.equals(...) would throw a 
raw
+        // NullPointerException instead of returning a clean error - a 
different (and equally
+        // unacceptable) failure mode than runScopedTestSuite's fail-open risk.
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
+                Map.of("runId", "any-run", "userLogin", userLogin), null);
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("errorMessage"), is("getScopedTestRunStatus 
requires an expectedComponentName"));
+    }
+
+    @Test
+    void getScopedTestRunStatusReturnsErrorForAnEmptyExpectedComponentName() {
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+
+        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
+                Map.of("runId", "any-run", "userLogin", userLogin), "");
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("errorMessage"), is("getScopedTestRunStatus 
requires an expectedComponentName"));
+    }
+
+    @Test
+    void getScopedTestRunStatusMasksANullComponentNameRunAsUnknownRunId() {
+        // Proves the fail-closed guarantee for a run registered with 
componentName=null (e.g. a
+        // hypothetical future unscoped internal registration path): the 
scoped wrapper's equality
+        // check must still deny it, returning the same masked "No such runId" 
response a genuine
+        // mismatch gets - never a crash, never real data.
+        DispatchContext dctx = mock(DispatchContext.class);
+        Security security = mock(Security.class);
+        GenericValue userLogin = mock(GenericValue.class);
+        when(dctx.getSecurity()).thenReturn(security);
+        when(userLogin.getString("userLoginId")).thenReturn("admin");
+        when(security.hasPermission("TESTEXEC_ADMIN", 
userLogin)).thenReturn(true);
+        TestRunServices.TRACKER.register("scoped-run-null-component", 
"content-tests", null, "admin", Map.of());
+
+        Map<String, Object> result = 
TestRunServices.getScopedTestRunStatus(dctx,
+                Map.of("runId", "scoped-run-null-component", "userLogin", 
userLogin), "example");
+
+        assertThat(result.get("responseMessage"), is("error"));
+        assertThat(result.get("errorMessage"), is("No such runId: 
scoped-run-null-component"));
+    }
+}
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunTrackerTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunTrackerTest.java
new file mode 100644
index 0000000000..7f9d7ad81c
--- /dev/null
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunTrackerTest.java
@@ -0,0 +1,139 @@
+/*******************************************************************************
+ * 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;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+class TestRunTrackerTest {
+
+    @Test
+    void registerStartsARunInQueuedState() {
+        TestRunTracker tracker = new TestRunTracker();
+
+        tracker.register("run-1", "example-tests", "example", "system", 
Map.of("exampleName", "custom"));
+
+        TestRunRecord record = tracker.get("run-1");
+        assertThat(record, notNullValue());
+        assertThat(record.status(), is(TestRunRecord.Status.QUEUED));
+        assertThat(record.suiteName(), is("example-tests"));
+        assertThat(record.componentName(), is("example"));
+        assertThat(record.triggeredBy(), is("system"));
+        assertThat(record.paramsUsed(), is(Map.of("exampleName", "custom")));
+    }
+
+    @Test
+    void markRunningTransitionsFromQueuedToRunning() {
+        TestRunTracker tracker = new TestRunTracker();
+        tracker.register("run-1", "example-tests", "example", "system", 
Map.of());
+
+        tracker.markRunning("run-1");
+
+        assertThat(tracker.get("run-1").status(), 
is(TestRunRecord.Status.RUNNING));
+    }
+
+    @Test
+    void markPassedRecordsTerminalStateAndSummary() {
+        TestRunTracker tracker = new TestRunTracker();
+        tracker.register("run-1", "example-tests", "example", "system", 
Map.of());
+        tracker.markRunning("run-1");
+
+        tracker.markPassed("run-1", Map.of("total", 3, "passed", 3, "failed", 
0));
+
+        TestRunRecord record = tracker.get("run-1");
+        assertThat(record.status(), is(TestRunRecord.Status.PASSED));
+        assertThat(record.resultSummary(), is(Map.of("total", 3, "passed", 3, 
"failed", 0)));
+        assertThat(record.completedAt(), notNullValue());
+    }
+
+    @Test
+    void markFailedRecordsTerminalStateAndSummary() {
+        TestRunTracker tracker = new TestRunTracker();
+        tracker.register("run-1", "example-tests", "example", "system", 
Map.of());
+
+        tracker.markFailed("run-1", Map.of("total", 3, "passed", 2, "failed", 
1));
+
+        assertThat(tracker.get("run-1").status(), 
is(TestRunRecord.Status.FAILED));
+    }
+
+    @Test
+    void markErrorRecordsTerminalStateWithNoSummary() {
+        TestRunTracker tracker = new TestRunTracker();
+        tracker.register("run-1", "example-tests", "example", "system", 
Map.of());
+
+        tracker.markError("run-1", new RuntimeException("suite blew up"));
+
+        TestRunRecord record = tracker.get("run-1");
+        assertThat(record.status(), is(TestRunRecord.Status.ERROR));
+        assertThat(record.errorMessage(), is("suite blew up"));
+    }
+
+    @Test
+    void getReturnsNullForAnUnknownRunId() {
+        TestRunTracker tracker = new TestRunTracker();
+
+        assertThat(tracker.get("no-such-run"), nullValue());
+    }
+
+    @Test
+    void registerHandlesNullValuesInParamsWithoutThrowingNpe() {
+        TestRunTracker tracker = new TestRunTracker();
+        Map<String, Object> callerParams = new HashMap<>();
+        callerParams.put("exampleName", "value");
+        callerParams.put("nullParam", null);
+
+        tracker.register("run-1", "example-tests", "example", "system", 
callerParams);
+
+        TestRunRecord record = tracker.get("run-1");
+        assertThat(record, notNullValue());
+        assertThat(record.paramsUsed().get("exampleName"), is("value"));
+        assertThat(record.paramsUsed().get("nullParam"), nullValue());
+    }
+
+    @Test
+    void registerDefensivelyCopiesTheCallersParamsMap() {
+        TestRunTracker tracker = new TestRunTracker();
+        Map<String, Object> callerParams = new HashMap<>();
+        callerParams.put("exampleName", "original");
+
+        tracker.register("run-1", "example-tests", "example", "system", 
callerParams);
+        callerParams.put("exampleName", "mutated-after-register");
+        callerParams.put("extraKey", "should-not-appear");
+
+        assertThat(tracker.get("run-1").paramsUsed(), is(Map.of("exampleName", 
"original")));
+    }
+
+    @Test
+    void componentNameSurvivesStatusTransitions() {
+        TestRunTracker tracker = new TestRunTracker();
+        tracker.register("run-1", "example-tests", "example", "system", 
Map.of());
+
+        tracker.markRunning("run-1");
+        tracker.markPassed("run-1", Map.of());
+
+        assertThat(tracker.get("run-1").componentName(), is("example"));
+    }
+}
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
index 41b96216c0..4ba60ffcf7 100644
--- 
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
@@ -21,6 +21,7 @@ package org.apache.ofbiz.testtools.report;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
+import java.util.Map;
 
 import org.apache.ofbiz.base.lang.JSON;
 import org.junit.jupiter.api.Test;
@@ -102,4 +103,35 @@ class TestReportArchiverTest {
         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}"));
     }
+
+    @Test
+    void archiveRecordsTriggerAndParamsUsedWhenSupplied(@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, "example-tests.xml").toPath(),
+                "<testsuite name=\"x\" tests=\"1\" failures=\"0\" errors=\"0\" 
skipped=\"0\"></testsuite>");
+
+        TestRunManifest manifest = TestReportArchiver.archive(new 
TestReportArchiver.ArchiveRequest(
+                baseDir, tmp, "example-tests", "api", "PASSED", resultsDir, 
null,
+                "api", Map.of("exampleName", "Caller Supplied Name")));
+
+        assertThat(manifest.getTrigger(), is("api"));
+        assertThat(manifest.getParamsUsed(), is(Map.of("exampleName", "Caller 
Supplied Name")));
+    }
+
+    @Test
+    void archiveDefaultsTriggerToGradleWhenNotSupplied(@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, "example-tests.xml").toPath(),
+                "<testsuite name=\"x\" tests=\"1\" failures=\"0\" errors=\"0\" 
skipped=\"0\"></testsuite>");
+
+        TestRunManifest manifest = TestReportArchiver.archive(new 
TestReportArchiver.ArchiveRequest(
+                baseDir, tmp, "example-tests", "testIntegration", "PASSED", 
resultsDir, null));
+
+        assertThat(manifest.getTrigger(), is("gradle"));
+        assertThat(manifest.getParamsUsed(), is(Map.of()));
+    }
 }

Reply via email to