/*
 * Copyright 2012, Mozilla Foundation and contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

'use strict';

// A copy of this code exists in firefox mochitests. They should be kept
// in sync. Hence the exports synonym for non AMD contexts.
this.EXPORTED_SYMBOLS = [ 'helpers' ];
var helpers = {};
this.helpers = helpers;

var TargetFactory = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.TargetFactory;
var require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;

var assert = { ok: ok, is: is, log: info };
var util = require('gcli/util/util');
var promise = require('gcli/util/promise');
var cli = require('gcli/cli');
var KeyEvent = require('gcli/util/util').KeyEvent;
var gcli = require('gcli/index');

/**
 * See notes in helpers.checkOptions()
 */
var createFFDisplayAutomator = function(display) {
  var automator = {
    setInput: function(typed) {
      return display.inputter.setInput(typed);
    },

    setCursor: function(cursor) {
      return display.inputter.setCursor(cursor);
    },

    focus: function() {
      return display.inputter.focus();
    },

    fakeKey: function(keyCode) {
      var fakeEvent = {
        keyCode: keyCode,
        preventDefault: function() { },
        timeStamp: new Date().getTime()
      };

      display.inputter.onKeyDown(fakeEvent);

      if (keyCode === KeyEvent.DOM_VK_BACK_SPACE) {
        var input = display.inputter.element;
        input.value = input.value.slice(0, -1);
      }

      return display.inputter.handleKeyUp(fakeEvent);
    },

    getInputState: function() {
      return display.inputter.getInputState();
    },

    getCompleterTemplateData: function() {
      return display.completer._getCompleterTemplateData();
    },

    getErrorMessage: function() {
      return display.tooltip.errorEle.textContent;
    }
  };

  Object.defineProperty(automator, 'focusManager', {
    get: function() { return display.focusManager; },
    enumerable: true
  });

  Object.defineProperty(automator, 'field', {
    get: function() { return display.tooltip.field; },
    enumerable: true
  });

  return automator;
};

/**
 * Warning: For use with Firefox Mochitests only.
 *
 * Open a new tab at a URL and call a callback on load, and then tidy up when
 * the callback finishes.
 * The function will be passed a set of test options, and will usually return a
 * promise to indicate that the tab can be cleared up. (To be formal, we call
 * Promise.resolve() on the return value of the callback function)
 *
 * The options used by addTab include:
 * - chromeWindow: XUL window parent of created tab. a.k.a 'window' in mochitest
 * - tab: The new XUL tab element, as returned by gBrowser.addTab()
 * - target: The debug target as defined by the devtools framework
 * - browser: The XUL browser element for the given tab
 * - window: Content window for the created tab. a.k.a 'content' in mochitest
 * - isFirefox: Always true. Allows test sharing with GCLI
 *
 * Normally addTab will create an options object containing the values as
 * described above. However these options can be customized by the third
 * 'options' parameter. This has the ability to customize the value of
 * chromeWindow or isFirefox, and to add new properties.
 *
 * @param url The URL for the new tab
 * @param callback The function to call on page load
 * @param options An optional set of options to customize the way the tests run
 */
helpers.addTab = function(url, callback, options) {
  waitForExplicitFinish();

  options = options || {};
  options.chromeWindow = options.chromeWindow || window;
  options.isFirefox = true;

  var tabbrowser = options.chromeWindow.gBrowser;
  options.tab = tabbrowser.addTab();
  tabbrowser.selectedTab = options.tab;
  options.browser = tabbrowser.getBrowserForTab(options.tab);
  options.target = TargetFactory.forTab(options.tab);

  var loaded = helpers.listenOnce(options.browser, "load", true).then(function(ev) {
    options.document = options.browser.contentDocument;
    options.window = options.document.defaultView;

    var reply = callback.call(null, options);

    return promise.resolve(reply).then(null, function(error) {
      ok(false, error);
    }).then(function() {
      tabbrowser.removeTab(options.tab);

      delete options.window;
      delete options.document;

      delete options.target;
      delete options.browser;
      delete options.tab;

      delete options.chromeWindow;
      delete options.isFirefox;
    });
  });

  options.browser.contentWindow.location = url;
  return loaded;
};

/**
 * Open a new tab
 * @param url Address of the page to open
 * @param options Object to which we add properties describing the new tab. The
 * following properties are added:
 * - chromeWindow
 * - tab
 * - browser
 * - target
 * - document
 * - window
 * @return A promise which resolves to the options object when the 'load' event
 * happens on the new tab
 */
