FWDTestEngine.java
/*
** Module : FWDTestEngine.java
** Abstract : Test engine.
**
** Copyright (c) 2023-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ----------------Description----------------------
** 001 VVT 20230318 Created initial version.
** 002 VVT 20230418 All JUnit5 discovery selector types are now passed to the server
** for possible future extensions.
** 003 VVT 20230428 Fixed error propagation (See #3827-350) and source formatting.
** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 005 CA 20240311 Allow server:clientConfig:outputToFile to be set for the FWD unit test client, too.
** 006 VVT 20240618 Forced ConsoleHelper.terminate() after test engine initialization failure. See #8529-7.
** 007 GBB 20240709 Hard-coded config name replaced by ConfigItem constant.
** Diagnostics supplier moved to processTemporaryClient.
** 008 VVT 20240703 Fixed: ClientCore.loadNativeLibrary() was called too late. See #8921.
** 009 GBB 20240826 Removing single client.
** 010 VVT 20250217 discover() method fixed: initialization failure must not throw exceptions in order not
** to abort the test running process and let other test engines to work. See #9649.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.testengine;
import static org.junit.platform.engine.TestExecutionResult.*;
import java.net.*;
import java.util.logging.*;
import com.goldencode.p2j.util.*;
import org.junit.platform.commons.util.*;
import org.junit.platform.engine.*;
import org.junit.platform.engine.discovery.*;
import org.junit.platform.engine.support.hierarchical.*;
import org.junit.platform.engine.support.hierarchical.ThrowableCollector.*;
import org.opentest4j.*;
import com.goldencode.p2j.cfg.*;
import com.goldencode.p2j.main.*;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.ui.client.chui.driver.console.*;
import com.goldencode.p2j.ui.client.driver.*;
/**
* FWD Test engine.
*
*/
public class FWDTestEngine
extends HierarchicalTestEngine<FWDEngineExecutionContext>
{
/**
* Engine state
*
*/
private enum State
{
/**
* Engine was initialized successfully.
*/
INITIALIZED,
/**
* Engine initialization failed, engine is inoperable
*
* Inoperable engines will never discover any tests.
*
* This feature gives other test engines a chance to work.
*/
INOPERABLE,
/**
* Engine just created and is uninitialized.
*/
NONE
}
/**
* Logger for this test engine
*/
final static Logger LOG = Logger.getLogger(FWDTestEngine.class.getName());
/**
* The name of an optional JUnit5 configuration parameter which contains FWD
* configuration file path.
*/
private static final String CFG_FWD_CONFIG_FILE = "fwd_config_file";
/**
* Default FWD configuration file value.
*/
private static final String DEFAULT_FWD_CONFIG_FILE = "unit_test.xml";
/**
* Engine initialization state: initially {@link State#NONE}, when after initialization attempt
* changes either to either {@link State#INITIALIZED} or {@link State#INOPERABLE},
* depending on whether initialization was successful or not.
*
* Keeping state prevents multiple engine initialization attempts.
*/
private static State state = State.NONE;
/**
* Unit test support on the server proxy instance.
*/
private static UnitTestEngine unitTestSupport;
/**
* Initialize the engine.
*
* @param fwdConfigFile
* FWD client config file, never null
*
* @return {@code true} if initialization was successful.
*/
private static boolean initialize(final String fwdConfigFile)
{
ClientCore.loadNativeLibrary();
// process configuration. In most use cases (e.g. running from IDE), it is not possible to pass
// any command line arguments: so only config files can be used for the purpose.
try
{
final BootstrapConfig bc = new BootstrapConfig(fwdConfigFile, null, null, null);
final String uuid = bc.getString(ConfigItem.SPAWNER_UUID, null);
// save off the UI driver so the same instance can be used across all
// iterations of the core loop
ScreenDriver<?> driver = (uuid == null) ? null : ClientCore.processTemporaryClient(uuid,
bc,
() -> "");
final ClientCore.InitStruct is = new ClientCore.InitStruct();
ClientCore.setOutputToFile(bc);
is.driver = driver;
ClientCore.initialize(is, bc, uuid);
unitTestSupport = (UnitTestEngine) RemoteObject
.obtainNetworkInstance(UnitTestEngine.class, null);
// Do server-side initialization
unitTestSupport.initialize();
return true;
}
catch (Throwable thr)
{
ConsoleHelper.terminate();
LOG.warning("Test engine cannot initialize and will be disabled: " + thr);
return false;
}
}
/**
* Discover tests according to the supplied {@link EngineDiscoveryRequest}.
* <p>
* The supplied {@link UniqueId} must be used as the unique ID of the
* returned root {@link TestDescriptor}. In addition, the {@code UniqueId}
* must be used to create unique IDs for children of the root's descriptor
* by calling {@link UniqueId#append}.
*
* @param discoveryRequest the discovery request; never {@code null}
* @param uniqueId the unique ID to be used for this test engine's
* {@code TestDescriptor}; never {@code null}
*
* @return the root {@code TestDescriptor} of this engine, typically an
* instance of {@code EngineDescriptor}
*
* @see org.junit.platform.engine.support.descriptor.EngineDescriptor
*/
@Override
public TestDescriptor discover(final EngineDiscoveryRequest discoveryRequest,
final UniqueId uniqueId)
{
switch (state)
{
case INITIALIZED:
break;
case NONE:
final ConfigurationParameters configurationParameters = discoveryRequest
.getConfigurationParameters();
try
{
if (initialize(configurationParameters.get(CFG_FWD_CONFIG_FILE)
.orElse(DEFAULT_FWD_CONFIG_FILE)))
{
state = State.INITIALIZED;
break;
}
}
catch (@SuppressWarnings("unused") final Throwable t)
{
// no-op, continue as inoperable
}
state = State.INOPERABLE;
//$FALL-THROUGH$
case INOPERABLE:
return new NoopDescriptor(UniqueId.forEngine(getId()), "No-op");
}
return unitTestSupport.discover(uniqueId,
discoveryRequest.getSelectorsByType(ClasspathRootSelector.class).stream()
.map(ClasspathRootSelector::getClasspathRoot).toArray(URI[]::new),
discoveryRequest.getSelectorsByType(PackageSelector.class).stream()
.map(PackageSelector::getPackageName).toArray(String[]::new),
discoveryRequest.getSelectorsByType(ClassSelector.class).stream()
.map(ClassSelector::getClassName).toArray(String[]::new),
discoveryRequest.getSelectorsByType(MethodSelector.class).stream()
.map(s -> s.getClassName() + "#" + s.getMethodName())
.toArray(String[]::new),
discoveryRequest.getSelectorsByType(UriSelector.class).stream()
.map(s -> s.getUri())
.toArray(URI[]::new),
discoveryRequest.getSelectorsByType(FileSelector.class).stream()
.map(s -> s.getRawPath())
.toArray(String[]::new),
discoveryRequest.getSelectorsByType(DirectorySelector.class).stream()
.map(s -> s.getRawPath())
.toArray(String[]::new),
discoveryRequest.getSelectorsByType(ModuleSelector.class).stream()
.map(s -> s.getModuleName())
.toArray(String[]::new),
discoveryRequest.getSelectorsByType(ClasspathResourceSelector.class).stream()
.map(s -> s.getClasspathResourceName())
.toArray(String[]::new),
discoveryRequest.getSelectorsByType(IterationSelector.class).stream()
.map(s -> s.getIterationIndices())
.toArray(String[]::new)
);
}
/**
* Get the ID that uniquely identifies this test engine.
* <p>
* Each test engine must provide a unique ID. For example, JUnit Vintage
* and JUnit Jupiter use {@code "junit-vintage"} and {@code "junit-jupiter"},
* respectively. When in doubt, you may use the fully qualified name of your
* custom {@code TestEngine} implementation class.
*/
@Override
public String getId()
{
// Note: "junit-fwd" would be more natural, but the "junit-" prefix
// is reserved by JUnit5
return "fwd-junit";
}
/**
* Create the initial execution context for executing the supplied
* {@linkplain ExecutionRequest request}.
*
* @param request the request about to be executed
*
* @return the initial context that will be passed to nodes in the hierarchy
*/
@Override
protected FWDEngineExecutionContext createExecutionContext(ExecutionRequest request)
{
return new FWDEngineExecutionContext(unitTestSupport);
}
/**
* Create the {@linkplain org.junit.platform.engine.support.hierarchical.ThrowableCollector.Factory factory} for creating
* {@link ThrowableCollector} instances used to handle exceptions that occur
* during execution of this engine's tests.
* <p>
* An engine may use the information in the supplied <em>request</em>
* such as the contained
* {@linkplain ExecutionRequest#getConfigurationParameters() configuration parameters}
* to decide what kind of factory to return or how to configure it.
* <p>
* By default, this method returns a factory that always creates instances of
* {@link OpenTest4JAwareThrowableCollector}.
*
* @param request the request about to be executed
*
* @see OpenTest4JAwareThrowableCollector
* @see ThrowableCollector
*/
@Override
protected Factory createThrowableCollectorFactory(ExecutionRequest request)
{
return FWDThrowableCollector::new;
}
/**
* {@link ThrowableCollector} which handles {@link RuntimeException}s which wrap
* {@link AssertionFailedError} in the exception cause chain specially.
*
* Note: unfortunately, the data code in this class duplicates most of the parent class
* data and code.
*/
public final static class FWDThrowableCollector
extends OpenTest4JAwareThrowableCollector
{
/**
* Store the first {@link Throwable} thrown, all subsequent
* throwables if any, are added to the first one as suppressed.
*/
private Throwable throwable;
/**
* Default constructor.
*/
public FWDThrowableCollector()
{
// no-op
}
/**
* Assert that this {@code ThrowableCollector} is <em>empty</em> (i.e.,
* has not collected any {@code Throwables}).
* <p>
* If this collector is not empty, the first collected {@code Throwable}
* will be thrown with any additional {@code Throwables}
* {@linkplain Throwable#addSuppressed(Throwable) suppressed} in the
* first {@code Throwable}. Note, however, that the {@code Throwable}
* will not be wrapped. Rather, it will be
* {@linkplain ExceptionUtils#throwAsUncheckedException masked}
* as an unchecked exception.
*
* @see #getThrowable()
* @see ExceptionUtils#throwAsUncheckedException(Throwable)
*/
@Override
public void assertEmpty()
{
if (!isEmpty())
{
ExceptionUtils.throwAsUncheckedException(throwable);
}
}
/**
* Execute the supplied {@link Executable} and collect any {@link Throwable}
* thrown during the execution.
* <p>
* If the {@code Executable} throws an <em>unrecoverable</em> exception
* — for example, an {@link OutOfMemoryError} — this method will
* rethrow it.
*
* @param executable the {@code Executable} to execute
*
* @see #assertEmpty()
*/
@Override
public void execute(final Executable executable)
{
try
{
executable.execute();
}
catch (final Throwable t)
{
UnrecoverableExceptions.rethrowIfUnrecoverable(t);
final Throwable cause = t.getCause();
final Throwable effThrowable = cause instanceof AssertionFailedError ? cause : t;
if (throwable == null)
{
throwable = effThrowable;
}
else if (throwable != effThrowable)
{
throwable.addSuppressed(effThrowable);
}
}
}
/**
* Get the first {@link Throwable} collected by this
* {@code ThrowableCollector}.
* <p>
* If this collector is not empty, the first collected {@code Throwable}
* will be returned with any additional {@code Throwables}
* {@linkplain Throwable#addSuppressed(Throwable) suppressed} in the
* first {@code Throwable}.
* <p>
* If the first collected {@code Throwable} <em>aborted</em> execution
* and at least one later collected {@code Throwable} <em>failed</em>
* execution, the first <em>failing</em> {@code Throwable} will be returned
* with the previous <em>aborting</em> and any additional {@code Throwables}
* {@linkplain Throwable#addSuppressed(Throwable) suppressed} inside.
*
* @return the first collected {@code Throwable} or {@code null} if this
* {@code ThrowableCollector} is empty
*
* @see #isEmpty()
* @see #assertEmpty()
*/
@Override
public Throwable getThrowable()
{
return throwable;
}
/**
* Determine if this {@code ThrowableCollector} is <em>empty</em> (i.e.,
* has not collected any {@code Throwables}).
*/
@Override
public boolean isEmpty()
{
return throwable == null;
}
/**
* Determine if this {@code ThrowableCollector} is <em>not empty</em> (i.e.,
* has collected at least one {@code Throwable}).
*/
@Override
public boolean isNotEmpty()
{
return throwable != null;
}
/**
* Convert the collected {@link Throwable Throwables} into a {@link TestExecutionResult}.
*
* @return {@linkplain TestExecutionResult#aborted aborted} if the collected
* {@code Throwable} <em>aborted</em> execution;
* {@linkplain TestExecutionResult#failed failed} if it <em>failed</em>
* execution; and {@linkplain TestExecutionResult#successful successful}
* otherwise
*/
@Override
public TestExecutionResult toTestExecutionResult()
{
if (isEmpty())
{
return successful();
}
if (throwable instanceof TestAbortedException)
{
return aborted(throwable);
}
return failed(throwable);
}
}
}