From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- devtools/client/commandline/test/helpers.js | 1341 +++++++++++++++++++++++++++ 1 file changed, 1341 insertions(+) create mode 100644 devtools/client/commandline/test/helpers.js (limited to 'devtools/client/commandline/test/helpers.js') diff --git a/devtools/client/commandline/test/helpers.js b/devtools/client/commandline/test/helpers.js new file mode 100644 index 000000000..d365765a2 --- /dev/null +++ b/devtools/client/commandline/test/helpers.js @@ -0,0 +1,1341 @@ +/* + * 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, assert } = (function () { + + var helpers = {}; + + var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var { TargetFactory } = require("devtools/client/framework/target"); + var Services = require("Services"); + + var assert = { ok: ok, is: is, log: info }; + var util = require("gcli/util/util"); + var cli = require("gcli/cli"); + var KeyEvent = require("gcli/util/util").KeyEvent; + + const { GcliFront } = require("devtools/shared/fronts/gcli"); + +/** + * See notes in helpers.checkOptions() + */ + var createDeveloperToolbarAutomator = function (toolbar) { + var automator = { + setInput: function (typed) { + return toolbar.inputter.setInput(typed); + }, + + setCursor: function (cursor) { + return toolbar.inputter.setCursor(cursor); + }, + + focus: function () { + return toolbar.inputter.focus(); + }, + + fakeKey: function (keyCode) { + var fakeEvent = { + keyCode: keyCode, + preventDefault: function () { }, + timeStamp: new Date().getTime() + }; + + toolbar.inputter.onKeyDown(fakeEvent); + + if (keyCode === KeyEvent.DOM_VK_BACK_SPACE) { + var input = toolbar.inputter.element; + input.value = input.value.slice(0, -1); + } + + return toolbar.inputter.handleKeyUp(fakeEvent); + }, + + getInputState: function () { + return toolbar.inputter.getInputState(); + }, + + getCompleterTemplateData: function () { + return toolbar.completer._getCompleterTemplateData(); + }, + + getErrorMessage: function () { + return toolbar.tooltip.errorEle.textContent; + } + }; + + Object.defineProperty(automator, "focusManager", { + get: function () { return toolbar.focusManager; }, + enumerable: true + }); + + Object.defineProperty(automator, "field", { + get: function () { return toolbar.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 + * - 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) { + 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.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 + * @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.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 toolbar = options.chromeWindow.DeveloperToolbar; + options.automator = createDeveloperToolbarAutomator(toolbar); + options.requisition = toolbar.requisition; + return options; + }); + }; + +/** + * Navigate the current tab to a URL + */ + helpers.navigate = Task.async(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); + + let onLoaded = BrowserTestUtils.browserLoaded(options.browser); + options.browser.loadURI(url); + yield onLoaded; + + return options; + }); + +/** + * 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 which uses 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 toolbar = win.DeveloperToolbar; + innerOptions.automator = createDeveloperToolbarAutomator(toolbar); + innerOptions.requisition = toolbar.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, error.stack); + }; + + 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); + }; + + const MOCK_COMMANDS_URI = "chrome://mochitests/content/browser/devtools/client/commandline/test/mockCommands.js"; + + const defer = function () { + const deferred = { }; + deferred.promise = new Promise(function (resolve, reject) { + deferred.resolve = resolve; + deferred.reject = reject; + }); + return deferred; + }; + +/** + * This does several actions associated with running a GCLI test in mochitest + * 1. Create a new tab containing basic markup for GCLI tests + * 2. Open the developer toolbar + * 3. Register the mock commands with the server process + * 4. Wait for the proxy commands to be auto-regitstered with the client + * 5. Register the mock converters with the client process + * 6. Run all the tests + * 7. Tear down all the setup + */ + helpers.runTestModule = function (exports, name) { + return Task.spawn(function* () { + const uri = "data:text/html;charset=utf-8," + + "" + + "
" + name + "
"; + + const options = yield helpers.openTab(uri); + options.isRemote = true; + + yield helpers.openToolbar(options); + + const system = options.requisition.system; + + // Register a one time listener with the local set of commands + const addedDeferred = defer(); + const removedDeferred = defer(); + let state = "preAdd"; // Then 'postAdd' then 'postRemove' + + system.commands.onCommandsChange.add(function (ev) { + if (system.commands.get("tsslow") != null) { + if (state === "preAdd") { + addedDeferred.resolve(); + state = "postAdd"; + } + } + else { + if (state === "postAdd") { + removedDeferred.resolve(); + state = "postRemove"; + } + } + }); + + // Send a message to add the commands to the content process + const front = yield GcliFront.create(options.target); + yield front._testOnlyAddItemsByModule(MOCK_COMMANDS_URI); + + // This will cause the local set of commands to be updated with the + // command proxies, wait for that to complete. + yield addedDeferred.promise; + + // Now we need to add the converters to the local GCLI + const converters = mockCommands.items.filter(item => item.item === "converter"); + system.addItems(converters); + + // Next run the tests + yield helpers.runTests(options, exports); + + // Finally undo the mock commands and converters + system.removeItems(converters); + const removePromise = system.commands.onCommandsChange.once(); + yield front._testOnlyRemoveItemsByModule(MOCK_COMMANDS_URI); + yield removedDeferred.promise; + + // And close everything down + yield helpers.closeToolbar(options); + yield helpers.closeTab(options); + }).then(finish, helpers.handleError); + }; + +/** + * 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.isNode && "input" in checks) { + assert.is(helpers._actual.input(options), checks.input, "input" + suffix); + } + + if (!options.isNode && "cursor" in checks) { + assert.is(helpers._actual.cursor(options), checks.cursor, "cursor" + suffix); + } + + if (!options.isNode && "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.isNode && "markup" in checks) { + assert.is(helpers._actual.markup(options), checks.markup, "markup" + suffix); + } + + if (!options.isNode && "hints" in checks) { + var hintCheck = function (actualHints) { + assert.is(actualHints, checks.hints, "hints" + suffix); + }; + outstanding.push(helpers._actual.hints(options).then(hintCheck)); + } + + if (!options.isNode && "predictions" in checks) { + var predictionsCheck = function (actualPredictions) { + helpers.arrayIs(actualPredictions, + checks.predictions, + "predictions" + suffix); + }; + outstanding.push(helpers._actual.predictions(options).then(predictionsCheck)); + } + + if (!options.isNode && "predictionsContains" in checks) { + var containsCheck = function (actualPredictions) { + checks.predictionsContains.forEach(function (prediction) { + var index = actualPredictions.indexOf(prediction); + assert.ok(index !== -1, + "predictionsContains:" + prediction + suffix); + if (index === -1) { + log("Actual predictions (" + actualPredictions.length + "): " + + actualPredictions.join(", ")); + } + }); + }; + 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.isNode && 'tooltipState' in checks) { + assert.is(helpers._actual.tooltipState(options), + checks.tooltipState, + 'tooltipState' + suffix); + } + */ + + if (!options.isNode && "outputState" in checks) { + assert.is(helpers._actual.outputState(options), + checks.outputState, + "outputState" + suffix); + } + + if (!options.isNode && "options" in checks) { + helpers.arrayIs(helpers._actual.options(options), + checks.options, + "options" + suffix); + } + + if (!options.isNode && "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.isNode && "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.isNode) { + convertPromise = output.convert("string", context); + } + else { + convertPromise = output.convert("dom", context).then(function (node) { + return (node == null) ? "" : 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); + + // Tests need at least one pass, fail or todo. Create a dummy pass + assert.ok(true, "Each test requires at least one pass, fail or todo"); + + 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, assert: assert }; +})(); -- cgit v1.2.3