helpers.openTab = function(url, options) {
  waitForExplicitFinish();

  options = options || {};
  options.chromeWindow = options.chromeWindow || window;
  options.isFirefox = true;

  var tabbrowser = options.chromeWindow.gBrowser;
  options.tab = tabbrowser.addTab();
  tabbrowser.selectedTab = options.tab;
  options.browser = tabbrowser.getBrowserForTab(options.tab);
  options.target = TargetFactory.forTab(options.tab);

  options.browser.contentWindow.location = url;

  return helpers.listenOnce(options.browser, "load", true).then(function() {
    options.document = options.browser.contentDocument;
    options.window = options.document.defaultView;
    return options;
  });
};

/**
 * Undo the effects of |helpers.openTab|
 * @param options The options object passed to |helpers.openTab|
 * @return A promise resolved (with undefined) when the tab is closed
 */
helpers.closeTab = function(options) {
  options.chromeWindow.gBrowser.removeTab(options.tab);

  delete options.window;
  delete options.document;

  delete options.target;
  delete options.browser;
  delete options.tab;

  delete options.chromeWindow;
  delete options.isFirefox;

  return promise.resolve(undefined);
};

/**
 * Open the developer toolbar in a tab
 * @param options Object to which we add properties describing the developer
 * toolbar. The following properties are added:
 * - automator
 * - requisition
 * @return A promise which resolves to the options object when the 'load' event
 * happens on the new tab
 */
helpers.openToolbar = function(options) {
  return options.chromeWindow.DeveloperToolbar.show(true).then(function() {
    var display = options.chromeWindow.DeveloperToolbar.display;
    options.automator = createFFDisplayAutomator(display);
    options.requisition = display.requisition;
  });
};

/**
 * Undo the effects of |helpers.openToolbar|
 * @param options The options object passed to |helpers.openToolbar|
 * @return A promise resolved (with undefined) when the toolbar is closed
 */
helpers.closeToolbar = function(options) {
  return options.chromeWindow.DeveloperToolbar.hide().then(function() {
    delete options.automator;
    delete options.requisition;
  });
};

/**
 * A helper to work with Task.spawn so you can do:
 *   return Task.spawn(realTestFunc).then(finish, helpers.handleError);
 */
helpers.handleError = function(ex) {
  console.error(ex);
  ok(false, ex);
  finish();
};

/**
 * A helper for calling addEventListener and then removeEventListener as soon
 * as the event is called, passing the results on as a promise
 * @param element The DOM element to listen on
 * @param event The name of the event to listen for
 * @param useCapture Should we use the capturing phase?
 * @return A promise resolved with the event object when the event first happens
 */
helpers.listenOnce = function(element, event, useCapture) {
  var deferred = promise.defer();
  var onEvent = function(ev) {
    element.removeEventListener(event, onEvent, useCapture);
    deferred.resolve(ev);
  };
  element.addEventListener(event, onEvent, useCapture);
  return deferred.promise;
};

/**
 * A wrapper for calling Services.obs.[add|remove]Observer using promises.
 * @param topic The topic parameter to Services.obs.addObserver
 * @param ownsWeak The ownsWeak parameter to Services.obs.addObserver with a
 * default value of false
 * @return a promise that resolves when the ObserverService first notifies us
 * of the topic. The value of the promise is the first parameter to the observer
 * function other parameters are dropped.
 */
helpers.observeOnce = function(topic, ownsWeak=false) {
  let deferred = promise.defer();
  let resolver = function(subject) {
    Services.obs.removeObserver(resolver, topic);
    deferred.resolve(subject);
  };
  Services.obs.addObserver(resolver, topic, ownsWeak);
  return deferred.promise;
};

/**
 * Takes a function that uses a callback as its last parameter, and returns a
 * new function that returns a promise instead
 */
helpers.promiseify = function(functionWithLastParamCallback, scope) {
  return function() {
    let deferred = promise.defer();

    let args = [].slice.call(arguments);
    args.push(function(callbackParam) {
      deferred.resolve(callbackParam);
    });

    try {
      functionWithLastParamCallback.apply(scope, args);
    }
    catch (ex) {
      deferred.resolve(ex);
    }

    return deferred.promise;
  }
};

/**
 * Warning: For use with Firefox Mochitests only.
 *
 * As addTab, but that also opens the developer toolbar. In addition a new
 * 'automator' property is added to the options object with the display from GCLI
 * in the developer toolbar
 */
