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()));
+ }
}