UnitTestServer.java

/*
** Module   : UnitTestServer.java
** Abstract : FWD unit test remote protocol implementation
**
** Copyright (c) 2023-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description---------------------------------
** 001 VVT 20230318 Created initial version.
** 002 VVT 20230412 OEUnit support added. See #6237. Also minor style fixes.
** 003 VVT 20230418 All JUnit5 discovery selector types are now passed to the server
**                  for possible future extensions.
** 004 GBB 20230427 Adding LegacyLogManagerConfigs arg to AppServerManager.startAppServer after signature 
**                  changed.
** 005 VVT 20230428 Fixed error propagation (See #3827-350) and source formatting.
**     VVT 20230505 Fixed: individual method name extraction in discover(..).
**     VVT 20230511 Multiple JUnit5 session support added: the initialization
**                  at the beginning of the first discovery call, and shutdown
**                  at the end of the last cleanUp() call.
**     VVT 20230522 Legacy stack trace is now converted to a Java stack trace. See #3827-448.
** 006 GBB 20230608 Added a flag to LegacyLogManagerConfigs for server-side filesystem in use.
** 007 CA  20231214 Push a bogus external procedure as the global scope.
** 008 VVT 20231215 Fixed RuntimeException error when converting error stack trace, consisting of
**                  multiple parts: only the last part is now converted. See #8126.
**     VVT 20231215 Throwing RuntimeException error when converting invalid error stack trace element
**                  replaced by logging and ignoring the element. See #8126-5.
** 009 CA  20231221 Add ObjectOps scopeable support for the global external procedure.
** 010 VVT 20240111 Private methods are now filtered out in test discovery. See #8176.
**                  CentralLogger is now used for logging.
**     VVT 20240113 Severity decreased for the "Private method cannot be used..." log message.
**                  See #8176-19
** 011 VVT 20240117 LegacyLogManager is now initialized properly. See #8195.
**     VVT 20240117 Call to AppServerManager.startAppServer() removed. See #8195-15.
** 012 CA  20240308 Use client session initialization code from StandardServer. 
** 003 VVT 20240325 Unused 'testInstance' parameter removed from addOptionally() andf testAndCreate().
** 013 VVT 20240408 Minor style fixes. See #8406-70.
** 014 VVT 20240718 Factory is now used to create descriptor instances. See #8874.
** 015 CA  20250503 'pushCalleeInfo' must match the signature of calling an external program.
*/
/*
** 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 java.lang.annotation.*;
import java.lang.reflect.*;
import java.net.*;
import java.util.*;
import java.util.function.*;
import java.util.regex.*;

import org.junit.platform.commons.support.*;
import org.junit.platform.commons.util.*;
import org.junit.platform.engine.*;
import org.opentest4j.AssertionFailedError;

import com.goldencode.p2j.main.*;
import com.goldencode.p2j.oo.core.*;
import com.goldencode.p2j.oo.lang.*;
import com.goldencode.p2j.oo.oeunit.runner.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.trigger.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.testengine.api.*;
import com.goldencode.p2j.ui.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;
import com.google.common.collect.*;

/**
 * FWD unit testing engine remote operation server-side implementation.
 * 
 * This class contains static methods matching the {@link UnitTestEngine} interface.
 */
public class UnitTestServer
{
   /**
    * Logger for this test engine
    */
   final static CentralLogger LOG = CentralLogger.get(UnitTestServer.class.getName());
   
   /** Context local work area. */
   private final static ContextLocal<WorkArea> context = new ContextLocal<WorkArea>()
   {
      @Override
      protected WorkArea initialValue()
      {
         return new WorkArea();
      };
   };

   /**
    * Filter only class types which can contain tests or are test providers:
    * public non-abstract non-internal.
    *
    */
   private final static Predicate<Class<?>> isTestClassCandidate = candidate -> {
      try
      {
         final int modifiers = candidate.getModifiers();
         return !(Modifier.isPrivate(modifiers) || Modifier.isAbstract(modifiers)
                  || Modifier.isStatic(modifiers) || candidate.isLocalClass()
                  || candidate.isAnonymousClass() || candidate.isMemberClass());
      }
      catch (final NoClassDefFoundError cnf)
      {
         // Note: we get here, if the application classpath is incomplete,
         LOG.warning("Cannot load test class candidate: " + candidate + ": " + cnf.getMessage());
         return false;
      }
   };