helpers.addTabWithToolbar = function(url, callback, options) {
  return helpers.addTab(url, function(innerOptions) {
    var win = innerOptions.chromeWindow;

    return win.DeveloperToolbar.show(true).then(function() {
      var display = win.DeveloperToolbar.display;
      innerOptions.automator = createFFDisplayAutomator(display);
      innerOptions.requisition = display.requisition;

      var reply = callback.call(null, innerOptions);

      return promise.resolve(reply).then(null, function(error) {
        ok(false, error);
        console.error(error);
      }).then(function() {
        win.DeveloperToolbar.hide().then(function() {
          delete innerOptions.automator;
        });
      });
    });
  }, options);
};

/**
 * Warning: For use with Firefox Mochitests only.
 *
 * Run a set of test functions stored in the values of the 'exports' object
 * functions stored under setup/shutdown will be run at the start/end of the
 * sequence of tests.
 * A test will be considered finished when its return value is resolved.
 * @param options An object to be passed to the test functions
 * @param tests An object containing named test functions
 * @return a promise which will be resolved when all tests have been run and
 * their return values resolved
 */
helpers.runTests = function(options, tests) {
  var testNames = Object.keys(tests).filter(function(test) {
    return test != "setup" && test != "shutdown";
  });

  var recover = function(error) {
    ok(false, error);
    console.error(error);
  };

  info("SETUP");
  var setupDone = (tests.setup != null) ?
      promise.resolve(tests.setup(options)) :
      promise.resolve();

  var testDone = setupDone.then(function() {
    return util.promiseEach(testNames, function(testName) {
      info(testName);
      var action = tests[testName];

      if (typeof action === "function") {
        var reply = action.call(tests, options);
        return promise.resolve(reply);
      }
      else if (Array.isArray(action)) {
        return helpers.audit(options, action);
      }

      return promise.reject("test action '" + testName +
                            "' is not a function or helpers.audit() object");
    });
  }, recover);

  return testDone.then(function() {
    info("SHUTDOWN");
    return (tests.shutdown != null) ?
        promise.resolve(tests.shutdown(options)) :
        promise.resolve();
  }, recover);
};

///////////////////////////////////////////////////////////////////////////////

/**
 * Ensure that the options object is setup correctly
 * options should contain an automator object that looks like this:
 * {
 *   getInputState: function() { ... },
 *   setCursor: function(cursor) { ... },
 *   getCompleterTemplateData: function() { ... },
 *   focus: function() { ... },
 *   getErrorMessage: function() { ... },
 *   fakeKey: function(keyCode) { ... },
 *   setInput: function(typed) { ... },
 *   focusManager: ...,
 *   field: ...,
 * }
 */
function checkOptions(options) {
  if (options == null) {
    console.trace();
    throw new Error('Missing options object');
  }
  if (options.requisition == null) {
    console.trace();
    throw new Error('options.requisition == null');
  }
}

/**
 * Various functions to return the actual state of the command line
 */
helpers._actual = {
  input: function(options) {
    return options.automator.getInputState().typed;
  },

  hints: function(options) {
    return options.automator.getCompleterTemplateData().then(function(data) {
      var emptyParams = data.emptyParameters.join('');
      return (data.directTabText + emptyParams + data.arrowTabText)
                .replace(/\u00a0/g, ' ')
                .replace(/\u21E5/, '->')
                .replace(/ $/, '');
    });
  },

  markup: function(options) {
    var cursor = helpers._actual.cursor(options);
    var statusMarkup = options.requisition.getInputStatusMarkup(cursor);
    return statusMarkup.map(function(s) {
      return new Array(s.string.length + 1).join(s.status.toString()[0]);
    }).join('');
  },

  cursor: function(options) {
    return options.automator.getInputState().cursor.start;
  },

  current: function(options) {
    var cursor = helpers._actual.cursor(options);
    return options.requisition.getAssignmentAt(cursor).param.name;
  },

  status: function(options) {
    return options.requisition.status.toString();
  },

  predictions: function(options) {
    var cursor = helpers._actual.cursor(options);
    var assignment = options.requisition.getAssignmentAt(cursor);
    var context = options.requisition.executionContext;
    return assignment.getPredictions(context).then(function(predictions) {
      return predictions.map(function(prediction) {
        return prediction.name;
      });
    });
  },

  unassigned: function(options) {
    return options.requisition._unassigned.map(function(assignment) {
      return assignment.arg.toString();
    }.bind(this));
  },

  outputState: function(options) {
    var outputData = options.automator.focusManager._shouldShowOutput();
    return outputData.visible + ':' + outputData.reason;
  },

  tooltipState: function(options) {
    var tooltipData = options.automator.focusManager._shouldShowTooltip();
    return tooltipData.visible + ':' + tooltipData.reason;
  },

  options: function(options) {
    if (options.automator.field.menu == null) {
      return [];
    }
    return options.automator.field.menu.items.map(function(item) {
      return item.name.textContent ? item.name.textContent : item.name;
    });
  },

  message: function(options) {
    return options.automator.getErrorMessage();
  }
};

