/* * 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. var { helpers, gcli, assert } = (function() { var 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').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); return helpers.navigate(url, 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) { options = options || {}; options.chromeWindow = options.chromeWindow || window; return options.chromeWindow.DeveloperToolbar.show(true).then(function() { var display = options.chromeWindow.DeveloperToolbar.display; options.automator = createFFDisplayAutomator(display); options.requisition = display.requisition; return options; }); }; /** * Navigate the current tab to a URL */ helpers.navigate = function(url, options) { options = options || {}; options.chromeWindow = options.chromeWindow || window; options.tab = options.tab || options.chromeWindow.gBrowser.selectedTab; var tabbrowser = options.chromeWindow.gBrowser; options.browser = tabbrowser.getBrowserForTab(options.tab); var promise = helpers.listenOnce(options.browser, "load", true).then(function() { options.document = options.browser.contentDocument; options.window = options.document.defaultView; return options; }); options.browser.contentWindow.location = url; return promise; }; /** * 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) { return new Promise(function(resolve, reject) { var onEvent = function(ev) { element.removeEventListener(event, onEvent, useCapture); resolve(ev); }; element.addEventListener(event, onEvent, useCapture); }.bind(this)); }; /** * 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) { return new Promise(function(resolve, reject) { let resolver = function(subject) { Services.obs.removeObserver(resolver, topic); resolve(subject); }; Services.obs.addObserver(resolver, topic, ownsWeak); }.bind(this)); }; /** * 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 args = [].slice.call(arguments); return new Promise(resolve => { args.push((...results) => { resolve(results.length > 1 ? results : results[0]); }); functionWithLastParamCallback.apply(scope, args); }); }; }; /** * 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 * - error: If true, then it is expected that this command will fail (that * is, return a rejected promise or throw an exception) * - type: A string documenting the expected type of the return value * - post: Function to be called after the checks have been run, which will be * passed 2 parameters: the first being output data (with type, data, and * error properties), and the second being the converted text version of * the output data */ 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(''); }, function(ex) { options.automator.setInput(''); throw ex; }); }; /** * 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); } } return { helpers: helpers, gcli: gcli, assert: assert }; })();