   /**
    * Legacy stack trace entry pattern
    */
   private final static Pattern legacyErrorStackTraceElementRegexp = Pattern
            .compile("([^\\[]+)(\\[([^\\]]+)])? at line (\\d+) \\(([^)]+)\\)$");
   
   /**
    * Execute the <em>after</em> behavior of this node.
    * <p>
    * This method will be called once <em>after</em> {@linkplain #execute execution}
    * of this node.
    * 
    * @param  testId
    *         test descriptor ID
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   public static void after(final UniqueId testId)
   throws ErrorConditionException,
          AssertionFailedError
   {
      final AbstractFWDTestDescriptor desc = getDescriptor(testId);
      LOG.fine("after: " + testId + " desc: " + desc);
      convertError(() -> desc.afterImpl());
   }

   /**
    * Execute the <em>before</em> behavior of this node.
    * <p>
    * This method will be called once <em>before</em> {@linkplain #execute execution}
    * of this node.
    * 
    * @param  testId 
    *         test descriptor ID
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   public static void before(final UniqueId testId)
   throws ErrorConditionException,
          AssertionFailedError
   {
      final AbstractFWDTestDescriptor desc = getDescriptor(testId);
      LOG.fine("before: " + testId + " desc: " + desc);
      convertError(() -> desc.beforeImpl());
   }

   /**
    * Clean up the supplied {@code context} after execution.
    * <p>
    * Reset the test class instance.
    * 
    * @param testId 
    *        test descriptor ID
    * 
    * @see   #prepare
    */
   public static void cleanUp(final UniqueId testId)
   {
      final AbstractFWDTestDescriptor desc = getDescriptor(testId);
      LOG.fine("cleanUp : " + testId + " desc: " + desc);      
      try
      {
         desc.cleanUpImpl();
      }
      finally
      {
         if (desc instanceof FWDEngineDescriptor)
         {
            final WorkArea workArea = context.get();
            workArea.unprocessed--;
            if (workArea.unprocessed == 0)
            {
               // Cleanup
               TransactionManager.setProcessingQuit(true);
               
               // The topmost scope was pushed on in the beginning of discovery
               TransactionManager.popScope();

            }
         }
      }
   }

