diff -r 000000000000 -r 6474c204b198 browser/devtools/commandline/test/helpers.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/commandline/test/helpers.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,1245 @@ +/* + * 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 = { + '': function(options) { + return helpers.pressTab(options); + }, + '': function(options) { + return helpers.pressReturn(options); + }, + '': function(options) { + return helpers.pressKey(options, KeyEvent.DOM_VK_UP); + }, + '': function(options) { + return helpers.pressKey(options, KeyEvent.DOM_VK_DOWN); + }, + '': function(options) { + return helpers.pressKey(options, KeyEvent.DOM_VK_BACK_SPACE); + } +}; + +/** + * Used in helpers.setInput to cut an input string like 'blahfoo' into + * an array like [ 'blah', '', 'foo', '' ]. + * When using this RegExp, you also need to filter out the blank strings. + */ +var CHUNKER = /([^<]*)(<[A-Z]+>)/; + +/** + * Alter the input to typed optionally leaving the cursor at + * cursor. + * @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 + if (typed.indexOf('<') === -1) { + inputPromise = automator.setInput(typed); + } + else { + // Cut the input up into input strings separated by '' 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); + } +}