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
       * &mdash; for example, an {@link OutOfMemoryError} &mdash; 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);
      }

   }

}