   /**
    * Discover test sources provided as arguments.
    * <p>
    * Note: only the classroots, packages, classes, methods and modules are supported
    * by this engine. All other parameters can have no relation neither to ABLUnit nor OEUnit, and
    * are preserved for possible future extensions.
    * 
    * @param  uniqueId
    *         the engine unique ID 
    * @param  classroots
    *         the list of class root URLs
    * @param  packages
    *         the list of packages
    * @param  classes
    *         the list of class names 
    * @param  methods 
    *         the list of method specifications
    * @param  uris 
    *         the list of URIs
    * @param  files
    *         the list of files
    * @param  directories 
    *         the list of directories
    * @param  modules
    *         the list of modules
    * @param  resources 
    *         the list of resources
    * @param  iterations 
    *         the list of iterations (currently not supported)
    * 
    * @return the test descriptor discovered or {@code null} if no match was found.
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   public static TestDescriptor discover(final UniqueId uniqueId,
                                         final URI[] classroots,
                                         final String[] packages,
                                         final String[] classes,
                                         final String[] methods,
                                         @SuppressWarnings("unused")
                                         final URI[] uris,
                                         @SuppressWarnings("unused")
                                         final String[] files,
                                         @SuppressWarnings("unused")
                                         final String[] directories,
                                         final String[] modules,
                                         @SuppressWarnings("unused")
                                         final String[] resources,
                                         @SuppressWarnings("unused")
                                         final String[] iterations)
   throws ErrorConditionException,
          AssertionFailedError
   {
      final FWDEngineDescriptor engineDescriptor = new FWDEngineDescriptor(uniqueId);
      
      register(engineDescriptor);
      
      final WorkArea wa = context.get();
      if (wa.unprocessed == 0)
      {
         // If we are here for the first time, do the initialization
         
         // force DatabaseTriggerManager context-local instantiation before the new scope is pushed
         DatabaseTriggerManager.get();
      
         // add a bogus scope
         Object bogus = new Object();
         ProcedureManager.getProcedureHelper().pushCalleeInfo(UnitTestServer.class, false, bogus, bogus, ProcedureManager.EXTERNAL_PROGRAM, "bogus-ut.p", false, false, false, false);
         ObjectOps.getObjectHelper().registerPendingScopeable();
         TransactionManager.pushScope("bogus", TransactionManager.SUB_TRANSACTION, true, true, false, false, BlockType.EXTERNAL_PROC);
      
         // make sure that the global block gets a special notification - but only for non-appserver sessions.
         LogicalTerminal.registerCleaner();
         
      }
      
      convertError(() -> {
         try
         {
            for (final String methodId : methods)
            {
               final int delimeterIdx = methodId.indexOf('#');
               final String className = methodId.substring(0, delimeterIdx);
               final Class<?> clazz = Class.forName(className);
               final String methodName = methodId.substring(delimeterIdx + 1);
               final Method method = clazz.getMethod(methodName);

               addOptionally(engineDescriptor, clazz, method);
            }

            for (final String selector : classes)
            {
               scanClassRecursively(engineDescriptor, Class.forName(selector));
            }
         }
         catch (final ClassNotFoundException | NoSuchMethodException | SecurityException e)
         {
            throw new RuntimeException(e);
         }

         for (final String pkg : packages)
         {
            // Note: we need to restrict the search by only the classes immediately located in the package,
            // excluding all classes in sub-packages
            final List<Class<?>> packageClasses = ReflectionSupport.findAllClassesInPackage(pkg,
                     (Class<?> c) -> pkg.equals(c.getPackage().getName()), name -> true);
            for (Class<?> javaClass : packageClasses)
            {
               scanClassRecursively(engineDescriptor, javaClass);
            }
         }

         for (final URI uri : classroots)
         {
            final List<Class<?>> classrootClasses = ReflectionSupport
                     .findAllClassesInClasspathRoot(uri, isTestClassCandidate, name -> true);
            for (Class<?> javaClass : classrootClasses)
            {
               scanClassRecursively(engineDescriptor, javaClass);
            }
         }

         for (final String module : modules)
         {
            // FIXME: this is not tested due to the lack of modules in FWD.
            final List<Class<?>> uriClasses = ReflectionSupport
                     .findAllClassesInModule(module, isTestClassCandidate, name -> true);
            for (Class<?> javaClass : uriClasses)
            {
               scanClassRecursively(engineDescriptor, javaClass);
            }
         }
      });

      wa.unprocessed++;

      return engineDescriptor;
   }
   
   /**
    * Execute the <em>behavior</em> of this node.
    * <p>
    * Locate the test descriptor by its unique ID and calls
    * {@link AbstractFWDTestDescriptor#executeImpl()}
    * 
    * @param  testId
    *         test descriptor ID
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this  
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   public static void execute(final UniqueId testId)
   throws ErrorConditionException,
          AssertionFailedError
   {
      final AbstractFWDTestDescriptor desc = getDescriptor(testId);
      LOG.fine("execute: " + testId + " desc: " + desc);
      final String expectedException = (desc instanceof AbstractMethodTestDescriptor)
               ? ((AbstractMethodTestDescriptor) desc).expectedException
               : "";
      convertError(() -> desc.executeImpl(), expectedException);
   }

   /**
    * Initialize FWD server.
    */
   public static void initialize()
   {
      StandardServer.initializeClientSession();

      DatabaseManager.autoConnect();
      
      SessionUtils.setErrorStackTrace(true);
   }