function shouldOutputUnquoted(value) {
  var type = typeof value;
  return value == null || type === 'boolean' || type === 'number';
}

function outputArray(array) {
  return (array.length === 0) ?
      '[ ]' :
      '[ \'' + array.join('\', \'') + '\' ]';
}

helpers._createDebugCheck = function(options) {
  checkOptions(options);
  var requisition = options.requisition;
  var command = requisition.commandAssignment.value;
  var cursor = helpers._actual.cursor(options);
  var input = helpers._actual.input(options);
  var padding = new Array(input.length + 1).join(' ');

  var hintsPromise = helpers._actual.hints(options);
  var predictionsPromise = helpers._actual.predictions(options);

  return promise.all(hintsPromise, predictionsPromise).then(function(values) {
    var hints = values[0];
    var predictions = values[1];
    var output = '';

    output += 'return helpers.audit(options, [\n';
    output += '  {\n';

    if (cursor === input.length) {
      output += '    setup:    \'' + input + '\',\n';
    }
    else {
      output += '    name: \'' + input + ' (cursor=' + cursor + ')\',\n';
      output += '    setup: function() {\n';
      output += '      return helpers.setInput(options, \'' + input + '\', ' + cursor + ');\n';
      output += '    },\n';
    }

    output += '    check: {\n';

    output += '      input:  \'' + input + '\',\n';
    output += '      hints:  ' + padding + '\'' + hints + '\',\n';
    output += '      markup: \'' + helpers._actual.markup(options) + '\',\n';
    output += '      cursor: ' + cursor + ',\n';
    output += '      current: \'' + helpers._actual.current(options) + '\',\n';
    output += '      status: \'' + helpers._actual.status(options) + '\',\n';
    output += '      options: ' + outputArray(helpers._actual.options(options)) + ',\n';
    output += '      message: \'' + helpers._actual.message(options) + '\',\n';
    output += '      predictions: ' + outputArray(predictions) + ',\n';
    output += '      unassigned: ' + outputArray(requisition._unassigned) + ',\n';
    output += '      outputState: \'' + helpers._actual.outputState(options) + '\',\n';
    output += '      tooltipState: \'' + helpers._actual.tooltipState(options) + '\'' +
              (command ? ',' : '') +'\n';

    if (command) {
      output += '      args: {\n';
      output += '        command: { name: \'' + command.name + '\' },\n';

      requisition.getAssignments().forEach(function(assignment) {
        output += '        ' + assignment.param.name + ': { ';

        if (typeof assignment.value === 'string') {
          output += 'value: \'' + assignment.value + '\', ';
        }
        else if (shouldOutputUnquoted(assignment.value)) {
          output += 'value: ' + assignment.value + ', ';
        }
        else {
          output += '/*value:' + assignment.value + ',*/ ';
        }

        output += 'arg: \'' + assignment.arg + '\', ';
        output += 'status: \'' + assignment.getStatus().toString() + '\', ';
        output += 'message: \'' + assignment.message + '\'';
        output += ' },\n';
      });

      output += '      }\n';
    }

    output += '    },\n';
    output += '    exec: {\n';
    output += '      output: \'\',\n';
    output += '      type: \'string\',\n';
    output += '      error: false\n';
    output += '    }\n';
    output += '  }\n';
    output += ']);';

    return output;
  }.bind(this), util.errorHandler);
};

/**
 * Simulate focusing the input field
 */
helpers.focusInput = function(options) {
  checkOptions(options);
  options.automator.focus();
};

/**
 * Simulate pressing TAB in the input field
 */
helpers.pressTab = function(options) {
  checkOptions(options);
  return helpers.pressKey(options, KeyEvent.DOM_VK_TAB);
};

/**
 * Simulate pressing RETURN in the input field
 */
helpers.pressReturn = function(options) {
  checkOptions(options);
  return helpers.pressKey(options, KeyEvent.DOM_VK_RETURN);
};

/**
 * Simulate pressing a key by keyCode in the input field
 */
helpers.pressKey = function(options, keyCode) {
  checkOptions(options);
  return options.automator.fakeKey(keyCode);
};

/**
 * A list of special key presses and how to to them, for the benefit of
 * helpers.setInput
 */
