diff options
Diffstat (limited to 'devtools/client/webconsole/test/head.js')
-rw-r--r-- | devtools/client/webconsole/test/head.js | 1844 |
1 files changed, 1844 insertions, 0 deletions
diff --git a/devtools/client/webconsole/test/head.js b/devtools/client/webconsole/test/head.js new file mode 100644 index 000000000..519cb78b0 --- /dev/null +++ b/devtools/client/webconsole/test/head.js @@ -0,0 +1,1844 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../framework/test/shared-head.js */ +"use strict"; + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this); + +var {Utils: WebConsoleUtils} = require("devtools/client/webconsole/utils"); +var {Messages} = require("devtools/client/webconsole/console-output"); +const asyncStorage = require("devtools/shared/async-storage"); +const HUDService = require("devtools/client/webconsole/hudservice"); + +// Services.prefs.setBoolPref("devtools.debugger.log", true); + +var gPendingOutputTest = 0; + +// The various categories of messages. +const CATEGORY_NETWORK = 0; +const CATEGORY_CSS = 1; +const CATEGORY_JS = 2; +const CATEGORY_WEBDEV = 3; +const CATEGORY_INPUT = 4; +const CATEGORY_OUTPUT = 5; +const CATEGORY_SECURITY = 6; +const CATEGORY_SERVER = 7; + +// The possible message severities. +const SEVERITY_ERROR = 0; +const SEVERITY_WARNING = 1; +const SEVERITY_INFO = 2; +const SEVERITY_LOG = 3; + +// The indent of a console group in pixels. +const GROUP_INDENT = 12; + +const WEBCONSOLE_STRINGS_URI = "devtools/client/locales/webconsole.properties"; +var WCUL10n = new WebConsoleUtils.L10n(WEBCONSOLE_STRINGS_URI); + +const DOCS_GA_PARAMS = "?utm_source=mozilla" + + "&utm_medium=firefox-console-errors" + + "&utm_campaign=default"; + +flags.testing = true; + +function loadTab(url) { + let deferred = promise.defer(); + + let tab = gBrowser.selectedTab = gBrowser.addTab(url); + let browser = gBrowser.getBrowserForTab(tab); + + browser.addEventListener("load", function onLoad() { + browser.removeEventListener("load", onLoad, true); + deferred.resolve({tab: tab, browser: browser}); + }, true); + + return deferred.promise; +} + +function loadBrowser(browser) { + return BrowserTestUtils.browserLoaded(browser); +} + +function closeTab(tab) { + let deferred = promise.defer(); + + let container = gBrowser.tabContainer; + + container.addEventListener("TabClose", function onTabClose() { + container.removeEventListener("TabClose", onTabClose, true); + deferred.resolve(null); + }, true); + + gBrowser.removeTab(tab); + + return deferred.promise; +} + +/** + * Load the page and return the associated HUD. + * + * @param string uri + * The URI of the page to load. + * @param string consoleType [optional] + * The console type, either "browserConsole" or "webConsole". Defaults to + * "webConsole". + * @return object + * The HUD associated with the console + */ +function* loadPageAndGetHud(uri, consoleType) { + let { browser } = yield loadTab("data:text/html;charset=utf-8,Loading tab for tests"); + + let hud; + if (consoleType === "browserConsole") { + hud = yield HUDService.openBrowserConsoleOrFocus(); + } else { + hud = yield openConsole(); + } + + ok(hud, "Console was opened"); + + let loaded = loadBrowser(browser); + yield BrowserTestUtils.loadURI(gBrowser.selectedBrowser, uri); + yield loaded; + + yield waitForMessages({ + webconsole: hud, + messages: [{ + text: uri, + category: CATEGORY_NETWORK, + severity: SEVERITY_LOG, + }], + }); + + return hud; +} + +function afterAllTabsLoaded(callback, win) { + win = win || window; + + let stillToLoad = 0; + + function onLoad() { + this.removeEventListener("load", onLoad, true); + stillToLoad--; + if (!stillToLoad) { + callback(); + } + } + + for (let a = 0; a < win.gBrowser.tabs.length; a++) { + let browser = win.gBrowser.tabs[a].linkedBrowser; + if (browser.webProgress.isLoadingDocument) { + stillToLoad++; + browser.addEventListener("load", onLoad, true); + } + } + + if (!stillToLoad) { + callback(); + } +} + +/** + * Check if a log entry exists in the HUD output node. + * + * @param {Element} outputNode + * the HUD output node. + * @param {string} matchString + * the string you want to check if it exists in the output node. + * @param {string} msg + * the message describing the test + * @param {boolean} [onlyVisible=false] + * find only messages that are visible, not hidden by the filter. + * @param {boolean} [failIfFound=false] + * fail the test if the string is found in the output node. + * @param {string} cssClass [optional] + * find only messages with the given CSS class. + */ +function testLogEntry(outputNode, matchString, msg, onlyVisible, + failIfFound, cssClass) { + let selector = ".message"; + // Skip entries that are hidden by the filter. + if (onlyVisible) { + selector += ":not(.filtered-by-type):not(.filtered-by-string)"; + } + if (cssClass) { + selector += "." + aClass; + } + + let msgs = outputNode.querySelectorAll(selector); + let found = false; + for (let i = 0, n = msgs.length; i < n; i++) { + let message = msgs[i].textContent.indexOf(matchString); + if (message > -1) { + found = true; + break; + } + } + + is(found, !failIfFound, msg); +} + +/** + * A convenience method to call testLogEntry(). + * + * @param str string + * The string to find. + */ +function findLogEntry(str) { + testLogEntry(outputNode, str, "found " + str); +} + +/** + * Open the Web Console for the given tab. + * + * @param nsIDOMElement [tab] + * Optional tab element for which you want open the Web Console. The + * default tab is taken from the global variable |tab|. + * @param function [callback] + * Optional function to invoke after the Web Console completes + * initialization (web-console-created). + * @return object + * A promise that is resolved once the web console is open. + */ +var openConsole = function (tab) { + let webconsoleOpened = promise.defer(); + let target = TargetFactory.forTab(tab || gBrowser.selectedTab); + gDevTools.showToolbox(target, "webconsole").then(toolbox => { + let hud = toolbox.getCurrentPanel().hud; + hud.jsterm._lazyVariablesView = false; + webconsoleOpened.resolve(hud); + }); + return webconsoleOpened.promise; +}; + +/** + * Close the Web Console for the given tab. + * + * @param nsIDOMElement [tab] + * Optional tab element for which you want close the Web Console. The + * default tab is taken from the global variable |tab|. + * @param function [callback] + * Optional function to invoke after the Web Console completes + * closing (web-console-destroyed). + * @return object + * A promise that is resolved once the web console is closed. + */ +var closeConsole = Task.async(function* (tab) { + let target = TargetFactory.forTab(tab || gBrowser.selectedTab); + let toolbox = gDevTools.getToolbox(target); + if (toolbox) { + yield toolbox.destroy(); + } +}); + +/** + * Listen for a new tab to open and return a promise that resolves when one + * does and completes the load event. + * @return a promise that resolves to the tab object + */ +var waitForTab = Task.async(function* () { + info("Waiting for a tab to open"); + yield once(gBrowser.tabContainer, "TabOpen"); + let tab = gBrowser.selectedTab; + let browser = tab.linkedBrowser; + yield once(browser, "load", true); + info("The tab load completed"); + return tab; +}); + +/** + * Dump the output of all open Web Consoles - used only for debugging purposes. + */ +function dumpConsoles() { + if (gPendingOutputTest) { + console.log("dumpConsoles start"); + for (let [, hud] of HUDService.consoles) { + if (!hud.outputNode) { + console.debug("no output content for", hud.hudId); + continue; + } + + console.debug("output content for", hud.hudId); + for (let elem of hud.outputNode.childNodes) { + dumpMessageElement(elem); + } + } + console.log("dumpConsoles end"); + + gPendingOutputTest = 0; + } +} + +/** + * Dump to output debug information for the given webconsole message. + * + * @param nsIDOMNode message + * The message element you want to display. + */ +function dumpMessageElement(message) { + let text = message.textContent; + let repeats = message.querySelector(".message-repeats"); + if (repeats) { + repeats = repeats.getAttribute("value"); + } + console.debug("id", message.getAttribute("id"), + "date", message.timestamp, + "class", message.className, + "category", message.category, + "severity", message.severity, + "repeats", repeats, + "clipboardText", message.clipboardText, + "text", text); +} + +var finishTest = Task.async(function* () { + dumpConsoles(); + + let browserConsole = HUDService.getBrowserConsole(); + if (browserConsole) { + if (browserConsole.jsterm) { + browserConsole.jsterm.clearOutput(true); + } + yield HUDService.toggleBrowserConsole(); + } + + let target = TargetFactory.forTab(gBrowser.selectedTab); + yield gDevTools.closeToolbox(target); + + finish(); +}); + +// Always use the 'old' frontend for tests that rely on it +Services.prefs.setBoolPref("devtools.webconsole.new-frontend-enabled", false); +registerCleanupFunction(function* () { + Services.prefs.clearUserPref("devtools.webconsole.new-frontend-enabled"); +}); + +registerCleanupFunction(function* () { + flags.testing = false; + + // Remove stored console commands in between tests + yield asyncStorage.removeItem("webConsoleHistory"); + + dumpConsoles(); + + let browserConsole = HUDService.getBrowserConsole(); + if (browserConsole) { + if (browserConsole.jsterm) { + browserConsole.jsterm.clearOutput(true); + } + yield HUDService.toggleBrowserConsole(); + } + + let target = TargetFactory.forTab(gBrowser.selectedTab); + yield gDevTools.closeToolbox(target); + + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +}); + +waitForExplicitFinish(); + +/** + * Polls a given function waiting for it to become true. + * + * @param object options + * Options object with the following properties: + * - validator + * A validator function that returns a boolean. This is called every few + * milliseconds to check if the result is true. When it is true, the + * promise is resolved and polling stops. If validator never returns + * true, then polling timeouts after several tries and the promise is + * rejected. + * - name + * Name of test. This is used to generate the success and failure + * messages. + * - timeout + * Timeout for validator function, in milliseconds. Default is 5000. + * @return object + * A Promise object that is resolved based on the validator function. + */ +function waitForSuccess(options) { + let deferred = promise.defer(); + let start = Date.now(); + let timeout = options.timeout || 5000; + let {validator} = options; + + function wait() { + if ((Date.now() - start) > timeout) { + // Log the failure. + ok(false, "Timed out while waiting for: " + options.name); + deferred.reject(null); + return; + } + + if (validator(options)) { + ok(true, options.name); + deferred.resolve(null); + } else { + setTimeout(wait, 100); + } + } + + setTimeout(wait, 100); + + return deferred.promise; +} + +var openInspector = Task.async(function* (tab = gBrowser.selectedTab) { + let target = TargetFactory.forTab(tab); + let toolbox = yield gDevTools.showToolbox(target, "inspector"); + return toolbox.getCurrentPanel(); +}); + +/** + * Find variables or properties in a VariablesView instance. + * + * @param object view + * The VariablesView instance. + * @param array rules + * The array of rules you want to match. Each rule is an object with: + * - name (string|regexp): property name to match. + * - value (string|regexp): property value to match. + * - isIterator (boolean): check if the property is an iterator. + * - isGetter (boolean): check if the property is a getter. + * - isGenerator (boolean): check if the property is a generator. + * - dontMatch (boolean): make sure the rule doesn't match any property. + * @param object options + * Options for matching: + * - webconsole: the WebConsole instance we work with. + * @return object + * A promise object that is resolved when all the rules complete + * matching. The resolved callback is given an array of all the rules + * you wanted to check. Each rule has a new property: |matchedProp| + * which holds a reference to the Property object instance from the + * VariablesView. If the rule did not match, then |matchedProp| is + * undefined. + */ +function findVariableViewProperties(view, rules, options) { + // Initialize the search. + function init() { + // Separate out the rules that require expanding properties throughout the + // view. + let expandRules = []; + let filterRules = rules.filter((rule) => { + if (typeof rule.name == "string" && rule.name.indexOf(".") > -1) { + expandRules.push(rule); + return false; + } + return true; + }); + + // Search through the view those rules that do not require any properties to + // be expanded. Build the array of matchers, outstanding promises to be + // resolved. + let outstanding = []; + finder(filterRules, view, outstanding); + + // Process the rules that need to expand properties. + let lastStep = processExpandRules.bind(null, expandRules); + + // Return the results - a promise resolved to hold the updated rules array. + let returnResults = onAllRulesMatched.bind(null, rules); + + return promise.all(outstanding).then(lastStep).then(returnResults); + } + + function onMatch(prop, rule, matched) { + if (matched && !rule.matchedProp) { + rule.matchedProp = prop; + } + } + + function finder(rules, vars, promises) { + for (let [, prop] of vars) { + for (let rule of rules) { + let matcher = matchVariablesViewProperty(prop, rule, options); + promises.push(matcher.then(onMatch.bind(null, prop, rule))); + } + } + } + + function processExpandRules(rules) { + let rule = rules.shift(); + if (!rule) { + return promise.resolve(null); + } + + let deferred = promise.defer(); + let expandOptions = { + rootVariable: view, + expandTo: rule.name, + webconsole: options.webconsole, + }; + + variablesViewExpandTo(expandOptions).then(function onSuccess(prop) { + let name = rule.name; + let lastName = name.split(".").pop(); + rule.name = lastName; + + let matched = matchVariablesViewProperty(prop, rule, options); + return matched.then(onMatch.bind(null, prop, rule)).then(function () { + rule.name = name; + }); + }, function onFailure() { + return promise.resolve(null); + }).then(processExpandRules.bind(null, rules)).then(function () { + deferred.resolve(null); + }); + + return deferred.promise; + } + + function onAllRulesMatched(rules) { + for (let rule of rules) { + let matched = rule.matchedProp; + if (matched && !rule.dontMatch) { + ok(true, "rule " + rule.name + " matched for property " + matched.name); + } else if (matched && rule.dontMatch) { + ok(false, "rule " + rule.name + " should not match property " + + matched.name); + } else { + ok(rule.dontMatch, "rule " + rule.name + " did not match any property"); + } + } + return rules; + } + + return init(); +} + +/** + * Check if a given Property object from the variables view matches the given + * rule. + * + * @param object prop + * The variable's view Property instance. + * @param object rule + * Rules for matching the property. See findVariableViewProperties() for + * details. + * @param object options + * Options for matching. See findVariableViewProperties(). + * @return object + * A promise that is resolved when all the checks complete. Resolution + * result is a boolean that tells your promise callback the match + * result: true or false. + */ +function matchVariablesViewProperty(prop, rule, options) { + function resolve(result) { + return promise.resolve(result); + } + + if (rule.name) { + let match = rule.name instanceof RegExp ? + rule.name.test(prop.name) : + prop.name == rule.name; + if (!match) { + return resolve(false); + } + } + + if (rule.value) { + let displayValue = prop.displayValue; + if (prop.displayValueClassName == "token-string") { + displayValue = displayValue.substring(1, displayValue.length - 1); + } + + let match = rule.value instanceof RegExp ? + rule.value.test(displayValue) : + displayValue == rule.value; + if (!match) { + info("rule " + rule.name + " did not match value, expected '" + + rule.value + "', found '" + displayValue + "'"); + return resolve(false); + } + } + + if ("isGetter" in rule) { + let isGetter = !!(prop.getter && prop.get("get")); + if (rule.isGetter != isGetter) { + info("rule " + rule.name + " getter test failed"); + return resolve(false); + } + } + + if ("isGenerator" in rule) { + let isGenerator = prop.displayValue == "Generator"; + if (rule.isGenerator != isGenerator) { + info("rule " + rule.name + " generator test failed"); + return resolve(false); + } + } + + let outstanding = []; + + if ("isIterator" in rule) { + let isIterator = isVariableViewPropertyIterator(prop, options.webconsole); + outstanding.push(isIterator.then((result) => { + if (result != rule.isIterator) { + info("rule " + rule.name + " iterator test failed"); + } + return result == rule.isIterator; + })); + } + + outstanding.push(promise.resolve(true)); + + return promise.all(outstanding).then(function _onMatchDone(results) { + let ruleMatched = results.indexOf(false) == -1; + return resolve(ruleMatched); + }); +} + +/** + * Check if the given variables view property is an iterator. + * + * @param object prop + * The Property instance you want to check. + * @param object webConsole + * The WebConsole instance to work with. + * @return object + * A promise that is resolved when the check completes. The resolved + * callback is given a boolean: true if the property is an iterator, or + * false otherwise. + */ +function isVariableViewPropertyIterator(prop, webConsole) { + if (prop.displayValue == "Iterator") { + return promise.resolve(true); + } + + let deferred = promise.defer(); + + variablesViewExpandTo({ + rootVariable: prop, + expandTo: "__proto__.__iterator__", + webconsole: webConsole, + }).then(function onSuccess() { + deferred.resolve(true); + }, function onFailure() { + deferred.resolve(false); + }); + + return deferred.promise; +} + +/** + * Recursively expand the variables view up to a given property. + * + * @param options + * Options for view expansion: + * - rootVariable: start from the given scope/variable/property. + * - expandTo: string made up of property names you want to expand. + * For example: "body.firstChild.nextSibling" given |rootVariable: + * document|. + * - webconsole: a WebConsole instance. If this is not provided all + * property expand() calls will be considered sync. Things may fail! + * @return object + * A promise that is resolved only when the last property in |expandTo| + * is found, and rejected otherwise. Resolution reason is always the + * last property - |nextSibling| in the example above. Rejection is + * always the last property that was found. + */ +function variablesViewExpandTo(options) { + let root = options.rootVariable; + let expandTo = options.expandTo.split("."); + let jsterm = (options.webconsole || {}).jsterm; + let lastDeferred = promise.defer(); + + function fetch(prop) { + if (!prop.onexpand) { + ok(false, "property " + prop.name + " cannot be expanded: !onexpand"); + return promise.reject(prop); + } + + let deferred = promise.defer(); + + if (prop._fetched || !jsterm) { + executeSoon(function () { + deferred.resolve(prop); + }); + } else { + jsterm.once("variablesview-fetched", function _onFetchProp() { + executeSoon(() => deferred.resolve(prop)); + }); + } + + prop.expand(); + + return deferred.promise; + } + + function getNext(prop) { + let name = expandTo.shift(); + let newProp = prop.get(name); + + if (expandTo.length > 0) { + ok(newProp, "found property " + name); + if (newProp) { + fetch(newProp).then(getNext, fetchError); + } else { + lastDeferred.reject(prop); + } + } else if (newProp) { + lastDeferred.resolve(newProp); + } else { + lastDeferred.reject(prop); + } + } + + function fetchError(prop) { + lastDeferred.reject(prop); + } + + if (!root._fetched) { + fetch(root).then(getNext, fetchError); + } else { + getNext(root); + } + + return lastDeferred.promise; +} + +/** + * Update the content of a property in the variables view. + * + * @param object options + * Options for the property update: + * - property: the property you want to change. + * - field: string that tells what you want to change: + * - use "name" to change the property name, + * - or "value" to change the property value. + * - string: the new string to write into the field. + * - webconsole: reference to the Web Console instance we work with. + * @return object + * A Promise object that is resolved once the property is updated. + */ +var updateVariablesViewProperty = Task.async(function* (options) { + let view = options.property._variablesView; + view.window.focus(); + options.property.focus(); + + switch (options.field) { + case "name": + EventUtils.synthesizeKey("VK_RETURN", { shiftKey: true }, view.window); + break; + case "value": + EventUtils.synthesizeKey("VK_RETURN", {}, view.window); + break; + default: + throw new Error("options.field is incorrect"); + } + + let deferred = promise.defer(); + + executeSoon(() => { + EventUtils.synthesizeKey("A", { accelKey: true }, view.window); + + for (let c of options.string) { + EventUtils.synthesizeKey(c, {}, view.window); + } + + if (options.webconsole) { + options.webconsole.jsterm.once("variablesview-fetched") + .then((varView) => deferred.resolve(varView)); + } + + EventUtils.synthesizeKey("VK_RETURN", {}, view.window); + + if (!options.webconsole) { + executeSoon(() => { + deferred.resolve(null); + }); + } + }); + + return deferred.promise; +}); + +/** + * Open the JavaScript debugger. + * + * @param object options + * Options for opening the debugger: + * - tab: the tab you want to open the debugger for. + * @return object + * A promise that is resolved once the debugger opens, or rejected if + * the open fails. The resolution callback is given one argument, an + * object that holds the following properties: + * - target: the Target object for the Tab. + * - toolbox: the Toolbox instance. + * - panel: the jsdebugger panel instance. + * - panelWin: the window object of the panel iframe. + */ +function openDebugger(options = {}) { + if (!options.tab) { + options.tab = gBrowser.selectedTab; + } + + let deferred = promise.defer(); + + let target = TargetFactory.forTab(options.tab); + let toolbox = gDevTools.getToolbox(target); + let dbgPanelAlreadyOpen = toolbox && toolbox.getPanel("jsdebugger"); + + gDevTools.showToolbox(target, "jsdebugger").then(function onSuccess(tool) { + let panel = tool.getCurrentPanel(); + let panelWin = panel.panelWin; + + panel._view.Variables.lazyEmpty = false; + + let resolveObject = { + target: target, + toolbox: tool, + panel: panel, + panelWin: panelWin, + }; + + if (dbgPanelAlreadyOpen) { + deferred.resolve(resolveObject); + } else { + panelWin.DebuggerController.waitForSourcesLoaded().then(() => { + deferred.resolve(resolveObject); + }); + } + }, function onFailure(reason) { + console.debug("failed to open the toolbox for 'jsdebugger'", reason); + deferred.reject(reason); + }); + + return deferred.promise; +} + +/** + * Returns true if the caret in the debugger editor is placed at the specified + * position. + * @param panel The debugger panel. + * @param {number} line The line number. + * @param {number} [col] The column number. + * @returns {boolean} + */ +function isDebuggerCaretPos(panel, line, col = 1) { + let editor = panel.panelWin.DebuggerView.editor; + let cursor = editor.getCursor(); + + // Source editor starts counting line and column numbers from 0. + info("Current editor caret position: " + (cursor.line + 1) + ", " + + (cursor.ch + 1)); + return cursor.line == (line - 1) && cursor.ch == (col - 1); +} + +/** + * Wait for messages in the Web Console output. + * + * @param object options + * Options for what you want to wait for: + * - webconsole: the webconsole instance you work with. + * - matchCondition: "any" or "all". Default: "all". The promise + * returned by this function resolves when all of the messages are + * matched, if the |matchCondition| is "all". If you set the condition to + * "any" then the promise is resolved by any message rule that matches, + * irrespective of order - waiting for messages stops whenever any rule + * matches. + * - messages: an array of objects that tells which messages to wait for. + * Properties: + * - text: string or RegExp to match the textContent of each new + * message. + * - noText: string or RegExp that must not match in the message + * textContent. + * - repeats: the number of message repeats, as displayed by the Web + * Console. + * - category: match message category. See CATEGORY_* constants at + * the top of this file. + * - severity: match message severity. See SEVERITY_* constants at + * the top of this file. + * - count: how many unique web console messages should be matched by + * this rule. + * - consoleTrace: boolean, set to |true| to match a console.trace() + * message. Optionally this can be an object of the form + * { file, fn, line } that can match the specified file, function + * and/or line number in the trace message. + * - consoleTime: string that matches a console.time() timer name. + * Provide this if you want to match a console.time() message. + * - consoleTimeEnd: same as above, but for console.timeEnd(). + * - consoleDir: boolean, set to |true| to match a console.dir() + * message. + * - consoleGroup: boolean, set to |true| to match a console.group() + * message. + * - consoleTable: boolean, set to |true| to match a console.table() + * message. + * - longString: boolean, set to |true} to match long strings in the + * message. + * - collapsible: boolean, set to |true| to match messages that can + * be collapsed/expanded. + * - type: match messages that are instances of the given object. For + * example, you can point to Messages.NavigationMarker to match any + * such message. + * - objects: boolean, set to |true| if you expect inspectable + * objects in the message. + * - source: object of the shape { url, line }. This is used to + * match the source URL and line number of the error message or + * console API call. + * - prefix: prefix text to check for in the prefix element. + * - stacktrace: array of objects of the form { file, fn, line } that + * can match frames in the stacktrace associated with the message. + * - groupDepth: number used to check the depth of the message in + * a group. + * - url: URL to match for network requests. + * @return object + * A promise object is returned once the messages you want are found. + * The promise is resolved with the array of rule objects you give in + * the |messages| property. Each objects is the same as provided, with + * additional properties: + * - matched: a Set of web console messages that matched the rule. + * - clickableElements: a list of inspectable objects. This is available + * if any of the following properties are present in the rule: + * |consoleTrace| or |objects|. + * - longStrings: a list of long string ellipsis elements you can click + * in the message element, to expand a long string. This is available + * only if |longString| is present in the matching rule. + */ +function waitForMessages(options) { + info("Waiting for messages..."); + + gPendingOutputTest++; + let webconsole = options.webconsole; + let rules = WebConsoleUtils.cloneObject(options.messages, true); + let rulesMatched = 0; + let listenerAdded = false; + let deferred = promise.defer(); + options.matchCondition = options.matchCondition || "all"; + + function checkText(rule, text) { + let result = false; + if (Array.isArray(rule)) { + result = rule.every((s) => checkText(s, text)); + } else if (typeof rule == "string") { + result = text.indexOf(rule) > -1; + } else if (rule instanceof RegExp) { + result = rule.test(text); + } else { + result = rule == text; + } + return result; + } + + function checkConsoleTable(rule, element) { + let elemText = element.textContent; + + if (!checkText("console.table():", elemText)) { + return false; + } + + rule.category = CATEGORY_WEBDEV; + rule.severity = SEVERITY_LOG; + rule.type = Messages.ConsoleTable; + + return true; + } + + function checkConsoleTrace(rule, element) { + let elemText = element.textContent; + let trace = rule.consoleTrace; + + if (!checkText("console.trace():", elemText)) { + return false; + } + + rule.category = CATEGORY_WEBDEV; + rule.severity = SEVERITY_LOG; + rule.type = Messages.ConsoleTrace; + + if (!rule.stacktrace && typeof trace == "object" && trace !== true) { + if (Array.isArray(trace)) { + rule.stacktrace = trace; + } else { + rule.stacktrace = [trace]; + } + } + + return true; + } + + function checkConsoleTime(rule, element) { + let elemText = element.textContent; + let time = rule.consoleTime; + + if (!checkText(time + ": timer started", elemText)) { + return false; + } + + rule.category = CATEGORY_WEBDEV; + rule.severity = SEVERITY_LOG; + + return true; + } + + function checkConsoleTimeEnd(rule, element) { + let elemText = element.textContent; + let time = rule.consoleTimeEnd; + let regex = new RegExp(time + ": -?\\d+([,.]\\d+)?ms"); + + if (!checkText(regex, elemText)) { + return false; + } + + rule.category = CATEGORY_WEBDEV; + rule.severity = SEVERITY_LOG; + + return true; + } + + function checkConsoleDir(rule, element) { + if (!element.classList.contains("inlined-variables-view")) { + return false; + } + + let elemText = element.textContent; + if (!checkText(rule.consoleDir, elemText)) { + return false; + } + + let iframe = element.querySelector("iframe"); + if (!iframe) { + ok(false, "console.dir message has no iframe"); + return false; + } + + return true; + } + + function checkConsoleGroup(rule) { + if (!isNaN(parseInt(rule.consoleGroup, 10))) { + rule.groupDepth = rule.consoleGroup; + } + rule.category = CATEGORY_WEBDEV; + rule.severity = SEVERITY_LOG; + + return true; + } + + function checkSource(rule, element) { + let location = getRenderedSource(element); + if (!location) { + return false; + } + + if (!checkText(rule.source.url, location.url)) { + return false; + } + + if ("line" in rule.source && location.line != rule.source.line) { + return false; + } + + return true; + } + + function checkCollapsible(rule, element) { + let msg = element._messageObject; + if (!msg || !!msg.collapsible != rule.collapsible) { + return false; + } + + return true; + } + + function checkStacktrace(rule, element) { + let stack = rule.stacktrace; + let frames = element.querySelectorAll(".stacktrace > .stack-trace > .frame-link"); + if (!frames.length) { + return false; + } + + for (let i = 0; i < stack.length; i++) { + let frame = frames[i]; + let expected = stack[i]; + if (!frame) { + ok(false, "expected frame #" + i + " but didnt find it"); + return false; + } + + if (expected.file) { + let url = frame.getAttribute("data-url"); + if (!checkText(expected.file, url)) { + ok(false, "frame #" + i + " does not match file name: " + + expected.file + " != " + url); + displayErrorContext(rule, element); + return false; + } + } + + if (expected.fn) { + let fn = frame.querySelector(".frame-link-function-display-name").textContent; + if (!checkText(expected.fn, fn)) { + ok(false, "frame #" + i + " does not match the function name: " + + expected.fn + " != " + fn); + displayErrorContext(rule, element); + return false; + } + } + + if (expected.line) { + let line = frame.getAttribute("data-line"); + if (!checkText(expected.line, line)) { + ok(false, "frame #" + i + " does not match the line number: " + + expected.line + " != " + line); + displayErrorContext(rule, element); + return false; + } + } + } + + return true; + } + + function hasXhrLabel(element) { + let xhr = element.querySelector(".xhr"); + if (!xhr) { + return false; + } + return true; + } + + function checkMessage(rule, element) { + let elemText = element.textContent; + + if (rule.text && !checkText(rule.text, elemText)) { + return false; + } + + if (rule.noText && checkText(rule.noText, elemText)) { + return false; + } + + if (rule.consoleTable && !checkConsoleTable(rule, element)) { + return false; + } + + if (rule.consoleTrace && !checkConsoleTrace(rule, element)) { + return false; + } + + if (rule.consoleTime && !checkConsoleTime(rule, element)) { + return false; + } + + if (rule.consoleTimeEnd && !checkConsoleTimeEnd(rule, element)) { + return false; + } + + if (rule.consoleDir && !checkConsoleDir(rule, element)) { + return false; + } + + if (rule.consoleGroup && !checkConsoleGroup(rule, element)) { + return false; + } + + if (rule.source && !checkSource(rule, element)) { + return false; + } + + if ("collapsible" in rule && !checkCollapsible(rule, element)) { + return false; + } + + if (rule.isXhr && !hasXhrLabel(element)) { + return false; + } + + if (!rule.isXhr && hasXhrLabel(element)) { + return false; + } + + let partialMatch = !!(rule.consoleTrace || rule.consoleTime || + rule.consoleTimeEnd); + + // The rule tries to match the newer types of messages, based on their + // object constructor. + if (rule.type) { + if (!element._messageObject || + !(element._messageObject instanceof rule.type)) { + if (partialMatch) { + ok(false, "message type for rule: " + displayRule(rule)); + displayErrorContext(rule, element); + } + return false; + } + partialMatch = true; + } + + if ("category" in rule && element.category != rule.category) { + if (partialMatch) { + is(element.category, rule.category, + "message category for rule: " + displayRule(rule)); + displayErrorContext(rule, element); + } + return false; + } + + if ("severity" in rule && element.severity != rule.severity) { + if (partialMatch) { + is(element.severity, rule.severity, + "message severity for rule: " + displayRule(rule)); + displayErrorContext(rule, element); + } + return false; + } + + if (rule.text) { + partialMatch = true; + } + + if (rule.stacktrace && !checkStacktrace(rule, element)) { + if (partialMatch) { + ok(false, "failed to match stacktrace for rule: " + displayRule(rule)); + displayErrorContext(rule, element); + } + return false; + } + + if (rule.category == CATEGORY_NETWORK && "url" in rule && + !checkText(rule.url, element.url)) { + return false; + } + + if ("repeats" in rule) { + let repeats = element.querySelector(".message-repeats"); + if (!repeats || repeats.getAttribute("value") != rule.repeats) { + return false; + } + } + + if ("groupDepth" in rule) { + let indentNode = element.querySelector(".indent"); + let indent = (GROUP_INDENT * rule.groupDepth) + "px"; + if (!indentNode || indentNode.style.width != indent) { + is(indentNode.style.width, indent, + "group depth check failed for message rule: " + displayRule(rule)); + return false; + } + } + + if ("longString" in rule) { + let longStrings = element.querySelectorAll(".longStringEllipsis"); + if (rule.longString != !!longStrings[0]) { + if (partialMatch) { + is(!!longStrings[0], rule.longString, + "long string existence check failed for message rule: " + + displayRule(rule)); + displayErrorContext(rule, element); + } + return false; + } + rule.longStrings = longStrings; + } + + if ("objects" in rule) { + let clickables = element.querySelectorAll(".message-body a"); + if (rule.objects != !!clickables[0]) { + if (partialMatch) { + is(!!clickables[0], rule.objects, + "objects existence check failed for message rule: " + + displayRule(rule)); + displayErrorContext(rule, element); + } + return false; + } + rule.clickableElements = clickables; + } + + if ("prefix" in rule) { + let prefixNode = element.querySelector(".prefix"); + is(prefixNode && prefixNode.textContent, rule.prefix, "Check prefix"); + } + + let count = rule.count || 1; + if (!rule.matched) { + rule.matched = new Set(); + } + rule.matched.add(element); + + return rule.matched.size == count; + } + + function onMessagesAdded(event, newMessages) { + for (let msg of newMessages) { + let elem = msg.node; + let location = getRenderedSource(elem); + if (location && location.url) { + let url = location.url; + // Prevent recursion with the browser console and any potential + // messages coming from head.js. + if (url.indexOf("devtools/client/webconsole/test/head.js") != -1) { + continue; + } + } + + for (let rule of rules) { + if (rule._ruleMatched) { + continue; + } + + let matched = checkMessage(rule, elem); + if (matched) { + rule._ruleMatched = true; + rulesMatched++; + ok(1, "matched rule: " + displayRule(rule)); + if (maybeDone()) { + return; + } + } + } + } + } + + function allRulesMatched() { + return options.matchCondition == "all" && rulesMatched == rules.length || + options.matchCondition == "any" && rulesMatched > 0; + } + + function maybeDone() { + if (allRulesMatched()) { + if (listenerAdded) { + webconsole.ui.off("new-messages", onMessagesAdded); + } + gPendingOutputTest--; + deferred.resolve(rules); + return true; + } + return false; + } + + function testCleanup() { + if (allRulesMatched()) { + return; + } + + if (webconsole.ui) { + webconsole.ui.off("new-messages", onMessagesAdded); + } + + for (let rule of rules) { + if (!rule._ruleMatched) { + ok(false, "failed to match rule: " + displayRule(rule)); + } + } + } + + function displayRule(rule) { + return rule.name || rule.text; + } + + function displayErrorContext(rule, element) { + console.log("error occured during rule " + displayRule(rule)); + console.log("while checking the following message"); + dumpMessageElement(element); + } + + executeSoon(() => { + let messages = []; + for (let elem of webconsole.outputNode.childNodes) { + messages.push({ + node: elem, + update: false, + }); + } + + onMessagesAdded("new-messages", messages); + + if (!allRulesMatched()) { + listenerAdded = true; + registerCleanupFunction(testCleanup); + webconsole.ui.on("new-messages", onMessagesAdded); + } + }); + + return deferred.promise; +} + +function whenDelayedStartupFinished(win, callback) { + Services.obs.addObserver(function observer(subject, topic) { + if (win == subject) { + Services.obs.removeObserver(observer, topic); + executeSoon(callback); + } + }, "browser-delayed-startup-finished", false); +} + +/** + * Check the web console output for the given inputs. Each input is checked for + * the expected JS eval result, the result of calling print(), the result of + * console.log(). The JS eval result is also checked if it opens the variables + * view on click. + * + * @param object hud + * The web console instance to work with. + * @param array inputTests + * An array of input tests. An input test element is an object. Each + * object has the following properties: + * - input: string, JS input value to execute. + * + * - output: string|RegExp, expected JS eval result. + * + * - inspectable: boolean, when true, the test runner expects the JS eval + * result is an object that can be clicked for inspection. + * + * - noClick: boolean, when true, the test runner does not click the JS + * eval result. Some objects, like |window|, have a lot of properties and + * opening vview for them is very slow (they can cause timeouts in debug + * builds). + * + * - consoleOutput: string|RegExp, optional, expected consoleOutput + * If not provided consoleOuput = output; + * + * - printOutput: string|RegExp, optional, expected output for + * |print(input)|. If this is not provided, printOutput = output. + * + * - variablesViewLabel: string|RegExp, optional, the expected variables + * view label when the object is inspected. If this is not provided, then + * |output| is used. + * + * - inspectorIcon: boolean, when true, the test runner expects the + * result widget to contain an inspectorIcon element (className + * open-inspector). + * + * - expectedTab: string, optional, the full URL of the new tab which + * must open. If this is not provided, any new tabs that open will cause + * a test failure. + */ +function checkOutputForInputs(hud, inputTests) { + let container = gBrowser.tabContainer; + + function* runner() { + for (let [i, entry] of inputTests.entries()) { + info("checkInput(" + i + "): " + entry.input); + yield checkInput(entry); + } + container = null; + } + + function* checkInput(entry) { + yield checkConsoleLog(entry); + yield checkPrintOutput(entry); + yield checkJSEval(entry); + } + + function* checkConsoleLog(entry) { + info("Logging"); + hud.jsterm.clearOutput(); + hud.jsterm.execute("console.log(" + entry.input + ")"); + + let consoleOutput = "consoleOutput" in entry ? + entry.consoleOutput : entry.output; + + let [result] = yield waitForMessages({ + webconsole: hud, + messages: [{ + name: "console.log() output: " + consoleOutput, + text: consoleOutput, + category: CATEGORY_WEBDEV, + severity: SEVERITY_LOG, + }], + }); + + let msg = [...result.matched][0]; + + if (entry.consoleLogClick) { + yield checkObjectClick(entry, msg); + } + + if (typeof entry.inspectorIcon == "boolean") { + info("Checking Inspector Link"); + yield checkLinkToInspector(entry.inspectorIcon, msg); + } + } + + function checkPrintOutput(entry) { + info("Printing"); + hud.jsterm.clearOutput(); + hud.jsterm.execute("print(" + entry.input + ")"); + + let printOutput = entry.printOutput || entry.output; + + return waitForMessages({ + webconsole: hud, + messages: [{ + name: "print() output: " + printOutput, + text: printOutput, + category: CATEGORY_OUTPUT, + }], + }); + } + + function* checkJSEval(entry) { + info("Evaluating"); + hud.jsterm.clearOutput(); + hud.jsterm.execute(entry.input); + + let evalOutput = entry.evalOutput || entry.output; + + let [result] = yield waitForMessages({ + webconsole: hud, + messages: [{ + name: "JS eval output: " + entry.evalOutput, + text: entry.evalOutput, + category: CATEGORY_OUTPUT, + }], + }); + + let msg = [...result.matched][0]; + if (!entry.noClick) { + yield checkObjectClick(entry, msg); + } + if (typeof entry.inspectorIcon == "boolean") { + info("Checking Inspector Link: " + entry.input); + yield checkLinkToInspector(entry.inspectorIcon, msg); + } + } + + function* checkObjectClick(entry, msg) { + info("Clicking"); + let body; + if (entry.getClickableNode) { + body = entry.getClickableNode(msg); + } else { + body = msg.querySelector(".message-body a") || + msg.querySelector(".message-body"); + } + ok(body, "the message body"); + + let deferredVariablesView = promise.defer(); + entry._onVariablesViewOpen = onVariablesViewOpen.bind(null, entry, + deferredVariablesView); + hud.jsterm.on("variablesview-open", entry._onVariablesViewOpen); + + let deferredTab = promise.defer(); + entry._onTabOpen = onTabOpen.bind(null, entry, deferredTab); + container.addEventListener("TabOpen", entry._onTabOpen, true); + + body.scrollIntoView(); + + if (!entry.suppressClick) { + EventUtils.synthesizeMouse(body, 2, 2, {}, hud.iframeWindow); + } + + if (entry.inspectable) { + info("message body tagName '" + body.tagName + "' className '" + + body.className + "'"); + yield deferredVariablesView.promise; + } else { + hud.jsterm.off("variablesview-open", entry._onVariablesView); + entry._onVariablesView = null; + } + + if (entry.expectedTab) { + yield deferredTab.promise; + } else { + container.removeEventListener("TabOpen", entry._onTabOpen, true); + entry._onTabOpen = null; + } + + yield promise.resolve(null); + } + + function onVariablesViewOpen(entry, {resolve, reject}, event, view, options) { + info("Variables view opened"); + let label = entry.variablesViewLabel || entry.output; + if (typeof label == "string" && options.label != label) { + return; + } + if (label instanceof RegExp && !label.test(options.label)) { + return; + } + + hud.jsterm.off("variablesview-open", entry._onVariablesViewOpen); + entry._onVariablesViewOpen = null; + ok(entry.inspectable, "variables view was shown"); + + resolve(null); + } + + function onTabOpen(entry, {resolve, reject}, event) { + container.removeEventListener("TabOpen", entry._onTabOpen, true); + entry._onTabOpen = null; + let tab = event.target; + let browser = gBrowser.getBrowserForTab(tab); + + Task.spawn(function* () { + yield loadBrowser(browser); + let uri = yield ContentTask.spawn(browser, {}, function* () { + return content.location.href; + }); + ok(entry.expectedTab && entry.expectedTab == uri, + "opened tab '" + uri + "', expected tab '" + entry.expectedTab + "'"); + yield closeTab(tab); + }).then(resolve, reject); + } + + return Task.spawn(runner); +} + +/** + * Check the web console DOM element output for the given inputs. + * Each input is checked for the expected JS eval result. The JS eval result is + * also checked if it opens the inspector with the correct node selected on + * inspector icon click + * + * @param object hud + * The web console instance to work with. + * @param array inputTests + * An array of input tests. An input test element is an object. Each + * object has the following properties: + * - input: string, JS input value to execute. + * + * - output: string, expected JS eval result. + * + * - displayName: string, expected NodeFront's displayName. + * + * - attr: Array, expected NodeFront's attributes + */ +function checkDomElementHighlightingForInputs(hud, inputs) { + function* runner() { + let toolbox = gDevTools.getToolbox(hud.target); + + // Loading the inspector panel at first, to make it possible to listen for + // new node selections + yield toolbox.selectTool("inspector"); + let inspector = toolbox.getCurrentPanel(); + yield toolbox.selectTool("webconsole"); + + info("Iterating over the test data"); + for (let data of inputs) { + let [result] = yield jsEval(data.input, {text: data.output}); + let {msg} = yield checkWidgetAndMessage(result); + yield checkNodeHighlight(toolbox, inspector, msg, data); + } + } + + function jsEval(input, message) { + info("Executing '" + input + "' in the web console"); + + hud.jsterm.clearOutput(); + hud.jsterm.execute(input); + + return waitForMessages({ + webconsole: hud, + messages: [message] + }); + } + + function* checkWidgetAndMessage(result) { + info("Getting the output ElementNode widget"); + + let msg = [...result.matched][0]; + let widget = [...msg._messageObject.widgets][0]; + ok(widget, "ElementNode widget found in the output"); + + info("Waiting for the ElementNode widget to be linked to the inspector"); + yield widget.linkToInspector(); + + return {widget, msg}; + } + + function* checkNodeHighlight(toolbox, inspector, msg, testData) { + let inspectorIcon = msg.querySelector(".open-inspector"); + ok(inspectorIcon, "Inspector icon found in the ElementNode widget"); + + info("Clicking on the inspector icon and waiting for the " + + "inspector to be selected"); + let onInspectorSelected = toolbox.once("inspector-selected"); + let onInspectorUpdated = inspector.once("inspector-updated"); + let onNewNode = toolbox.selection.once("new-node-front"); + let onNodeHighlight = toolbox.once("node-highlight"); + + EventUtils.synthesizeMouseAtCenter(inspectorIcon, {}, + inspectorIcon.ownerDocument.defaultView); + yield onInspectorSelected; + yield onInspectorUpdated; + yield onNodeHighlight; + let nodeFront = yield onNewNode; + + ok(true, "Inspector selected and new node got selected"); + + is(nodeFront.displayName, testData.displayName, + "The correct node was highlighted"); + + if (testData.attrs) { + let attrs = nodeFront.attributes; + for (let i in testData.attrs) { + is(attrs[i].name, testData.attrs[i].name, + "Expected attribute's name is present"); + is(attrs[i].value, testData.attrs[i].value, + "Expected attribute's value is present"); + } + } + + info("Unhighlight the node by moving away from the markup view"); + let onNodeUnhighlight = toolbox.once("node-unhighlight"); + let btn = inspector.toolbox.doc.querySelector(".toolbox-dock-button"); + EventUtils.synthesizeMouseAtCenter(btn, {type: "mousemove"}, + inspector.toolbox.win); + yield onNodeUnhighlight; + + info("Switching back to the console"); + yield toolbox.selectTool("webconsole"); + } + + return Task.spawn(runner); +} + +/** + * Finish the request and resolve with the request object. + * + * @param {Function} predicate A predicate function that takes the request + * object as an argument and returns true if the request was the expected one, + * false otherwise. The returned promise is resolved ONLY if the predicate + * matches a request. Defaults to accepting any request. + * @return promise + * @resolves The request object. + */ +function waitForFinishedRequest(predicate = () => true) { + registerCleanupFunction(function () { + HUDService.lastFinishedRequest.callback = null; + }); + + return new Promise(resolve => { + HUDService.lastFinishedRequest.callback = request => { + // Check if this is the expected request + if (predicate(request)) { + // Match found. Clear the listener. + HUDService.lastFinishedRequest.callback = null; + + resolve(request); + } else { + info(`Ignoring unexpected request ${JSON.stringify(request, null, 2)}`); + } + }; + }); +} + +/** + * Wait for eventName on target. + * @param {Object} target An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Boolean} useCapture Optional for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function once(target, eventName, useCapture = false) { + info("Waiting for event: '" + eventName + "' on " + target + "."); + + let deferred = promise.defer(); + + for (let [add, remove] of [ + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"], + ["on", "off"] + ]) { + if ((add in target) && (remove in target)) { + target[add](eventName, function onEvent(...aArgs) { + target[remove](eventName, onEvent, useCapture); + deferred.resolve.apply(deferred, aArgs); + }, useCapture); + break; + } + } + + return deferred.promise; +} + +/** + * Checks a link to the inspector + * + * @param {boolean} hasLinkToInspector Set to true if the message should + * link to the inspector panel. + * @param {element} msg The message to test. + */ +function checkLinkToInspector(hasLinkToInspector, msg) { + let elementNodeWidget = [...msg._messageObject.widgets][0]; + if (!elementNodeWidget) { + ok(!hasLinkToInspector, "The message has no ElementNode widget"); + return true; + } + + return elementNodeWidget.linkToInspector().then(() => { + // linkToInspector resolved, check for the .open-inspector element + if (hasLinkToInspector) { + ok(msg.querySelectorAll(".open-inspector").length, + "The ElementNode widget is linked to the inspector"); + } else { + ok(!msg.querySelectorAll(".open-inspector").length, + "The ElementNode widget isn't linked to the inspector"); + } + }, () => { + // linkToInspector promise rejected, node not linked to inspector + ok(!hasLinkToInspector, + "The ElementNode widget isn't linked to the inspector"); + }); +} + +function getSourceActor(sources, URL) { + let item = sources.getItemForAttachment(a => a.source.url === URL); + return item && item.value; +} + +/** + * Make a request against an actor and resolve with the packet. + * @param object client + * The client to use when making the request. + * @param function requestType + * The client request function to run. + * @param array args + * The arguments to pass into the function. + */ +function getPacket(client, requestType, args) { + return new Promise(resolve => { + client[requestType](...args, packet => resolve(packet)); + }); +} + +/** + * Verify that clicking on a link from a popup notification message tries to + * open the expected URL. + */ +function simulateMessageLinkClick(element, expectedLink) { + let deferred = promise.defer(); + + // Invoke the click event and check if a new tab would + // open to the correct page. + let oldOpenUILinkIn = window.openUILinkIn; + window.openUILinkIn = function (link) { + if (link == expectedLink) { + ok(true, "Clicking the message link opens the desired page"); + window.openUILinkIn = oldOpenUILinkIn; + deferred.resolve(); + } + }; + + let event = new MouseEvent("click", { + detail: 1, + button: 0, + bubbles: true, + cancelable: true + }); + element.dispatchEvent(event); + + return deferred.promise; +} + +function getRenderedSource(root) { + let location = root.querySelector(".message-location .frame-link"); + return location ? { + url: location.getAttribute("data-url"), + line: location.getAttribute("data-line"), + column: location.getAttribute("data-column"), + } : null; +} |