   /**
    * Prepare test descriptor.
    * <p>
    * Locate the test descriptor by its unique ID and calls
    * {@link AbstractFWDTestDescriptor#prepareImpl()}
    * 
    * @param  testId 
    *         test descriptor ID
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   public static void prepare(final UniqueId testId)
   throws ErrorConditionException,
          AssertionFailedError
   {
      final AbstractFWDTestDescriptor desc = getDescriptor(testId);
      LOG.fine("prepare : " + testId + " desc: " + desc);
      desc.prepareImpl();
   }

   /**
    * Run the provided code, convert legacy errors thrown into {@link AbstractAssertionFailedError}
    * along with legacy error stack trace, throw the result.
    * <p>
    * This is a FWD extension to support test runners (e.g. Eclipse), which "know" how to display
    * {@link AssertionFailedError} specially.
    * 
    * @param  action
    *         the action to run, must return the legacy error thrown or {@code null}
    * @param  expectedExceptionClassName
    *         the expected legacy error class name or an empty string if no legacy error was expected
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   protected final static void convertError(final Runnable action, final String expectedExceptionClassName)
   throws ErrorConditionException,
          AssertionFailedError
   {
      // Run the code, catch legacy error
      LegacyError legacyError;
      try
      {
         action.run();
         legacyError = null;
      }
      catch (final LegacyErrorException e)
      {
         legacyError = e.getErrorRef();
      }

      final Class<?> expectedException;
      if (!expectedExceptionClassName.isEmpty())
      {
         expectedException = ObjectOps.resolveClass(expectedExceptionClassName);
         if (expectedException == null)
         {
            throw new RuntimeException(
                     "Cannot resolve legacy class to Java class: " + expectedExceptionClassName);
         }
      }
      else
      {
         expectedException = null;
      }

      final AssertionFailedError error;
      
      if (expectedException == null)
      {
         if (legacyError == null)
         {
            // Success: no error, and none expected 
            return;
         }

         // An error occurred, but non expected
         if (legacyError instanceof AbstractAssertionFailedError)
         {
            final AbstractAssertionFailedError afe = (AbstractAssertionFailedError) legacyError;
            final BaseDataType expected = afe.getExpected();
            final BaseDataType actual = afe.getActual();
            final String message = afe.getMessageInt();

            error = new AssertionFailedError(message,
                     expected == null ? "null" : expected.toStringMessage(),
                     actual == null ? "null" : actual.toStringMessage());
         }
         else
         {
            error = new AssertionFailedError(legacyError.getMessage(new integer(1)).toStringMessage());
         }


      }
      else if (legacyError == null)
      {
         // Legacy exception expected 
         throw new AssertionFailedError(
                  "Throwing " + expectedExceptionClassName + " was expected, but nothing was thrown");
      }
      else if (!expectedException.isAssignableFrom(legacyError.getClass()))
      {
         error = new AssertionFailedError(
                  "Throwing " + expectedExceptionClassName + " was expected, but " + legacyError.toString()
                           + " was thrown");
      }
      else
      {
         // An expected exception was thrown
         return;
      }
      
      // Transform legacy error call stack into a Java call stack
      final character callStack = legacyError.getCallStack();
      if (!callStack.isUnknown())
      {
         final String stackString = callStack.toStringMessage();
         final String sep = System.getProperty("line.separator");
         final String[] chunks = stackString.split(sep);
         final List<StackTraceElement> trace = new ArrayList<>();

         for (final String chunk : chunks)
         {
            if ("Server StackTrace:".equals(chunk))
            {
               // FIXME: we do not know what to do with a multi-part stack traces,
               // so for now will ignore all parts but the last part.
               trace.clear();
               continue;
            }
            
            final Matcher matcher = legacyErrorStackTraceElementRegexp.matcher(chunk);
            if (!matcher.matches())
            {
               LOG.severe("Cannot parse legacy error stack trace element: " + chunk);
               continue;
            }
            
            final String javaClassMethod = matcher.group(1);            
            final int separator = javaClassMethod.lastIndexOf('.');
            final String javaClass = javaClassMethod.substring(0, separator);
            final String javaMethod = javaClassMethod.substring(separator + 1);

            // The part containing legacy class and method data between squares might be missing.
            final int group = matcher.groupCount() == 5 ? 4 : 3;
            final String lineNumber = matcher.group(group);
            final String fileName = matcher.group(group + 1);
            trace.add(new StackTraceElement(javaClass, javaMethod, fileName,
                     Integer.valueOf(lineNumber)));
         }

         error.setStackTrace(trace.toArray(new StackTraceElement[trace.size()]));
      }
      
      throw error;

   }

   /**
    * Scan a class for test methods, declared in the class.
    * 
    * @param parentDescriptor
    *        the parent descriptor to register found test descriptors in
    * @param javaClass
    *        the Java class to scan
    * @param explicitMethods
    *        optional list of explicit test methods to execute, can be {@code null}
    */
   private final static void addOptionally(final TestDescriptor parentDescriptor,
                                           final Class<?> javaClass,
                                           final Method explicitMethods)
   {
      final AbstractClassTestDescriptor instance = testAndCreate(javaClass, parentDescriptor,
               explicitMethods);
      if (instance != null)
      {
         parentDescriptor.addChild(instance);
      }
   }