var ACTIONS = {
  '<TAB>': function(options) {
    return helpers.pressTab(options);
  },
  '<RETURN>': function(options) {
    return helpers.pressReturn(options);
  },
  '<UP>': function(options) {
    return helpers.pressKey(options, KeyEvent.DOM_VK_UP);
  },
  '<DOWN>': function(options) {
    return helpers.pressKey(options, KeyEvent.DOM_VK_DOWN);
  },
  '<BACKSPACE>': function(options) {
    return helpers.pressKey(options, KeyEvent.DOM_VK_BACK_SPACE);
  }
};

/**
 * Used in helpers.setInput to cut an input string like 'blah<TAB>foo<UP>' into
 * an array like [ 'blah', '<TAB>', 'foo', '<UP>' ].
 * When using this RegExp, you also need to filter out the blank strings.
 */
var CHUNKER = /([^<]*)(<[A-Z]+>)/;

/**
 * Alter the input to <code>typed</code> optionally leaving the cursor at
 * <code>cursor</code>.
 * @return A promise of the number of key-presses to respond
 */
helpers.setInput = function(options, typed, cursor) {
  checkOptions(options);
  var inputPromise;
  var automator = options.automator;
  // We try to measure average keypress time, but setInput can simulate
  // several, so we try to keep track of how many
  var chunkLen = 1;

  // The easy case is a simple string without things like <TAB>
  if (typed.indexOf('<') === -1) {
    inputPromise = automator.setInput(typed);
  }
  else {
    // Cut the input up into input strings separated by '<KEY>' tokens. The
    // CHUNKS RegExp leaves blanks so we filter them out.
    var chunks = typed.split(CHUNKER).filter(function(s) {
      return s !== '';
    });
    chunkLen = chunks.length + 1;

    // We're working on this in chunks so first clear the input
    inputPromise = automator.setInput('').then(function() {
      return util.promiseEach(chunks, function(chunk) {
        if (chunk.charAt(0) === '<') {
          var action = ACTIONS[chunk];
          if (typeof action !== 'function') {
            console.error('Known actions: ' + Object.keys(ACTIONS).join());
            throw new Error('Key action not found "' + chunk + '"');
          }
          return action(options);
        }
        else {
          return automator.setInput(automator.getInputState().typed + chunk);
        }
      });
    });
  }

  return inputPromise.then(function() {
    if (cursor != null) {
      automator.setCursor({ start: cursor, end: cursor });
    }

    if (automator.focusManager) {
      automator.focusManager.onInputChange();
    }

    // Firefox testing is noisy and distant, so logging helps
    if (options.isFirefox) {
      var cursorStr = (cursor == null ? '' : ', ' + cursor);
      log('setInput("' + typed + '"' + cursorStr + ')');
    }

    return chunkLen;
  });
};

/**
 * Helper for helpers.audit() to ensure that all the 'check' properties match.
 * See helpers.audit for more information.
 * @param name The name to use in error messages
 * @param checks See helpers.audit for a list of available checks
 * @return A promise which resolves to undefined when the checks are complete
 */
