michael@0: /* michael@0: * Copyright 2012, Mozilla Foundation and contributors michael@0: * michael@0: * Licensed under the Apache License, Version 2.0 (the "License"); michael@0: * you may not use this file except in compliance with the License. michael@0: * You may obtain a copy of the License at michael@0: * michael@0: * http://www.apache.org/licenses/LICENSE-2.0 michael@0: * michael@0: * Unless required by applicable law or agreed to in writing, software michael@0: * distributed under the License is distributed on an "AS IS" BASIS, michael@0: * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. michael@0: * See the License for the specific language governing permissions and michael@0: * limitations under the License. michael@0: */ michael@0: michael@0: 'use strict'; michael@0: michael@0: // A copy of this code exists in firefox mochitests. They should be kept michael@0: // in sync. Hence the exports synonym for non AMD contexts. michael@0: this.EXPORTED_SYMBOLS = [ 'helpers' ]; michael@0: var helpers = {}; michael@0: this.helpers = helpers; michael@0: michael@0: var TargetFactory = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.TargetFactory; michael@0: var require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; michael@0: michael@0: var assert = { ok: ok, is: is, log: info }; michael@0: var util = require('gcli/util/util'); michael@0: var promise = require('gcli/util/promise'); michael@0: var cli = require('gcli/cli'); michael@0: var KeyEvent = require('gcli/util/util').KeyEvent; michael@0: var gcli = require('gcli/index'); michael@0: michael@0: /** michael@0: * See notes in helpers.checkOptions() michael@0: */ michael@0: var createFFDisplayAutomator = function(display) { michael@0: var automator = { michael@0: setInput: function(typed) { michael@0: return display.inputter.setInput(typed); michael@0: }, michael@0: michael@0: setCursor: function(cursor) { michael@0: return display.inputter.setCursor(cursor); michael@0: }, michael@0: michael@0: focus: function() { michael@0: return display.inputter.focus(); michael@0: }, michael@0: michael@0: fakeKey: function(keyCode) { michael@0: var fakeEvent = { michael@0: keyCode: keyCode, michael@0: preventDefault: function() { }, michael@0: timeStamp: new Date().getTime() michael@0: }; michael@0: michael@0: display.inputter.onKeyDown(fakeEvent); michael@0: michael@0: if (keyCode === KeyEvent.DOM_VK_BACK_SPACE) { michael@0: var input = display.inputter.element; michael@0: input.value = input.value.slice(0, -1); michael@0: } michael@0: michael@0: return display.inputter.handleKeyUp(fakeEvent); michael@0: }, michael@0: michael@0: getInputState: function() { michael@0: return display.inputter.getInputState(); michael@0: }, michael@0: michael@0: getCompleterTemplateData: function() { michael@0: return display.completer._getCompleterTemplateData(); michael@0: }, michael@0: michael@0: getErrorMessage: function() { michael@0: return display.tooltip.errorEle.textContent; michael@0: } michael@0: }; michael@0: michael@0: Object.defineProperty(automator, 'focusManager', { michael@0: get: function() { return display.focusManager; }, michael@0: enumerable: true michael@0: }); michael@0: michael@0: Object.defineProperty(automator, 'field', { michael@0: get: function() { return display.tooltip.field; }, michael@0: enumerable: true michael@0: }); michael@0: michael@0: return automator; michael@0: }; michael@0: michael@0: /** michael@0: * Warning: For use with Firefox Mochitests only. michael@0: * michael@0: * Open a new tab at a URL and call a callback on load, and then tidy up when michael@0: * the callback finishes. michael@0: * The function will be passed a set of test options, and will usually return a michael@0: * promise to indicate that the tab can be cleared up. (To be formal, we call michael@0: * Promise.resolve() on the return value of the callback function) michael@0: * michael@0: * The options used by addTab include: michael@0: * - chromeWindow: XUL window parent of created tab. a.k.a 'window' in mochitest michael@0: * - tab: The new XUL tab element, as returned by gBrowser.addTab() michael@0: * - target: The debug target as defined by the devtools framework michael@0: * - browser: The XUL browser element for the given tab michael@0: * - window: Content window for the created tab. a.k.a 'content' in mochitest michael@0: * - isFirefox: Always true. Allows test sharing with GCLI michael@0: * michael@0: * Normally addTab will create an options object containing the values as michael@0: * described above. However these options can be customized by the third michael@0: * 'options' parameter. This has the ability to customize the value of michael@0: * chromeWindow or isFirefox, and to add new properties. michael@0: * michael@0: * @param url The URL for the new tab michael@0: * @param callback The function to call on page load michael@0: * @param options An optional set of options to customize the way the tests run michael@0: */ michael@0: helpers.addTab = function(url, callback, options) { michael@0: waitForExplicitFinish(); michael@0: michael@0: options = options || {}; michael@0: options.chromeWindow = options.chromeWindow || window; michael@0: options.isFirefox = true; michael@0: michael@0: var tabbrowser = options.chromeWindow.gBrowser; michael@0: options.tab = tabbrowser.addTab(); michael@0: tabbrowser.selectedTab = options.tab; michael@0: options.browser = tabbrowser.getBrowserForTab(options.tab); michael@0: options.target = TargetFactory.forTab(options.tab); michael@0: michael@0: var loaded = helpers.listenOnce(options.browser, "load", true).then(function(ev) { michael@0: options.document = options.browser.contentDocument; michael@0: options.window = options.document.defaultView; michael@0: michael@0: var reply = callback.call(null, options); michael@0: michael@0: return promise.resolve(reply).then(null, function(error) { michael@0: ok(false, error); michael@0: }).then(function() { michael@0: tabbrowser.removeTab(options.tab); michael@0: michael@0: delete options.window; michael@0: delete options.document; michael@0: michael@0: delete options.target; michael@0: delete options.browser; michael@0: delete options.tab; michael@0: michael@0: delete options.chromeWindow; michael@0: delete options.isFirefox; michael@0: }); michael@0: }); michael@0: michael@0: options.browser.contentWindow.location = url; michael@0: return loaded; michael@0: }; michael@0: michael@0: /** michael@0: * Open a new tab michael@0: * @param url Address of the page to open michael@0: * @param options Object to which we add properties describing the new tab. The michael@0: * following properties are added: michael@0: * - chromeWindow michael@0: * - tab michael@0: * - browser michael@0: * - target michael@0: * - document michael@0: * - window michael@0: * @return A promise which resolves to the options object when the 'load' event michael@0: * happens on the new tab michael@0: */ michael@0: helpers.openTab = function(url, options) { michael@0: waitForExplicitFinish(); michael@0: michael@0: options = options || {}; michael@0: options.chromeWindow = options.chromeWindow || window; michael@0: options.isFirefox = true; michael@0: michael@0: var tabbrowser = options.chromeWindow.gBrowser; michael@0: options.tab = tabbrowser.addTab(); michael@0: tabbrowser.selectedTab = options.tab; michael@0: options.browser = tabbrowser.getBrowserForTab(options.tab); michael@0: options.target = TargetFactory.forTab(options.tab); michael@0: michael@0: options.browser.contentWindow.location = url; michael@0: michael@0: return helpers.listenOnce(options.browser, "load", true).then(function() { michael@0: options.document = options.browser.contentDocument; michael@0: options.window = options.document.defaultView; michael@0: return options; michael@0: }); michael@0: }; michael@0: michael@0: /** michael@0: * Undo the effects of |helpers.openTab| michael@0: * @param options The options object passed to |helpers.openTab| michael@0: * @return A promise resolved (with undefined) when the tab is closed michael@0: */ michael@0: helpers.closeTab = function(options) { michael@0: options.chromeWindow.gBrowser.removeTab(options.tab); michael@0: michael@0: delete options.window; michael@0: delete options.document; michael@0: michael@0: delete options.target; michael@0: delete options.browser; michael@0: delete options.tab; michael@0: michael@0: delete options.chromeWindow; michael@0: delete options.isFirefox; michael@0: michael@0: return promise.resolve(undefined); michael@0: }; michael@0: michael@0: /** michael@0: * Open the developer toolbar in a tab michael@0: * @param options Object to which we add properties describing the developer michael@0: * toolbar. The following properties are added: michael@0: * - automator michael@0: * - requisition michael@0: * @return A promise which resolves to the options object when the 'load' event michael@0: * happens on the new tab michael@0: */ michael@0: helpers.openToolbar = function(options) { michael@0: return options.chromeWindow.DeveloperToolbar.show(true).then(function() { michael@0: var display = options.chromeWindow.DeveloperToolbar.display; michael@0: options.automator = createFFDisplayAutomator(display); michael@0: options.requisition = display.requisition; michael@0: }); michael@0: }; michael@0: michael@0: /** michael@0: * Undo the effects of |helpers.openToolbar| michael@0: * @param options The options object passed to |helpers.openToolbar| michael@0: * @return A promise resolved (with undefined) when the toolbar is closed michael@0: */ michael@0: helpers.closeToolbar = function(options) { michael@0: return options.chromeWindow.DeveloperToolbar.hide().then(function() { michael@0: delete options.automator; michael@0: delete options.requisition; michael@0: }); michael@0: }; michael@0: michael@0: /** michael@0: * A helper to work with Task.spawn so you can do: michael@0: * return Task.spawn(realTestFunc).then(finish, helpers.handleError); michael@0: */ michael@0: helpers.handleError = function(ex) { michael@0: console.error(ex); michael@0: ok(false, ex); michael@0: finish(); michael@0: }; michael@0: michael@0: /** michael@0: * A helper for calling addEventListener and then removeEventListener as soon michael@0: * as the event is called, passing the results on as a promise michael@0: * @param element The DOM element to listen on michael@0: * @param event The name of the event to listen for michael@0: * @param useCapture Should we use the capturing phase? michael@0: * @return A promise resolved with the event object when the event first happens michael@0: */ michael@0: helpers.listenOnce = function(element, event, useCapture) { michael@0: var deferred = promise.defer(); michael@0: var onEvent = function(ev) { michael@0: element.removeEventListener(event, onEvent, useCapture); michael@0: deferred.resolve(ev); michael@0: }; michael@0: element.addEventListener(event, onEvent, useCapture); michael@0: return deferred.promise; michael@0: }; michael@0: michael@0: /** michael@0: * A wrapper for calling Services.obs.[add|remove]Observer using promises. michael@0: * @param topic The topic parameter to Services.obs.addObserver michael@0: * @param ownsWeak The ownsWeak parameter to Services.obs.addObserver with a michael@0: * default value of false michael@0: * @return a promise that resolves when the ObserverService first notifies us michael@0: * of the topic. The value of the promise is the first parameter to the observer michael@0: * function other parameters are dropped. michael@0: */ michael@0: helpers.observeOnce = function(topic, ownsWeak=false) { michael@0: let deferred = promise.defer(); michael@0: let resolver = function(subject) { michael@0: Services.obs.removeObserver(resolver, topic); michael@0: deferred.resolve(subject); michael@0: }; michael@0: Services.obs.addObserver(resolver, topic, ownsWeak); michael@0: return deferred.promise; michael@0: }; michael@0: michael@0: /** michael@0: * Takes a function that uses a callback as its last parameter, and returns a michael@0: * new function that returns a promise instead michael@0: */ michael@0: helpers.promiseify = function(functionWithLastParamCallback, scope) { michael@0: return function() { michael@0: let deferred = promise.defer(); michael@0: michael@0: let args = [].slice.call(arguments); michael@0: args.push(function(callbackParam) { michael@0: deferred.resolve(callbackParam); michael@0: }); michael@0: michael@0: try { michael@0: functionWithLastParamCallback.apply(scope, args); michael@0: } michael@0: catch (ex) { michael@0: deferred.resolve(ex); michael@0: } michael@0: michael@0: return deferred.promise; michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * Warning: For use with Firefox Mochitests only. michael@0: * michael@0: * As addTab, but that also opens the developer toolbar. In addition a new michael@0: * 'automator' property is added to the options object with the display from GCLI michael@0: * in the developer toolbar michael@0: */ michael@0: helpers.addTabWithToolbar = function(url, callback, options) { michael@0: return helpers.addTab(url, function(innerOptions) { michael@0: var win = innerOptions.chromeWindow; michael@0: michael@0: return win.DeveloperToolbar.show(true).then(function() { michael@0: var display = win.DeveloperToolbar.display; michael@0: innerOptions.automator = createFFDisplayAutomator(display); michael@0: innerOptions.requisition = display.requisition; michael@0: michael@0: var reply = callback.call(null, innerOptions); michael@0: michael@0: return promise.resolve(reply).then(null, function(error) { michael@0: ok(false, error); michael@0: console.error(error); michael@0: }).then(function() { michael@0: win.DeveloperToolbar.hide().then(function() { michael@0: delete innerOptions.automator; michael@0: }); michael@0: }); michael@0: }); michael@0: }, options); michael@0: }; michael@0: michael@0: /** michael@0: * Warning: For use with Firefox Mochitests only. michael@0: * michael@0: * Run a set of test functions stored in the values of the 'exports' object michael@0: * functions stored under setup/shutdown will be run at the start/end of the michael@0: * sequence of tests. michael@0: * A test will be considered finished when its return value is resolved. michael@0: * @param options An object to be passed to the test functions michael@0: * @param tests An object containing named test functions michael@0: * @return a promise which will be resolved when all tests have been run and michael@0: * their return values resolved michael@0: */ michael@0: helpers.runTests = function(options, tests) { michael@0: var testNames = Object.keys(tests).filter(function(test) { michael@0: return test != "setup" && test != "shutdown"; michael@0: }); michael@0: michael@0: var recover = function(error) { michael@0: ok(false, error); michael@0: console.error(error); michael@0: }; michael@0: michael@0: info("SETUP"); michael@0: var setupDone = (tests.setup != null) ? michael@0: promise.resolve(tests.setup(options)) : michael@0: promise.resolve(); michael@0: michael@0: var testDone = setupDone.then(function() { michael@0: return util.promiseEach(testNames, function(testName) { michael@0: info(testName); michael@0: var action = tests[testName]; michael@0: michael@0: if (typeof action === "function") { michael@0: var reply = action.call(tests, options); michael@0: return promise.resolve(reply); michael@0: } michael@0: else if (Array.isArray(action)) { michael@0: return helpers.audit(options, action); michael@0: } michael@0: michael@0: return promise.reject("test action '" + testName + michael@0: "' is not a function or helpers.audit() object"); michael@0: }); michael@0: }, recover); michael@0: michael@0: return testDone.then(function() { michael@0: info("SHUTDOWN"); michael@0: return (tests.shutdown != null) ? michael@0: promise.resolve(tests.shutdown(options)) : michael@0: promise.resolve(); michael@0: }, recover); michael@0: }; michael@0: michael@0: /////////////////////////////////////////////////////////////////////////////// michael@0: michael@0: /** michael@0: * Ensure that the options object is setup correctly michael@0: * options should contain an automator object that looks like this: michael@0: * { michael@0: * getInputState: function() { ... }, michael@0: * setCursor: function(cursor) { ... }, michael@0: * getCompleterTemplateData: function() { ... }, michael@0: * focus: function() { ... }, michael@0: * getErrorMessage: function() { ... }, michael@0: * fakeKey: function(keyCode) { ... }, michael@0: * setInput: function(typed) { ... }, michael@0: * focusManager: ..., michael@0: * field: ..., michael@0: * } michael@0: */ michael@0: function checkOptions(options) { michael@0: if (options == null) { michael@0: console.trace(); michael@0: throw new Error('Missing options object'); michael@0: } michael@0: if (options.requisition == null) { michael@0: console.trace(); michael@0: throw new Error('options.requisition == null'); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Various functions to return the actual state of the command line michael@0: */ michael@0: helpers._actual = { michael@0: input: function(options) { michael@0: return options.automator.getInputState().typed; michael@0: }, michael@0: michael@0: hints: function(options) { michael@0: return options.automator.getCompleterTemplateData().then(function(data) { michael@0: var emptyParams = data.emptyParameters.join(''); michael@0: return (data.directTabText + emptyParams + data.arrowTabText) michael@0: .replace(/\u00a0/g, ' ') michael@0: .replace(/\u21E5/, '->') michael@0: .replace(/ $/, ''); michael@0: }); michael@0: }, michael@0: michael@0: markup: function(options) { michael@0: var cursor = helpers._actual.cursor(options); michael@0: var statusMarkup = options.requisition.getInputStatusMarkup(cursor); michael@0: return statusMarkup.map(function(s) { michael@0: return new Array(s.string.length + 1).join(s.status.toString()[0]); michael@0: }).join(''); michael@0: }, michael@0: michael@0: cursor: function(options) { michael@0: return options.automator.getInputState().cursor.start; michael@0: }, michael@0: michael@0: current: function(options) { michael@0: var cursor = helpers._actual.cursor(options); michael@0: return options.requisition.getAssignmentAt(cursor).param.name; michael@0: }, michael@0: michael@0: status: function(options) { michael@0: return options.requisition.status.toString(); michael@0: }, michael@0: michael@0: predictions: function(options) { michael@0: var cursor = helpers._actual.cursor(options); michael@0: var assignment = options.requisition.getAssignmentAt(cursor); michael@0: var context = options.requisition.executionContext; michael@0: return assignment.getPredictions(context).then(function(predictions) { michael@0: return predictions.map(function(prediction) { michael@0: return prediction.name; michael@0: }); michael@0: }); michael@0: }, michael@0: michael@0: unassigned: function(options) { michael@0: return options.requisition._unassigned.map(function(assignment) { michael@0: return assignment.arg.toString(); michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: outputState: function(options) { michael@0: var outputData = options.automator.focusManager._shouldShowOutput(); michael@0: return outputData.visible + ':' + outputData.reason; michael@0: }, michael@0: michael@0: tooltipState: function(options) { michael@0: var tooltipData = options.automator.focusManager._shouldShowTooltip(); michael@0: return tooltipData.visible + ':' + tooltipData.reason; michael@0: }, michael@0: michael@0: options: function(options) { michael@0: if (options.automator.field.menu == null) { michael@0: return []; michael@0: } michael@0: return options.automator.field.menu.items.map(function(item) { michael@0: return item.name.textContent ? item.name.textContent : item.name; michael@0: }); michael@0: }, michael@0: michael@0: message: function(options) { michael@0: return options.automator.getErrorMessage(); michael@0: } michael@0: }; michael@0: michael@0: function shouldOutputUnquoted(value) { michael@0: var type = typeof value; michael@0: return value == null || type === 'boolean' || type === 'number'; michael@0: } michael@0: michael@0: function outputArray(array) { michael@0: return (array.length === 0) ? michael@0: '[ ]' : michael@0: '[ \'' + array.join('\', \'') + '\' ]'; michael@0: } michael@0: michael@0: helpers._createDebugCheck = function(options) { michael@0: checkOptions(options); michael@0: var requisition = options.requisition; michael@0: var command = requisition.commandAssignment.value; michael@0: var cursor = helpers._actual.cursor(options); michael@0: var input = helpers._actual.input(options); michael@0: var padding = new Array(input.length + 1).join(' '); michael@0: michael@0: var hintsPromise = helpers._actual.hints(options); michael@0: var predictionsPromise = helpers._actual.predictions(options); michael@0: michael@0: return promise.all(hintsPromise, predictionsPromise).then(function(values) { michael@0: var hints = values[0]; michael@0: var predictions = values[1]; michael@0: var output = ''; michael@0: michael@0: output += 'return helpers.audit(options, [\n'; michael@0: output += ' {\n'; michael@0: michael@0: if (cursor === input.length) { michael@0: output += ' setup: \'' + input + '\',\n'; michael@0: } michael@0: else { michael@0: output += ' name: \'' + input + ' (cursor=' + cursor + ')\',\n'; michael@0: output += ' setup: function() {\n'; michael@0: output += ' return helpers.setInput(options, \'' + input + '\', ' + cursor + ');\n'; michael@0: output += ' },\n'; michael@0: } michael@0: michael@0: output += ' check: {\n'; michael@0: michael@0: output += ' input: \'' + input + '\',\n'; michael@0: output += ' hints: ' + padding + '\'' + hints + '\',\n'; michael@0: output += ' markup: \'' + helpers._actual.markup(options) + '\',\n'; michael@0: output += ' cursor: ' + cursor + ',\n'; michael@0: output += ' current: \'' + helpers._actual.current(options) + '\',\n'; michael@0: output += ' status: \'' + helpers._actual.status(options) + '\',\n'; michael@0: output += ' options: ' + outputArray(helpers._actual.options(options)) + ',\n'; michael@0: output += ' message: \'' + helpers._actual.message(options) + '\',\n'; michael@0: output += ' predictions: ' + outputArray(predictions) + ',\n'; michael@0: output += ' unassigned: ' + outputArray(requisition._unassigned) + ',\n'; michael@0: output += ' outputState: \'' + helpers._actual.outputState(options) + '\',\n'; michael@0: output += ' tooltipState: \'' + helpers._actual.tooltipState(options) + '\'' + michael@0: (command ? ',' : '') +'\n'; michael@0: michael@0: if (command) { michael@0: output += ' args: {\n'; michael@0: output += ' command: { name: \'' + command.name + '\' },\n'; michael@0: michael@0: requisition.getAssignments().forEach(function(assignment) { michael@0: output += ' ' + assignment.param.name + ': { '; michael@0: michael@0: if (typeof assignment.value === 'string') { michael@0: output += 'value: \'' + assignment.value + '\', '; michael@0: } michael@0: else if (shouldOutputUnquoted(assignment.value)) { michael@0: output += 'value: ' + assignment.value + ', '; michael@0: } michael@0: else { michael@0: output += '/*value:' + assignment.value + ',*/ '; michael@0: } michael@0: michael@0: output += 'arg: \'' + assignment.arg + '\', '; michael@0: output += 'status: \'' + assignment.getStatus().toString() + '\', '; michael@0: output += 'message: \'' + assignment.message + '\''; michael@0: output += ' },\n'; michael@0: }); michael@0: michael@0: output += ' }\n'; michael@0: } michael@0: michael@0: output += ' },\n'; michael@0: output += ' exec: {\n'; michael@0: output += ' output: \'\',\n'; michael@0: output += ' type: \'string\',\n'; michael@0: output += ' error: false\n'; michael@0: output += ' }\n'; michael@0: output += ' }\n'; michael@0: output += ']);'; michael@0: michael@0: return output; michael@0: }.bind(this), util.errorHandler); michael@0: }; michael@0: michael@0: /** michael@0: * Simulate focusing the input field michael@0: */ michael@0: helpers.focusInput = function(options) { michael@0: checkOptions(options); michael@0: options.automator.focus(); michael@0: }; michael@0: michael@0: /** michael@0: * Simulate pressing TAB in the input field michael@0: */ michael@0: helpers.pressTab = function(options) { michael@0: checkOptions(options); michael@0: return helpers.pressKey(options, KeyEvent.DOM_VK_TAB); michael@0: }; michael@0: michael@0: /** michael@0: * Simulate pressing RETURN in the input field michael@0: */ michael@0: helpers.pressReturn = function(options) { michael@0: checkOptions(options); michael@0: return helpers.pressKey(options, KeyEvent.DOM_VK_RETURN); michael@0: }; michael@0: michael@0: /** michael@0: * Simulate pressing a key by keyCode in the input field michael@0: */ michael@0: helpers.pressKey = function(options, keyCode) { michael@0: checkOptions(options); michael@0: return options.automator.fakeKey(keyCode); michael@0: }; michael@0: michael@0: /** michael@0: * A list of special key presses and how to to them, for the benefit of michael@0: * helpers.setInput michael@0: */ michael@0: var ACTIONS = { michael@0: '': function(options) { michael@0: return helpers.pressTab(options); michael@0: }, michael@0: '': function(options) { michael@0: return helpers.pressReturn(options); michael@0: }, michael@0: '': function(options) { michael@0: return helpers.pressKey(options, KeyEvent.DOM_VK_UP); michael@0: }, michael@0: '': function(options) { michael@0: return helpers.pressKey(options, KeyEvent.DOM_VK_DOWN); michael@0: }, michael@0: '': function(options) { michael@0: return helpers.pressKey(options, KeyEvent.DOM_VK_BACK_SPACE); michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * Used in helpers.setInput to cut an input string like 'blahfoo' into michael@0: * an array like [ 'blah', '', 'foo', '' ]. michael@0: * When using this RegExp, you also need to filter out the blank strings. michael@0: */ michael@0: var CHUNKER = /([^<]*)(<[A-Z]+>)/; michael@0: michael@0: /** michael@0: * Alter the input to typed optionally leaving the cursor at michael@0: * cursor. michael@0: * @return A promise of the number of key-presses to respond michael@0: */ michael@0: helpers.setInput = function(options, typed, cursor) { michael@0: checkOptions(options); michael@0: var inputPromise; michael@0: var automator = options.automator; michael@0: // We try to measure average keypress time, but setInput can simulate michael@0: // several, so we try to keep track of how many michael@0: var chunkLen = 1; michael@0: michael@0: // The easy case is a simple string without things like michael@0: if (typed.indexOf('<') === -1) { michael@0: inputPromise = automator.setInput(typed); michael@0: } michael@0: else { michael@0: // Cut the input up into input strings separated by '' tokens. The michael@0: // CHUNKS RegExp leaves blanks so we filter them out. michael@0: var chunks = typed.split(CHUNKER).filter(function(s) { michael@0: return s !== ''; michael@0: }); michael@0: chunkLen = chunks.length + 1; michael@0: michael@0: // We're working on this in chunks so first clear the input michael@0: inputPromise = automator.setInput('').then(function() { michael@0: return util.promiseEach(chunks, function(chunk) { michael@0: if (chunk.charAt(0) === '<') { michael@0: var action = ACTIONS[chunk]; michael@0: if (typeof action !== 'function') { michael@0: console.error('Known actions: ' + Object.keys(ACTIONS).join()); michael@0: throw new Error('Key action not found "' + chunk + '"'); michael@0: } michael@0: return action(options); michael@0: } michael@0: else { michael@0: return automator.setInput(automator.getInputState().typed + chunk); michael@0: } michael@0: }); michael@0: }); michael@0: } michael@0: michael@0: return inputPromise.then(function() { michael@0: if (cursor != null) { michael@0: automator.setCursor({ start: cursor, end: cursor }); michael@0: } michael@0: michael@0: if (automator.focusManager) { michael@0: automator.focusManager.onInputChange(); michael@0: } michael@0: michael@0: // Firefox testing is noisy and distant, so logging helps michael@0: if (options.isFirefox) { michael@0: var cursorStr = (cursor == null ? '' : ', ' + cursor); michael@0: log('setInput("' + typed + '"' + cursorStr + ')'); michael@0: } michael@0: michael@0: return chunkLen; michael@0: }); michael@0: }; michael@0: michael@0: /** michael@0: * Helper for helpers.audit() to ensure that all the 'check' properties match. michael@0: * See helpers.audit for more information. michael@0: * @param name The name to use in error messages michael@0: * @param checks See helpers.audit for a list of available checks michael@0: * @return A promise which resolves to undefined when the checks are complete michael@0: */ michael@0: helpers._check = function(options, name, checks) { michael@0: // A test method to check that all args are assigned in some way michael@0: var requisition = options.requisition; michael@0: requisition._args.forEach(function(arg) { michael@0: if (arg.assignment == null) { michael@0: assert.ok(false, 'No assignment for ' + arg); michael@0: } michael@0: }); michael@0: michael@0: if (checks == null) { michael@0: return promise.resolve(); michael@0: } michael@0: michael@0: var outstanding = []; michael@0: var suffix = name ? ' (for \'' + name + '\')' : ''; michael@0: michael@0: if (!options.isNoDom && 'input' in checks) { michael@0: assert.is(helpers._actual.input(options), checks.input, 'input' + suffix); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'cursor' in checks) { michael@0: assert.is(helpers._actual.cursor(options), checks.cursor, 'cursor' + suffix); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'current' in checks) { michael@0: assert.is(helpers._actual.current(options), checks.current, 'current' + suffix); michael@0: } michael@0: michael@0: if ('status' in checks) { michael@0: assert.is(helpers._actual.status(options), checks.status, 'status' + suffix); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'markup' in checks) { michael@0: assert.is(helpers._actual.markup(options), checks.markup, 'markup' + suffix); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'hints' in checks) { michael@0: var hintCheck = function(actualHints) { michael@0: assert.is(actualHints, checks.hints, 'hints' + suffix); michael@0: }; michael@0: outstanding.push(helpers._actual.hints(options).then(hintCheck)); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'predictions' in checks) { michael@0: var predictionsCheck = function(actualPredictions) { michael@0: helpers.arrayIs(actualPredictions, michael@0: checks.predictions, michael@0: 'predictions' + suffix); michael@0: }; michael@0: outstanding.push(helpers._actual.predictions(options).then(predictionsCheck)); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'predictionsContains' in checks) { michael@0: var containsCheck = function(actualPredictions) { michael@0: checks.predictionsContains.forEach(function(prediction) { michael@0: var index = actualPredictions.indexOf(prediction); michael@0: assert.ok(index !== -1, michael@0: 'predictionsContains:' + prediction + suffix); michael@0: }); michael@0: }; michael@0: outstanding.push(helpers._actual.predictions(options).then(containsCheck)); michael@0: } michael@0: michael@0: if ('unassigned' in checks) { michael@0: helpers.arrayIs(helpers._actual.unassigned(options), michael@0: checks.unassigned, michael@0: 'unassigned' + suffix); michael@0: } michael@0: michael@0: /* TODO: Fix this michael@0: if (!options.isNoDom && 'tooltipState' in checks) { michael@0: assert.is(helpers._actual.tooltipState(options), michael@0: checks.tooltipState, michael@0: 'tooltipState' + suffix); michael@0: } michael@0: */ michael@0: michael@0: if (!options.isNoDom && 'outputState' in checks) { michael@0: assert.is(helpers._actual.outputState(options), michael@0: checks.outputState, michael@0: 'outputState' + suffix); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'options' in checks) { michael@0: helpers.arrayIs(helpers._actual.options(options), michael@0: checks.options, michael@0: 'options' + suffix); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'error' in checks) { michael@0: assert.is(helpers._actual.message(options), checks.error, 'error' + suffix); michael@0: } michael@0: michael@0: if (checks.args != null) { michael@0: Object.keys(checks.args).forEach(function(paramName) { michael@0: var check = checks.args[paramName]; michael@0: michael@0: // We allow an 'argument' called 'command' to be the command itself, but michael@0: // what if the command has a parameter called 'command' (for example, an michael@0: // 'exec' command)? We default to using the parameter because checking michael@0: // the command value is less useful michael@0: var assignment = requisition.getAssignment(paramName); michael@0: if (assignment == null && paramName === 'command') { michael@0: assignment = requisition.commandAssignment; michael@0: } michael@0: michael@0: if (assignment == null) { michael@0: assert.ok(false, 'Unknown arg: ' + paramName + suffix); michael@0: return; michael@0: } michael@0: michael@0: if ('value' in check) { michael@0: if (typeof check.value === 'function') { michael@0: try { michael@0: check.value(assignment.value); michael@0: } michael@0: catch (ex) { michael@0: assert.ok(false, '' + ex); michael@0: } michael@0: } michael@0: else { michael@0: assert.is(assignment.value, michael@0: check.value, michael@0: 'arg.' + paramName + '.value' + suffix); michael@0: } michael@0: } michael@0: michael@0: if ('name' in check) { michael@0: assert.is(assignment.value.name, michael@0: check.name, michael@0: 'arg.' + paramName + '.name' + suffix); michael@0: } michael@0: michael@0: if ('type' in check) { michael@0: assert.is(assignment.arg.type, michael@0: check.type, michael@0: 'arg.' + paramName + '.type' + suffix); michael@0: } michael@0: michael@0: if ('arg' in check) { michael@0: assert.is(assignment.arg.toString(), michael@0: check.arg, michael@0: 'arg.' + paramName + '.arg' + suffix); michael@0: } michael@0: michael@0: if ('status' in check) { michael@0: assert.is(assignment.getStatus().toString(), michael@0: check.status, michael@0: 'arg.' + paramName + '.status' + suffix); michael@0: } michael@0: michael@0: if (!options.isNoDom && 'message' in check) { michael@0: if (typeof check.message.test === 'function') { michael@0: assert.ok(check.message.test(assignment.message), michael@0: 'arg.' + paramName + '.message' + suffix); michael@0: } michael@0: else { michael@0: assert.is(assignment.message, michael@0: check.message, michael@0: 'arg.' + paramName + '.message' + suffix); michael@0: } michael@0: } michael@0: }); michael@0: } michael@0: michael@0: return promise.all(outstanding).then(function() { michael@0: // Ensure the promise resolves to nothing michael@0: return undefined; michael@0: }); michael@0: }; michael@0: michael@0: /** michael@0: * Helper for helpers.audit() to ensure that all the 'exec' properties work. michael@0: * See helpers.audit for more information. michael@0: * @param name The name to use in error messages michael@0: * @param expected See helpers.audit for a list of available exec checks michael@0: * @return A promise which resolves to undefined when the checks are complete michael@0: */ michael@0: helpers._exec = function(options, name, expected) { michael@0: var requisition = options.requisition; michael@0: if (expected == null) { michael@0: return promise.resolve({}); michael@0: } michael@0: michael@0: var origLogErrors = cli.logErrors; michael@0: if (expected.error) { michael@0: cli.logErrors = false; michael@0: } michael@0: michael@0: try { michael@0: return requisition.exec({ hidden: true }).then(function(output) { michael@0: if ('type' in expected) { michael@0: assert.is(output.type, michael@0: expected.type, michael@0: 'output.type for: ' + name); michael@0: } michael@0: michael@0: if ('error' in expected) { michael@0: assert.is(output.error, michael@0: expected.error, michael@0: 'output.error for: ' + name); michael@0: } michael@0: michael@0: if (!('output' in expected)) { michael@0: return { output: output }; michael@0: } michael@0: michael@0: var context = requisition.conversionContext; michael@0: var convertPromise; michael@0: if (options.isNoDom) { michael@0: convertPromise = output.convert('string', context); michael@0: } michael@0: else { michael@0: convertPromise = output.convert('dom', context).then(function(node) { michael@0: return node.textContent.trim(); michael@0: }); michael@0: } michael@0: michael@0: return convertPromise.then(function(textOutput) { michael@0: var doTest = function(match, against) { michael@0: // Only log the real textContent if the test fails michael@0: if (against.match(match) != null) { michael@0: assert.ok(true, 'html output for \'' + name + '\' ' + michael@0: 'should match /' + (match.source || match) + '/'); michael@0: } else { michael@0: assert.ok(false, 'html output for \'' + name + '\' ' + michael@0: 'should match /' + (match.source || match) + '/. ' + michael@0: 'Actual textContent: "' + against + '"'); michael@0: } michael@0: }; michael@0: michael@0: if (typeof expected.output === 'string') { michael@0: assert.is(textOutput, michael@0: expected.output, michael@0: 'html output for ' + name); michael@0: } michael@0: else if (Array.isArray(expected.output)) { michael@0: expected.output.forEach(function(match) { michael@0: doTest(match, textOutput); michael@0: }); michael@0: } michael@0: else { michael@0: doTest(expected.output, textOutput); michael@0: } michael@0: michael@0: if (expected.error) { michael@0: cli.logErrors = origLogErrors; michael@0: } michael@0: return { output: output, text: textOutput }; michael@0: }); michael@0: }.bind(this)).then(function(data) { michael@0: if (expected.error) { michael@0: cli.logErrors = origLogErrors; michael@0: } michael@0: michael@0: return data; michael@0: }); michael@0: } michael@0: catch (ex) { michael@0: assert.ok(false, 'Failure executing \'' + name + '\': ' + ex); michael@0: util.errorHandler(ex); michael@0: michael@0: if (expected.error) { michael@0: cli.logErrors = origLogErrors; michael@0: } michael@0: return promise.resolve({}); michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * Helper to setup the test michael@0: */ michael@0: helpers._setup = function(options, name, audit) { michael@0: if (typeof audit.setup === 'string') { michael@0: return helpers.setInput(options, audit.setup); michael@0: } michael@0: michael@0: if (typeof audit.setup === 'function') { michael@0: return promise.resolve(audit.setup.call(audit)); michael@0: } michael@0: michael@0: return promise.reject('\'setup\' property must be a string or a function. Is ' + audit.setup); michael@0: }; michael@0: michael@0: /** michael@0: * Helper to shutdown the test michael@0: */ michael@0: helpers._post = function(name, audit, data) { michael@0: if (typeof audit.post === 'function') { michael@0: return promise.resolve(audit.post.call(audit, data.output, data.text)); michael@0: } michael@0: return promise.resolve(audit.post); michael@0: }; michael@0: michael@0: /* michael@0: * We do some basic response time stats so we can see if we're getting slow michael@0: */ michael@0: var totalResponseTime = 0; michael@0: var averageOver = 0; michael@0: var maxResponseTime = 0; michael@0: var maxResponseCulprit; michael@0: var start; michael@0: michael@0: /** michael@0: * Restart the stats collection process michael@0: */ michael@0: helpers.resetResponseTimes = function() { michael@0: start = new Date().getTime(); michael@0: totalResponseTime = 0; michael@0: averageOver = 0; michael@0: maxResponseTime = 0; michael@0: maxResponseCulprit = undefined; michael@0: }; michael@0: michael@0: /** michael@0: * Expose an average response time in milliseconds michael@0: */ michael@0: Object.defineProperty(helpers, 'averageResponseTime', { michael@0: get: function() { michael@0: return averageOver === 0 ? michael@0: undefined : michael@0: Math.round(100 * totalResponseTime / averageOver) / 100; michael@0: }, michael@0: enumerable: true michael@0: }); michael@0: michael@0: /** michael@0: * Expose a maximum response time in milliseconds michael@0: */ michael@0: Object.defineProperty(helpers, 'maxResponseTime', { michael@0: get: function() { return Math.round(maxResponseTime * 100) / 100; }, michael@0: enumerable: true michael@0: }); michael@0: michael@0: /** michael@0: * Expose the name of the test that provided the maximum response time michael@0: */ michael@0: Object.defineProperty(helpers, 'maxResponseCulprit', { michael@0: get: function() { return maxResponseCulprit; }, michael@0: enumerable: true michael@0: }); michael@0: michael@0: /** michael@0: * Quick summary of the times michael@0: */ michael@0: Object.defineProperty(helpers, 'timingSummary', { michael@0: get: function() { michael@0: var elapsed = (new Date().getTime() - start) / 1000; michael@0: return 'Total ' + elapsed + 's, ' + michael@0: 'ave response ' + helpers.averageResponseTime + 'ms, ' + michael@0: 'max response ' + helpers.maxResponseTime + 'ms ' + michael@0: 'from \'' + helpers.maxResponseCulprit + '\''; michael@0: }, michael@0: enumerable: true michael@0: }); michael@0: michael@0: /** michael@0: * A way of turning a set of tests into something more declarative, this helps michael@0: * to allow tests to be asynchronous. michael@0: * @param audits An array of objects each of which contains: michael@0: * - setup: string/function to be called to set the test up. michael@0: * If audit is a string then it is passed to helpers.setInput(). michael@0: * If audit is a function then it is executed. The tests will wait while michael@0: * tests that return promises complete. michael@0: * - name: For debugging purposes. If name is undefined, and 'setup' michael@0: * is a string then the setup value will be used automatically michael@0: * - skipIf: A function to define if the test should be skipped. Useful for michael@0: * excluding tests from certain environments (e.g. nodom, firefox, etc). michael@0: * The name of the test will be used in log messages noting the skip michael@0: * See helpers.reason for pre-defined skip functions. The skip function must michael@0: * be synchronous, and will be passed the test options object. michael@0: * - skipRemainingIf: A function to skip all the remaining audits in this set. michael@0: * See skipIf for details of how skip functions work. michael@0: * - check: Check data. Available checks: michael@0: * - input: The text displayed in the input field michael@0: * - cursor: The position of the start of the cursor michael@0: * - status: One of 'VALID', 'ERROR', 'INCOMPLETE' michael@0: * - hints: The hint text, i.e. a concatenation of the directTabText, the michael@0: * emptyParameters and the arrowTabText. The text as inserted into the UI michael@0: * will include NBSP and Unicode RARR characters, these should be michael@0: * represented using normal space and '->' for the arrow michael@0: * - markup: What state should the error markup be in. e.g. 'VVVIIIEEE' michael@0: * - args: Maps of checks to make against the arguments: michael@0: * - value: i.e. assignment.value (which ignores defaultValue) michael@0: * - type: Argument/BlankArgument/MergedArgument/etc i.e. what's assigned michael@0: * Care should be taken with this since it's something of an michael@0: * implementation detail michael@0: * - arg: The toString value of the argument michael@0: * - status: i.e. assignment.getStatus michael@0: * - message: i.e. assignment.message michael@0: * - name: For commands - checks assignment.value.name michael@0: * - exec: Object to indicate we should execute the command and check the michael@0: * results. Available checks: michael@0: * - output: A string, RegExp or array of RegExps to compare with the output michael@0: * If typeof output is a string then the output should be exactly equal michael@0: * to the given string. If the type of output is a RegExp or array of michael@0: * RegExps then the output should match all RegExps michael@0: * - post: Function to be called after the checks have been run michael@0: */ michael@0: helpers.audit = function(options, audits) { michael@0: checkOptions(options); michael@0: var skipReason = null; michael@0: return util.promiseEach(audits, function(audit) { michael@0: var name = audit.name; michael@0: if (name == null && typeof audit.setup === 'string') { michael@0: name = audit.setup; michael@0: } michael@0: michael@0: if (assert.testLogging) { michael@0: log('- START \'' + name + '\' in ' + assert.currentTest); michael@0: } michael@0: michael@0: if (audit.skipRemainingIf) { michael@0: var skipRemainingIf = (typeof audit.skipRemainingIf === 'function') ? michael@0: audit.skipRemainingIf(options) : michael@0: !!audit.skipRemainingIf; michael@0: if (skipRemainingIf) { michael@0: skipReason = audit.skipRemainingIf.name ? michael@0: 'due to ' + audit.skipRemainingIf.name : michael@0: ''; michael@0: assert.log('Skipped ' + name + ' ' + skipReason); michael@0: return promise.resolve(undefined); michael@0: } michael@0: } michael@0: michael@0: if (audit.skipIf) { michael@0: var skip = (typeof audit.skipIf === 'function') ? michael@0: audit.skipIf(options) : michael@0: !!audit.skipIf; michael@0: if (skip) { michael@0: var reason = audit.skipIf.name ? 'due to ' + audit.skipIf.name : ''; michael@0: assert.log('Skipped ' + name + ' ' + reason); michael@0: return promise.resolve(undefined); michael@0: } michael@0: } michael@0: michael@0: if (skipReason != null) { michael@0: assert.log('Skipped ' + name + ' ' + skipReason); michael@0: return promise.resolve(undefined); michael@0: } michael@0: michael@0: var start = new Date().getTime(); michael@0: michael@0: var setupDone = helpers._setup(options, name, audit); michael@0: return setupDone.then(function(chunkLen) { michael@0: if (typeof chunkLen !== 'number') { michael@0: chunkLen = 1; michael@0: } michael@0: michael@0: // Nasty hack to allow us to auto-skip tests where we're actually testing michael@0: // a key-sequence (i.e. targeting terminal.js) when there is no terminal michael@0: if (chunkLen === -1) { michael@0: assert.log('Skipped ' + name + ' ' + skipReason); michael@0: return promise.resolve(undefined); michael@0: } michael@0: michael@0: if (assert.currentTest) { michael@0: var responseTime = (new Date().getTime() - start) / chunkLen; michael@0: totalResponseTime += responseTime; michael@0: if (responseTime > maxResponseTime) { michael@0: maxResponseTime = responseTime; michael@0: maxResponseCulprit = assert.currentTest + '/' + name; michael@0: } michael@0: averageOver++; michael@0: } michael@0: michael@0: var checkDone = helpers._check(options, name, audit.check); michael@0: return checkDone.then(function() { michael@0: var execDone = helpers._exec(options, name, audit.exec); michael@0: return execDone.then(function(data) { michael@0: return helpers._post(name, audit, data).then(function() { michael@0: if (assert.testLogging) { michael@0: log('- END \'' + name + '\' in ' + assert.currentTest); michael@0: } michael@0: }); michael@0: }); michael@0: }); michael@0: }); michael@0: }).then(function() { michael@0: return options.automator.setInput(''); michael@0: }); michael@0: }; michael@0: michael@0: /** michael@0: * Compare 2 arrays. michael@0: */ michael@0: helpers.arrayIs = function(actual, expected, message) { michael@0: assert.ok(Array.isArray(actual), 'actual is not an array: ' + message); michael@0: assert.ok(Array.isArray(expected), 'expected is not an array: ' + message); michael@0: michael@0: if (!Array.isArray(actual) || !Array.isArray(expected)) { michael@0: return; michael@0: } michael@0: michael@0: assert.is(actual.length, expected.length, 'array length: ' + message); michael@0: michael@0: for (var i = 0; i < actual.length && i < expected.length; i++) { michael@0: assert.is(actual[i], expected[i], 'member[' + i + ']: ' + message); michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * A quick helper to log to the correct place michael@0: */ michael@0: function log(message) { michael@0: if (typeof info === 'function') { michael@0: info(message); michael@0: } michael@0: else { michael@0: console.log(message); michael@0: } michael@0: }