   /**
    * Test if a method is annotated with the given annotation,
    * and is not annotated with the {@link Disabled} annotation,
    * and is public.
    * 
    * Log methods, which are properly annotated, but are not public.
    * 
    * @param method
    *        the method to test
    * @param annoClass
    *        the annotation to test
    * 
    * @return {@code true} is the method argument matches the criteria
    */
   private final static boolean methodIsAnnotatedAndPublic(
            final Method method,
            final Class<? extends Annotation> annoClass)
   {
      final boolean isAnnotated = AnnotationUtils.isAnnotated(method, annoClass)
               && !AnnotationUtils.isAnnotated(method, Disabled.class);
      if (!isAnnotated)
      {
         return false;
      }
      
      if (!Modifier.isPublic(method.getModifiers()))
      {
         LOG.fine("Private method cannot be used for unit testing: " + method);
         return false;
      }
      
      return true;
   }
   
   /**
    * Collect all methods annotated with the given class, but not annotated as disabled.
    * 
    * @param  clazz
    *         the class to scan for methods
    * @param  annoClass
    *         the annotation class to match
    * @param  topDown
    *         if {@code true}, then traverse class hierarchy top-down, otherwise traverse bottom-up.
    * 
    * @return the list of methods found; never {@code null}
    */
   private final static List<Method> collectAnnotatedMethods(final Class<?> clazz,
                                                             final Class<? extends Annotation> annoClass,
                                                             final boolean topDown)
   {
      try
      {
         return ReflectionUtils.findMethods(clazz,
                  m -> methodIsAnnotatedAndPublic(m, annoClass),
                  topDown ? ReflectionUtils.HierarchyTraversalMode.TOP_DOWN
                           : ReflectionUtils.HierarchyTraversalMode.BOTTOM_UP);
      }
      catch (final NoClassDefFoundError ncd)
      {
         // We get here if the class hierarchy is incomplete.
         LOG.warning("Cannot load test class: " + clazz + ": " + ncd.getMessage());
         return Collections.emptyList();
      }
   }

   /**
    * Run the provided code, convert legacy errors thrown to {@link AbstractAssertionFailedError}
    * and throw the result.
    * <p>
    * This is a FWD extension to support test runners (e.g. Eclipse), which "knows" how to display
    * {@link AbstractAssertionFailedError} specially.
    * 
    * @param  action
    *         the action to run, must return the legacy error thrown or {@code null}
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   private final static void convertError(final Runnable action)
   throws ErrorConditionException,
          AssertionFailedError
   {
      convertError(action, "");
   }

   /**
    * Find descriptor by its ID.
    * 
    * @param  testId
    *         unique ID
    * 
    * @return the test descriptor.
    */
   private final static AbstractFWDTestDescriptor getDescriptor(final UniqueId testId)
   {
      return context.get().testDescriptorRegistry.get(testId);
   }

   /**
    * Register a new descriptor.
    * 
    * @param descriptor
    *        the descriptor to register
    */
   private final static void register(final AbstractFWDTestDescriptor descriptor)
   {
      final Map<UniqueId, AbstractFWDTestDescriptor> registry = context
               .get().testDescriptorRegistry;
      final UniqueId uniqueId = descriptor.getUniqueId();

      // The same class can be collected more than once in case test suites are present 
      if (!registry.containsKey(uniqueId))
      {
         registry.put(uniqueId, descriptor);
      }
   }