helpers._check = function(options, name, checks) {
  // A test method to check that all args are assigned in some way
  var requisition = options.requisition;
  requisition._args.forEach(function(arg) {
    if (arg.assignment == null) {
      assert.ok(false, 'No assignment for ' + arg);
    }
  });

  if (checks == null) {
    return promise.resolve();
  }

  var outstanding = [];
  var suffix = name ? ' (for \'' + name + '\')' : '';

  if (!options.isNoDom && 'input' in checks) {
    assert.is(helpers._actual.input(options), checks.input, 'input' + suffix);
  }

  if (!options.isNoDom && 'cursor' in checks) {
    assert.is(helpers._actual.cursor(options), checks.cursor, 'cursor' + suffix);
  }

  if (!options.isNoDom && 'current' in checks) {
    assert.is(helpers._actual.current(options), checks.current, 'current' + suffix);
  }

  if ('status' in checks) {
    assert.is(helpers._actual.status(options), checks.status, 'status' + suffix);
  }

  if (!options.isNoDom && 'markup' in checks) {
    assert.is(helpers._actual.markup(options), checks.markup, 'markup' + suffix);
  }

  if (!options.isNoDom && 'hints' in checks) {
    var hintCheck = function(actualHints) {
      assert.is(actualHints, checks.hints, 'hints' + suffix);
    };
    outstanding.push(helpers._actual.hints(options).then(hintCheck));
  }

  if (!options.isNoDom && 'predictions' in checks) {
    var predictionsCheck = function(actualPredictions) {
      helpers.arrayIs(actualPredictions,
                       checks.predictions,
                       'predictions' + suffix);
    };
    outstanding.push(helpers._actual.predictions(options).then(predictionsCheck));
  }

  if (!options.isNoDom && 'predictionsContains' in checks) {
    var containsCheck = function(actualPredictions) {
      checks.predictionsContains.forEach(function(prediction) {
        var index = actualPredictions.indexOf(prediction);
        assert.ok(index !== -1,
                  'predictionsContains:' + prediction + suffix);
      });
    };
    outstanding.push(helpers._actual.predictions(options).then(containsCheck));
  }

  if ('unassigned' in checks) {
    helpers.arrayIs(helpers._actual.unassigned(options),
                     checks.unassigned,
                     'unassigned' + suffix);
  }

  /* TODO: Fix this
  if (!options.isNoDom && 'tooltipState' in checks) {
    assert.is(helpers._actual.tooltipState(options),
              checks.tooltipState,
              'tooltipState' + suffix);
  }
  */

  if (!options.isNoDom && 'outputState' in checks) {
    assert.is(helpers._actual.outputState(options),
              checks.outputState,
              'outputState' + suffix);
  }

  if (!options.isNoDom && 'options' in checks) {
    helpers.arrayIs(helpers._actual.options(options),
                     checks.options,
                     'options' + suffix);
  }

  if (!options.isNoDom && 'error' in checks) {
    assert.is(helpers._actual.message(options), checks.error, 'error' + suffix);
  }

  if (checks.args != null) {
    Object.keys(checks.args).forEach(function(paramName) {
      var check = checks.args[paramName];

      // We allow an 'argument' called 'command' to be the command itself, but
      // what if the command has a parameter called 'command' (for example, an
      // 'exec' command)? We default to using the parameter because checking
      // the command value is less useful
      var assignment = requisition.getAssignment(paramName);
      if (assignment == null && paramName === 'command') {
        assignment = requisition.commandAssignment;
      }

      if (assignment == null) {
        assert.ok(false, 'Unknown arg: ' + paramName + suffix);
        return;
      }

      if ('value' in check) {
        if (typeof check.value === 'function') {
          try {
            check.value(assignment.value);
          }
          catch (ex) {
            assert.ok(false, '' + ex);
          }
        }
        else {
          assert.is(assignment.value,
                    check.value,
                    'arg.' + paramName + '.value' + suffix);
        }
      }

      if ('name' in check) {
        assert.is(assignment.value.name,
                  check.name,
                  'arg.' + paramName + '.name' + suffix);
      }

      if ('type' in check) {
        assert.is(assignment.arg.type,
                  check.type,
                  'arg.' + paramName + '.type' + suffix);
      }

      if ('arg' in check) {
        assert.is(assignment.arg.toString(),
                  check.arg,
                  'arg.' + paramName + '.arg' + suffix);
      }

      if ('status' in check) {
        assert.is(assignment.getStatus().toString(),
                  check.status,
                  'arg.' + paramName + '.status' + suffix);
      }

      if (!options.isNoDom && 'message' in check) {
        if (typeof check.message.test === 'function') {
          assert.ok(check.message.test(assignment.message),
                    'arg.' + paramName + '.message' + suffix);
        }
        else {
          assert.is(assignment.message,
                    check.message,
                    'arg.' + paramName + '.message' + suffix);
        }
      }
    });
  }

  return promise.all(outstanding).then(function() {
    // Ensure the promise resolves to nothing
    return undefined;
  });
};

/**
 * Helper for helpers.audit() to ensure that all the 'exec' properties work.
 * See helpers.audit for more information.
 * @param name The name to use in error messages
 * @param expected See helpers.audit for a list of available exec checks
 * @return A promise which resolves to undefined when the checks are complete
 */
