/*
** Module   : TestFactory.java
** Abstract : instantiates a test from a test definition 
**
** Copyright (c) 2009-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 GES 20090113 Instantiates a test from a test definition.
** 002 CA  20130509 Fixed substitutions for WAIT-FOR-SCREEN-BUFFER.
** 003 IAS 20150713 Added screen template file name to check-screen-buffer failure message
** 004 CA  20210827 Refactored to allow other test types, beside terminal (which can be executed from the same 
**                  test suite).  Added SOAP and REST web service tests.
** 005 CA  20210902 Allow request/response body files to be resolved from baseline paths.
** 006 CA  20220118 Added Basic authorization.
**     TJD 20220803 Updated for Java 11
** 007 CA  20240725 Added cookie support for request and response.
*/

/*
** 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 should have received a copy of the GNU Affero General Public License
** along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

package com.goldencode.harness.test;

import java.io.*;
import java.nio.charset.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.locks.*;
import org.w3c.dom.*;
import com.goldencode.compile.*;
import com.goldencode.util.*;
import com.goldencode.harness.*;
import com.goldencode.harness.transport.*;

/**
 * Instantiates a test from a test definition.
 */
public class TestFactory
implements DefinitionConstants
{
   /** Simple index of the classes that have been generated. */
   private static int classNum = 0;
   
   /**
    * Read the test definition stored in the specified XML file and
    * instantiate a test instance using the parsed content of that test
    * definition.
    *
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test-set resource pool.
    * @param    cfgs
    *           Configuration required to access the target system.
    * @param    filename
    *           Path and filename of the test definition.
    * @param    rc
    *           Object in which to store the details of any failure.
    *                    
    * @return   The test or <code>null</code> if any failure has occurred.
    */
   public static Test read(ProjectIndex        index,
                           ResourcePool        pool,
                           Map<String, SessionSettings> cfgs,
                           String              filename,
                           FailureNotification rc)
   {
      Document dom = XmlHelper.loadDocument(filename, rc);
      
      if (dom == null)
      {
         // any failures have already been logged in the rc
         return null;
      }
      
      ResourcePool tpool = new ResourcePool(pool);
      tpool.push(true);
      
      List<Cleanup>  post  = new ArrayList<Cleanup>(); 
      List<Testable> steps = loadTest(index, tpool, cfgs, dom, null, post, rc);
      
      tpool.pop(true);
      
      String name    = "";
      String descr   = "";
      int    timeout = -1;
      
      Element root = dom.getDocumentElement();
      
      if (root != null)
      {
         name    = root.getAttribute(ATTR_NAME);
         descr   = root.getAttribute(ATTR_DESCR);
         timeout = index.readAttrInt(root, ATTR_TIMEOUT, -1);
         
         if (name == null || name.length() == 0)
         {
            rc.setResult(ExitCode.MISSING_CONTENT);
            rc.setDetails("A test requires a 'name' attribute.");
            return null;
         }
      }
      
      return (steps != null) ? new Test(name, descr, steps, post, timeout)
                             : null;
   }
   
   /**
    * Read the given element's attributes and create a rectangle using that
    * data. By default, the rectangle will be created with the screen size
    * as read from the session configuration. Any specified attributes will
    * override the associated defaults for that same coordinate of the
    * rectangle, but any unspecified attributes will still be defaulted.
    * <p>
    * There are two ways to specify a rectangle via attributes:
    * <p>
    * <ul>
    *    <li> Explicit Coordinates (by specifying the upper-left and lower-right
    *         points). Attribute names:
    *    <ul>
    *       <li> LEFT
    *       <li> TOP
    *       <li> RIGHT
    *       <li> BOTTOM
    *    </ul>
    *    <li> Relative Coordinates (by specifying the starting row/column and
    *         a height and width). Attribute names:
    *    <ul>
    *       <li> ROW
    *       <li> COLUMN
    *       <li> HEIGHT
    *       <li> WIDTH
    *    </ul>
    * </ul>
    * <p>
    * Preference is given to the explicit coordinates approach. If any of the
    * attributes for that approach exist, then that is the method used. This
    * means that any relative coordinates would be ignored in that case. 
    * <p>
    * The origin is zero-based and is in the upper-left corner of the screen. 
    *
    * @param    elem
    *           The XML node from which to read.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    defRows
    *           The default number of rows for the rectangle if not otherwise
    *           specified.
    * @param    defCols
    *           The default number of columns for the rectangle if not otherwise
    *           specified.
    *
    * @return   The rectangle.
    */
   public static Rectangle readRectangle(Element      elem,
                                         ProjectIndex index,
                                         int          defRows,
                                         int          defCols)
   {
      int left   = -1;
      int right  = -1;
      int top    = -1;
      int bottom = -1;
      
      if (elem.hasAttribute(ATTR_LEFT) || elem.hasAttribute(ATTR_RIGHT) ||
          elem.hasAttribute(ATTR_TOP)  || elem.hasAttribute(ATTR_BOTTOM))
      {
         int endY = defRows - 1;
         int endX = defCols - 1;
         
         left   = index.readAttrInt(elem, ATTR_LEFT, 0);
         right  = index.readAttrInt(elem, ATTR_RIGHT, endX);
         top    = index.readAttrInt(elem, ATTR_TOP, 0);
         bottom = index.readAttrInt(elem, ATTR_BOTTOM, endY);
      }
      else
      {
         left  = index.readAttrInt(elem, ATTR_ROW, 0);
         top   = index.readAttrInt(elem, ATTR_COLUMN, 0);
         
         int endY   = defRows;
         int endX   = defCols;
         int height = index.readAttrInt(elem, ATTR_HEIGHT, endY);
         int width  = index.readAttrInt(elem, ATTR_WIDTH, endX);
         
         right  = (left + width) - 1;
         bottom = (top + height) - 1;
      }
      
      return new Rectangle(left, top, right, bottom);
   }
   
   /**
    * Read a specific attribute from the given element.
    * 
    * @param    el
    *           The element.
    * @param    name
    *           The attribute name.
    *           
    * @return   The attribute value, or <code>null</code> if the attribute does not exist.
    */
   private static String readAttribute(Element el, String name)
   {
      return el.hasAttribute(name) ? el.getAttribute(name) : null;
   }
   
   /**
    * Read the specified file.
    * 
    * @param    index
    *           Helper to access resources for the test project.
    * @param    file
    *           The file to read.
    *           
    * @return   The file content or <code>null</code> if the file does not exist.
    */
   private static String readFileContent(ProjectIndex index, String file)
   {
      if (file == null || file.isEmpty())
      {
         return null;
      }
      
      file = index.findBaseLine(file);
      
      try 
      {
         byte[] bytes = Files.readAllBytes(Paths.get(new File(file).toURI()));
         String content = new String(bytes, StandardCharsets.UTF_8);
         return content;
      }
      catch (IOException exc)
      {
         throw new RuntimeException("Could not read file " + file, exc);
      }
   }

   /**
    * Read the authorization settings from the given element and store it in the configuration.
    * <p>
    * Only Oauth1 and Basic is supported at this time.
    * 
    * @param    el
    *           The element node.
    * @param    cfg
    *           The session configuration.
    *            
    * @return   The authorization configuration.
    */
   private static WebAuthorization readAuthorization(Element el, WebSessionSettings cfg)
   {
      WebAuthorization auth = null;
      String authType = el.getAttribute(ATTR_TYPE);
      auth = WebAuthorizationFactory.createAuthorization(authType, cfg);
      
      NodeList elParams = el.getElementsByTagName(ELEM_PARAMETER);
      for (int i = 0; i < elParams.getLength(); i++)
      {
         Element elParam = (Element) elParams.item(i);
         String paramName = readAttribute(elParam, ATTR_NAME);
         String paramValue = readAttribute(elParam, ATTR_VALUE);
         auth.setParameter(paramName, paramValue);
      }
      
      return auth;
   }
   
   /**
    * Load a new Web Service configuration from the specified node, in the specified test.
    * 
    * @param    el
    *           The element node.
    * @param    test
    *           The test to configure.
    * @param    index
    *           Helper to access resources for the test project.
    */
   private static void loadWebService(Element el, WebService test, ProjectIndex index)
   {
      test.setService(readAttribute(el, ATTR_SERVICE));
      test.setMethod(readAttribute(el, ATTR_METHOD));
      
      NodeList nodes = el.getChildNodes();
      for (int i = 0; i < nodes.getLength(); i++)
      {
         Node node = nodes.item(i);
         
         if (!(node instanceof Element))
         {
            continue;
         }
         
         Element subEl = (Element) node;
         switch (subEl.getTagName().toLowerCase())
         {
            case ELEM_AUTHORIZATION:
               test.setAuthorization(readAuthorization(subEl, test.getConfiguration()));
               break;
               
            case ELEM_QUERY:
               test.addQueryParameter(readAttribute(subEl, ATTR_NAME), readAttribute(subEl, ATTR_VALUE));
               break;
               
            case ELEM_HEADER:
               test.addHeader(readAttribute(subEl, ATTR_NAME), readAttribute(subEl, ATTR_VALUE));
               break;
               
            case ELEM_COOKIE:
               test.addCookie(readAttribute(subEl, ATTR_NAME), readAttribute(subEl, ATTR_VALUE));
               break;
               
            case ELEM_BODY:
               test.setRequestBody(readRequestBody(subEl, test, index));
               break;
               
            case ELEM_RESPONSE:
               readResponse(subEl, test, index);
               break;
               
            default:
               throw new IllegalStateException("Unknown tag: " + subEl.getTagName());
         }
      }
   }
   
   /**
    * Read the response configuration for this Web test.
    * 
    * @param    el
    *           The element node.
    * @param    test
    *           The test to configure.
    * @param    index
    *           Helper to access resources for the test project.
    */
   private static void readResponse(Element el, WebService test, ProjectIndex index)
   {
      String bodyFile = readFileContent(index, el.getAttribute(ATTR_FILENAME));
      String bodyCdata = readCData(el);
      
      if (bodyFile != null && bodyCdata != null)
      {
         throw new IllegalStateException("Not allowed to specify both body file " + bodyFile + 
                                         " and CDATA body!");
      }

      HttpResponseTest httpResponse = new HttpResponseTest(bodyFile == null ? bodyCdata : bodyFile);
      test.setResponse(httpResponse);
      
      NodeList nodes = el.getChildNodes();
      for (int i = 0; i < nodes.getLength(); i++)
      {
         Node node = nodes.item(i);
         
         if (!(node instanceof Element))
         {
            continue;
         }
         
         Element subEl = (Element) node;
         switch (subEl.getTagName().toLowerCase())
         {
            case ELEM_STATUS:
               // status check
               httpResponse.setStatus(readAttribute(subEl, ATTR_CODE), readAttribute(subEl, ATTR_REASON));
               break;

            case ELEM_HEADER:
               // header check
               httpResponse.addHeader(readAttribute(subEl, ATTR_NAME), 
                                      readAttribute(subEl, ATTR_VALUE), 
                                      readAttribute(subEl, ATTR_SAVE));
               break;

            case ELEM_COOKIE:
               // cookie check
               httpResponse.addCookie(readAttribute(subEl, ATTR_NAME), 
                                      readAttribute(subEl, ATTR_VALUE), 
                                      readAttribute(subEl, ATTR_SAVE));
               break;
               
            case ELEM_BODY:
               List<String> excludes = new ArrayList<>();
               List<String> includes = new ArrayList<>();
               
               NodeList bodyNodes = subEl.getChildNodes();
               for (int j = 0; j < bodyNodes.getLength(); j++)
               {
                  Node matchNode = bodyNodes.item(j);
                  
                  if (!(matchNode instanceof Element))
                  {
                     continue;
                  }
                  
                  Element elMatch = (Element) matchNode;
                  
                  switch (elMatch.getTagName().toLowerCase())
                  {
                     case ELEM_EXCLUDE:
                        excludes.add(readAttribute(elMatch, ATTR_PATH));
                        break;
                        
                     case ELEM_INCLUDE:
                        includes.add(readAttribute(elMatch, ATTR_PATH));
                        break;
                        
                     default:
                        throw new IllegalStateException("Unknown tag: " + elMatch.getTagName());
                  }
               }
               httpResponse.setBodyMatch(readAttribute(subEl, ATTR_SAVE), excludes, includes);
               break;
               
            case ELEM_EXISTS:
               httpResponse.addPathExistsMatch(readAttribute(subEl, ATTR_PATH), readAttribute(subEl, ATTR_SAVE));
               break;
               
            case ELEM_REGEX:
               httpResponse.addPathRegexMatch(readAttribute(subEl, ATTR_PATH), 
                                              readAttribute(subEl, ATTR_VALUE),
                                              readAttribute(subEl, ATTR_SAVE));
               break;
               
            case ELEM_EXACT:
               httpResponse.addPathExactMatch(readAttribute(subEl, ATTR_PATH), 
                                              readAttribute(subEl, ATTR_VALUE),
                                              readAttribute(subEl, ATTR_SAVE));
               break;
               
            default:
               throw new IllegalStateException("Unknown tag: " + subEl.getTagName());
         }
      }
   }
   
   /**
    * Read the request body for configuration for a Web test.
    * 
    * @param    el
    *           The element node.
    * @param    test
    *           The test to configure.
    * @param    index
    *           Helper to access resources for the test project.
    *           
    * @return   The request body configuration.
    */
   private static HttpRequestBody readRequestBody(Element el, WebService test, ProjectIndex index)
   {
      String bodyFile = readFileContent(index, el.getAttribute(ATTR_FILENAME));
      String bodyCdata = readCData(el);
      
      if (bodyFile != null && bodyCdata != null)
      {
         throw new IllegalStateException("Not allowed to specify both body file " + bodyFile + 
                                         " and CDATA body!");
      }

      HttpRequestBody httpBody = new HttpRequestBody(bodyFile == null ? bodyCdata : bodyFile, test);
      
      NodeList replaceEls = el.getElementsByTagName(ELEM_REPLACE);
      for (int i = 0; i < replaceEls.getLength(); i++)
      {
         Element rel = (Element) replaceEls.item(i);
         
         String path = readAttribute(rel, ATTR_PATH);
         String var = readAttribute(rel, ATTR_VARIABLE);
         String value = readAttribute(rel, ATTR_VALUE);
         httpBody.addSubstitution(path, var, value);
      }
      
      return httpBody;
   }
   
   /**
    * Read the CDATA sections and concatenate them.
    * 
    * @param    el
    *           The element node.
    *           
    * @return   The string value for all CDATA sections, or <code>null</code> if none exist.
    */
   private static String readCData(Element el)
   {
      String text = null;
      
      NodeList children = el.getChildNodes();
      for (int i = 0; i < children.getLength(); i++)
      {
         Node child = children.item(i);
         
         if (child.getNodeType() == Node.CDATA_SECTION_NODE)
         {
            CDATASection cdata = (CDATASection) child;
            if (text == null)
            {
               text = "";
            }
            
            text += cdata.getData();
         }
      }
      
      return text;
   }

   /**
    * Read the parsed document and create the list of steps that are
    * defined there.
    * <p>
    * Include directive processing is handled via recursion to this same
    * method. Otherwise, each directive found as an immediate child of the
    * root node will be matched and parsed to obtain the details. Then a
    * {@link Testable} instance will be created and added to the list.
    *
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test-set resource pool.
    * @param    cfgs
    *           Configuration required to access the target system.
    * @param    dom
    *           The document repsenting the XML file.
    * @param    list
    *           The list of steps to which to append or <code>null</code>
    *           if a new list is to be created (and then filled).
    * @param    post
    *           The list of cleanup processing to execute after testing (even
    *           on failure).
    * @param    rc
    *           Object in which to store the details of any failure.
    *                            
    * @return   The list of steps for the test, in the order of proper
    *           execution. <code>null</code> will be returned on any
    *           failure.
    */
   private static List<Testable> loadTest(ProjectIndex        index,
                                          ResourcePool        pool,
                                          Map<String, SessionSettings> cfgs,
                                          Document            dom,
                                          List<Testable>      list,
                                          List<Cleanup>       post,
                                          FailureNotification rc)
   {
      if (list == null)
      {
         list = new ArrayList<Testable>();
      }
      
      if (dom != null)
      {
         Element root = dom.getDocumentElement();
         
         if (root != null)
         {
            NodeList directives = root.getChildNodes();
            
            if (directives != null)
            {
               int len = directives.getLength();
               
               // iterate through all directives (which must be immediate
               // children of the root node)
               for (int i = 0; i < len; i++)
               {
                  Node next = directives.item(i);
                  
                  if (next.getNodeType() != Node.ELEMENT_NODE)
                     continue;
                  
                  Element elem   = (Element) next;
                  int     repeat = index.readAttrInt(elem, ATTR_REPEAT, 1);
                  
                  // support step duplication
                  while (repeat > 0)
                  {
                     Testable step = processStepDefinition(index,
                                                           pool,
                                                           cfgs,
                                                           elem,
                                                           list,
                                                           post,
                                                           rc);
                     
                     // step definition failure details are recorded in rc
                     if (rc.isFailed())
                        return null;
                     
                     if (step != null)
                     {
                        list.add(step);
                     }
                     
                     repeat--;
                  }
               }
            }
         }
      }
      
      return list;
   }
   
   /**
    * Read the step definition from the given element and return the newly
    * instantiated step.
    *
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test-set resource pool.
    * @param    cfgs
    *           Configuration required to access the target system.
    * @param    elem
    *           The step definition.
    * @param    list
    *           The list of steps to which to append or <code>null</code>
    *           if a new list is to be created (and then filled).
    * @param    post
    *           The list of cleanup processing to execute after testing (even
    *           on failure).
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The newly created step. This may be <code>null</code> if
    *           there was an error or if the element is not a test step
    *           (e.g. a variable definition).
    */
   private static Testable processStepDefinition(ProjectIndex        index,
                                                 ResourcePool        pool,
                                                 Map<String, SessionSettings> cfgs,
                                                 Element             elem, 
                                                 List<Testable>      list,
                                                 List<Cleanup>       post,
                                                 FailureNotification rc)
   {
      TerminalSessionSettings termCfg = (TerminalSessionSettings) cfgs.get("terminal");
      WebSessionSettings webCfg = (WebSessionSettings) cfgs.get("web");
      
      int         millis    = 0;
      int         row       = 0;
      int         col       = 0;
      int         elen      = 0;
      String      value     = null;
      String      fname     = null;
      String      line      = null;
      String      local     = null;
      String      remote    = null;
      String      oldName   = null;
      String      newName   = null;
      String      baseline  = null;
      String      actual    = null;
      String      link      = null;
      String      name      = elem.getTagName().toLowerCase();
      boolean     clear     = false;
      Testable    step      = null;
      NodeList    excl      = null;
      SFTPSession sftp      = null;
         
      Rectangle[] excludes = null; 
      
      if (ELEM_REST.equals(name))
      {
         step = new Rest(webCfg, rc, pool);
         loadWebService(elem, (WebService) step, index);
      }
      else if (ELEM_SOAP.equals(name))
      {
         step = new Soap(webCfg, rc, pool);
         loadWebService(elem, (WebService) step, index);
      }
      else if (ELEM_INCLUDE.equals(name))
      {
         fname = elem.getAttribute(ATTR_FILENAME);
         
         // honor search paths
         fname = index.findTest(fname);
         
         if (fname == null || fname.length() == 0)
         {
            rc.setResult(ExitCode.MISSING_FILENAME);
            rc.setDetails("Include directive requires a filename.");
            return null;
         }
         
         Document dom2 = XmlHelper.loadDocument(fname, rc);
         
         if (dom2 == null)
         {
            // any failures have already been logged in the rc        
            return null;
         }
         
         // use recursion to load the include file in-line
         list = loadTest(index, pool, cfgs, dom2, list, post, rc);
         
         if (list == null)
         {
            // errors are already coded in rc
            return null;
         }
         else
         {
            // a hack to make the return processing correct (this step will be
            // added back by the caller...)
            step = list.remove(list.size() - 1);
         }
      }
      else if (ELEM_PAUSE.equals(name))
      {
         step = loadPause(elem, index, pool, rc);
      }
      else if (ELEM_SEND_SPECIAL_KEY.equals(name))
      {
         step = loadSendSpecialKey(elem, index, pool, rc);
      }
      else if (ELEM_SEND_TEXT.equals(name))
      {
         step = loadSendText(elem, index, pool, rc);
      }
      else if (ELEM_CLEAR_SCREEN_BUFFER.equals(name))
      {
         step = new ClearScreenBuffer();
      }
      else if (ELEM_LOG_CURSOR_POS.equals(name))
      {
         step = new LogCursorPos();
      }
      else if (ELEM_CHECK_CURSOR_POS.equals(name))
      {
         row  = index.readAttrInt(elem, ATTR_ROW, -1);
         col  = index.readAttrInt(elem, ATTR_COLUMN, -1);
         step = new CheckCursorPos(row, col);
      }
      else if (ELEM_LOG.equals(name))
      {
         step = loadLog(elem, index, pool, rc);
      }
      else if (ELEM_LOG_SCREEN_BUFFER.equals(name))
      {
         millis = index.readAttrInt(elem, ATTR_MILLIS, 0);
         clear  = index.readAttrBool(elem, ATTR_CLEAR, false);
         step = new LogScreenBuffer(clear, millis);
      }
      else if (ELEM_WAIT_FOR_SCREEN_BUFFER.equals(name))
      {
         millis = index.readAttrInt(elem, ATTR_MILLIS, 0);
         value  = index.readAttr(elem, ATTR_VALUE);
         
         // honor ANY mode
         if (value != null && value.length() == 0)
         {
            value = null;
         }

         step = new WaitForScreenBuffer(millis, value);
         SubstitutionReport sr = ((WaitForScreenBuffer) step).getSubstitutionReport();
         
         processSubstitutions(sr, elem, index, pool, rc);
      }
      else if (ELEM_CHECK_SCREEN_BUFFER.equals(name))
      {
         millis = index.readAttrInt(elem, ATTR_MILLIS, 0);
         
         boolean wait = index.readAttrBool(elem, ATTR_WAIT, true);
         
         Rectangle rect = getRectangle(index,
                                       elem,
                                       termCfg.termRows,
                                       termCfg.termColumns);
         
         boolean found = false;
         
         // create the two dimensional array 
         char[][] contents = new char[rect.getHeight()][rect.getWidth()];
         
         // option 1: read the screen region from a file
         fname = elem.getAttribute(ATTR_FILENAME);
         
         // honor search paths
         fname = index.findBaseLine(fname);
         
         if (fname != null && fname.length() > 0)
         {
            File screen = new File(fname);
            
            if (screen.exists() && screen.isFile())
            {
               try
               {
                  FileReader     fr = new FileReader(screen);
                  BufferedReader br = new BufferedReader(fr);
                  
                  line = br.readLine();
                  int yIdx = 0;
                  
                  // read each line until EOF or the contents are
                  // full
                  while (line != null && yIdx < contents.length)
                  {
                     // copy the data
                     copyString(line, contents[yIdx]);
                     
                     // process the next line if there is one
                     yIdx++;
                     line = br.readLine();
                  }
                  
                  found = true;
               }
               
               catch (FileNotFoundException fnfe)
               {
                  rc.setResult(ExitCode.INVALID_FILENAME);
                  String spec = "Screen buffer file missing (%s).";
                  String err  = LogHelper.dumpThrowable(fnfe);
                  rc.setDetails(String.format(spec, err));
                  return null;
               }
               
               catch (IOException ioe)
               {
                  rc.setResult(ExitCode.UNKNOWN_FAILURE);
                  rc.setDetails(LogHelper.dumpThrowable(ioe));
                  return null;
               }
            }
            else
            {
               rc.setResult(ExitCode.INVALID_FILENAME);
               String spec2 = "Screen (%s) missing or not a file.";
               rc.setDetails(String.format(spec2, fname));
               return null;
            }
         }
         
         // option 2: read the screen from child elements
         if (!found)
         {
            NodeList lines = elem.getElementsByTagName(ELEM_LINE);
            int      llen  = lines.getLength();
            
            if (llen > 0)
            {
               for (int q = 0; q < llen; q++)
               {
                  Element nextLine = (Element) lines.item(q);
                  value = index.readAttr(nextLine, ATTR_VALUE);
                  copyString(value, contents[q]);
               }
               
               found = true;
            }
         }
         
         // option 3: read a simple string from an attribute
         if (!found && elem.hasAttribute(ATTR_VALUE))
         {
            line = index.readAttr(elem, ATTR_VALUE);
            
            row = index.readAttrInt(elem, ATTR_ROW, 0);
            col = index.readAttrInt(elem, ATTR_COLUMN, 0);
            
            int end = (col + line.length()) - 1; 
            rect = new Rectangle(col, row, end, row);
            
            contents = new char[1][rect.getWidth()];
            copyString(line, contents[0]);
            
            found = true;
         }
         
         if (!found)
         {
            rc.setResult(ExitCode.MISSING_CONTENT);
            rc.setDetails("No screen comparison data found.");
            return null;
         }
         
         // process exclusion regions
         excl = elem.getElementsByTagName(ELEM_EXCLUDE);
         elen = excl.getLength();
         
         excludes = null; 
         
         if (elen > 0)
         {
            excludes = new Rectangle[elen];
            
            for (int k = 0; k < elen; k++)
            {
               Element ex = (Element) excl.item(k);
               excludes[k] = getRectangle(index,
                                          ex,
                                          termCfg.termRows,
                                          termCfg.termColumns);
            }
         }
         
         ScreenRegion region = new ScreenRegion(rect, fname, contents, excludes);
         
         step = new CheckScreenBuffer(region, wait, millis);
      }
      else if (ELEM_BINARY_FILE_COMPARISON.equals(name))
      {
         baseline = elem.getAttribute(ATTR_BASELINE);
         
         // honor search paths
         baseline = index.findBaseLine(baseline);
         
         sftp   = null;
         actual = null;
         
         if (elem.hasAttribute(ATTR_ACTUAL))
         {
            actual = elem.getAttribute(ATTR_ACTUAL);
         }
         else if (elem.hasAttribute(ATTR_REMOTE))
         {
            actual = index.readAttr(elem, ATTR_REMOTE);
            sftp = new SFTPSession(termCfg);
         }
         
         if (baseline == null || baseline.length() == 0)
         {
            rc.setResult(ExitCode.INVALID_FILENAME);
            rc.setDetails("Missing baseline file in binary file comparison.");
            return null;
         }
         
         if (actual == null || actual.length() == 0)
         {
            rc.setResult(ExitCode.INVALID_FILENAME);
            rc.setDetails("Missing comparison file in binary file comparison.");
            return null;
         }
         
         // process exclusion regions
         excl = elem.getElementsByTagName(ELEM_EXCLUDE);
         elen = excl.getLength();
         
         ExclusionRegion[] gaps = null; 
         
         if (elen > 0)
         {
            gaps = new ExclusionRegion[elen];
            
            for (int z = 0; z < elen; z++)
            {
               Element ex = (Element) excl.item(z);
               
               long start = index.readAttrLong(ex, ATTR_START, -1);
               long end   = index.readAttrLong(ex, ATTR_END, -1);
               
               if (start < 0 || end < 0 || end < start)
               {
                  rc.setResult(ExitCode.INVALID_CONTENT);
                  String errspec = "Invalid start (%d) or end (%d) " +
                                   "for binary-file-comparison.";
                  rc.setDetails(String.format(errspec, start, end));
                  return null;
               }
               
               gaps[z] = new ExclusionRegion(start, end);
            }
         }
         
         step = new BinaryFileComparison(baseline,
                                         actual,
                                         index.getDownloadDirectory(),
                                         sftp,
                                         gaps);
      }
      else if (ELEM_TEXT_FILE_COMPARISON.equals(name))
      {
         baseline = elem.getAttribute(ATTR_BASELINE);
         
         // honor search paths
         baseline = index.findBaseLine(baseline);
         
         sftp   = null;
         actual = null;
         
         if (elem.hasAttribute(ATTR_ACTUAL))
         {
            actual = elem.getAttribute(ATTR_ACTUAL);
         }
         else if (elem.hasAttribute(ATTR_REMOTE))
         {
            actual = index.readAttr(elem, ATTR_REMOTE);
            sftp = new SFTPSession(termCfg);
         }
         
         if (baseline == null || baseline.length() == 0)
         {
            rc.setResult(ExitCode.INVALID_FILENAME);
            rc.setDetails("Missing baseline file in binary file comparison.");
            return null;
         }
         
         if (actual == null || actual.length() == 0)
         {
            rc.setResult(ExitCode.INVALID_FILENAME);
            rc.setDetails("Missing comparison file in binary file comparison.");
            return null;
         }
         
         // process exclusion regions
         excl = elem.getElementsByTagName(ELEM_EXCLUDE);
         elen = excl.getLength();
         
         RowExclusion[] gaps = null; 
         
         if (elen > 0)
         {
            gaps = new RowExclusion[elen];
            
            for (int z = 0; z < elen; z++)
            {
               Element ex = (Element) excl.item(z);
               
               int start = index.readAttrInt(ex, ATTR_START, -1);
               int end   = index.readAttrInt(ex, ATTR_END, -1);
               
               if (start < 0 || end < 0 || end < start)
               {
                  rc.setResult(ExitCode.INVALID_CONTENT);
                  String errspec = "Invalid start (%d) or end (%d) " +
                                   "for text-file-comparison.";
                  rc.setDetails(String.format(errspec, start, end));
                  return null;
               }
               
               if (!ex.hasAttribute(ATTR_ROW))
               {
                  rc.setResult(ExitCode.MISSING_CONTENT);
                  rc.setDetails("No row number found for exclusion region.");
                  return null;
               }
               
               row = index.readAttrInt(ex, ATTR_ROW, -1);
               
               String type = index.readAttr(ex, ATTR_TYPE, "absolute");
               type = type.toLowerCase();
               
               if (type.equals("absolute"))
               {
                  gaps[z] = new AbsoluteRowExclusion(row, start, end);
               }
               else if (type.equals("each-page"))
               {
                  gaps[z] = new EachPageExclusion(row, start, end);
               }
               else if (type.equals("last-page"))
               {
                  gaps[z] = new LastPageExclusion(row, start, end);
               }
               else if (type.equals("specific-page"))
               {
                  int page = XmlHelper.getAttributeInt(ex,
                                                       ATTR_PAGE_NUMBER,
                                                       -1);
                  
                  if (page < 0)
                  {
                     rc.setResult(ExitCode.INVALID_CONTENT);
                     String errspec = "Invalid page number (%d) " +
                                      "for exclusion region.";
                     rc.setDetails(String.format(errspec, page));
                     return null;
                  }
                  
                  gaps[z] = new SpecificPageExclusion(page, row, start, end);
               }
               else
               {
                  rc.setResult(ExitCode.INVALID_CONTENT);
                  String err = String.format("Unknown type '%s' for text " +
                                             "exclusion region.",
                                             type);
                  rc.setDetails(err);
                  return null;
               }
               
               boolean ignSz = index.readAttrBool(ex, ATTR_IGNORE_SIZE, false);
               gaps[z].setIgnoreSize(ignSz);
            }
         }
         
         step = new TextFileComparison(baseline,
                                       actual,
                                       index.getDownloadDirectory(),
                                       sftp,
                                       gaps);
      }
      else if (ELEM_SFTP.equals(name))
      {
         NodeList cmds = elem.getChildNodes();
         int      clen = cmds.getLength();
         
         if (clen > 0)
         {
            sftp = new SFTPSession(termCfg);
            list.add(new SftpConnect(sftp));
            
            for (int x = 0; x < clen; x++)
            {
               Node node = cmds.item(x);
               
               if (node.getNodeType() != Node.ELEMENT_NODE)
                  continue;
               
               Element cmd = (Element) node;
               name = cmd.getTagName().toLowerCase();
               
               if (ELEM_LS.equals(name))
               {
                  fname = index.readAttr(cmd, ATTR_PATH);
                  list.add(new SftpLs(sftp, fname));
               }
               else if (ELEM_GET.equals(name))
               {
                  remote = index.readAttr(cmd, ATTR_REMOTE);
                  local  = index.readAttr(cmd, ATTR_LOCAL);
                  
                  if (local == null || local.length() == 0)
                  {
                     local = remote;
                  }
                  
                  list.add(new SftpGet(sftp, remote, local));
               }
               else if (ELEM_PUT.equals(name))
               {
                  local  = index.readAttr(cmd, ATTR_LOCAL);
                  remote = index.readAttr(cmd, ATTR_REMOTE);
                  
                  if (remote == null || remote.length() == 0)
                  {
                     remote = local;
                  }
                  
                  list.add(new SftpPut(sftp, local, remote));
               }
               else if (ELEM_CD.equals(name))
               {
                  fname = index.readAttr(cmd, ATTR_PATH);
                  list.add(new SftpCd(sftp, fname));
               }
               else if (ELEM_LCD.equals(name))
               {
                  fname = index.readAttr(cmd, ATTR_PATH);
                  list.add(new SftpLcd(sftp, fname));
               }
               else if (ELEM_PWD.equals(name))
               {
                  list.add(new SftpPwd(sftp));
               }
               else if (ELEM_LPWD.equals(name))
               {
                  list.add(new SftpLpwd(sftp));
               }
               else if (ELEM_SYMLINK.equals(name))
               {
                  actual = index.readAttr(cmd, ATTR_ACTUAL);
                  link   = index.readAttr(cmd, ATTR_LINK);
                  list.add(new SftpSymlink(sftp, actual, link));
               }
               else if (ELEM_RENAME.equals(name))
               {
                  oldName = index.readAttr(cmd, ATTR_OLD);
                  newName = index.readAttr(cmd, ATTR_NEW);
                  list.add(new SftpRename(sftp, oldName, newName));
               }
               else if (ELEM_RM.equals(name))
               {
                  fname = index.readAttr(cmd, ATTR_FILESPEC);
                  list.add(new SftpRm(sftp, fname));
               }
               else if (ELEM_RMDIR.equals(name))
               {
                  fname = index.readAttr(cmd, ATTR_PATH);
                  list.add(new SftpRmdir(sftp, fname));
               }
               else if (ELEM_MKDIR.equals(name))
               {
                  fname = index.readAttr(cmd, ATTR_PATH);
                  list.add(new SftpMkdir(sftp, fname));
               }
               else
               {
                  rc.setResult(ExitCode.INVALID_CONTENT);
                  rc.setDetails(String.format("Unknown SFTP command %s.",
                                              name));
                  return null;
               }
            }
            
            step = new SftpDisconnect(sftp);
         }
      }
      else if (ELEM_ACQUIRE_MUTEX.equals(name))
      {
         step = loadAcquireMutex(elem, post, pool, rc);
      }
      else if (ELEM_RELEASE_MUTEX.equals(name))
      {
         step = loadReleaseMutex(elem, pool, rc);
      }
      else if (ELEM_POST_EVENT_SEM.equals(name))
      {
         step = loadPostEvent(elem, index, pool, rc);
      }
      else if (ELEM_RESET_EVENT_SEM.equals(name))
      {
         step = loadResetEvent(elem, pool, rc);
      }
      else if (ELEM_CHECK_POST_COUNT.equals(name))
      {
         step = loadCheckPostCount(elem, index, pool, rc);
      }
      else if (ELEM_WAIT_EVENT_SEM.equals(name))
      {
         step = loadWaitEvent(elem, index, pool, rc);
      }                                        
      else if (ELEM_VARIABLE.equals(name))
      {
         // this doesn't result in a step, it is a resource definition that
         // goes into the resource pool
         TestPlanFactory.loadVariable(elem, index, pool, rc);
      }                                        
      else if (ELEM_CHECK_VARIABLE.equals(name))
      {
         step = loadCheckVariable(elem, pool, rc);
      }                                        
      else if (ELEM_READ_CURSOR_POS.equals(name))
      {
         step = loadReadCursorPos(elem, index, pool, rc);
      }                                        
      else if (ELEM_READ_STRING.equals(name))
      {
         step = loadReadString(elem, index, pool, rc);
      }                                        
      else if (ELEM_RUN_CODE.equals(name))
      {
         step = loadRunCode(elem, index, pool, rc);
      }                                        
      else
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Unknown step %s.", name));
      }
      
      return step;
   }
   
   /**
    * Read a rectangle definition from the element or make a copy of a well
    * known named rectangle.
    *
    * @param    index
    *           Helper to access resources for the test project.
    * @param    elem
    *           Element which contains rectangle definition.
    * @param    defRows
    *           The default number of rows for the rectangle if not otherwise
    *           specified.
    * @param    defCols
    *           The default number of columns for the rectangle if not otherwise
    *           specified.
    *
    * @return   The rectangle.
    */
   private static Rectangle getRectangle(ProjectIndex index,
                                         Element      elem,
                                         int          defRows,
                                         int          defCols)
   {
      Rectangle result = null;
      
      if (elem.hasAttribute(ATTR_NAMED_RECTANGLE))
      {
         String name = index.readAttr(elem, ATTR_NAMED_RECTANGLE);
         result = index.getRectangle(name);
      }
      
      if (result == null)
      {
         result = readRectangle(elem, index, defRows, defCols);
      }
      
      return result;
   }
   
   /**
    * Copy the contents of the given source string into the target array
    * without overwriting the array boundaries.
    *
    * @param    source
    *           The text to copy.
    * @param    target
    *           The target into which to copy.
    */
   private static void copyString(String source, char[] target)
   {
      int slen = source.length();
      
      // copy the string contents into the array
      for (int z = 0; z < slen && z < target.length; z++)
      {
         target[z] = source.charAt(z);
      }
   }
   
   /**
    * Read the drain mode parameters (if they exist) and configure drain mode
    * in the step as needed.
    *
    * @param    step
    *           The step to configure.
    * @param    elem
    *           The XML node from which to read.
    * @param    index
    *           Test project metadata.
    */
   private static void processDrainMode(SendKey      step,
                                        Element      elem,
                                        ProjectIndex index)
   {
      if (elem.hasAttribute(ATTR_TIMEOUT))
      {
         int timeout  = index.readAttrInt(elem, ATTR_TIMEOUT, -1);
         int minimum  = index.readAttrInt(elem, ATTR_MINIMUM, 0);
         
         step.enableDrainMode(timeout, minimum);
      }
   }
                                 
   /**
    * Read the configuration for the given <code>ACQUIRE-MUTEX</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    post
    *           Cleanup list to maintain.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadAcquireMutex(Element             elem,
                                            List<Cleanup>       post,
                                            ResourcePool        pool,
                                            FailureNotification rc)
   {
      Testable      step  = null;
      String        name  = elem.getAttribute(ATTR_NAME);
      ReentrantLock mutex = findMutex(ELEM_ACQUIRE_MUTEX, name, pool, rc);
      
      if (mutex != null)
      {
         step = new AcquireMutex(name, mutex);
         
         // add cleanup processing
         post.add(new AcquireMutexCleanup(mutex));
      }
      
      return step;
   }
   
   /**
    * Read the configuration for the given <code>RELEASE-MUTEX</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadReleaseMutex(Element             elem,
                                            ResourcePool        pool,
                                            FailureNotification rc)
   {
      String        name  = elem.getAttribute(ATTR_NAME);
      ReentrantLock mutex = findMutex(ELEM_RELEASE_MUTEX, name, pool, rc);
      
      return (mutex != null) ?  new ReleaseMutex(name, mutex) : null;
   }
   
   /**
    * Helper to access the referenced mutex semaphore.
    *
    * @param    op
    *           Operation name.
    * @param    name
    *           The name of the mutex.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static ReentrantLock findMutex(String              op,
                                          String              name,
                                          ResourcePool        pool,
                                          FailureNotification rc)
   {
      if (name == null || name.length() == 0)
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails(String.format("Missing mutex name in %s.", op));
         return null;
      }
      
      ReentrantLock mutex = pool.findMutex(name);
      
      if (mutex == null)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("No such mutex name %s.", name));
      }
      
      return mutex;
   }
   
   /**
    * Read the configuration for the given <code>POST-EVENT-SEM</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */                          
   private static Testable loadPostEvent(Element             elem,
                                         ProjectIndex        index,
                                         ResourcePool        pool,
                                         FailureNotification rc)
   {
      String         name  = elem.getAttribute(ATTR_NAME);
      EventSemaphore event = findEventSem(ELEM_POST_EVENT_SEM, name, pool, rc);
      
      if (event == null)
      {
         return null;
      }
      
      boolean reset = index.readAttrBool(elem, ATTR_RESET, false);
      
      return new PostEventSem(name, event, reset);
   }
   
   /**
    * Read the configuration for the given <code>RESET-EVENT-SEM</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */                          
   private static Testable loadResetEvent(Element             elem,
                                          ResourcePool        pool,
                                          FailureNotification rc)
   {
      String         name  = elem.getAttribute(ATTR_NAME);
      EventSemaphore event = findEventSem(ELEM_RESET_EVENT_SEM, name, pool, rc);
      
      return (event != null) ?  new ResetEventSem(name, event) : null;
   }
   
   /**
    * Read the configuration for the given <code>CHECK-POST-COUNT</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */                          
   private static Testable loadCheckPostCount(Element             elem,
                                              ProjectIndex        index,
                                              ResourcePool        pool,
                                              FailureNotification rc)
   {
      String         name  = elem.getAttribute(ATTR_NAME);
      EventSemaphore event = findEventSem(ELEM_CHECK_POST_COUNT,
                                          name,
                                          pool,
                                          rc);
      int num = index.readAttrInt(elem, ATTR_EXPECTED, -1);
      
      return (event != null) ?  new CheckPostCount(name, event, num) : null;
   }
   
   /**
    * Read the configuration for the given <code>WAIT-EVENT-SEM</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */                          
   private static Testable loadWaitEvent(Element             elem,
                                         ProjectIndex        index,
                                         ResourcePool        pool,
                                         FailureNotification rc)
   {
      String         name  = elem.getAttribute(ATTR_NAME);
      EventSemaphore event = findEventSem(ELEM_WAIT_EVENT_SEM, name, pool, rc);
      int num = index.readAttrInt(elem, ATTR_TIMEOUT, 0);
      
      return (event != null) ?  new WaitEventSem(name, event, num) : null;
   }
   
   /**
    * Helper to access the referenced event semaphore.
    *
    * @param    op
    *           Operation name.
    * @param    name
    *           The name of the mutex.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static EventSemaphore findEventSem(String              op,
                                              String              name,
                                              ResourcePool        pool,
                                              FailureNotification rc)
   {
      if (name == null || name.length() == 0)
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails(String.format("Missing event sem name in %s.", op));
         return null;
      }
      
      EventSemaphore event = pool.findEventSem(name);
      
      if (event == null)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("No such event semaphore name %s.", name));
      }
      
      return event;
   }
   
   /**
    * Read the configuration for the given <code>LOG</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */                          
   private static Testable loadLog(Element             elem,
                                   ProjectIndex        index,
                                   ResourcePool        pool,
                                   FailureNotification rc)
   {
      String     value = index.readAttr(elem, ATTR_VALUE);
      Variable[] subs  = loadSimpleSubstitutions(elem, index, pool, rc);
      
      return new Log(value, subs);
   }
   
   /**
    * Read the configuration for the given <code>PAUSE</code> element
    * and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadPause(Element             elem,
                                     ProjectIndex        index,
                                     ResourcePool        pool,
                                     FailureNotification rc)
   {
      Testable step = null;
      
      int millis = 0;
      
      if (elem.hasAttribute(ATTR_MILLIS))
      {
         millis = index.readAttrInt(elem, ATTR_MILLIS, 0);
         step   = new Pause(millis);
      }
      else if (elem.hasAttribute(ATTR_VARIABLE))
      {
         String vname = index.readAttr(elem, ATTR_VARIABLE);
         
         if (vname == null || vname.length() == 0)
         {
            rc.setResult(ExitCode.INVALID_CONTENT);
            rc.setDetails("Missing variable name for pause directive.");
            return null;
         }
         
         Variable var = pool.findVariable(vname);
         
         if (var == null)
         {
            rc.setResult(ExitCode.INVALID_CONTENT);
            rc.setDetails(String.format("Invalid variable name %s for pause " +
                                        "directive.",
                                        vname));
            return null;
         }
         
         step = new Pause(var);
      }
      else
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails("Pause directive requires either a 'millis' or a " +
                       "'variable' attribute.");
         return null;
      }
      
      return step;
   }
   
   /**
    * Read the any substitutions list specified as sub-elements and return
    * them as an array of variables. Simple substitutions don't have the
    * need for mapping lists of variables to parameter names.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The substitution variables or <code>null</code> if there are
    *           no substitutions.
    */
   private static Variable[] loadSimpleSubstitutions(Element             elem,
                                                     ProjectIndex        index,
                                                     ResourcePool        pool,
                                                     FailureNotification rc)
   {
      NodeList nodes = elem.getElementsByTagName(ELEM_SUBSTITUTION);
      
      int len = nodes.getLength();
      
      Variable[] subs = new Variable[len];
      
      for (int i = 0; i < len; i++)
      {
         Element sub = (Element) nodes.item(i);
         String  var = index.readAttr(sub, ATTR_VARIABLE);
         
         subs[i] = pool.findVariable(var);
      }
      
      return subs;
   }
   
   /**
    * Read the configuration for the given <code>SEND-SPECIAL-KEY</code>
    * element and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadSendSpecialKey(Element             elem,
                                              ProjectIndex        index,
                                              ResourcePool        pool,
                                              FailureNotification rc)
   {
      SubstitutionReport step = null;
      
      String value     = index.readAttr(elem, ATTR_VALUE);
      String modifiers = index.readAttr(elem, ATTR_MODIFIERS);
      
      step = new SendSpecialKey(value, modifiers);
      
      processDrainMode((SendKey) step, elem, index);
      processSubstitutions(step, elem, index, pool, rc);
      
      return step;
   }
   
   /**
    * Read the configuration for the given <code>SEND-TEXT</code>
    * element and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadSendText(Element             elem,
                                        ProjectIndex        index,
                                        ResourcePool        pool,
                                        FailureNotification rc)
   {
      SubstitutionReport step = null;
      
      String value     = index.readAttr(elem, ATTR_VALUE);
      String modifiers = index.readAttr(elem, ATTR_MODIFIERS);
      String special   = index.readAttr(elem, ATTR_SPECIAL);
      
      if (special != null && special.length() == 0)
         special = null;
      
      int delay = index.readAttrInt(elem, ATTR_DELAY, 0);
      
      step = new SendText(value, special, modifiers, delay);
      
      processDrainMode((SendKey) step, elem, index);
      processSubstitutions(step, elem, index, pool, rc);
      
      return step;
   }
   
   /**
    * Read COMPLEX substitution sub-elements if they exist, configure and then
    * load these into the given instance.
    *
    * @param    step
    *           The testable activity that supports runtime substitutions.
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    */
   private static void processSubstitutions(SubstitutionReport  step,
                                            Element             elem,
                                            ProjectIndex        index,
                                            ResourcePool        pool,
                                            FailureNotification rc)
   {
      NodeList nodes = elem.getElementsByTagName(ELEM_SUBSTITUTION);
      
      int len = nodes.getLength();
      
      Substitution[] list = new Substitution[len];
      
      for (int i = 0; i < len; i++)
      {
         Element  sub      = (Element) nodes.item(i);
         NodeList subNodes = sub.getElementsByTagName(ELEM_PARAMETER);
         
         String sname = sub.getAttribute(ATTR_NAME);
         
         if (sname == null || sname.length() == 0)
         {
            rc.setResult(ExitCode.MISSING_CONTENT);
            rc.setDetails("Complex substitution requires a 'name' attribute.");
            return;
         }
         
         String spec = index.readAttr(sub, ATTR_SPEC);
         
         if (spec == null || spec.length() == 0)
         {
            rc.setResult(ExitCode.MISSING_CONTENT);
            rc.setDetails("Complex substitution requires a 'spec' attribute.");
            return;
         }
         
         int slen = subNodes.getLength();
         
         Variable[] subs = new Variable[slen];
         
         for (int j = 0; j < slen; j++)
         {
            Element parm = (Element) subNodes.item(j);
            String  var  = index.readAttr(parm, ATTR_VARIABLE);
            
            if (var == null || var.length() == 0)
            {
               rc.setResult(ExitCode.MISSING_CONTENT);
               rc.setDetails("Missing 'variable' attribute in parameter.");
               return;
            }
            
            subs[j] = pool.findVariable(var);
            
            if (subs[j] == null)
            {
               rc.setResult(ExitCode.INVALID_CONTENT);
               rc.setDetails(String.format("Invalid 'variable' attribute '%s'" +
                                           "in parameter.",
                                           var));
               return;
            }
         }
      
         Setter setter = step.obtainSetter(sname);
         
         if (setter == null)
         {
            rc.setResult(ExitCode.INVALID_CONTENT);
            rc.setDetails(String.format("Invalid setter name '%s'" +
                                        "in parameter.",
                                        sname));
            return;
         }
         
         list[i] = new Substitution(setter, spec, subs);
      }
      
      step.setSubstitutions(list);
   }
   
   /**
    * Read the configuration for the given <code>CHECK-VARIABLE</code>
    * element and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadCheckVariable(Element             elem,
                                             ResourcePool        pool,
                                             FailureNotification rc)
   {
      String name = elem.getAttribute(ATTR_NAME);
      
      if (name == null || name.length() == 0)
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails("Missing 'name' attribute in check-variable.");
         return null;
      }
      
      Variable var = pool.findVariable(name);
      
      if (var == null)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Invalid variable name '%s' " +
                                     "in check-variable.",
                                     name));
         return null;
      }
      
      return new CheckVariable(var);
   }
   
   /**
    * Read the configuration for the given <code>READ-CURSOR-POS</code>
    * element and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadReadCursorPos(Element             elem,
                                             ProjectIndex        index,
                                             ResourcePool        pool,
                                             FailureNotification rc)
   {
      String rname = index.readAttr(elem, ATTR_ROW_VARIABLE);
      String cname = index.readAttr(elem, ATTR_COLUMN_VARIABLE);
      
      boolean rvalid = (rname != null && rname.length() > 0);
      boolean cvalid = (cname != null && cname.length() > 0);
      
      if (!rvalid && !cvalid)
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails(String.format("Either or both '%s' and/or '%s' must be" +
                                     " specified.",
                                     ATTR_ROW_VARIABLE,
                                     ATTR_COLUMN_VARIABLE));
         return null;
      }
      
      Variable rvar = rvalid ? pool.findVariable(rname) : null;
      Variable cvar = cvalid ? pool.findVariable(cname) : null;
      
      if (rvalid && rvar == null)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Invalid %s name '%s' in %s.",
                                     ATTR_ROW_VARIABLE,
                                     rname,
                                     ELEM_READ_CURSOR_POS));
         return null;
      }
      
      if (cvalid && cvar == null)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Invalid %s name '%s' in %s.",
                                     ATTR_COLUMN_VARIABLE,
                                     cname,
                                     ELEM_READ_CURSOR_POS));
         return null;
      }
      
      return new ReadCursorPos(rvar, cvar);
   }
   
   /**
    * Read the configuration for the given <code>READ-STRING</code>
    * element and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadReadString(Element             elem,
                                          ProjectIndex        index,
                                          ResourcePool        pool,
                                          FailureNotification rc)
   {
      String name  = elem.getAttribute(ATTR_NAME);
      int    row   = index.readAttrInt(elem, ATTR_ROW, -1);
      int    col   = index.readAttrInt(elem, ATTR_COLUMN, -1);
      int    width = index.readAttrInt(elem, ATTR_WIDTH, -1);
      
      if (name == null || name.length() == 0)
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails("Missing name attribute for read-string.");
         return null;
      }
      
      if (!elem.hasAttribute(ATTR_ROW)) 
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails("Missing row attribute for read-string.");
         return null;
      }
      
      if (row < 0)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Invalid row attribute %d for " +
                                     "read-string.",
                                     row));
         return null;
      }
      
      if (!elem.hasAttribute(ATTR_COLUMN)) 
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails("Missing column attribute for read-string.");
         return null;
      }
      
      if (col < 0)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Invalid column attribute %d for " +
                                     "read-string.",
                                     col));
         return null;
      }
      
      if (!elem.hasAttribute(ATTR_WIDTH)) 
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails("Missing width attribute for read-string.");
         return null;
      }
      
      if (width < 0)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Invalid width attribute %d for " +
                                     "read-string.",
                                     width));
         return null;
      }
      
      Variable var = pool.findVariable(name);
      
      if (var == null)
      {
         rc.setResult(ExitCode.INVALID_CONTENT);
         rc.setDetails(String.format("Invalid variable name '%s' in " +
                                     "read-string.",
                                     name));
         return null;
      }
      
      return new ReadString(var, row, col, width);
   }
   
   /**
    * Read the configuration for the given <code>RUN-CODE</code>
    * element and instantiate/configure the associated step.
    *
    * @param    elem
    *           The node of the XML tree representing the step.
    * @param    index
    *           Helper to access resources for the test project.
    * @param    pool
    *           The test resource pool.
    * @param    rc
    *           Object in which to store the details of any failure.
    *
    * @return   The loaded step or <code>null</code> on any failure.
    */
   private static Testable loadRunCode(Element             elem,
                                       ProjectIndex        index,
                                       ResourcePool        pool,
                                       FailureNotification rc)
   {
      Testable step = null;
      
      NodeList contents = elem.getChildNodes();
      
      int len = contents.getLength();
         
      String              code  = null;
      ArrayList<String>   ilist = new ArrayList<String>();
      ArrayList<Variable> vlist = new ArrayList<Variable>();
      
      for (int x = 0; x < len; x++)
      {
         Node  node = contents.item(x);
         short type = node.getNodeType();
         
         if (type == Node.ELEMENT_NODE)
         {
            Element enod = (Element) node;
            String  enam = enod.getTagName().toLowerCase();
            
            // prepare the import list
            if (ELEM_IMPORT.equals(enam))
            {
               String value = index.readAttr(enod, ATTR_VALUE);
               
               if (value == null || value.length() == 0)
               {
                  rc.setResult(ExitCode.MISSING_CONTENT);
                  rc.setDetails("Import node requires a 'value' attribute.");
                  return null;
               }
               
               ilist.add(value);
            }
         }
         else if (type == Node.CDATA_SECTION_NODE)
         {
            if (code != null)
            {
               rc.setResult(ExitCode.INVALID_CONTENT);
               rc.setDetails("Multiple CDATA sections are not allowed in " +
                             "a run-code directive.");
               return null;
            }
            
            // obtain the source code
            CDATASection cdata = (CDATASection) node;
            code = cdata.getData();
         }
      }
      
      if (code != null)
      {
         // prepare the code into lines
         String[] lines = code.split("\n");
         
         // trim extra spaces
         for (int j = 0; j < lines.length; j++)
         {
            lines[j] = lines[j].trim();
         }
         
         // search for variable names
         String[] varnames = pool.getVariableNames();
         
         for (String vname : varnames)
         {
            Variable nextVar    = pool.findVariable(vname);
            String   actualName = nextVar.getName();
            
            if (code.indexOf(actualName) >= 0)
            {
               vlist.add(nextVar);
            }
         }
         
         String[]   imports = null;
         Variable[] vars    = null;
         
         if (ilist.size() > 0)
            imports = ilist.toArray(new String[0]);
         
         if (vlist.size() > 0)
            vars = vlist.toArray(new Variable[0]);
         
         int nextNum = 0;
         
         synchronized (TestFactory.class)
         {
            nextNum = classNum;
            classNum++;
         }
         
         String className = String.format("Temp%d", nextNum);
         String src       = CodeTemplate.emit(className, imports, lines, vars);
         
         DynamicCode core = null;
         
         try
         {
            StringBuilder sb = new StringBuilder();
            
            String fullName = CodeTemplate.PKG + "." + className;
            Class<? extends DynamicCode> cls =
               (Class<? extends DynamicCode>)
               InMemoryCompiler.compile(fullName, src, sb);
            
            if (cls != null)
            {
               core = cls.getDeclaredConstructor().newInstance();
            }
            else
            {
               rc.setResult(ExitCode.INVALID_CONTENT);
               rc.setDetails(sb.toString());
               return null;
            }
         }
         
         catch (Throwable thr)
         {
            rc.setResult(ExitCode.INVALID_CONTENT);
            rc.setDetails(LogHelper.dumpThrowable(thr));
            return null;
         }
         
         if (core == null)
         {
            rc.setResult(ExitCode.UNKNOWN_FAILURE);
            rc.setDetails("Could not create dynamic code object.");
            return null;
         }
         
         String  descr = elem.getAttribute(ATTR_DESCR);
         boolean debug = index.readAttrBool(elem, ATTR_DEBUG, false);
         
         step = new RunCode(descr, src, debug, core, vars);
      }
      else
      {
         rc.setResult(ExitCode.MISSING_CONTENT);
         rc.setDetails("No source code found for RUN-CODE element.");
         return null;
      }
      
      return step;
   }
}