   /**
    * Recursively scan a class for all test methods, including whose defined in the class
    * and referenced classes, if the class is marked as test suite.
    * 
    * @param  engineDescriptor
    *         the engine descriptor to register found test descriptors in
    * @param  javaClass
    *         the Java class to scan
    * 
    * @throws ErrorConditionException 
    *         remote code may throw this
    * @throws AssertionFailedError
    *         remote legacy errors are converted to this
    *         exception type
    */
   private final static void scanClassRecursively(final TestDescriptor engineDescriptor,
                                                  final Class<?> javaClass)
   throws ErrorConditionException,
          AssertionFailedError
   {
      if (!isTestClassCandidate.test(javaClass))
      {
         // Class cannot relate to unit tests.
         return;
      }

      addOptionally(engineDescriptor, javaClass, null);

      // Add suites
      if (AnnotationSupport.isAnnotated(javaClass, Suite.class))
      {
         final SelectClasses classesAnnotation = javaClass.getAnnotation(SelectClasses.class);
         if (classesAnnotation != null)
         {
            for (final String className : classesAnnotation.value())
            {
               final Class<?> referencedClass = ObjectOps.resolveClass(className);
               if (referencedClass != null)
               {
                  scanClassRecursively(engineDescriptor, referencedClass);
               }
               else
               {
                  LOG.severe("Cannot resolve legacy class " + className + " referenced in suite "
                           + javaClass);
               }
            }
         }

         final SelectProcedures proceduresAnnotation = javaClass
                  .getAnnotation(SelectProcedures.class);
         if (proceduresAnnotation != null)
         {
            for (final String procedureName : proceduresAnnotation.value())
            {
               try
               {
                  final String className = SourceNameMapper.convertNameToClass(procedureName);
                  if (className == null)
                  {
                     throw new RuntimeException("Cannot resolve class for procedure: "
                              + procedureName
                              + " referenced from " + javaClass);
                  }

                  addOptionally(engineDescriptor, Class.forName(className), null);
               }
               catch (ClassNotFoundException e)
               {
                  throw new RuntimeException("Cannot find class: " + e);
               }
            }
         }
      }
   }

   /**
    * Test a Java class candidate for existent tests methods. If found, create a new class
    * test descriptor.
    * 
    * @param  clazz
    *         the class under test 
    * @param  parentDescriptor
    *         the parent descriptor
    * @param  optionalMethod
    *         the explicit method or {@code null} to scan the class for all test methods
    * @return the class test descriptor or {@code null} if this class has no test methods.
    */
   private final static AbstractClassTestDescriptor testAndCreate(final Class<?> clazz,
                                                                  final TestDescriptor parentDescriptor,
                                                                  final Method optionalMethod)
   {

      if (!isTestClassCandidate.test(clazz))
      {
         return null;
      }

      final List<Method> test = optionalMethod != null ? Lists.newArrayList(optionalMethod)
               : collectAnnotatedMethods(clazz, Test.class, true);

      final boolean isTestSuite = TestSuite.class.isAssignableFrom(clazz);
      if (test.isEmpty() && !isTestSuite)
      {
         return null;
      }

      final List<Method> beforeEach = collectAnnotatedMethods(clazz, BeforeEach.class, true);
      final List<Method> afterEach = collectAnnotatedMethods(clazz, AfterEach.class, false);
      final List<Method> beforeAll = collectAnnotatedMethods(clazz, BeforeAll.class, true);
      final List<Method> afterAll = collectAnnotatedMethods(clazz, AfterAll.class, false);

      final AbstractClassTestDescriptor classDescriptor;
      if (BaseObject.class.isAssignableFrom(clazz))
      {
         // Legacy class, either ABLUnit or OEUnit
         classDescriptor = new FWDClassTestDescriptor(clazz, parentDescriptor, test, beforeEach, afterEach,
                  beforeAll, afterAll, null);
         if (isTestSuite)
         {
            final Class c = clazz;
            BlockManager.doBlock("method-caller", new Block((Body) () -> {

               final object<? extends TestSuite> inst = new ObjectVar<>(c);
               inst.assign(ObjectOps.newInstance(c));
               final TestSuite suite = inst.ref();
               for (final _BaseObject_ childTestInstance : suite.getTests())
               {
                  addOptionally(classDescriptor, childTestInstance.getClass(), null);
               }
            }));
         }
      }
      else
      {
         // Legacy ABLUnit procedure
         classDescriptor = FWDDescriptorFactory.newProcedureTestDescriptor(clazz, parentDescriptor, test,
                  beforeEach, afterEach, beforeAll, afterAll);
      }

      register(classDescriptor);

      for (final TestDescriptor child : classDescriptor.getChildren())
      {
         register((AbstractFWDTestDescriptor) child);
      }

      return classDescriptor;
   }
   
   /**
    * Context local work area.
    */
   private static class WorkArea
   {
      /**
       * Test descriptors registered by their UIDs.
       */
      private final Map<UniqueId, AbstractFWDTestDescriptor> testDescriptorRegistry = new HashMap<>();
      
      /**
       * The number of root test descriptors which were not processed yet.
       */
      private int unprocessed;
   }

}