helpers._exec = function(options, name, expected) {
  var requisition = options.requisition;
  if (expected == null) {
    return promise.resolve({});
  }

  var origLogErrors = cli.logErrors;
  if (expected.error) {
    cli.logErrors = false;
  }

  try {
    return requisition.exec({ hidden: true }).then(function(output) {
      if ('type' in expected) {
        assert.is(output.type,
                  expected.type,
                  'output.type for: ' + name);
      }

      if ('error' in expected) {
        assert.is(output.error,
                  expected.error,
                  'output.error for: ' + name);
      }

      if (!('output' in expected)) {
        return { output: output };
      }

      var context = requisition.conversionContext;
      var convertPromise;
      if (options.isNoDom) {
        convertPromise = output.convert('string', context);
      }
      else {
        convertPromise = output.convert('dom', context).then(function(node) {
          return node.textContent.trim();
        });
      }

      return convertPromise.then(function(textOutput) {
        var doTest = function(match, against) {
          // Only log the real textContent if the test fails
          if (against.match(match) != null) {
            assert.ok(true, 'html output for \'' + name + '\' ' +
                            'should match /' + (match.source || match) + '/');
          } else {
            assert.ok(false, 'html output for \'' + name + '\' ' +
                             'should match /' + (match.source || match) + '/. ' +
                             'Actual textContent: "' + against + '"');
          }
        };

        if (typeof expected.output === 'string') {
          assert.is(textOutput,
                    expected.output,
                    'html output for ' + name);
        }
        else if (Array.isArray(expected.output)) {
          expected.output.forEach(function(match) {
            doTest(match, textOutput);
          });
        }
        else {
          doTest(expected.output, textOutput);
        }

        if (expected.error) {
          cli.logErrors = origLogErrors;
        }
        return { output: output, text: textOutput };
      });
    }.bind(this)).then(function(data) {
      if (expected.error) {
        cli.logErrors = origLogErrors;
      }

      return data;
    });
  }
  catch (ex) {
    assert.ok(false, 'Failure executing \'' + name + '\': ' + ex);
    util.errorHandler(ex);

    if (expected.error) {
      cli.logErrors = origLogErrors;
    }
    return promise.resolve({});
  }
};

/**
 * Helper to setup the test
 */
helpers._setup = function(options, name, audit) {
  if (typeof audit.setup === 'string') {
    return helpers.setInput(options, audit.setup);
  }

  if (typeof audit.setup === 'function') {
    return promise.resolve(audit.setup.call(audit));
  }

  return promise.reject('\'setup\' property must be a string or a function. Is ' + audit.setup);
};

/**
 * Helper to shutdown the test
 */
helpers._post = function(name, audit, data) {
  if (typeof audit.post === 'function') {
    return promise.resolve(audit.post.call(audit, data.output, data.text));
  }
  return promise.resolve(audit.post);
};

/*
 * We do some basic response time stats so we can see if we're getting slow
 */
var totalResponseTime = 0;
var averageOver = 0;
var maxResponseTime = 0;
var maxResponseCulprit;
var start;

/**
 * Restart the stats collection process
 */
helpers.resetResponseTimes = function() {
  start = new Date().getTime();
  totalResponseTime = 0;
  averageOver = 0;
  maxResponseTime = 0;
  maxResponseCulprit = undefined;
};

/**
 * Expose an average response time in milliseconds
 */
Object.defineProperty(helpers, 'averageResponseTime', {
  get: function() {
    return averageOver === 0 ?
        undefined :
        Math.round(100 * totalResponseTime / averageOver) / 100;
  },
  enumerable: true
});

/**
 * Expose a maximum response time in milliseconds
 */
Object.defineProperty(helpers, 'maxResponseTime', {
  get: function() { return Math.round(maxResponseTime * 100) / 100; },
  enumerable: true
});

/**
 * Expose the name of the test that provided the maximum response time
 */
Object.defineProperty(helpers, 'maxResponseCulprit', {
  get: function() { return maxResponseCulprit; },
  enumerable: true
});

/**
 * Quick summary of the times
 */
Object.defineProperty(helpers, 'timingSummary', {
  get: function() {
    var elapsed = (new Date().getTime() - start) / 1000;
    return 'Total ' + elapsed + 's, ' +
           'ave response ' + helpers.averageResponseTime + 'ms, ' +
           'max response ' + helpers.maxResponseTime + 'ms ' +
           'from \'' + helpers.maxResponseCulprit + '\'';
  },
  enumerable: true
});

/**
 * A way of turning a set of tests into something more declarative, this helps
 * to allow tests to be asynchronous.
 * @param audits An array of objects each of which contains:
 * - setup: string/function to be called to set the test up.
 *     If audit is a string then it is passed to helpers.setInput().
 *     If audit is a function then it is executed. The tests will wait while
 *     tests that return promises complete.
 * - name: For debugging purposes. If name is undefined, and 'setup'
 *     is a string then the setup value will be used automatically
 * - skipIf: A function to define if the test should be skipped. Useful for
 *     excluding tests from certain environments (e.g. nodom, firefox, etc).
 *     The name of the test will be used in log messages noting the skip
 *     See helpers.reason for pre-defined skip functions. The skip function must
 *     be synchronous, and will be passed the test options object.
 * - skipRemainingIf: A function to skip all the remaining audits in this set.
 *     See skipIf for details of how skip functions work.
 * - check: Check data. Available checks:
 *   - input: The text displayed in the input field
 *   - cursor: The position of the start of the cursor
 *   - status: One of 'VALID', 'ERROR', 'INCOMPLETE'
 *   - hints: The hint text, i.e. a concatenation of the directTabText, the
 *       emptyParameters and the arrowTabText. The text as inserted into the UI
 *       will include NBSP and Unicode RARR characters, these should be
 *       represented using normal space and '->' for the arrow
 *   - markup: What state should the error markup be in. e.g. 'VVVIIIEEE'
 *   - args: Maps of checks to make against the arguments:
 *     - value: i.e. assignment.value (which ignores defaultValue)
 *     - type: Argument/BlankArgument/MergedArgument/etc i.e. what's assigned
 *             Care should be taken with this since it's something of an
 *             implementation detail
 *     - arg: The toString value of the argument
 *     - status: i.e. assignment.getStatus
 *     - message: i.e. assignment.message
 *     - name: For commands - checks assignment.value.name
 * - exec: Object to indicate we should execute the command and check the
 *     results. Available checks:
 *   - output: A string, RegExp or array of RegExps to compare with the output
 *       If typeof output is a string then the output should be exactly equal
 *       to the given string. If the type of output is a RegExp or array of
 *       RegExps then the output should match all RegExps
 * - post: Function to be called after the checks have been run
 */
helpers.audit = function(options, audits) {
  checkOptions(options);
  var skipReason = null;
  return util.promiseEach(audits, function(audit) {
    var name = audit.name;
    if (name == null && typeof audit.setup === 'string') {
      name = audit.setup;
    }

    if (assert.testLogging) {
      log('- START \'' + name + '\' in ' + assert.currentTest);
    }

    if (audit.skipRemainingIf) {
      var skipRemainingIf = (typeof audit.skipRemainingIf === 'function') ?
          audit.skipRemainingIf(options) :
          !!audit.skipRemainingIf;
      if (skipRemainingIf) {
        skipReason = audit.skipRemainingIf.name ?
            'due to ' + audit.skipRemainingIf.name :
            '';
        assert.log('Skipped ' + name + ' ' + skipReason);
        return promise.resolve(undefined);
      }
    }

    if (audit.skipIf) {
      var skip = (typeof audit.skipIf === 'function') ?
          audit.skipIf(options) :
          !!audit.skipIf;
      if (skip) {
        var reason = audit.skipIf.name ? 'due to ' + audit.skipIf.name : '';
        assert.log('Skipped ' + name + ' ' + reason);
        return promise.resolve(undefined);
      }
    }

    if (skipReason != null) {
      assert.log('Skipped ' + name + ' ' + skipReason);
      return promise.resolve(undefined);
    }

    var start = new Date().getTime();

    var setupDone = helpers._setup(options, name, audit);
    return setupDone.then(function(chunkLen) {
      if (typeof chunkLen !== 'number') {
        chunkLen = 1;
      }

      // Nasty hack to allow us to auto-skip tests where we're actually testing
      // a key-sequence (i.e. targeting terminal.js) when there is no terminal
      if (chunkLen === -1) {
        assert.log('Skipped ' + name + ' ' + skipReason);
        return promise.resolve(undefined);
      }

      if (assert.currentTest) {
        var responseTime = (new Date().getTime() - start) / chunkLen;
        totalResponseTime += responseTime;
        if (responseTime > maxResponseTime) {
          maxResponseTime = responseTime;
          maxResponseCulprit = assert.currentTest + '/' + name;
        }
        averageOver++;
      }

      var checkDone = helpers._check(options, name, audit.check);
      return checkDone.then(function() {
        var execDone = helpers._exec(options, name, audit.exec);
        return execDone.then(function(data) {
          return helpers._post(name, audit, data).then(function() {
            if (assert.testLogging) {
              log('- END \'' + name + '\' in ' + assert.currentTest);
            }
          });
        });
      });
    });
  }).then(function() {
    return options.automator.setInput('');
  });
};

/**
 * Compare 2 arrays.
 */
helpers.arrayIs = function(actual, expected, message) {
  assert.ok(Array.isArray(actual), 'actual is not an array: ' + message);
  assert.ok(Array.isArray(expected), 'expected is not an array: ' + message);

  if (!Array.isArray(actual) || !Array.isArray(expected)) {
    return;
  }

  assert.is(actual.length, expected.length, 'array length: ' + message);

  for (var i = 0; i < actual.length && i < expected.length; i++) {
    assert.is(actual[i], expected[i], 'member[' + i + ']: ' + message);
  }
};

/**
 * A quick helper to log to the correct place
 */
function log(message) {
  if (typeof info === 'function') {
    info(message);
  }
  else {
    console.log(message);
  }
}
