diff options
Diffstat (limited to '')
19 files changed, 9379 insertions, 0 deletions
diff --git a/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js b/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js new file mode 100644 index 0000000000..ed38198efb --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js @@ -0,0 +1,565 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Accessible states used to check node's state from the accessiblity API + * perspective. + * + * Note: if gecko is built with --disable-accessibility, the interfaces + * are not defined. This is why we use getters instead to be able to use + * these statically. + */ + +this.AccessibilityUtils = (function() { + const FORCE_DISABLE_ACCESSIBILITY_PREF = "accessibility.force_disabled"; + + // Accessible states. + const { + STATE_FOCUSABLE, + STATE_INVISIBLE, + STATE_LINKED, + STATE_UNAVAILABLE, + } = Ci.nsIAccessibleStates; + + // Accessible action for showing long description. + const CLICK_ACTION = "click"; + + // Roles that are considered focusable with the keyboard. + const KEYBOARD_FOCUSABLE_ROLES = new Set([ + Ci.nsIAccessibleRole.ROLE_BUTTONMENU, + Ci.nsIAccessibleRole.ROLE_CHECKBUTTON, + Ci.nsIAccessibleRole.ROLE_COMBOBOX, + Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX, + Ci.nsIAccessibleRole.ROLE_ENTRY, + Ci.nsIAccessibleRole.ROLE_LINK, + Ci.nsIAccessibleRole.ROLE_LISTBOX, + Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT, + Ci.nsIAccessibleRole.ROLE_PUSHBUTTON, + Ci.nsIAccessibleRole.ROLE_RADIOBUTTON, + Ci.nsIAccessibleRole.ROLE_SLIDER, + Ci.nsIAccessibleRole.ROLE_SPINBUTTON, + Ci.nsIAccessibleRole.ROLE_SUMMARY, + Ci.nsIAccessibleRole.ROLE_SWITCH, + Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON, + ]); + + // Roles that are user interactive. + const INTERACTIVE_ROLES = new Set([ + ...KEYBOARD_FOCUSABLE_ROLES, + Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM, + Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION, + Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION, + Ci.nsIAccessibleRole.ROLE_MENUITEM, + Ci.nsIAccessibleRole.ROLE_OPTION, + Ci.nsIAccessibleRole.ROLE_OUTLINE, + Ci.nsIAccessibleRole.ROLE_OUTLINEITEM, + Ci.nsIAccessibleRole.ROLE_PAGETAB, + Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM, + Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM, + Ci.nsIAccessibleRole.ROLE_RICH_OPTION, + ]); + + // Roles that are considered interactive when they are focusable. + const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([ + // If article is focusable, we can assume it is inside a feed. + Ci.nsIAccessibleRole.ROLE_ARTICLE, + // Column header can be focusable. + Ci.nsIAccessibleRole.ROLE_COLUMNHEADER, + Ci.nsIAccessibleRole.ROLE_GRID_CELL, + Ci.nsIAccessibleRole.ROLE_MENUBAR, + Ci.nsIAccessibleRole.ROLE_MENUPOPUP, + Ci.nsIAccessibleRole.ROLE_PAGETABLIST, + // Row header can be focusable. + Ci.nsIAccessibleRole.ROLE_ROWHEADER, + Ci.nsIAccessibleRole.ROLE_SCROLLBAR, + Ci.nsIAccessibleRole.ROLE_SEPARATOR, + Ci.nsIAccessibleRole.ROLE_TOOLBAR, + ]); + + // Roles that are considered form controls. + const FORM_ROLES = new Set([ + Ci.nsIAccessibleRole.ROLE_CHECKBUTTON, + Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION, + Ci.nsIAccessibleRole.ROLE_COMBOBOX, + Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX, + Ci.nsIAccessibleRole.ROLE_ENTRY, + Ci.nsIAccessibleRole.ROLE_LISTBOX, + Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT, + Ci.nsIAccessibleRole.ROLE_PROGRESSBAR, + Ci.nsIAccessibleRole.ROLE_RADIOBUTTON, + Ci.nsIAccessibleRole.ROLE_SLIDER, + Ci.nsIAccessibleRole.ROLE_SPINBUTTON, + Ci.nsIAccessibleRole.ROLE_SWITCH, + ]); + + const DEFAULT_ENV = Object.freeze({ + // Checks that accessible object has at least one accessible action. + actionCountRule: true, + // Checks that accessible object (and its corresponding node) is focusable + // (has focusable state and its node's tabindex is not set to -1). + focusableRule: true, + // Checks that clickable accessible object (and its corresponding node) has + // appropriate interactive semantics. + ifClickableThenInteractiveRule: true, + // Checks that accessible object has a role that is considered to be + // interactive. + interactiveRule: true, + // Checks that accessible object has a non-empty label. + labelRule: true, + // Checks that a node has a corresponging accessible object. + mustHaveAccessibleRule: true, + // Checks that accessible object (and its corresponding node) have a non- + // negative tabindex. Platform accessibility API still sets focusable state + // on tabindex=-1 nodes. + nonNegativeTabIndexRule: true, + }); + + let gA11YChecks = false; + + let gEnv = { + ...DEFAULT_ENV, + }; + + /** + * Get role attribute for an accessible object if specified for its + * corresponding {@code DOMNode}. + * + * @param {nsIAccessible} accessible + * Accessible for which to determine its role attribute value. + * + * @returns {String} + * Role attribute value if specified. + */ + function getAriaRoles(accessible) { + try { + return accessible.attributes.getStringProperty("xml-roles"); + } catch (e) { + // No xml-roles. nsPersistentProperties throws if the attribute for a key + // is not found. + } + + return ""; + } + + /** + * Get related accessible objects that are targets of labelled by relation e.g. + * labels. + * @param {nsIAccessible} accessible + * Accessible objects to get labels for. + * + * @returns {Array} + * A list of accessible objects that are labels for a given accessible. + */ + function getLabels(accessible) { + const relation = accessible.getRelationByType( + Ci.nsIAccessibleRelation.RELATION_LABELLED_BY + ); + return [...relation.getTargets().enumerate(Ci.nsIAccessible)]; + } + + /** + * Test if an accessible has a {@code hidden} attribute. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @return {boolean} + * True if the accessible object has a {@code hidden} attribute, false + * otherwise. + */ + function hasHiddenAttribute(accessible) { + let hidden = false; + try { + hidden = accessible.attributes.getStringProperty("hidden"); + } catch (e) {} + // If the property is missing, error will be thrown + return hidden && hidden === "true"; + } + + /** + * Test if an accessible is hidden from the user. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @return {boolean} + * True if accessible is hidden from user, false otherwise. + */ + function isHidden(accessible) { + if (!accessible) { + return true; + } + + while (accessible) { + if (hasHiddenAttribute(accessible)) { + return true; + } + + accessible = accessible.parent; + } + + return false; + } + + /** + * Check if an accessible has a given state. + * + * @param {nsIAccessible} accessible + * Accessible object to test. + * @param {number} stateToMatch + * State to match. + * + * @return {boolean} + * True if |accessible| has |stateToMatch|, false otherwise. + */ + function matchState(accessible, stateToMatch) { + const state = {}; + accessible.getState(state, {}); + + return !!(state.value & stateToMatch); + } + + /** + * Determine if accessible is focusable with the keyboard. + * + * @param {nsIAccessible} accessible + * Accessible for which to determine if it is keyboard focusable. + * + * @returns {Boolean} + * True if focusable with the keyboard. + */ + function isKeyboardFocusable(accessible) { + // State will be focusable even if the tabindex is negative. + return ( + matchState(accessible, STATE_FOCUSABLE) && + // Platform accessibility will still report STATE_FOCUSABLE even with the + // tabindex="-1" so we need to check that it is >= 0 to be considered + // keyboard focusable. + (!gEnv.nonNegativeTabIndexRule || accessible.DOMNode.tabIndex > -1) + ); + } + + function buildMessage(message, DOMNode) { + if (DOMNode) { + const { id, tagName, className } = DOMNode; + message += `: id: ${id}, tagName: ${tagName}, className: ${className}`; + } + + return message; + } + + /** + * Fail a test with a given message because of an issue with a given + * accessible object. This is used for cases where there's an actual + * accessibility failure that prevents UI from being accessible to keyboard/AT + * users. + * + * @param {String} message + * @param {nsIAccessible} accessible + * Accessible to log along with the failure message. + */ + function a11yFail(message, { DOMNode }) { + SpecialPowers.SimpleTest.ok(false, buildMessage(message, DOMNode)); + } + + /** + * Log a todo statement with a given message because of an issue with a given + * accessible object. This is used for cases where accessibility best + * practices are not followed or for something that is not as severe to be + * considered a failure. + * @param {String} message + * @param {nsIAccessible} accessible + * Accessible to log along with the todo message. + */ + function a11yWarn(message, { DOMNode }) { + SpecialPowers.SimpleTest.todo(false, buildMessage(message, DOMNode)); + } + + /** + * Test if the node's unavailable via the accessibility API. + * + * @param {nsIAccessible} accessible + * Accessible object. + */ + function assertEnabled(accessible) { + if (matchState(accessible, STATE_UNAVAILABLE)) { + a11yFail( + "Node is enabled but disabled via the accessibility API", + accessible + ); + } + } + + /** + * Test if it is possible to focus on a node with the keyboard. This method + * also checks for additional keyboard focus issues that might arise. + * + * @param {nsIAccessible} accessible + * Accessible object for a node. + */ + function assertFocusable(accessible) { + if (gEnv.focusableRule && !isKeyboardFocusable(accessible)) { + const ariaRoles = getAriaRoles(accessible); + // Do not force ARIA combobox or listbox to be focusable. + if (!ariaRoles.includes("combobox") && !ariaRoles.includes("listbox")) { + a11yFail("Node is not focusable via the accessibility API", accessible); + } + + return; + } + + if (!INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) { + // ROLE_TABLE is used for grids too which are considered interactive. + if ( + accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE && + !getAriaRoles(accessible).includes("grid") + ) { + a11yWarn( + "Focusable nodes should have interactive semantics", + accessible + ); + + return; + } + } + + if (accessible.DOMNode.tabIndex > 0) { + a11yWarn("Avoid using tabindex attribute greater than zero", accessible); + } + } + + /** + * Test if it is possible to interact with a node via the accessibility API. + * + * @param {nsIAccessible} accessible + * Accessible object for a node. + */ + function assertInteractive(accessible) { + if (gEnv.actionCountRule && accessible.actionCount === 0) { + a11yFail("Node does not support any accessible actions", accessible); + + return; + } + + if (gEnv.interactiveRule && !INTERACTIVE_ROLES.has(accessible.role)) { + if ( + // Labels that have a label for relation with their target are clickable. + (accessible.role !== Ci.nsIAccessibleRole.ROLE_LABEL || + accessible.getRelationByType( + Ci.nsIAccessibleRelation.RELATION_LABEL_FOR + ).targetsCount === 0) && + // Images that are inside an anchor (have linked state). + (accessible.role !== Ci.nsIAccessibleRole.ROLE_GRAPHIC || + !matchState(accessible, STATE_LINKED)) + ) { + // Look for click action in the list of actions. + for (let i = 0; i < accessible.actionCount; i++) { + if ( + gEnv.ifClickableThenInteractiveRule && + accessible.getActionName(i) === CLICK_ACTION + ) { + a11yFail( + "Clickable nodes must have interactive semantics", + accessible + ); + } + } + } + + a11yFail( + "Node does not have a correct interactive role and may not be " + + "manipulated via the accessibility API", + accessible + ); + } + } + + /** + * Test if the node is labelled appropriately for accessibility API. + * + * @param {nsIAccessible} accessible + * Accessible object for a node. + */ + function assertLabelled(accessible) { + const name = accessible.name && accessible.name.trim(); + if (gEnv.labelRule && !name) { + a11yFail("Interactive elements must be labeled", accessible); + + return; + } + + const { DOMNode } = accessible; + if (FORM_ROLES.has(accessible.role)) { + const labels = getLabels(accessible); + const hasNameFromVisibleLabel = labels.some( + label => !matchState(label, STATE_INVISIBLE) + ); + + if (!hasNameFromVisibleLabel) { + a11yWarn("Form elements should have a visible text label", accessible); + } + } else if ( + accessible.role === Ci.nsIAccessibleRole.ROLE_LINK && + DOMNode.nodeName === "AREA" && + DOMNode.hasAttribute("href") + ) { + const alt = DOMNode.getAttribute("alt"); + if (alt && alt.trim() !== name) { + a11yFail( + "Use alt attribute to label area elements that have the href attribute", + accessible + ); + } + } + } + + /** + * Test if the node's visible via accessibility API. + * + * @param {nsIAccessible} accessible + * Accessible object for a node. + */ + function assertVisible(accessible) { + if (isHidden(accessible)) { + a11yFail( + "Node is not currently visible via the accessibility API and may not " + + "be manipulated by it", + accessible + ); + } + } + + /** + * Walk node ancestry and force refresh driver tick in every document. + * @param {DOMNode} node + * Node for traversing the ancestry. + */ + function forceRefreshDriverTick(node) { + const wins = []; + let bc = BrowsingContext.getFromWindow(node.ownerDocument.defaultView); // eslint-disable-line + while (bc) { + wins.push(bc.associatedWindow); + bc = bc.embedderWindowGlobal?.browsingContext; + } + + let win = wins.pop(); + while (win) { + // Stop the refresh driver from doing its regular ticks and force two + // refresh driver ticks: first to let layout update and notify a11y, and + // the second to let a11y process updates. + win.windowUtils.advanceTimeAndRefresh(100); + win.windowUtils.advanceTimeAndRefresh(100); + // Go back to normal refresh driver ticks. + win.windowUtils.restoreNormalRefresh(); + win = wins.pop(); + } + } + + /** + * Get an accessible object for a node. + * Note: this method will not resolve if accessible object does not become + * available for a given node. + * + * @param {DOMNode} node + * Node to get the accessible object for. + * + * @return {nsIAccessible} + * Accessibility object for a given node. + */ + function getAccessible(node) { + const accessibilityService = Cc[ + "@mozilla.org/accessibilityService;1" + ].getService(Ci.nsIAccessibilityService); + if (!accessibilityService) { + // This is likely a build with --disable-accessibility + return null; + } + + let acc = accessibilityService.getAccessibleFor(node); + if (acc) { + return acc; + } + + // Force refresh tick throughout document hierarchy + forceRefreshDriverTick(node); + return accessibilityService.getAccessibleFor(node); + } + + function runIfA11YChecks(task) { + return (...args) => (gA11YChecks ? task(...args) : null); + } + + /** + * AccessibilityUtils provides utility methods for retrieving accessible objects + * and performing accessibility related checks. + * Current methods: + * assertCanBeClicked + * setEnv + * resetEnv + * + */ + const AccessibilityUtils = { + assertCanBeClicked(node) { + const acc = getAccessible(node); + if (!acc) { + if (gEnv.mustHaveAccessibleRule) { + a11yFail("Node is not accessible via accessibility API", { + DOMNode: node, + }); + } + + return; + } + + assertInteractive(acc); + assertFocusable(acc); + assertVisible(acc); + assertEnabled(acc); + assertLabelled(acc); + }, + + setEnv(env = DEFAULT_ENV) { + gEnv = { + ...DEFAULT_ENV, + ...env, + }; + }, + + resetEnv() { + gEnv = { ...DEFAULT_ENV }; + }, + + reset(a11yChecks = false) { + gA11YChecks = a11yChecks; + + const { Services } = SpecialPowers; + // Disable accessibility service if it is running and if a11y checks are + // disabled. + if (!gA11YChecks && Services.appinfo.accessibilityEnabled) { + Services.prefs.setIntPref(FORCE_DISABLE_ACCESSIBILITY_PREF, 1); + Services.prefs.clearUserPref(FORCE_DISABLE_ACCESSIBILITY_PREF); + } + + // Reset accessibility environment flags that might've been set within the + // test. + this.resetEnv(); + }, + }; + + AccessibilityUtils.assertCanBeClicked = runIfA11YChecks( + AccessibilityUtils.assertCanBeClicked.bind(AccessibilityUtils) + ); + + AccessibilityUtils.setEnv = runIfA11YChecks( + AccessibilityUtils.setEnv.bind(AccessibilityUtils) + ); + + AccessibilityUtils.resetEnv = runIfA11YChecks( + AccessibilityUtils.resetEnv.bind(AccessibilityUtils) + ); + + return AccessibilityUtils; +})(); diff --git a/testing/mochitest/tests/SimpleTest/ChromeTask.js b/testing/mochitest/tests/SimpleTest/ChromeTask.js new file mode 100644 index 0000000000..9e6fd77a1a --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/ChromeTask.js @@ -0,0 +1,174 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +function ChromeTask_ChromeScript() { + /* eslint-env mozilla/chrome-script */ + + "use strict"; + + const { Assert: AssertCls } = ChromeUtils.importESModule( + "resource://testing-common/Assert.sys.mjs" + ); + + addMessageListener("chrome-task:spawn", async function(aData) { + let id = aData.id; + let source = aData.runnable || "()=>{}"; + + function getStack(aStack) { + let frames = []; + for (let frame = aStack; frame; frame = frame.caller) { + frames.push(frame.filename + ":" + frame.name + ":" + frame.lineNumber); + } + return frames.join("\n"); + } + + /* eslint-disable no-unused-vars */ + var Assert = new AssertCls((err, message, stack) => { + sendAsyncMessage("chrome-task:test-result", { + id, + condition: !err, + name: err ? err.message : message, + stack: getStack(err ? err.stack : stack), + }); + }); + + var ok = Assert.ok.bind(Assert); + var is = Assert.equal.bind(Assert); + var isnot = Assert.notEqual.bind(Assert); + + function todo(expr, name) { + sendAsyncMessage("chrome-task:test-todo", { id, expr, name }); + } + + function todo_is(a, b, name) { + sendAsyncMessage("chrome-task:test-todo_is", { id, a, b, name }); + } + + function info(name) { + sendAsyncMessage("chrome-task:test-info", { id, name }); + } + /* eslint-enable no-unused-vars */ + + try { + let runnablestr = ` + (() => { + return (${source}); + })();`; + + // eslint-disable-next-line no-eval + let runnable = eval(runnablestr); + let result = await runnable.call(this, aData.arg); + sendAsyncMessage("chrome-task:complete", { + id, + result, + }); + } catch (ex) { + sendAsyncMessage("chrome-task:complete", { + id, + error: ex.toString(), + }); + } + }); +} + +/** + * This object provides the public module functions. + */ +var ChromeTask = { + /** + * the ChromeScript if it has already been loaded. + */ + _chromeScript: null, + + /** + * Mapping from message id to associated promise. + */ + _promises: new Map(), + + /** + * Incrementing integer to generate unique message id. + */ + _messageID: 1, + + /** + * Creates and starts a new task in the chrome process. + * + * @param arg A single serializable argument that will be passed to the + * task when executed on the content process. + * @param task + * - A generator or function which will be serialized and sent to + * the remote browser to be executed. Unlike Task.spawn, this + * argument may not be an iterator as it will be serialized and + * sent to the remote browser. + * @return A promise object where you can register completion callbacks to be + * called when the task terminates. + * @resolves With the final returned value of the task if it executes + * successfully. + * @rejects An error message if execution fails. + */ + spawn: function ChromeTask_spawn(arg, task) { + // Load the frame script if needed. + let handle = ChromeTask._chromeScript; + if (!handle) { + handle = SpecialPowers.loadChromeScript(ChromeTask_ChromeScript); + handle.addMessageListener("chrome-task:complete", ChromeTask.onComplete); + handle.addMessageListener("chrome-task:test-result", ChromeTask.onResult); + handle.addMessageListener("chrome-task:test-info", ChromeTask.onInfo); + handle.addMessageListener("chrome-task:test-todo", ChromeTask.onTodo); + handle.addMessageListener( + "chrome-task:test-todo_is", + ChromeTask.onTodoIs + ); + ChromeTask._chromeScript = handle; + } + + let deferred = {}; + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + let id = ChromeTask._messageID++; + ChromeTask._promises.set(id, deferred); + + handle.sendAsyncMessage("chrome-task:spawn", { + id, + runnable: task.toString(), + arg, + }); + + return deferred.promise; + }, + + onComplete(aData) { + let deferred = ChromeTask._promises.get(aData.id); + ChromeTask._promises.delete(aData.id); + + if (aData.error) { + deferred.reject(aData.error); + } else { + deferred.resolve(aData.result); + } + }, + + onResult(aData) { + SimpleTest.record(aData.condition, aData.name); + }, + + onInfo(aData) { + SimpleTest.info(aData.name); + }, + + onTodo(aData) { + SimpleTest.todo(aData.expr, aData.name); + }, + + onTodoIs(aData) { + SimpleTest.todo_is(aData.a, aData.b, aData.name); + }, +}; diff --git a/testing/mochitest/tests/SimpleTest/EventUtils.js b/testing/mochitest/tests/SimpleTest/EventUtils.js new file mode 100644 index 0000000000..d4600e3107 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/EventUtils.js @@ -0,0 +1,3665 @@ +/** + * EventUtils provides some utility methods for creating and sending DOM events. + * + * When adding methods to this file, please add a performance test for it. + */ + +// Certain functions assume this is loaded into browser window scope. +// This is modifiable because certain chrome tests create their own gBrowser. +/* global gBrowser:true */ + +// This file is used both in privileged and unprivileged contexts, so we have to +// be careful about our access to Components.interfaces. We also want to avoid +// naming collisions with anything that might be defined in the scope that imports +// this script. +// +// Even if the real |Components| doesn't exist, we might shim in a simple JS +// placebo for compat. An easy way to differentiate this from the real thing +// is whether the property is read-only or not. The real |Components| property +// is read-only. +/* global _EU_Ci, _EU_Cc, _EU_Cu, _EU_ChromeUtils, _EU_OS */ +window.__defineGetter__("_EU_Ci", function() { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? Ci : SpecialPowers.Ci; +}); + +window.__defineGetter__("_EU_Cc", function() { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? Cc : SpecialPowers.Cc; +}); + +window.__defineGetter__("_EU_Cu", function() { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? Cu : SpecialPowers.Cu; +}); + +window.__defineGetter__("_EU_ChromeUtils", function() { + var c = Object.getOwnPropertyDescriptor(window, "ChromeUtils"); + return c && c.value && !c.writable ? ChromeUtils : SpecialPowers.ChromeUtils; +}); + +window.__defineGetter__("_EU_OS", function() { + delete this._EU_OS; + try { + this._EU_OS = _EU_ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ).platform; + } catch (ex) { + this._EU_OS = null; + } + return this._EU_OS; +}); + +function _EU_isMac(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "macosx"; + } + if (aWindow) { + try { + return aWindow.navigator.platform.indexOf("Mac") > -1; + } catch (ex) {} + } + return navigator.platform.indexOf("Mac") > -1; +} + +function _EU_isWin(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "win"; + } + if (aWindow) { + try { + return aWindow.navigator.platform.indexOf("Win") > -1; + } catch (ex) {} + } + return navigator.platform.indexOf("Win") > -1; +} + +function _EU_isLinux(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "linux"; + } + if (aWindow) { + try { + return aWindow.navigator.platform.startsWith("Linux"); + } catch (ex) {} + } + return navigator.platform.startsWith("Linux"); +} + +function _EU_isAndroid(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "android"; + } + if (aWindow) { + try { + return aWindow.navigator.userAgent.includes("Android"); + } catch (ex) {} + } + return navigator.userAgent.includes("Android"); +} + +function _EU_maybeWrap(o) { + // We're used in some contexts where there is no SpecialPowers and also in + // some where it exists but has no wrap() method. And this is somewhat + // independent of whether window.Components is a thing... + var haveWrap = false; + try { + haveWrap = SpecialPowers.wrap != undefined; + } catch (e) { + // Just leave it false. + } + if (!haveWrap) { + // Not much we can do here. + return o; + } + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? o : SpecialPowers.wrap(o); +} + +function _EU_maybeUnwrap(o) { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? o : SpecialPowers.unwrap(o); +} + +function _EU_getPlatform() { + if (_EU_isWin()) { + return "windows"; + } + if (_EU_isMac()) { + return "mac"; + } + if (_EU_isAndroid()) { + return "android"; + } + if (_EU_isLinux()) { + return "linux"; + } + return "unknown"; +} + +/** + * promiseElementReadyForUserInput() dispatches mousemove events to aElement + * and waits one of them for a while. Then, returns "resolved" state when it's + * successfully received. Otherwise, if it couldn't receive mousemove event on + * it, this throws an exception. So, aElement must be an element which is + * assumed non-collapsed visible element in the window. + * + * This is useful if you need to synthesize mouse events via the main process + * but your test cannot check whether the element is now in APZ to deliver + * a user input event. + */ +async function promiseElementReadyForUserInput( + aElement, + aWindow = window, + aLogFunc = null +) { + if (typeof aElement == "string") { + aElement = aWindow.document.getElementById(aElement); + } + + function waitForMouseMoveForHittest() { + return new Promise(resolve => { + let timeout; + const onHit = () => { + if (aLogFunc) { + aLogFunc("mousemove received"); + } + aWindow.clearInterval(timeout); + resolve(true); + }; + aElement.addEventListener("mousemove", onHit, { + capture: true, + once: true, + }); + synthesizeMouseAtCenter(aElement, { type: "mousemove" }, aWindow); + timeout = aWindow.setInterval(() => { + if (aLogFunc) { + aLogFunc("mousemove not received in this 300ms"); + } + aElement.removeEventListener("mousemove", onHit, { + capture: true, + }); + resolve(false); + }, 300); + }); + } + for (let i = 0; i < 20; i++) { + if (await waitForMouseMoveForHittest()) { + return Promise.resolve(); + } + } + throw new Error("The element or the window did not become interactive"); +} + +function getElement(id) { + return typeof id == "string" ? document.getElementById(id) : id; +} + +this.$ = this.getElement; + +function computeButton(aEvent) { + if (typeof aEvent.button != "undefined") { + return aEvent.button; + } + return aEvent.type == "contextmenu" ? 2 : 0; +} + +function computeButtons(aEvent, utils) { + if (typeof aEvent.buttons != "undefined") { + return aEvent.buttons; + } + + if (typeof aEvent.button != "undefined") { + return utils.MOUSE_BUTTONS_NOT_SPECIFIED; + } + + if (typeof aEvent.type != "undefined" && aEvent.type != "mousedown") { + return utils.MOUSE_BUTTONS_NO_BUTTON; + } + + return utils.MOUSE_BUTTONS_NOT_SPECIFIED; +} + +/** + * Send a mouse event to the node aTarget (aTarget can be an id, or an + * actual node) . The "event" passed in to aEvent is just a JavaScript + * object with the properties set that the real mouse event object should + * have. This includes the type of the mouse event. Pretty much all those + * properties are optional. + * E.g. to send an click event to the node with id 'node' you might do this: + * + * ``sendMouseEvent({type:'click'}, 'node');`` + */ +function sendMouseEvent(aEvent, aTarget, aWindow) { + if ( + ![ + "click", + "contextmenu", + "dblclick", + "mousedown", + "mouseup", + "mouseover", + "mouseout", + ].includes(aEvent.type) + ) { + throw new Error( + "sendMouseEvent doesn't know about event type '" + aEvent.type + "'" + ); + } + + if (!aWindow) { + aWindow = window; + } + + if (typeof aTarget == "string") { + aTarget = aWindow.document.getElementById(aTarget); + } + + if (aEvent.type === "click" && this.AccessibilityUtils) { + this.AccessibilityUtils.assertCanBeClicked(aTarget); + } + + var event = aWindow.document.createEvent("MouseEvent"); + + var typeArg = aEvent.type; + var canBubbleArg = true; + var cancelableArg = true; + var viewArg = aWindow; + var detailArg = + aEvent.detail || + // eslint-disable-next-line no-nested-ternary + (aEvent.type == "click" || + aEvent.type == "mousedown" || + aEvent.type == "mouseup" + ? 1 + : aEvent.type == "dblclick" + ? 2 + : 0); + var screenXArg = aEvent.screenX || 0; + var screenYArg = aEvent.screenY || 0; + var clientXArg = aEvent.clientX || 0; + var clientYArg = aEvent.clientY || 0; + var ctrlKeyArg = aEvent.ctrlKey || false; + var altKeyArg = aEvent.altKey || false; + var shiftKeyArg = aEvent.shiftKey || false; + var metaKeyArg = aEvent.metaKey || false; + var buttonArg = computeButton(aEvent); + var relatedTargetArg = aEvent.relatedTarget || null; + + event.initMouseEvent( + typeArg, + canBubbleArg, + cancelableArg, + viewArg, + detailArg, + screenXArg, + screenYArg, + clientXArg, + clientYArg, + ctrlKeyArg, + altKeyArg, + shiftKeyArg, + metaKeyArg, + buttonArg, + relatedTargetArg + ); + + // If documentURIObject exists or `window` is a stub object, we're in + // a chrome scope, so don't bother trying to go through SpecialPowers. + if (!window.document || window.document.documentURIObject) { + return aTarget.dispatchEvent(event); + } + return SpecialPowers.dispatchEvent(aWindow, aTarget, event); +} + +function isHidden(aElement) { + var box = aElement.getBoundingClientRect(); + return box.width == 0 && box.height == 0; +} + +/** + * Send a drag event to the node aTarget (aTarget can be an id, or an + * actual node) . The "event" passed in to aEvent is just a JavaScript + * object with the properties set that the real drag event object should + * have. This includes the type of the drag event. + */ +function sendDragEvent(aEvent, aTarget, aWindow = window) { + if ( + ![ + "drag", + "dragstart", + "dragend", + "dragover", + "dragenter", + "dragleave", + "drop", + ].includes(aEvent.type) + ) { + throw new Error( + "sendDragEvent doesn't know about event type '" + aEvent.type + "'" + ); + } + + if (typeof aTarget == "string") { + aTarget = aWindow.document.getElementById(aTarget); + } + + /* + * Drag event cannot be performed if the element is hidden, except 'dragend' + * event where the element can becomes hidden after start dragging. + */ + if (aEvent.type != "dragend" && isHidden(aTarget)) { + var targetName = aTarget.nodeName; + if ("id" in aTarget && aTarget.id) { + targetName += "#" + aTarget.id; + } + throw new Error(`${aEvent.type} event target ${targetName} is hidden`); + } + + var event = aWindow.document.createEvent("DragEvent"); + + var typeArg = aEvent.type; + var canBubbleArg = true; + var cancelableArg = true; + var viewArg = aWindow; + var detailArg = aEvent.detail || 0; + var screenXArg = aEvent.screenX || 0; + var screenYArg = aEvent.screenY || 0; + var clientXArg = aEvent.clientX || 0; + var clientYArg = aEvent.clientY || 0; + var ctrlKeyArg = aEvent.ctrlKey || false; + var altKeyArg = aEvent.altKey || false; + var shiftKeyArg = aEvent.shiftKey || false; + var metaKeyArg = aEvent.metaKey || false; + var buttonArg = computeButton(aEvent); + var relatedTargetArg = aEvent.relatedTarget || null; + var dataTransfer = aEvent.dataTransfer || null; + + event.initDragEvent( + typeArg, + canBubbleArg, + cancelableArg, + viewArg, + detailArg, + screenXArg, + screenYArg, + clientXArg, + clientYArg, + ctrlKeyArg, + altKeyArg, + shiftKeyArg, + metaKeyArg, + buttonArg, + relatedTargetArg, + dataTransfer + ); + + if (aEvent._domDispatchOnly) { + return aTarget.dispatchEvent(event); + } + + var utils = _getDOMWindowUtils(aWindow); + return utils.dispatchDOMEventViaPresShellForTesting(aTarget, event); +} + +/** + * Send the char aChar to the focused element. This method handles casing of + * chars (sends the right charcode, and sends a shift key for uppercase chars). + * No other modifiers are handled at this point. + * + * For now this method only works for ASCII characters and emulates the shift + * key state on US keyboard layout. + */ +function sendChar(aChar, aWindow) { + var hasShift; + // Emulate US keyboard layout for the shiftKey state. + switch (aChar) { + case "!": + case "@": + case "#": + case "$": + case "%": + case "^": + case "&": + case "*": + case "(": + case ")": + case "_": + case "+": + case "{": + case "}": + case ":": + case '"': + case "|": + case "<": + case ">": + case "?": + hasShift = true; + break; + default: + hasShift = + aChar.toLowerCase() != aChar.toUpperCase() && + aChar == aChar.toUpperCase(); + break; + } + synthesizeKey(aChar, { shiftKey: hasShift }, aWindow); +} + +/** + * Send the string aStr to the focused element. + * + * For now this method only works for ASCII characters and emulates the shift + * key state on US keyboard layout. + */ +function sendString(aStr, aWindow) { + for (var i = 0; i < aStr.length; ++i) { + sendChar(aStr.charAt(i), aWindow); + } +} + +/** + * Send the non-character key aKey to the focused node. + * The name of the key should be the part that comes after ``DOM_VK_`` in the + * KeyEvent constant name for this key. + * No modifiers are handled at this point. + */ +function sendKey(aKey, aWindow) { + var keyName = "VK_" + aKey.toUpperCase(); + synthesizeKey(keyName, { shiftKey: false }, aWindow); +} + +/** + * Parse the key modifier flags from aEvent. Used to share code between + * synthesizeMouse and synthesizeKey. + */ +function _parseModifiers(aEvent, aWindow = window) { + var nsIDOMWindowUtils = _EU_Ci.nsIDOMWindowUtils; + var mval = 0; + if (aEvent.shiftKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SHIFT; + } + if (aEvent.ctrlKey) { + mval |= nsIDOMWindowUtils.MODIFIER_CONTROL; + } + if (aEvent.altKey) { + mval |= nsIDOMWindowUtils.MODIFIER_ALT; + } + if (aEvent.metaKey) { + mval |= nsIDOMWindowUtils.MODIFIER_META; + } + if (aEvent.accelKey) { + mval |= _EU_isMac(aWindow) + ? nsIDOMWindowUtils.MODIFIER_META + : nsIDOMWindowUtils.MODIFIER_CONTROL; + } + if (aEvent.altGrKey) { + mval |= nsIDOMWindowUtils.MODIFIER_ALTGRAPH; + } + if (aEvent.capsLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_CAPSLOCK; + } + if (aEvent.fnKey) { + mval |= nsIDOMWindowUtils.MODIFIER_FN; + } + if (aEvent.fnLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_FNLOCK; + } + if (aEvent.numLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_NUMLOCK; + } + if (aEvent.scrollLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SCROLLLOCK; + } + if (aEvent.symbolKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SYMBOL; + } + if (aEvent.symbolLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SYMBOLLOCK; + } + if (aEvent.osKey) { + mval |= nsIDOMWindowUtils.MODIFIER_OS; + } + + return mval; +} + +/** + * Synthesize a mouse event on a target. The actual client point is determined + * by taking the aTarget's client box and offseting it by aOffsetX and + * aOffsetY. This allows mouse clicks to be simulated by calling this method. + * + * aEvent is an object which may contain the properties: + * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`, + * `button`, `type`. + * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`. + * + * If the type is specified, an mouse event of that type is fired. Otherwise, + * a mousedown followed by a mouseup is performed. + * + * aWindow is optional, and defaults to the current window object. + * + * Returns whether the event had preventDefault() called on it. + */ +function synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + return synthesizeMouseAtPoint( + rect.left + aOffsetX, + rect.top + aOffsetY, + aEvent, + aWindow + ); +} +function synthesizeTouch(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + return synthesizeTouchAtPoint( + rect.left + aOffsetX, + rect.top + aOffsetY, + aEvent, + aWindow + ); +} + +/* + * Synthesize a mouse event at a particular point in aWindow. + * + * aEvent is an object which may contain the properties: + * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`, + * `button`, `type`. + * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`. + * + * If the type is specified, an mouse event of that type is fired. Otherwise, + * a mousedown followed by a mouseup is performed. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeMouseAtPoint(left, top, aEvent, aWindow = window) { + var utils = _getDOMWindowUtils(aWindow); + var defaultPrevented = false; + + if (utils) { + var button = computeButton(aEvent); + var clickCount = aEvent.clickCount || 1; + var modifiers = _parseModifiers(aEvent, aWindow); + var pressure = "pressure" in aEvent ? aEvent.pressure : 0; + + // aWindow might be cross-origin from us. + var MouseEvent = _EU_maybeWrap(aWindow).MouseEvent; + + // Default source to mouse. + var inputSource = + "inputSource" in aEvent + ? aEvent.inputSource + : MouseEvent.MOZ_SOURCE_MOUSE; + // Compute a pointerId if needed. + var id; + if ("id" in aEvent) { + id = aEvent.id; + } else { + var isFromPen = inputSource === MouseEvent.MOZ_SOURCE_PEN; + id = isFromPen + ? utils.DEFAULT_PEN_POINTER_ID + : utils.DEFAULT_MOUSE_POINTER_ID; + } + + var isDOMEventSynthesized = + "isSynthesized" in aEvent ? aEvent.isSynthesized : true; + var isWidgetEventSynthesized = + "isWidgetEventSynthesized" in aEvent + ? aEvent.isWidgetEventSynthesized + : false; + if ("type" in aEvent && aEvent.type) { + defaultPrevented = utils.sendMouseEvent( + aEvent.type, + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + computeButtons(aEvent, utils), + id + ); + } else { + utils.sendMouseEvent( + "mousedown", + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + computeButtons(Object.assign({ type: "mousedown" }, aEvent), utils), + id + ); + utils.sendMouseEvent( + "mouseup", + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + computeButtons(Object.assign({ type: "mouseup" }, aEvent), utils), + id + ); + } + } + + return defaultPrevented; +} + +function synthesizeTouchAtPoint(left, top, aEvent, aWindow = window) { + var utils = _getDOMWindowUtils(aWindow); + let defaultPrevented = false; + + if (utils) { + var id = aEvent.id || utils.DEFAULT_TOUCH_POINTER_ID; + var rx = aEvent.rx || 1; + var ry = aEvent.ry || 1; + var angle = aEvent.angle || 0; + var force = aEvent.force || (aEvent.type === "touchend" ? 0 : 1); + var tiltX = aEvent.tiltX || 0; + var tiltY = aEvent.tiltY || 0; + var twist = aEvent.twist || 0; + var modifiers = _parseModifiers(aEvent, aWindow); + + if ("type" in aEvent && aEvent.type) { + defaultPrevented = utils.sendTouchEvent( + aEvent.type, + [id], + [left], + [top], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + modifiers + ); + } else { + utils.sendTouchEvent( + "touchstart", + [id], + [left], + [top], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + modifiers + ); + utils.sendTouchEvent( + "touchend", + [id], + [left], + [top], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + modifiers + ); + } + } + return defaultPrevented; +} + +// Call synthesizeMouse with coordinates at the center of aTarget. +function synthesizeMouseAtCenter(aTarget, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + return synthesizeMouse( + aTarget, + rect.width / 2, + rect.height / 2, + aEvent, + aWindow + ); +} +function synthesizeTouchAtCenter(aTarget, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + synthesizeTouchAtPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + aEvent, + aWindow + ); +} + +/** + * Synthesize a wheel event without flush layout at a particular point in + * aWindow. + * + * aEvent is an object which may contain the properties: + * shiftKey, ctrlKey, altKey, metaKey, accessKey, deltaX, deltaY, deltaZ, + * deltaMode, lineOrPageDeltaX, lineOrPageDeltaY, isMomentum, + * isNoLineOrPageDelta, isCustomizedByPrefs, expectedOverflowDeltaX, + * expectedOverflowDeltaY + * + * deltaMode must be defined, others are ok even if undefined. + * + * expectedOverflowDeltaX and expectedOverflowDeltaY take integer value. The + * value is just checked as 0 or positive or negative. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeWheelAtPoint(aLeft, aTop, aEvent, aWindow = window) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return; + } + + var modifiers = _parseModifiers(aEvent, aWindow); + var options = 0; + if (aEvent.isNoLineOrPageDelta) { + options |= utils.WHEEL_EVENT_CAUSED_BY_NO_LINE_OR_PAGE_DELTA_DEVICE; + } + if (aEvent.isMomentum) { + options |= utils.WHEEL_EVENT_CAUSED_BY_MOMENTUM; + } + if (aEvent.isCustomizedByPrefs) { + options |= utils.WHEEL_EVENT_CUSTOMIZED_BY_USER_PREFS; + } + if (typeof aEvent.expectedOverflowDeltaX !== "undefined") { + if (aEvent.expectedOverflowDeltaX === 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_ZERO; + } else if (aEvent.expectedOverflowDeltaX > 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_POSITIVE; + } else { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_NEGATIVE; + } + } + if (typeof aEvent.expectedOverflowDeltaY !== "undefined") { + if (aEvent.expectedOverflowDeltaY === 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_ZERO; + } else if (aEvent.expectedOverflowDeltaY > 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_POSITIVE; + } else { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_NEGATIVE; + } + } + + // Avoid the JS warnings "reference to undefined property" + if (!aEvent.deltaX) { + aEvent.deltaX = 0; + } + if (!aEvent.deltaY) { + aEvent.deltaY = 0; + } + if (!aEvent.deltaZ) { + aEvent.deltaZ = 0; + } + + var lineOrPageDeltaX = + // eslint-disable-next-line no-nested-ternary + aEvent.lineOrPageDeltaX != null + ? aEvent.lineOrPageDeltaX + : aEvent.deltaX > 0 + ? Math.floor(aEvent.deltaX) + : Math.ceil(aEvent.deltaX); + var lineOrPageDeltaY = + // eslint-disable-next-line no-nested-ternary + aEvent.lineOrPageDeltaY != null + ? aEvent.lineOrPageDeltaY + : aEvent.deltaY > 0 + ? Math.floor(aEvent.deltaY) + : Math.ceil(aEvent.deltaY); + utils.sendWheelEvent( + aLeft, + aTop, + aEvent.deltaX, + aEvent.deltaY, + aEvent.deltaZ, + aEvent.deltaMode, + modifiers, + lineOrPageDeltaX, + lineOrPageDeltaY, + options + ); +} + +/** + * Synthesize a wheel event on a target. The actual client point is determined + * by taking the aTarget's client box and offseting it by aOffsetX and + * aOffsetY. + * + * aEvent is an object which may contain the properties: + * shiftKey, ctrlKey, altKey, metaKey, accessKey, deltaX, deltaY, deltaZ, + * deltaMode, lineOrPageDeltaX, lineOrPageDeltaY, isMomentum, + * isNoLineOrPageDelta, isCustomizedByPrefs, expectedOverflowDeltaX, + * expectedOverflowDeltaY + * + * deltaMode must be defined, others are ok even if undefined. + * + * expectedOverflowDeltaX and expectedOverflowDeltaY take integer value. The + * value is just checked as 0 or positive or negative. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeWheel(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + synthesizeWheelAtPoint( + rect.left + aOffsetX, + rect.top + aOffsetY, + aEvent, + aWindow + ); +} + +const _FlushModes = { + FLUSH: 0, + NOFLUSH: 1, +}; + +function _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aFlushMode = _FlushModes.FLUSH, + aWindow = window +) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return; + } + + if (utils.isMozAfterPaintPending) { + // If a paint is pending, then APZ may be waiting for a scroll acknowledgement + // from the content thread. If we send a wheel event now, it could be ignored + // by APZ (or its scroll offset could be overridden). To avoid problems we + // just wait for the paint to complete. + aWindow.waitForAllPaintsFlushed(function() { + _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aFlushMode, + aWindow + ); + }); + return; + } + + var onwheel = function() { + SpecialPowers.removeSystemEventListener(window, "wheel", onwheel); + + // Wait one frame since the wheel event has not caused a refresh observer + // to be added yet. + setTimeout(function() { + utils.advanceTimeAndRefresh(1000); + + if (!aCallback) { + utils.advanceTimeAndRefresh(0); + return; + } + + var waitForPaints = function() { + SpecialPowers.Services.obs.removeObserver( + waitForPaints, + "apz-repaints-flushed" + ); + aWindow.waitForAllPaintsFlushed(function() { + utils.restoreNormalRefresh(); + aCallback(); + }); + }; + + SpecialPowers.Services.obs.addObserver( + waitForPaints, + "apz-repaints-flushed" + ); + if (!utils.flushApzRepaints(aWindow)) { + waitForPaints(); + } + }, 0); + }; + + // Listen for the system wheel event, because it happens after all of + // the other wheel events, including legacy events. + SpecialPowers.addSystemEventListener(aWindow, "wheel", onwheel); + if (aFlushMode === _FlushModes.FLUSH) { + synthesizeWheel(aTarget, aOffsetX, aOffsetY, aEvent, aWindow); + } else { + synthesizeWheelAtPoint(aOffsetX, aOffsetY, aEvent, aWindow); + } +} + +/** + * This is a wrapper around synthesizeWheel that waits for the wheel event + * to be dispatched and for the subsequent layout/paints to be flushed. + * + * This requires including paint_listener.js. Tests must call + * DOMWindowUtils.restoreNormalRefresh() before finishing, if they use this + * function. + * + * If no callback is provided, the caller is assumed to have its own method of + * determining scroll completion and the refresh driver is not automatically + * restored. + */ +function sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aWindow = window +) { + _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + _FlushModes.FLUSH, + aWindow + ); +} + +/** + * Similar to sendWheelAndPaint but without flushing layout for obtaining + * ``aTarget`` position in ``aWindow`` before sending the wheel event. + * ``aOffsetX`` and ``aOffsetY`` should be offsets against aWindow. + */ +function sendWheelAndPaintNoFlush( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aWindow = window +) { + _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + _FlushModes.NOFLUSH, + aWindow + ); +} + +function synthesizeNativeTapAtCenter( + aTarget, + aLongTap = false, + aCallback = null, + aWindow = window +) { + let rect = aTarget.getBoundingClientRect(); + return synthesizeNativeTap( + aTarget, + rect.width / 2, + rect.height / 2, + aLongTap, + aCallback, + aWindow + ); +} + +function synthesizeNativeTap( + aTarget, + aOffsetX, + aOffsetY, + aLongTap = false, + aCallback = null, + aWindow = window +) { + let utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return; + } + + let scale = aWindow.devicePixelRatio; + let rect = aTarget.getBoundingClientRect(); + let x = (aWindow.mozInnerScreenX + rect.left + aOffsetX) * scale; + let y = (aWindow.mozInnerScreenY + rect.top + aOffsetY) * scale; + + let observer = { + observe: (subject, topic, data) => { + if (aCallback && topic == "mouseevent") { + aCallback(data); + } + }, + }; + utils.sendNativeTouchTap(x, y, aLongTap, observer); +} + +/** + * Similar to synthesizeMouse but generates a native widget level event + * (so will actually move the "real" mouse cursor etc. Be careful because + * this can impact later code as well! (e.g. with hover states etc.) + * + * @description There are 3 mutually exclusive ways of indicating the location of the + * mouse event: set ``atCenter``, or pass ``offsetX`` and ``offsetY``, + * or pass ``screenX`` and ``screenY``. Do not attempt to mix these. + * + * @param {object} aParams + * @param {string} aParams.type "click", "mousedown", "mouseup" or "mousemove" + * @param {Element} aParams.target Origin of offsetX and offsetY, must be an element + * @param {Boolean} [aParams.atCenter] + * Instead of offsetX/Y, synthesize the event at center of `target`. + * @param {Number} [aParams.offsetX] + * X offset in `target` (in CSS pixels if `scale` is "screenPixelsPerCSSPixel") + * @param {Number} [aParams.offsetY] + * Y offset in `target` (in CSS pixels if `scale` is "screenPixelsPerCSSPixel") + * @param {Number} [aParams.screenX] + * X offset in screen (in CSS pixels if `scale` is "screenPixelsPerCSSPixel"), + * Neither offsetX/Y nor atCenter must be set if this is set. + * @param {Number} [aParams.screenY] + * Y offset in screen (in CSS pixels if `scale` is "screenPixelsPerCSSPixel"), + * Neither offsetX/Y nor atCenter must be set if this is set. + * @param {String} [aParams.scale="screenPixelsPerCSSPixel"] + * If scale is "screenPixelsPerCSSPixel", devicePixelRatio will be used. + * If scale is "inScreenPixels", clientX/Y nor scaleX/Y are not adjusted with screenPixelsPerCSSPixel. + * @param {Number} [aParams.button=0] + * Defaults to 0, if "click", "mousedown", "mouseup", set same value as DOM MouseEvent.button + * @param {Object} [aParams.modifiers={}] + * Active modifiers, see `_parseNativeModifiers` + * @param {Window} [aParams.win=window] + * The window to use its utils. Defaults to the window in which EventUtils.js is running. + * @param {Element} [aParams.elementOnWidget=target] + * Defaults to target. If element under the point is in another widget from target's widget, + * e.g., when it's in a XUL <panel>, specify this. + */ +function synthesizeNativeMouseEvent(aParams, aCallback = null) { + const { + type, + target, + offsetX, + offsetY, + atCenter, + screenX, + screenY, + scale = "screenPixelsPerCSSPixel", + button = 0, + modifiers = {}, + win = window, + elementOnWidget = target, + } = aParams; + if (atCenter) { + if (offsetX != undefined || offsetY != undefined) { + throw Error( + `atCenter is specified, but offsetX (${offsetX}) and/or offsetY (${offsetY}) are also specified` + ); + } + if (screenX != undefined || screenY != undefined) { + throw Error( + `atCenter is specified, but screenX (${screenX}) and/or screenY (${screenY}) are also specified` + ); + } + if (!target) { + throw Error("atCenter is specified, but target is not specified"); + } + } else if (offsetX != undefined && offsetY != undefined) { + if (screenX != undefined || screenY != undefined) { + throw Error( + `offsetX/Y are specified, but screenX (${screenX}) and/or screenY (${screenY}) are also specified` + ); + } + if (!target) { + throw Error( + "offsetX and offsetY are specified, but target is not specified" + ); + } + } else if (screenX != undefined && screenY != undefined) { + if (offsetX != undefined || offsetY != undefined) { + throw Error( + `screenX/Y are specified, but offsetX (${offsetX}) and/or offsetY (${offsetY}) are also specified` + ); + } + } + const utils = _getDOMWindowUtils(win); + if (!utils) { + return; + } + + const rect = target?.getBoundingClientRect(); + let resolution = 1.0; + try { + resolution = _getDOMWindowUtils(win.top).getResolution(); + } catch (e) { + // XXX How to get mobile viewport scale on Fission+xorigin since + // window.top access isn't allowed due to cross-origin? + } + const scaleValue = (() => { + if (scale === "inScreenPixels") { + return 1.0; + } + if (scale === "screenPixelsPerCSSPixel") { + return win.devicePixelRatio; + } + throw Error(`invalid scale value (${scale}) is specified`); + })(); + // XXX mozInnerScreen might be invalid value on mobile viewport (Bug 1701546), + // so use window.top's mozInnerScreen. But this won't work fission+xorigin + // with mobile viewport until mozInnerScreen returns valid value with + // scale. + const x = (() => { + if (screenX != undefined) { + return screenX * scaleValue; + } + let winInnerOffsetX = win.mozInnerScreenX; + try { + winInnerOffsetX = + win.top.mozInnerScreenX + + (win.mozInnerScreenX - win.top.mozInnerScreenX) * resolution; + } catch (e) { + // XXX fission+xorigin test throws permission denied since win.top is + // cross-origin. + } + return ( + (((atCenter ? rect.width / 2 : offsetX) + rect.left) * resolution + + winInnerOffsetX) * + scaleValue + ); + })(); + const y = (() => { + if (screenY != undefined) { + return screenY * scaleValue; + } + let winInnerOffsetY = win.mozInnerScreenY; + try { + winInnerOffsetY = + win.top.mozInnerScreenY + + (win.mozInnerScreenY - win.top.mozInnerScreenY) * resolution; + } catch (e) { + // XXX fission+xorigin test throws permission denied since win.top is + // cross-origin. + } + return ( + (((atCenter ? rect.height / 2 : offsetY) + rect.top) * resolution + + winInnerOffsetY) * + scaleValue + ); + })(); + const modifierFlags = _parseNativeModifiers(modifiers); + + const observer = { + observe: (subject, topic, data) => { + if (aCallback && topic == "mouseevent") { + aCallback(data); + } + }, + }; + if (type === "click") { + utils.sendNativeMouseEvent( + x, + y, + utils.NATIVE_MOUSE_MESSAGE_BUTTON_DOWN, + button, + modifierFlags, + elementOnWidget, + function() { + utils.sendNativeMouseEvent( + x, + y, + utils.NATIVE_MOUSE_MESSAGE_BUTTON_UP, + button, + modifierFlags, + elementOnWidget, + observer + ); + } + ); + return; + } + utils.sendNativeMouseEvent( + x, + y, + (() => { + switch (type) { + case "mousedown": + return utils.NATIVE_MOUSE_MESSAGE_BUTTON_DOWN; + case "mouseup": + return utils.NATIVE_MOUSE_MESSAGE_BUTTON_UP; + case "mousemove": + return utils.NATIVE_MOUSE_MESSAGE_MOVE; + default: + throw Error(`Invalid type is specified: ${type}`); + } + })(), + button, + modifierFlags, + elementOnWidget, + observer + ); +} + +function promiseNativeMouseEvent(aParams) { + return new Promise(resolve => synthesizeNativeMouseEvent(aParams, resolve)); +} + +function synthesizeNativeMouseEventAndWaitForEvent(aParams, aCallback) { + const listener = aParams.eventTargetToListen || aParams.target; + const eventType = aParams.eventTypeToWait || aParams.type; + listener.addEventListener(eventType, aCallback, { + capture: true, + once: true, + }); + synthesizeNativeMouseEvent(aParams); +} + +function promiseNativeMouseEventAndWaitForEvent(aParams) { + return new Promise(resolve => + synthesizeNativeMouseEventAndWaitForEvent(aParams, resolve) + ); +} + +/** + * This is a wrapper around synthesizeNativeMouseEvent that waits for the mouse + * event to be dispatched to the target content. + * + * This API is supposed to be used in those test cases that synthesize some + * input events to chrome process and have some checks in content. + */ +function synthesizeAndWaitNativeMouseMove( + aTarget, + aOffsetX, + aOffsetY, + aCallback, + aWindow = window +) { + let browser = gBrowser.selectedTab.linkedBrowser; + let mm = browser.messageManager; + let { ContentTask } = _EU_ChromeUtils.importESModule( + "resource://testing-common/ContentTask.sys.mjs" + ); + + let eventRegisteredPromise = new Promise(resolve => { + mm.addMessageListener("Test:MouseMoveRegistered", function processed( + message + ) { + mm.removeMessageListener("Test:MouseMoveRegistered", processed); + resolve(); + }); + }); + let eventReceivedPromise = ContentTask.spawn( + browser, + [aOffsetX, aOffsetY], + ([clientX, clientY]) => { + return new Promise(resolve => { + addEventListener("mousemove", function onMouseMoveEvent(e) { + if (e.clientX == clientX && e.clientY == clientY) { + removeEventListener("mousemove", onMouseMoveEvent); + resolve(); + } + }); + sendAsyncMessage("Test:MouseMoveRegistered"); + }); + } + ); + eventRegisteredPromise.then(() => { + synthesizeNativeMouseEvent({ + type: "mousemove", + target: aTarget, + offsetX: aOffsetX, + offsetY: aOffsetY, + win: aWindow, + }); + }); + return eventReceivedPromise; +} + +/** + * Synthesize a key event. It is targeted at whatever would be targeted by an + * actual keypress by the user, typically the focused element. + * + * @param {String} aKey + * Should be either: + * + * - key value (recommended). If you specify a non-printable key name, + * prepend the ``KEY_`` prefix. Otherwise, specifying a printable key, the + * key value should be specified. + * + * - keyCode name starting with ``VK_`` (e.g., ``VK_RETURN``). This is available + * only for compatibility with legacy API. Don't use this with new tests. + * + * @param {Object} [aEvent] + * Optional event object with more specifics about the key event to + * synthesize. + * @param {String} [aEvent.code] + * If you don't specify this explicitly, it'll be guessed from aKey + * of US keyboard layout. Note that this value may be different + * between browsers. For example, "Insert" is never set only on + * macOS since actual key operation won't cause this code value. + * In such case, the value becomes empty string. + * If you need to emulate non-US keyboard layout or virtual keyboard + * which doesn't emulate hardware key input, you should set this value + * to empty string explicitly. + * @param {Number} [aEvent.repeat] + * If you emulate auto-repeat, you should set the count of repeat. + * This method will automatically synthesize keydown (and keypress). + * @param {*} aEvent.location + * If you want to specify this, you can specify this explicitly. + * However, if you don't specify this value, it will be computed + * from code value. + * @param {String} aEvent.type + * Basically, you shouldn't specify this. Then, this function will + * synthesize keydown (, keypress) and keyup. + * If keydown is specified, this only fires keydown (and keypress if + * it should be fired). + * If keyup is specified, this only fires keyup. + * @param {Number} aEvent.keyCode + * Must be 0 - 255 (0xFF). If this is specified explicitly, + * .keyCode value is initialized with this value. + * @param {Window} aWindow + * Is optional and defaults to the current window object. + * @param {Function} aCallback + * Is optional and can be used to receive notifications from TIP. + * + * @description + * ``accelKey``, ``altKey``, ``altGraphKey``, ``ctrlKey``, ``capsLockKey``, + * ``fnKey``, ``fnLockKey``, ``numLockKey``, ``metaKey``, ``osKey``, + * ``scrollLockKey``, ``shiftKey``, ``symbolKey``, ``symbolLockKey`` + * Basically, you shouldn't use these attributes. nsITextInputProcessor + * manages modifier key state when you synthesize modifier key events. + * However, if some of these attributes are true, this function activates + * the modifiers only during dispatching the key events. + * Note that if some of these values are false, they are ignored (i.e., + * not inactivated with this function). + * + */ +function synthesizeKey(aKey, aEvent = undefined, aWindow = window, aCallback) { + var event = aEvent === undefined || aEvent === null ? {} : aEvent; + + var TIP = _getTIP(aWindow, aCallback); + if (!TIP) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + var modifiers = _emulateToActivateModifiers(TIP, event, aWindow); + var keyEventDict = _createKeyboardEventDictionary(aKey, event, TIP, aWindow); + var keyEvent = new KeyboardEvent("", keyEventDict.dictionary); + var dispatchKeydown = + !("type" in event) || event.type === "keydown" || !event.type; + var dispatchKeyup = + !("type" in event) || event.type === "keyup" || !event.type; + + try { + if (dispatchKeydown) { + TIP.keydown(keyEvent, keyEventDict.flags); + if ("repeat" in event && event.repeat > 1) { + keyEventDict.dictionary.repeat = true; + var repeatedKeyEvent = new KeyboardEvent("", keyEventDict.dictionary); + for (var i = 1; i < event.repeat; i++) { + TIP.keydown(repeatedKeyEvent, keyEventDict.flags); + } + } + } + if (dispatchKeyup) { + TIP.keyup(keyEvent, keyEventDict.flags); + } + } finally { + _emulateToInactivateModifiers(TIP, modifiers, aWindow); + } +} + +/** + * This is a wrapper around synthesizeKey that waits for the key event to be + * dispatched to the target content. It returns a promise which is resolved + * when the content receives the key event. + * + * This API is supposed to be used in those test cases that synthesize some + * input events to chrome process and have some checks in content. + */ +function synthesizeAndWaitKey( + aKey, + aEvent, + aWindow = window, + checkBeforeSynthesize, + checkAfterSynthesize +) { + let browser = gBrowser.selectedTab.linkedBrowser; + let mm = browser.messageManager; + let keyCode = _createKeyboardEventDictionary(aKey, aEvent, null, aWindow) + .dictionary.keyCode; + let { ContentTask } = _EU_ChromeUtils.importESModule( + "resource://testing-common/ContentTask.sys.mjs" + ); + + let keyRegisteredPromise = new Promise(resolve => { + mm.addMessageListener("Test:KeyRegistered", function processed(message) { + mm.removeMessageListener("Test:KeyRegistered", processed); + resolve(); + }); + }); + // eslint-disable-next-line no-shadow + let keyReceivedPromise = ContentTask.spawn(browser, keyCode, keyCode => { + return new Promise(resolve => { + addEventListener("keyup", function onKeyEvent(e) { + if (e.keyCode == keyCode) { + removeEventListener("keyup", onKeyEvent); + resolve(); + } + }); + sendAsyncMessage("Test:KeyRegistered"); + }); + }); + keyRegisteredPromise.then(() => { + if (checkBeforeSynthesize) { + checkBeforeSynthesize(); + } + synthesizeKey(aKey, aEvent, aWindow); + if (checkAfterSynthesize) { + checkAfterSynthesize(); + } + }); + return keyReceivedPromise; +} + +function _parseNativeModifiers(aModifiers, aWindow = window) { + let modifiers = 0; + if (aModifiers.capsLockKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CAPS_LOCK; + } + if (aModifiers.numLockKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_NUM_LOCK; + } + if (aModifiers.shiftKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_SHIFT_LEFT; + } + if (aModifiers.shiftRightKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_SHIFT_RIGHT; + } + if (aModifiers.ctrlKey) { + modifiers |= + SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_LEFT; + } + if (aModifiers.ctrlRightKey) { + modifiers |= + SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_RIGHT; + } + if (aModifiers.altKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_LEFT; + } + if (aModifiers.altRightKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_RIGHT; + } + if (aModifiers.metaKey) { + modifiers |= + SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_LEFT; + } + if (aModifiers.metaRightKey) { + modifiers |= + SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_RIGHT; + } + if (aModifiers.helpKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_HELP; + } + if (aModifiers.fnKey) { + modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_FUNCTION; + } + if (aModifiers.numericKeyPadKey) { + modifiers |= + SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_NUMERIC_KEY_PAD; + } + + if (aModifiers.accelKey) { + modifiers |= _EU_isMac(aWindow) + ? SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_LEFT + : SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_LEFT; + } + if (aModifiers.accelRightKey) { + modifiers |= _EU_isMac(aWindow) + ? SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_RIGHT + : SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_RIGHT; + } + if (aModifiers.altGrKey) { + modifiers |= _EU_isMac(aWindow) + ? SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_LEFT + : SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_GRAPH; + } + return modifiers; +} + +// Mac: Any unused number is okay for adding new keyboard layout. +// When you add new keyboard layout here, you need to modify +// TISInputSourceWrapper::InitByLayoutID(). +// Win: These constants can be found by inspecting registry keys under +// HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Keyboard Layouts + +const KEYBOARD_LAYOUT_ARABIC = { + name: "Arabic", + Mac: 6, + Win: 0x00000401, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_ARABIC_PC = { + name: "Arabic - PC", + Mac: 7, + Win: null, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_BRAZILIAN_ABNT = { + name: "Brazilian ABNT", + Mac: null, + Win: 0x00000416, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_DVORAK_QWERTY = { + name: "Dvorak-QWERTY", + Mac: 4, + Win: null, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_EN_US = { + name: "US", + Mac: 0, + Win: 0x00000409, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_FRENCH = { + name: "French", + Mac: 8, + Win: 0x0000040c, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_GREEK = { + name: "Greek", + Mac: 1, + Win: 0x00000408, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_GERMAN = { + name: "German", + Mac: 2, + Win: 0x00000407, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_HEBREW = { + name: "Hebrew", + Mac: 9, + Win: 0x0000040d, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_JAPANESE = { + name: "Japanese", + Mac: null, + Win: 0x00000411, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_KHMER = { + name: "Khmer", + Mac: null, + Win: 0x00000453, + hasAltGrOnWin: true, +}; // available on Win7 or later. +const KEYBOARD_LAYOUT_LITHUANIAN = { + name: "Lithuanian", + Mac: 10, + Win: 0x00010427, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_NORWEGIAN = { + name: "Norwegian", + Mac: 11, + Win: 0x00000414, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_RUSSIAN_MNEMONIC = { + name: "Russian - Mnemonic", + Mac: null, + Win: 0x00020419, + hasAltGrOnWin: true, +}; // available on Win8 or later. +const KEYBOARD_LAYOUT_SPANISH = { + name: "Spanish", + Mac: 12, + Win: 0x0000040a, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_SWEDISH = { + name: "Swedish", + Mac: 3, + Win: 0x0000041d, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_THAI = { + name: "Thai", + Mac: 5, + Win: 0x0002041e, + hasAltGrOnWin: false, +}; + +/** + * synthesizeNativeKey() dispatches native key event on active window. + * This is implemented only on Windows and Mac. Note that this function + * dispatches the key event asynchronously and returns immediately. If a + * callback function is provided, the callback will be called upon + * completion of the key dispatch. + * + * @param aKeyboardLayout One of KEYBOARD_LAYOUT_* defined above. + * @param aNativeKeyCode A native keycode value defined in + * NativeKeyCodes.js. + * @param aModifiers Modifier keys. If no modifire key is pressed, + * this must be {}. Otherwise, one or more items + * referred in _parseNativeModifiers() must be + * true. + * @param aChars Specify characters which should be generated + * by the key event. + * @param aUnmodifiedChars Specify characters of unmodified (except Shift) + * aChar value. + * @param aCallback If provided, this callback will be invoked + * once the native keys have been processed + * by Gecko. Will never be called if this + * function returns false. + * @return True if this function succeed dispatching + * native key event. Otherwise, false. + */ + +function synthesizeNativeKey( + aKeyboardLayout, + aNativeKeyCode, + aModifiers, + aChars, + aUnmodifiedChars, + aCallback, + aWindow = window +) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return false; + } + var nativeKeyboardLayout = null; + if (_EU_isMac(aWindow)) { + nativeKeyboardLayout = aKeyboardLayout.Mac; + } else if (_EU_isWin(aWindow)) { + nativeKeyboardLayout = aKeyboardLayout.Win; + } + if (nativeKeyboardLayout === null) { + return false; + } + + var observer = { + observe(aSubject, aTopic, aData) { + if (aCallback && aTopic == "keyevent") { + aCallback(aData); + } + }, + }; + utils.sendNativeKeyEvent( + nativeKeyboardLayout, + aNativeKeyCode, + _parseNativeModifiers(aModifiers, aWindow), + aChars, + aUnmodifiedChars, + observer + ); + return true; +} + +var _gSeenEvent = false; + +/** + * Indicate that an event with an original target of aExpectedTarget and + * a type of aExpectedEvent is expected to be fired, or not expected to + * be fired. + */ +function _expectEvent(aExpectedTarget, aExpectedEvent, aTestName) { + if (!aExpectedTarget || !aExpectedEvent) { + return null; + } + + _gSeenEvent = false; + + var type = + aExpectedEvent.charAt(0) == "!" + ? aExpectedEvent.substring(1) + : aExpectedEvent; + var eventHandler = function(event) { + var epassed = + !_gSeenEvent && + event.originalTarget == aExpectedTarget && + event.type == type; + is( + epassed, + true, + aTestName + " " + type + " event target " + (_gSeenEvent ? "twice" : "") + ); + _gSeenEvent = true; + }; + + aExpectedTarget.addEventListener(type, eventHandler); + return eventHandler; +} + +/** + * Check if the event was fired or not. The event handler aEventHandler + * will be removed. + */ +function _checkExpectedEvent( + aExpectedTarget, + aExpectedEvent, + aEventHandler, + aTestName +) { + if (aEventHandler) { + var expectEvent = aExpectedEvent.charAt(0) != "!"; + var type = expectEvent ? aExpectedEvent : aExpectedEvent.substring(1); + aExpectedTarget.removeEventListener(type, aEventHandler); + var desc = type + " event"; + if (!expectEvent) { + desc += " not"; + } + is(_gSeenEvent, expectEvent, aTestName + " " + desc + " fired"); + } + + _gSeenEvent = false; +} + +/** + * Similar to synthesizeMouse except that a test is performed to see if an + * event is fired at the right target as a result. + * + * aExpectedTarget - the expected originalTarget of the event. + * aExpectedEvent - the expected type of the event, such as 'select'. + * aTestName - the test name when outputing results + * + * To test that an event is not fired, use an expected type preceded by an + * exclamation mark, such as '!select'. This might be used to test that a + * click on a disabled element doesn't fire certain events for instance. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeMouseExpectEvent( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aExpectedTarget, + aExpectedEvent, + aTestName, + aWindow +) { + var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName); + synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow); + _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName); +} + +/** + * Similar to synthesizeKey except that a test is performed to see if an + * event is fired at the right target as a result. + * + * aExpectedTarget - the expected originalTarget of the event. + * aExpectedEvent - the expected type of the event, such as 'select'. + * aTestName - the test name when outputing results + * + * To test that an event is not fired, use an expected type preceded by an + * exclamation mark, such as '!select'. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeKeyExpectEvent( + key, + aEvent, + aExpectedTarget, + aExpectedEvent, + aTestName, + aWindow +) { + var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName); + synthesizeKey(key, aEvent, aWindow); + _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName); +} + +function disableNonTestMouseEvents(aDisable) { + var domutils = _getDOMWindowUtils(); + domutils.disableNonTestMouseEvents(aDisable); +} + +function _getDOMWindowUtils(aWindow = window) { + // Leave this here as something, somewhere, passes a falsy argument + // to this, causing the |window| default argument not to get picked up. + if (!aWindow) { + aWindow = window; + } + + // If documentURIObject exists or `window` is a stub object, we're in + // a chrome scope, so don't bother trying to go through SpecialPowers. + if (!window.document || window.document.documentURIObject) { + return aWindow.windowUtils; + } + + // we need parent.SpecialPowers for: + // layout/base/tests/test_reftests_with_caret.html + // chrome: toolkit/content/tests/chrome/test_findbar.xul + // chrome: toolkit/content/tests/chrome/test_popup_anchor.xul + if ("SpecialPowers" in window && window.SpecialPowers != undefined) { + return SpecialPowers.getDOMWindowUtils(aWindow); + } + if ("SpecialPowers" in parent && parent.SpecialPowers != undefined) { + return parent.SpecialPowers.getDOMWindowUtils(aWindow); + } + + // TODO: this is assuming we are in chrome space + return aWindow.windowUtils; +} + +function _defineConstant(name, value) { + Object.defineProperty(this, name, { + value, + enumerable: true, + writable: false, + }); +} + +const COMPOSITION_ATTR_RAW_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE; +_defineConstant("COMPOSITION_ATTR_RAW_CLAUSE", COMPOSITION_ATTR_RAW_CLAUSE); +const COMPOSITION_ATTR_SELECTED_RAW_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_SELECTED_RAW_CLAUSE; +_defineConstant( + "COMPOSITION_ATTR_SELECTED_RAW_CLAUSE", + COMPOSITION_ATTR_SELECTED_RAW_CLAUSE +); +const COMPOSITION_ATTR_CONVERTED_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_CONVERTED_CLAUSE; +_defineConstant( + "COMPOSITION_ATTR_CONVERTED_CLAUSE", + COMPOSITION_ATTR_CONVERTED_CLAUSE +); +const COMPOSITION_ATTR_SELECTED_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_SELECTED_CLAUSE; +_defineConstant( + "COMPOSITION_ATTR_SELECTED_CLAUSE", + COMPOSITION_ATTR_SELECTED_CLAUSE +); + +var TIPMap = new WeakMap(); + +function _getTIP(aWindow, aCallback) { + if (!aWindow) { + aWindow = window; + } + var tip; + if (TIPMap.has(aWindow)) { + tip = TIPMap.get(aWindow); + } else { + tip = _EU_Cc["@mozilla.org/text-input-processor;1"].createInstance( + _EU_Ci.nsITextInputProcessor + ); + TIPMap.set(aWindow, tip); + } + if (!tip.beginInputTransactionForTests(aWindow, aCallback)) { + tip = null; + TIPMap.delete(aWindow); + } + return tip; +} + +function _getKeyboardEvent(aWindow = window) { + if (typeof KeyboardEvent != "undefined") { + try { + // See if the object can be instantiated; sometimes this yields + // 'TypeError: can't access dead object' or 'KeyboardEvent is not a constructor'. + new KeyboardEvent("", {}); + return KeyboardEvent; + } catch (ex) {} + } + if (typeof content != "undefined" && "KeyboardEvent" in content) { + return content.KeyboardEvent; + } + return aWindow.KeyboardEvent; +} + +// eslint-disable-next-line complexity +function _guessKeyNameFromKeyCode(aKeyCode, aWindow = window) { + var KeyboardEvent = _getKeyboardEvent(aWindow); + switch (aKeyCode) { + case KeyboardEvent.DOM_VK_CANCEL: + return "Cancel"; + case KeyboardEvent.DOM_VK_HELP: + return "Help"; + case KeyboardEvent.DOM_VK_BACK_SPACE: + return "Backspace"; + case KeyboardEvent.DOM_VK_TAB: + return "Tab"; + case KeyboardEvent.DOM_VK_CLEAR: + return "Clear"; + case KeyboardEvent.DOM_VK_RETURN: + return "Enter"; + case KeyboardEvent.DOM_VK_SHIFT: + return "Shift"; + case KeyboardEvent.DOM_VK_CONTROL: + return "Control"; + case KeyboardEvent.DOM_VK_ALT: + return "Alt"; + case KeyboardEvent.DOM_VK_PAUSE: + return "Pause"; + case KeyboardEvent.DOM_VK_EISU: + return "Eisu"; + case KeyboardEvent.DOM_VK_ESCAPE: + return "Escape"; + case KeyboardEvent.DOM_VK_CONVERT: + return "Convert"; + case KeyboardEvent.DOM_VK_NONCONVERT: + return "NonConvert"; + case KeyboardEvent.DOM_VK_ACCEPT: + return "Accept"; + case KeyboardEvent.DOM_VK_MODECHANGE: + return "ModeChange"; + case KeyboardEvent.DOM_VK_PAGE_UP: + return "PageUp"; + case KeyboardEvent.DOM_VK_PAGE_DOWN: + return "PageDown"; + case KeyboardEvent.DOM_VK_END: + return "End"; + case KeyboardEvent.DOM_VK_HOME: + return "Home"; + case KeyboardEvent.DOM_VK_LEFT: + return "ArrowLeft"; + case KeyboardEvent.DOM_VK_UP: + return "ArrowUp"; + case KeyboardEvent.DOM_VK_RIGHT: + return "ArrowRight"; + case KeyboardEvent.DOM_VK_DOWN: + return "ArrowDown"; + case KeyboardEvent.DOM_VK_SELECT: + return "Select"; + case KeyboardEvent.DOM_VK_PRINT: + return "Print"; + case KeyboardEvent.DOM_VK_EXECUTE: + return "Execute"; + case KeyboardEvent.DOM_VK_PRINTSCREEN: + return "PrintScreen"; + case KeyboardEvent.DOM_VK_INSERT: + return "Insert"; + case KeyboardEvent.DOM_VK_DELETE: + return "Delete"; + case KeyboardEvent.DOM_VK_WIN: + return "OS"; + case KeyboardEvent.DOM_VK_CONTEXT_MENU: + return "ContextMenu"; + case KeyboardEvent.DOM_VK_SLEEP: + return "Standby"; + case KeyboardEvent.DOM_VK_F1: + return "F1"; + case KeyboardEvent.DOM_VK_F2: + return "F2"; + case KeyboardEvent.DOM_VK_F3: + return "F3"; + case KeyboardEvent.DOM_VK_F4: + return "F4"; + case KeyboardEvent.DOM_VK_F5: + return "F5"; + case KeyboardEvent.DOM_VK_F6: + return "F6"; + case KeyboardEvent.DOM_VK_F7: + return "F7"; + case KeyboardEvent.DOM_VK_F8: + return "F8"; + case KeyboardEvent.DOM_VK_F9: + return "F9"; + case KeyboardEvent.DOM_VK_F10: + return "F10"; + case KeyboardEvent.DOM_VK_F11: + return "F11"; + case KeyboardEvent.DOM_VK_F12: + return "F12"; + case KeyboardEvent.DOM_VK_F13: + return "F13"; + case KeyboardEvent.DOM_VK_F14: + return "F14"; + case KeyboardEvent.DOM_VK_F15: + return "F15"; + case KeyboardEvent.DOM_VK_F16: + return "F16"; + case KeyboardEvent.DOM_VK_F17: + return "F17"; + case KeyboardEvent.DOM_VK_F18: + return "F18"; + case KeyboardEvent.DOM_VK_F19: + return "F19"; + case KeyboardEvent.DOM_VK_F20: + return "F20"; + case KeyboardEvent.DOM_VK_F21: + return "F21"; + case KeyboardEvent.DOM_VK_F22: + return "F22"; + case KeyboardEvent.DOM_VK_F23: + return "F23"; + case KeyboardEvent.DOM_VK_F24: + return "F24"; + case KeyboardEvent.DOM_VK_NUM_LOCK: + return "NumLock"; + case KeyboardEvent.DOM_VK_SCROLL_LOCK: + return "ScrollLock"; + case KeyboardEvent.DOM_VK_VOLUME_MUTE: + return "AudioVolumeMute"; + case KeyboardEvent.DOM_VK_VOLUME_DOWN: + return "AudioVolumeDown"; + case KeyboardEvent.DOM_VK_VOLUME_UP: + return "AudioVolumeUp"; + case KeyboardEvent.DOM_VK_META: + return "Meta"; + case KeyboardEvent.DOM_VK_ALTGR: + return "AltGraph"; + case KeyboardEvent.DOM_VK_PROCESSKEY: + return "Process"; + case KeyboardEvent.DOM_VK_ATTN: + return "Attn"; + case KeyboardEvent.DOM_VK_CRSEL: + return "CrSel"; + case KeyboardEvent.DOM_VK_EXSEL: + return "ExSel"; + case KeyboardEvent.DOM_VK_EREOF: + return "EraseEof"; + case KeyboardEvent.DOM_VK_PLAY: + return "Play"; + default: + return "Unidentified"; + } +} + +function _createKeyboardEventDictionary( + aKey, + aKeyEvent, + aTIP = null, + aWindow = window +) { + var result = { dictionary: null, flags: 0 }; + var keyCodeIsDefined = "keyCode" in aKeyEvent; + var keyCode = + keyCodeIsDefined && aKeyEvent.keyCode >= 0 && aKeyEvent.keyCode <= 255 + ? aKeyEvent.keyCode + : 0; + var keyName = "Unidentified"; + var code = aKeyEvent.code; + if (!aTIP) { + aTIP = _getTIP(aWindow); + } + if (aKey.indexOf("KEY_") == 0) { + keyName = aKey.substr("KEY_".length); + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY; + if (code === undefined) { + code = aTIP.computeCodeValueOfNonPrintableKey( + keyName, + aKeyEvent.location + ); + } + } else if (aKey.indexOf("VK_") == 0) { + keyCode = _getKeyboardEvent(aWindow)["DOM_" + aKey]; + if (!keyCode) { + throw new Error("Unknown key: " + aKey); + } + keyName = _guessKeyNameFromKeyCode(keyCode, aWindow); + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY; + if (code === undefined) { + code = aTIP.computeCodeValueOfNonPrintableKey( + keyName, + aKeyEvent.location + ); + } + } else if (aKey != "") { + keyName = aKey; + if (!keyCodeIsDefined) { + keyCode = aTIP.guessKeyCodeValueOfPrintableKeyInUSEnglishKeyboardLayout( + aKey, + aKeyEvent.location + ); + } + if (!keyCode) { + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_KEEP_KEYCODE_ZERO; + } + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY; + if (code === undefined) { + code = aTIP.guessCodeValueOfPrintableKeyInUSEnglishKeyboardLayout( + keyName, + aKeyEvent.location + ); + } + } + var locationIsDefined = "location" in aKeyEvent; + if (locationIsDefined && aKeyEvent.location === 0) { + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_KEEP_KEY_LOCATION_STANDARD; + } + if (aKeyEvent.doNotMarkKeydownAsProcessed) { + result.flags |= + _EU_Ci.nsITextInputProcessor.KEY_DONT_MARK_KEYDOWN_AS_PROCESSED; + } + if (aKeyEvent.markKeyupAsProcessed) { + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_MARK_KEYUP_AS_PROCESSED; + } + result.dictionary = { + key: keyName, + code, + location: locationIsDefined ? aKeyEvent.location : 0, + repeat: "repeat" in aKeyEvent ? aKeyEvent.repeat === true : false, + keyCode, + }; + return result; +} + +function _emulateToActivateModifiers(aTIP, aKeyEvent, aWindow = window) { + if (!aKeyEvent) { + return null; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + + var modifiers = { + normal: [ + { key: "Alt", attr: "altKey" }, + { key: "AltGraph", attr: "altGraphKey" }, + { key: "Control", attr: "ctrlKey" }, + { key: "Fn", attr: "fnKey" }, + { key: "Meta", attr: "metaKey" }, + { key: "OS", attr: "osKey" }, + { key: "Shift", attr: "shiftKey" }, + { key: "Symbol", attr: "symbolKey" }, + { key: _EU_isMac(aWindow) ? "Meta" : "Control", attr: "accelKey" }, + ], + lockable: [ + { key: "CapsLock", attr: "capsLockKey" }, + { key: "FnLock", attr: "fnLockKey" }, + { key: "NumLock", attr: "numLockKey" }, + { key: "ScrollLock", attr: "scrollLockKey" }, + { key: "SymbolLock", attr: "symbolLockKey" }, + ], + }; + + for (let i = 0; i < modifiers.normal.length; i++) { + if (!aKeyEvent[modifiers.normal[i].attr]) { + continue; + } + if (aTIP.getModifierState(modifiers.normal[i].key)) { + continue; // already activated. + } + let event = new KeyboardEvent("", { key: modifiers.normal[i].key }); + aTIP.keydown( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + modifiers.normal[i].activated = true; + } + for (let i = 0; i < modifiers.lockable.length; i++) { + if (!aKeyEvent[modifiers.lockable[i].attr]) { + continue; + } + if (aTIP.getModifierState(modifiers.lockable[i].key)) { + continue; // already activated. + } + let event = new KeyboardEvent("", { key: modifiers.lockable[i].key }); + aTIP.keydown( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + aTIP.keyup( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + modifiers.lockable[i].activated = true; + } + return modifiers; +} + +function _emulateToInactivateModifiers(aTIP, aModifiers, aWindow = window) { + if (!aModifiers) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + for (let i = 0; i < aModifiers.normal.length; i++) { + if (!aModifiers.normal[i].activated) { + continue; + } + let event = new KeyboardEvent("", { key: aModifiers.normal[i].key }); + aTIP.keyup( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + } + for (let i = 0; i < aModifiers.lockable.length; i++) { + if (!aModifiers.lockable[i].activated) { + continue; + } + if (!aTIP.getModifierState(aModifiers.lockable[i].key)) { + continue; // who already inactivated this? + } + let event = new KeyboardEvent("", { key: aModifiers.lockable[i].key }); + aTIP.keydown( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + aTIP.keyup( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + } +} + +/** + * Synthesize a composition event and keydown event and keyup events unless + * you prevent to dispatch them explicitly (see aEvent.key's explanation). + * + * Note that you shouldn't call this with "compositionstart" unless you need to + * test compositionstart event which is NOT followed by compositionupdate + * event immediately. Typically, native IME starts composition with + * a pair of keydown and keyup event and dispatch compositionstart and + * compositionupdate (and non-standard text event) between them. So, in most + * cases, you should call synthesizeCompositionChange() directly. + * If you call this with compositionstart, keyup event will be fired + * immediately after compositionstart. In other words, you should use + * "compositionstart" only when you need to emulate IME which just starts + * composition with compositionstart event but does not send composing text to + * us until committing the composition. This is behavior of some Chinese IMEs. + * + * @param aEvent The composition event information. This must + * have |type| member. The value must be + * "compositionstart", "compositionend", + * "compositioncommitasis" or "compositioncommit". + * + * And also this may have |data| and |locale| which + * would be used for the value of each property of + * the composition event. Note that the |data| is + * ignored if the event type is "compositionstart" + * or "compositioncommitasis". + * + * If |key| is undefined, "keydown" and "keyup" + * events which are marked as "processed by IME" + * are dispatched. If |key| is not null, "keydown" + * and/or "keyup" events are dispatched (if the + * |key.type| is specified as "keydown", only + * "keydown" event is dispatched). Otherwise, + * i.e., if |key| is null, neither "keydown" nor + * "keyup" event is dispatched. + * + * If |key.doNotMarkKeydownAsProcessed| is not true, + * key value and keyCode value of "keydown" event + * will be set to "Process" and DOM_VK_PROCESSKEY. + * If |key.markKeyupAsProcessed| is true, + * key value and keyCode value of "keyup" event + * will be set to "Process" and DOM_VK_PROCESSKEY. + * @param aWindow Optional (If null, current |window| will be used) + * @param aCallback Optional (If non-null, use the callback for + * receiving notifications to IME) + */ +function synthesizeComposition(aEvent, aWindow = window, aCallback) { + var TIP = _getTIP(aWindow, aCallback); + if (!TIP) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + var modifiers = _emulateToActivateModifiers(TIP, aEvent.key, aWindow); + var keyEventDict = { dictionary: null, flags: 0 }; + var keyEvent = null; + if (aEvent.key && typeof aEvent.key.key === "string") { + keyEventDict = _createKeyboardEventDictionary( + aEvent.key.key, + aEvent.key, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent( + // eslint-disable-next-line no-nested-ternary + aEvent.key.type === "keydown" + ? "keydown" + : aEvent.key.type === "keyup" + ? "keyup" + : "", + keyEventDict.dictionary + ); + } else if (aEvent.key === undefined) { + keyEventDict = _createKeyboardEventDictionary( + "KEY_Process", + {}, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent("", keyEventDict.dictionary); + } + try { + switch (aEvent.type) { + case "compositionstart": + TIP.startComposition(keyEvent, keyEventDict.flags); + break; + case "compositioncommitasis": + TIP.commitComposition(keyEvent, keyEventDict.flags); + break; + case "compositioncommit": + TIP.commitCompositionWith(aEvent.data, keyEvent, keyEventDict.flags); + break; + } + } finally { + _emulateToInactivateModifiers(TIP, modifiers, aWindow); + } +} +/** + * Synthesize eCompositionChange event which causes a DOM text event, may + * cause compositionupdate event, and causes keydown event and keyup event + * unless you prevent to dispatch them explicitly (see aEvent.key's + * explanation). + * + * Note that if you call this when there is no composition, compositionstart + * event will be fired automatically. This is better than you use + * synthesizeComposition("compositionstart") in most cases. See the + * explanation of synthesizeComposition(). + * + * @param aEvent The compositionchange event's information, this has + * |composition| and |caret| members. |composition| has + * |string| and |clauses| members. |clauses| must be array + * object. Each object has |length| and |attr|. And |caret| + * has |start| and |length|. See the following tree image. + * + * aEvent + * +-- composition + * | +-- string + * | +-- clauses[] + * | +-- length + * | +-- attr + * +-- caret + * | +-- start + * | +-- length + * +-- key + * + * Set the composition string to |composition.string|. Set its + * clauses information to the |clauses| array. + * + * When it's composing, set the each clauses' length to the + * |composition.clauses[n].length|. The sum of the all length + * values must be same as the length of |composition.string|. + * Set nsICompositionStringSynthesizer.ATTR_* to the + * |composition.clauses[n].attr|. + * + * When it's not composing, set 0 to the + * |composition.clauses[0].length| and + * |composition.clauses[0].attr|. + * + * Set caret position to the |caret.start|. It's offset from + * the start of the composition string. Set caret length to + * |caret.length|. If it's larger than 0, it should be wide + * caret. However, current nsEditor doesn't support wide + * caret, therefore, you should always set 0 now. + * + * If |key| is undefined, "keydown" and "keyup" events which + * are marked as "processed by IME" are dispatched. If |key| + * is not null, "keydown" and/or "keyup" events are dispatched + * (if the |key.type| is specified as "keydown", only "keydown" + * event is dispatched). Otherwise, i.e., if |key| is null, + * neither "keydown" nor "keyup" event is dispatched. + * If |key.doNotMarkKeydownAsProcessed| is not true, key value + * and keyCode value of "keydown" event will be set to + * "Process" and DOM_VK_PROCESSKEY. + * If |key.markKeyupAsProcessed| is true key value and keyCode + * value of "keyup" event will be set to "Process" and + * DOM_VK_PROCESSKEY. + * + * @param aWindow Optional (If null, current |window| will be used) + * @param aCallback Optional (If non-null, use the callback for receiving + * notifications to IME) + */ +function synthesizeCompositionChange(aEvent, aWindow = window, aCallback) { + var TIP = _getTIP(aWindow, aCallback); + if (!TIP) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + + if ( + !aEvent.composition || + !aEvent.composition.clauses || + !aEvent.composition.clauses[0] + ) { + return; + } + + TIP.setPendingCompositionString(aEvent.composition.string); + if (aEvent.composition.clauses[0].length) { + for (var i = 0; i < aEvent.composition.clauses.length; i++) { + switch (aEvent.composition.clauses[i].attr) { + case TIP.ATTR_RAW_CLAUSE: + case TIP.ATTR_SELECTED_RAW_CLAUSE: + case TIP.ATTR_CONVERTED_CLAUSE: + case TIP.ATTR_SELECTED_CLAUSE: + TIP.appendClauseToPendingComposition( + aEvent.composition.clauses[i].length, + aEvent.composition.clauses[i].attr + ); + break; + case 0: + // Ignore dummy clause for the argument. + break; + default: + throw new Error("invalid clause attribute specified"); + } + } + } + + if (aEvent.caret) { + TIP.setCaretInPendingComposition(aEvent.caret.start); + } + + var modifiers = _emulateToActivateModifiers(TIP, aEvent.key, aWindow); + try { + var keyEventDict = { dictionary: null, flags: 0 }; + var keyEvent = null; + if (aEvent.key && typeof aEvent.key.key === "string") { + keyEventDict = _createKeyboardEventDictionary( + aEvent.key.key, + aEvent.key, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent( + // eslint-disable-next-line no-nested-ternary + aEvent.key.type === "keydown" + ? "keydown" + : aEvent.key.type === "keyup" + ? "keyup" + : "", + keyEventDict.dictionary + ); + } else if (aEvent.key === undefined) { + keyEventDict = _createKeyboardEventDictionary( + "KEY_Process", + {}, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent("", keyEventDict.dictionary); + } + TIP.flushPendingComposition(keyEvent, keyEventDict.flags); + } finally { + _emulateToInactivateModifiers(TIP, modifiers, aWindow); + } +} + +// Must be synchronized with nsIDOMWindowUtils. +const QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK = 0x0000; +const QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK = 0x0001; + +const QUERY_CONTENT_FLAG_SELECTION_NORMAL = 0x0000; +const QUERY_CONTENT_FLAG_SELECTION_SPELLCHECK = 0x0002; +const QUERY_CONTENT_FLAG_SELECTION_IME_RAWINPUT = 0x0004; +const QUERY_CONTENT_FLAG_SELECTION_IME_SELECTEDRAWTEXT = 0x0008; +const QUERY_CONTENT_FLAG_SELECTION_IME_CONVERTEDTEXT = 0x0010; +const QUERY_CONTENT_FLAG_SELECTION_IME_SELECTEDCONVERTEDTEXT = 0x0020; +const QUERY_CONTENT_FLAG_SELECTION_ACCESSIBILITY = 0x0040; +const QUERY_CONTENT_FLAG_SELECTION_FIND = 0x0080; +const QUERY_CONTENT_FLAG_SELECTION_URLSECONDARY = 0x0100; +const QUERY_CONTENT_FLAG_SELECTION_URLSTRIKEOUT = 0x0200; + +const QUERY_CONTENT_FLAG_OFFSET_RELATIVE_TO_INSERTION_POINT = 0x0400; + +const SELECTION_SET_FLAG_USE_NATIVE_LINE_BREAK = 0x0000; +const SELECTION_SET_FLAG_USE_XP_LINE_BREAK = 0x0001; +const SELECTION_SET_FLAG_REVERSE = 0x0002; + +/** + * Synthesize a query text content event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of getting text. If the length is too long, + * the extra length is ignored. + * @param aIsRelative Optional (If true, aOffset is relative to start of + * composition if there is, or start of selection.) + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryTextContent(aOffset, aLength, aIsRelative, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return null; + } + var flags = QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK; + if (aIsRelative === true) { + flags |= QUERY_CONTENT_FLAG_OFFSET_RELATIVE_TO_INSERTION_POINT; + } + return utils.sendQueryContentEvent( + utils.QUERY_TEXT_CONTENT, + aOffset, + aLength, + 0, + 0, + flags + ); +} + +/** + * Synthesize a query selected text event. + * + * @param aSelectionType Optional, one of QUERY_CONTENT_FLAG_SELECTION_*. + * If null, QUERY_CONTENT_FLAG_SELECTION_NORMAL will + * be used. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQuerySelectedText(aSelectionType, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + var flags = QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK; + if (aSelectionType) { + flags |= aSelectionType; + } + + return utils.sendQueryContentEvent( + utils.QUERY_SELECTED_TEXT, + 0, + 0, + 0, + 0, + flags + ); +} + +/** + * Synthesize a query caret rect event. + * + * @param aOffset The caret offset. 0 means left side of the first character + * in the selection root. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryCaretRect(aOffset, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return null; + } + return utils.sendQueryContentEvent( + utils.QUERY_CARET_RECT, + aOffset, + 0, + 0, + 0, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * Synthesize a selection set event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of the text. If the length is too long, + * the extra length is ignored. + * @param aReverse If true, the selection is from |aOffset + aLength| to + * |aOffset|. Otherwise, from |aOffset| to |aOffset + aLength|. + * @param aWindow Optional (If null, current |window| will be used) + * @return True, if succeeded. Otherwise false. + */ +function synthesizeSelectionSet(aOffset, aLength, aReverse, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return false; + } + var flags = aReverse ? SELECTION_SET_FLAG_REVERSE : 0; + return utils.sendSelectionSetEvent(aOffset, aLength, flags); +} + +/** + * Synthesize a query text rect event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of the text. If the length is too long, + * the extra length is ignored. + * @param aIsRelative Optional (If true, aOffset is relative to start of + * composition if there is, or start of selection.) + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryTextRect(aOffset, aLength, aIsRelative, aWindow) { + if (aIsRelative !== undefined && typeof aIsRelative !== "boolean") { + throw new Error( + "Maybe, you set Window object to the 3rd argument, but it should be a boolean value" + ); + } + var utils = _getDOMWindowUtils(aWindow); + let flags = QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK; + if (aIsRelative === true) { + flags |= QUERY_CONTENT_FLAG_OFFSET_RELATIVE_TO_INSERTION_POINT; + } + return utils.sendQueryContentEvent( + utils.QUERY_TEXT_RECT, + aOffset, + aLength, + 0, + 0, + flags + ); +} + +/** + * Synthesize a query text rect array event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of the text. If the length is too long, + * the extra length is ignored. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryTextRectArray(aOffset, aLength, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + return utils.sendQueryContentEvent( + utils.QUERY_TEXT_RECT_ARRAY, + aOffset, + aLength, + 0, + 0, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * Synthesize a query editor rect event. + * + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryEditorRect(aWindow) { + var utils = _getDOMWindowUtils(aWindow); + return utils.sendQueryContentEvent( + utils.QUERY_EDITOR_RECT, + 0, + 0, + 0, + 0, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * Synthesize a character at point event. + * + * @param aX, aY The offset in the client area of the DOM window. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeCharAtPoint(aX, aY, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + return utils.sendQueryContentEvent( + utils.QUERY_CHARACTER_AT_POINT, + 0, + 0, + aX, + aY, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * INTERNAL USE ONLY + * Create an event object to pass to sendDragEvent. + * + * @param aType The string represents drag event type. + * @param aDestElement The element to fire the drag event, used to calculate + * screenX/Y and clientX/Y. + * @param aDestWindow Optional; Defaults to the current window object. + * @param aDataTransfer dataTransfer for current drag session. + * @param aDragEvent The object contains properties to override the event + * object + * @return An object to pass to sendDragEvent. + */ +function createDragEventObject( + aType, + aDestElement, + aDestWindow, + aDataTransfer, + aDragEvent +) { + var destRect = aDestElement.getBoundingClientRect(); + var destClientX = destRect.left + destRect.width / 2; + var destClientY = destRect.top + destRect.height / 2; + var destScreenX = aDestWindow.mozInnerScreenX + destClientX; + var destScreenY = aDestWindow.mozInnerScreenY + destClientY; + if ("clientX" in aDragEvent && !("screenX" in aDragEvent)) { + aDragEvent.screenX = aDestWindow.mozInnerScreenX + aDragEvent.clientX; + } + if ("clientY" in aDragEvent && !("screenY" in aDragEvent)) { + aDragEvent.screenY = aDestWindow.mozInnerScreenY + aDragEvent.clientY; + } + + // Wrap only in plain mochitests + let dataTransfer; + if (aDataTransfer) { + dataTransfer = _EU_maybeUnwrap( + _EU_maybeWrap(aDataTransfer).mozCloneForEvent(aType) + ); + + // Copy over the drop effect. This isn't copied over by Clone, as it uses + // more complex logic in the actual implementation (see + // nsContentUtils::SetDataTransferInEvent for actual impl). + dataTransfer.dropEffect = aDataTransfer.dropEffect; + } + + return Object.assign( + { + type: aType, + screenX: destScreenX, + screenY: destScreenY, + clientX: destClientX, + clientY: destClientY, + dataTransfer, + _domDispatchOnly: aDragEvent._domDispatchOnly, + }, + aDragEvent + ); +} + +/** + * Emulate a event sequence of dragstart, dragenter, and dragover. + * + * @param {Element} aSrcElement + * The element to use to start the drag. + * @param {Element} aDestElement + * The element to fire the dragover, dragenter events + * @param {Array} aDragData + * The data to supply for the data transfer. + * This data is in the format: + * + * [ + * [ + * {"type": value, "data": value }, + * ..., + * ], + * ... + * ] + * + * Pass null to avoid modifying dataTransfer. + * @param {String} [aDropEffect="move"] + * The drop effect to set during the dragstart event, or 'move' if omitted. + * @param {Window} [aWindow=window] + * The window in which the drag happens. Defaults to the window in which + * EventUtils.js is loaded. + * @param {Window} [aDestWindow=aWindow] + * Used when aDestElement is in a different window than aSrcElement. + * Default is to match ``aWindow``. + * @param {Object} [aDragEvent={}] + * Defaults to empty object. Overwrites an object passed to sendDragEvent. + * @return {Array} + * A two element array, where the first element is the value returned + * from sendDragEvent for dragover event, and the second element is the + * dataTransfer for the current drag session. + */ +function synthesizeDragOver( + aSrcElement, + aDestElement, + aDragData, + aDropEffect, + aWindow, + aDestWindow, + aDragEvent = {} +) { + if (!aWindow) { + aWindow = window; + } + if (!aDestWindow) { + aDestWindow = aWindow; + } + + // eslint-disable-next-line mozilla/use-services + const obs = _EU_Cc["@mozilla.org/observer-service;1"].getService( + _EU_Ci.nsIObserverService + ); + const ds = _EU_Cc["@mozilla.org/widget/dragservice;1"].getService( + _EU_Ci.nsIDragService + ); + var sess = ds.getCurrentSession(); + + // This method runs before other callbacks, and acts as a way to inject the + // initial drag data into the DataTransfer. + function fillDrag(event) { + if (aDragData) { + for (var i = 0; i < aDragData.length; i++) { + var item = aDragData[i]; + for (var j = 0; j < item.length; j++) { + _EU_maybeWrap(event.dataTransfer).mozSetDataAt( + item[j].type, + item[j].data, + i + ); + } + } + } + event.dataTransfer.dropEffect = aDropEffect || "move"; + event.preventDefault(); + } + + function trapDrag(subject, topic) { + if (topic == "on-datatransfer-available") { + sess.dataTransfer = _EU_maybeUnwrap( + _EU_maybeWrap(subject).mozCloneForEvent("drop") + ); + sess.dataTransfer.dropEffect = subject.dropEffect; + } + } + + // need to use real mouse action + aWindow.addEventListener("dragstart", fillDrag, true); + obs.addObserver(trapDrag, "on-datatransfer-available"); + synthesizeMouseAtCenter(aSrcElement, { type: "mousedown" }, aWindow); + + var rect = aSrcElement.getBoundingClientRect(); + var x = rect.width / 2; + var y = rect.height / 2; + synthesizeMouse(aSrcElement, x, y, { type: "mousemove" }, aWindow); + synthesizeMouse(aSrcElement, x + 10, y + 10, { type: "mousemove" }, aWindow); + aWindow.removeEventListener("dragstart", fillDrag, true); + obs.removeObserver(trapDrag, "on-datatransfer-available"); + + var dataTransfer = sess.dataTransfer; + if (!dataTransfer) { + throw new Error("No data transfer object after synthesizing the mouse!"); + } + + // The EventStateManager will fire our dragenter event if it needs to. + var event = createDragEventObject( + "dragover", + aDestElement, + aDestWindow, + dataTransfer, + aDragEvent + ); + var result = sendDragEvent(event, aDestElement, aDestWindow); + + return [result, dataTransfer]; +} + +/** + * Emulate the drop event and mouseup event. + * This should be called after synthesizeDragOver. + * + * @param {*} aResult + * The first element of the array returned from ``synthesizeDragOver``. + * @param {DataTransfer} aDataTransfer + * The second element of the array returned from ``synthesizeDragOver``. + * @param {Element} aDestElement + * The element on which to fire the drop event. + * @param {Window} [aDestWindow=window] + * The window in which the drop happens. Defaults to the window in which + * EventUtils.js is loaded. + * @param {Object} [aDragEvent={}] + * Defaults to empty object. Overwrites an object passed to sendDragEvent. + * @return {String} + * "none" if aResult is true, ``aDataTransfer.dropEffect`` otherwise. + */ +function synthesizeDropAfterDragOver( + aResult, + aDataTransfer, + aDestElement, + aDestWindow, + aDragEvent = {} +) { + if (!aDestWindow) { + aDestWindow = window; + } + + var effect = aDataTransfer.dropEffect; + var event; + + if (aResult) { + effect = "none"; + } else if (effect != "none") { + event = createDragEventObject( + "drop", + aDestElement, + aDestWindow, + aDataTransfer, + aDragEvent + ); + sendDragEvent(event, aDestElement, aDestWindow); + } + synthesizeMouse(aDestElement, 2, 2, { type: "mouseup" }, aDestWindow); + + return effect; +} + +/** + * Emulate a drag and drop by emulating a dragstart and firing events dragenter, + * dragover, and drop. + * + * @param {Element} aSrcElement + * The element to use to start the drag. + * @param {Element} aDestElement + * The element to fire the dragover, dragenter events + * @param {Array} aDragData + * The data to supply for the data transfer. + * This data is in the format: + * + * [ + * [ + * {"type": value, "data": value }, + * ..., + * ], + * ... + * ] + * + * Pass null to avoid modifying dataTransfer. + * @param {String} [aDropEffect="move"] + * The drop effect to set during the dragstart event, or 'move' if omitted.. + * @param {Window} [aWindow=window] + * The window in which the drag happens. Defaults to the window in which + * EventUtils.js is loaded. + * @param {Window} [aDestWindow=aWindow] + * Used when aDestElement is in a different window than aSrcElement. + * Default is to match ``aWindow``. + * @param {Object} [aDragEvent={}] + * Defaults to empty object. Overwrites an object passed to sendDragEvent. + * @return {String} + * The drop effect that was desired. + */ +function synthesizeDrop( + aSrcElement, + aDestElement, + aDragData, + aDropEffect, + aWindow, + aDestWindow, + aDragEvent = {} +) { + if (!aWindow) { + aWindow = window; + } + if (!aDestWindow) { + aDestWindow = aWindow; + } + + var ds = _EU_Cc["@mozilla.org/widget/dragservice;1"].getService( + _EU_Ci.nsIDragService + ); + + let dropAction; + switch (aDropEffect) { + case null: + case undefined: + case "move": + dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_MOVE; + break; + case "copy": + dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_COPY; + break; + case "link": + dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_LINK; + break; + default: + throw new Error(`${aDropEffect} is an invalid drop effect value`); + } + + ds.startDragSessionForTests(dropAction); + + try { + var [result, dataTransfer] = synthesizeDragOver( + aSrcElement, + aDestElement, + aDragData, + aDropEffect, + aWindow, + aDestWindow, + aDragEvent + ); + return synthesizeDropAfterDragOver( + result, + dataTransfer, + aDestElement, + aDestWindow, + aDragEvent + ); + } finally { + ds.endDragSession(true, _parseModifiers(aDragEvent)); + } +} + +function _computeSrcElementFromSrcSelection(aSrcSelection) { + let srcElement = aSrcSelection.focusNode; + while (_EU_maybeWrap(srcElement).isNativeAnonymous) { + srcElement = _EU_maybeUnwrap( + _EU_maybeWrap(srcElement).flattenedTreeParentNode + ); + } + if (srcElement.nodeType !== Node.NODE_TYPE_ELEMENT) { + srcElement = srcElement.parentElement; + } + return srcElement; +} + +/** + * Emulate a drag and drop by emulating a dragstart by mousedown and mousemove, + * and firing events dragenter, dragover, drop, and dragend. + * This does not modify dataTransfer and tries to emulate the plain drag and + * drop as much as possible, compared to synthesizeDrop. + * Note that if synthesized dragstart is canceled, this throws an exception + * because in such case, Gecko does not start drag session. + * + * @param {Object} aParams + * @param {Event} aParams.dragEvent + * The DnD events will be generated with modifiers specified with this. + * @param {Element} aParams.srcElement + * The element to start dragging. If srcSelection is + * set, this is computed for element at focus node. + * @param {Selection|nil} aParams.srcSelection + * The selection to start to drag, set null if srcElement is set. + * @param {Element|nil} aParams.destElement + * The element to drop on. Pass null to emulate a drop on an invalid target. + * @param {Number} aParams.srcX + * The initial x coordinate inside srcElement or ignored if srcSelection is set. + * @param {Number} aParams.srcY + * The initial y coordinate inside srcElement or ignored if srcSelection is set. + * @param {Number} aParams.stepX + * The x-axis step for mousemove inside srcElement + * @param {Number} aParams.stepY + * The y-axis step for mousemove inside srcElement + * @param {Number} aParams.finalX + * The final x coordinate inside srcElement + * @param {Number} aParams.finalY + * The final x coordinate inside srcElement + * @param {Any} aParams.id + * The pointer event id + * @param {Window} aParams.srcWindow + * The window for dispatching event on srcElement, defaults to the current window object. + * @param {Window} aParams.destWindow + * The window for dispatching event on destElement, defaults to the current window object. + * @param {Boolean} aParams.expectCancelDragStart + * Set to true if the test cancels "dragstart" + * @param {Boolean} aParams.expectSrcElementDisconnected + * Set to true if srcElement will be disconnected and + * "dragend" event won't be fired. + * @param {Function} aParams.logFunc + * Set function which takes one argument if you need to log rect of target. E.g., `console.log`. + */ +// eslint-disable-next-line complexity +async function synthesizePlainDragAndDrop(aParams) { + let { + dragEvent = {}, + srcElement, + srcSelection, + destElement, + srcX = 2, + srcY = 2, + stepX = 9, + stepY = 9, + finalX = srcX + stepX * 2, + finalY = srcY + stepY * 2, + id = _getDOMWindowUtils(window).DEFAULT_MOUSE_POINTER_ID, + srcWindow = window, + destWindow = window, + expectCancelDragStart = false, + expectSrcElementDisconnected = false, + logFunc, + } = aParams; + // Don't modify given dragEvent object because we modify dragEvent below and + // callers may use the object multiple times so that callers must not assume + // that it'll be modified. + if (aParams.dragEvent !== undefined) { + dragEvent = Object.assign({}, aParams.dragEvent); + } + + function rectToString(aRect) { + return `left: ${aRect.left}, top: ${aRect.top}, right: ${aRect.right}, bottom: ${aRect.bottom}`; + } + + if (logFunc) { + logFunc("synthesizePlainDragAndDrop() -- START"); + } + + if (srcSelection) { + srcElement = _computeSrcElementFromSrcSelection(srcSelection); + let srcElementRect = srcElement.getBoundingClientRect(); + if (logFunc) { + logFunc( + `srcElement.getBoundingClientRect(): ${rectToString(srcElementRect)}` + ); + } + // Use last selection client rect because nsIDragSession.sourceNode is + // initialized from focus node which is usually in last rect. + let selectionRectList = srcSelection.getRangeAt(0).getClientRects(); + let lastSelectionRect = selectionRectList[selectionRectList.length - 1]; + if (logFunc) { + logFunc( + `srcSelection.getRangeAt(0).getClientRects()[${selectionRectList.length - + 1}]: ${rectToString(lastSelectionRect)}` + ); + } + // Click at center of last selection rect. + srcX = Math.floor(lastSelectionRect.left + lastSelectionRect.width / 2); + srcY = Math.floor(lastSelectionRect.top + lastSelectionRect.height / 2); + // Then, adjust srcX and srcY for making them offset relative to + // srcElementRect because they will be used when we call synthesizeMouse() + // with srcElement. + srcX = Math.floor(srcX - srcElementRect.left); + srcY = Math.floor(srcY - srcElementRect.top); + // Finally, recalculate finalX and finalY with new srcX and srcY if they + // are not specified by the caller. + if (aParams.finalX === undefined) { + finalX = srcX + stepX * 2; + } + if (aParams.finalY === undefined) { + finalY = srcY + stepY * 2; + } + } else if (logFunc) { + logFunc( + `srcElement.getBoundingClientRect(): ${rectToString( + srcElement.getBoundingClientRect() + )}` + ); + } + + const ds = _EU_Cc["@mozilla.org/widget/dragservice;1"].getService( + _EU_Ci.nsIDragService + ); + + try { + _getDOMWindowUtils(srcWindow).disableNonTestMouseEvents(true); + + await new Promise(r => setTimeout(r, 0)); + + let mouseDownEvent; + function onMouseDown(aEvent) { + mouseDownEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !srcElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + // If srcX and srcY does not point in one of rects in srcElement, + // "mousedown" target is not in srcElement. Such case must not + // be expected by this API users so that we should throw an exception + // for making debugging easier. + throw new Error( + 'event target of "mousedown" is not srcElement nor its descendant' + ); + } + } + try { + srcWindow.addEventListener("mousedown", onMouseDown, { capture: true }); + synthesizeMouse( + srcElement, + srcX, + srcY, + { type: "mousedown", id }, + srcWindow + ); + if (logFunc) { + logFunc(`mousedown at ${srcX}, ${srcY}`); + } + if (!mouseDownEvent) { + throw new Error('"mousedown" event is not fired'); + } + } finally { + srcWindow.removeEventListener("mousedown", onMouseDown, { + capture: true, + }); + } + + let dragStartEvent; + function onDragStart(aEvent) { + dragStartEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !srcElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + // If srcX and srcY does not point in one of rects in srcElement, + // "dragstart" target is not in srcElement. Such case must not + // be expected by this API users so that we should throw an exception + // for making debugging easier. + throw new Error( + 'event target of "dragstart" is not srcElement nor its descendant' + ); + } + } + let dragEnterEvent; + function onDragEnterGenerated(aEvent) { + dragEnterEvent = aEvent; + } + srcWindow.addEventListener("dragstart", onDragStart, { capture: true }); + srcWindow.addEventListener("dragenter", onDragEnterGenerated, { + capture: true, + }); + try { + // Wait for the next event tick after each event dispatch, so that UI + // elements (e.g. menu) work like the real user input. + await new Promise(r => setTimeout(r, 0)); + + srcX += stepX; + srcY += stepY; + synthesizeMouse( + srcElement, + srcX, + srcY, + { type: "mousemove", id }, + srcWindow + ); + if (logFunc) { + logFunc(`first mousemove at ${srcX}, ${srcY}`); + } + + await new Promise(r => setTimeout(r, 0)); + + srcX += stepX; + srcY += stepY; + synthesizeMouse( + srcElement, + srcX, + srcY, + { type: "mousemove", id }, + srcWindow + ); + if (logFunc) { + logFunc(`second mousemove at ${srcX}, ${srcY}`); + } + + await new Promise(r => setTimeout(r, 0)); + + if (!dragStartEvent) { + throw new Error('"dragstart" event is not fired'); + } + } finally { + srcWindow.removeEventListener("dragstart", onDragStart, { + capture: true, + }); + srcWindow.removeEventListener("dragenter", onDragEnterGenerated, { + capture: true, + }); + } + + let session = ds.getCurrentSession(); + if (!session) { + if (expectCancelDragStart) { + synthesizeMouse( + srcElement, + finalX, + finalY, + { type: "mouseup", id }, + srcWindow + ); + return; + } + throw new Error("drag hasn't been started by the operation"); + } else if (expectCancelDragStart) { + throw new Error("drag has been started by the operation"); + } + + if (destElement) { + if ( + (srcElement != destElement && !dragEnterEvent) || + destElement != dragEnterEvent.target + ) { + if (logFunc) { + logFunc( + `destElement.getBoundingClientRect(): ${rectToString( + destElement.getBoundingClientRect() + )}` + ); + } + + function onDragEnter(aEvent) { + dragEnterEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if (aEvent.target != destElement) { + throw new Error('event target of "dragenter" is not destElement'); + } + } + destWindow.addEventListener("dragenter", onDragEnter, { + capture: true, + }); + try { + let event = createDragEventObject( + "dragenter", + destElement, + destWindow, + null, + dragEvent + ); + sendDragEvent(event, destElement, destWindow); + if (!dragEnterEvent && !destElement.disabled) { + throw new Error('"dragenter" event is not fired'); + } + if (dragEnterEvent && destElement.disabled) { + throw new Error( + '"dragenter" event should not be fired on disable element' + ); + } + } finally { + destWindow.removeEventListener("dragenter", onDragEnter, { + capture: true, + }); + } + } + + let dragOverEvent; + function onDragOver(aEvent) { + dragOverEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if (aEvent.target != destElement) { + throw new Error('event target of "dragover" is not destElement'); + } + } + destWindow.addEventListener("dragover", onDragOver, { capture: true }); + try { + // dragover and drop are only fired to a valid drop target. If the + // destElement parameter is null, this function is being used to + // simulate a drag'n'drop over an invalid drop target. + let event = createDragEventObject( + "dragover", + destElement, + destWindow, + null, + dragEvent + ); + sendDragEvent(event, destElement, destWindow); + if (!dragOverEvent && !destElement.disabled) { + throw new Error('"dragover" event is not fired'); + } + if (dragEnterEvent && destElement.disabled) { + throw new Error( + '"dragover" event should not be fired on disable element' + ); + } + } finally { + destWindow.removeEventListener("dragover", onDragOver, { + capture: true, + }); + } + + await new Promise(r => setTimeout(r, 0)); + + // If there is not accept to drop the data, "drop" event shouldn't be + // fired. + // XXX nsIDragSession.canDrop is different only on Linux. It must be + // a bug of gtk/nsDragService since it manages `mCanDrop` by itself. + // Thus, we should use nsIDragSession.dragAction instead. + if (session.dragAction != _EU_Ci.nsIDragService.DRAGDROP_ACTION_NONE) { + let dropEvent; + function onDrop(aEvent) { + dropEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !destElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + throw new Error( + 'event target of "drop" is not destElement nor its descendant' + ); + } + } + destWindow.addEventListener("drop", onDrop, { capture: true }); + try { + let event = createDragEventObject( + "drop", + destElement, + destWindow, + null, + dragEvent + ); + sendDragEvent(event, destElement, destWindow); + if (!dropEvent && session.canDrop) { + throw new Error('"drop" event is not fired'); + } + } finally { + destWindow.removeEventListener("drop", onDrop, { capture: true }); + } + return; + } + } + + // Since we don't synthesize drop event, we need to set drag end point + // explicitly for "dragEnd" event which will be fired by + // endDragSession(). + dragEvent.clientX = finalX; + dragEvent.clientY = finalY; + let event = createDragEventObject( + "dragend", + destElement || srcElement, + destElement ? srcWindow : destWindow, + null, + dragEvent + ); + session.setDragEndPointForTests(event.screenX, event.screenY); + } finally { + await new Promise(r => setTimeout(r, 0)); + + if (ds.getCurrentSession()) { + const sourceNode = ds.sourceNode; + let dragEndEvent; + function onDragEnd(aEvent) { + dragEndEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !srcElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + throw new Error( + 'event target of "dragend" is not srcElement nor its descendant' + ); + } + if (expectSrcElementDisconnected) { + throw new Error( + `"dragend" event shouldn't be fired when the source node is disconnected (the source node is ${ + sourceNode?.isConnected ? "connected" : "null or disconnected" + })` + ); + } + } + srcWindow.addEventListener("dragend", onDragEnd, { capture: true }); + try { + ds.endDragSession(true, _parseModifiers(dragEvent)); + if (!expectSrcElementDisconnected && !dragEndEvent) { + // eslint-disable-next-line no-unsafe-finally + throw new Error( + `"dragend" event is not fired by nsIDragService.endDragSession()${ + ds.sourceNode && !ds.sourceNode.isConnected + ? "(sourceNode was disconnected)" + : "" + }` + ); + } + } finally { + srcWindow.removeEventListener("dragend", onDragEnd, { capture: true }); + } + } + _getDOMWindowUtils(srcWindow).disableNonTestMouseEvents(false); + if (logFunc) { + logFunc("synthesizePlainDragAndDrop() -- END"); + } + } +} + +function _checkDataTransferItems(aDataTransfer, aExpectedDragData) { + try { + // We must wrap only in plain mochitests, not chrome + let dataTransfer = _EU_maybeWrap(aDataTransfer); + if (!dataTransfer) { + return null; + } + if ( + aExpectedDragData == null || + dataTransfer.mozItemCount != aExpectedDragData.length + ) { + return dataTransfer; + } + for (let i = 0; i < dataTransfer.mozItemCount; i++) { + let dtTypes = dataTransfer.mozTypesAt(i); + if (dtTypes.length != aExpectedDragData[i].length) { + return dataTransfer; + } + for (let j = 0; j < dtTypes.length; j++) { + if (dtTypes[j] != aExpectedDragData[i][j].type) { + return dataTransfer; + } + let dtData = dataTransfer.mozGetDataAt(dtTypes[j], i); + if (aExpectedDragData[i][j].eqTest) { + if ( + !aExpectedDragData[i][j].eqTest( + dtData, + aExpectedDragData[i][j].data + ) + ) { + return dataTransfer; + } + } else if (aExpectedDragData[i][j].data != dtData) { + return dataTransfer; + } + } + } + } catch (ex) { + return ex; + } + return true; +} + +/** + * This callback type is used with ``synthesizePlainDragAndCancel()``. + * It should compare ``actualData`` and ``expectedData`` and return + * true if the two should be considered equal, false otherwise. + * + * @callback eqTest + * @param {*} actualData + * @param {*} expectedData + * @return {boolean} + */ + +/** + * synthesizePlainDragAndCancel() synthesizes drag start with + * synthesizePlainDragAndDrop(), but always cancel it with preventing default + * of "dragstart". Additionally, this checks whether the dataTransfer of + * "dragstart" event has only expected items. + * + * @param {Object} aParams + * The params which is set to the argument of ``synthesizePlainDragAndDrop()``. + * @param {Array} aExpectedDataTransferItems + * All expected dataTransfer items. + * This data is in the format: + * + * [ + * [ + * {"type": value, "data": value, eqTest: function} + * ..., + * ], + * ... + * ] + * + * This can also be null. + * You can optionally provide ``eqTest`` {@type eqTest} if the + * comparison to the expected data transfer items can't be done + * with x == y; + * @return {boolean} + * true if aExpectedDataTransferItems matches with + * DragEvent.dataTransfer of "dragstart" event. + * Otherwise, the dataTransfer object (may be null) or + * thrown exception, NOT false. Therefore, you shouldn't + * use. + */ +async function synthesizePlainDragAndCancel( + aParams, + aExpectedDataTransferItems +) { + let srcElement = aParams.srcSelection + ? _computeSrcElementFromSrcSelection(aParams.srcSelection) + : aParams.srcElement; + let result; + function onDragStart(aEvent) { + aEvent.preventDefault(); + result = _checkDataTransferItems( + aEvent.dataTransfer, + aExpectedDataTransferItems + ); + } + SpecialPowers.addSystemEventListener( + srcElement.ownerDocument, + "dragstart", + onDragStart, + { capture: true } + ); + try { + aParams.expectCancelDragStart = true; + await synthesizePlainDragAndDrop(aParams); + } finally { + SpecialPowers.removeSystemEventListener( + srcElement.ownerDocument, + "dragstart", + onDragStart, + { capture: true } + ); + } + return result; +} + +class EventCounter { + constructor(aTarget, aType, aOptions = {}) { + this.target = aTarget; + this.type = aType; + this.options = aOptions; + + this.eventCount = 0; + // Bug 1512817: + // SpecialPowers is picky and needs to be passed an explicit reference to + // the function to be called. To avoid having to bind "this", we therefore + // define the method this way, via a property. + this.handleEvent = aEvent => { + this.eventCount++; + }; + + if (aOptions.mozSystemGroup) { + SpecialPowers.addSystemEventListener( + aTarget, + aType, + this.handleEvent, + aOptions.capture + ); + } else { + aTarget.addEventListener(aType, this, aOptions); + } + } + + unregister() { + if (this.options.mozSystemGroup) { + SpecialPowers.removeSystemEventListener( + this.target, + this.type, + this.handleEvent, + this.options.capture + ); + } else { + this.target.removeEventListener(this.type, this, this.options); + } + } + + get count() { + return this.eventCount; + } +} diff --git a/testing/mochitest/tests/SimpleTest/ExtensionTestUtils.js b/testing/mochitest/tests/SimpleTest/ExtensionTestUtils.js new file mode 100644 index 0000000000..9b96bc8265 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/ExtensionTestUtils.js @@ -0,0 +1,180 @@ +const { ExtensionTestCommon } = SpecialPowers.ChromeUtils.import( + "resource://testing-common/ExtensionTestCommon.jsm" +); + +var ExtensionTestUtils = { + // Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled + // from mochitest-plain tests. + getBackgroundServiceWorkerEnabled() { + return ExtensionTestCommon.getBackgroundServiceWorkerEnabled(); + }, + + // A test helper used to check if the pref "extension.backgroundServiceWorker.forceInTestExtension" + // is set to true. + isInBackgroundServiceWorkerTests() { + return ExtensionTestCommon.isInBackgroundServiceWorkerTests(); + }, + + get testAssertions() { + return ExtensionTestCommon.testAssertions; + }, +}; + +ExtensionTestUtils.loadExtension = function(ext) { + // Cleanup functions need to be registered differently depending on + // whether we're in browser chrome or plain mochitests. + var registerCleanup; + /* global registerCleanupFunction */ + if (typeof registerCleanupFunction != "undefined") { + registerCleanup = registerCleanupFunction; + } else { + registerCleanup = SimpleTest.registerCleanupFunction.bind(SimpleTest); + } + + var testResolve; + var testDone = new Promise(resolve => { + testResolve = resolve; + }); + + var messageHandler = new Map(); + var messageAwaiter = new Map(); + + var messageQueue = new Set(); + + registerCleanup(() => { + if (messageQueue.size) { + let names = Array.from(messageQueue, ([msg]) => msg); + SimpleTest.is(JSON.stringify(names), "[]", "message queue is empty"); + } + if (messageAwaiter.size) { + let names = Array.from(messageAwaiter.keys()); + SimpleTest.is( + JSON.stringify(names), + "[]", + "no tasks awaiting on messages" + ); + } + }); + + function checkMessages() { + for (let message of messageQueue) { + let [msg, ...args] = message; + + let listener = messageAwaiter.get(msg); + if (listener) { + messageQueue.delete(message); + messageAwaiter.delete(msg); + + listener.resolve(...args); + return; + } + } + } + + function checkDuplicateListeners(msg) { + if (messageHandler.has(msg) || messageAwaiter.has(msg)) { + throw new Error("only one message handler allowed"); + } + } + + function testHandler(kind, pass, msg, ...args) { + if (kind == "test-eq") { + let [expected, actual] = args; + SimpleTest.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`); + } else if (kind == "test-log") { + SimpleTest.info(msg); + } else if (kind == "test-result") { + SimpleTest.ok(pass, msg); + } + } + + var handler = { + async testResult(kind, pass, msg, ...args) { + if (kind == "test-done") { + SimpleTest.ok(pass, msg); + await testResolve(msg); + } + testHandler(kind, pass, msg, ...args); + }, + + testMessage(msg, ...args) { + var msgHandler = messageHandler.get(msg); + if (msgHandler) { + msgHandler(...args); + } else { + messageQueue.add([msg, ...args]); + checkMessages(); + } + }, + }; + + // Mimic serialization of functions as done in `Extension.generateXPI` and + // `Extension.generateZipFile` because functions are dropped when `ext` object + // is sent to the main process via the message manager. + ext = Object.assign({}, ext); + if (ext.files) { + ext.files = Object.assign({}, ext.files); + for (let filename of Object.keys(ext.files)) { + let file = ext.files[filename]; + if (typeof file === "function" || Array.isArray(file)) { + ext.files[filename] = ExtensionTestCommon.serializeScript(file); + } + } + } + if ("background" in ext) { + ext.background = ExtensionTestCommon.serializeScript(ext.background); + } + + var extension = SpecialPowers.loadExtension(ext, handler); + + registerCleanup(async () => { + if (extension.state == "pending" || extension.state == "running") { + SimpleTest.ok(false, "Extension left running at test shutdown"); + await extension.unload(); + } else if (extension.state == "unloading") { + SimpleTest.ok(false, "Extension not fully unloaded at test shutdown"); + } + }); + + extension.awaitMessage = msg => { + return new Promise(resolve => { + checkDuplicateListeners(msg); + + messageAwaiter.set(msg, { resolve }); + checkMessages(); + }); + }; + + extension.onMessage = (msg, callback) => { + checkDuplicateListeners(msg); + messageHandler.set(msg, callback); + }; + + extension.awaitFinish = msg => { + return testDone.then(actual => { + if (msg) { + SimpleTest.is(actual, msg, "test result correct"); + } + return actual; + }); + }; + + SimpleTest.info(`Extension loaded`); + return extension; +}; + +ExtensionTestUtils.failOnSchemaWarnings = (warningsAsErrors = true) => { + let prefName = "extensions.webextensions.warnings-as-errors"; + let prefPromise = SpecialPowers.setBoolPref(prefName, warningsAsErrors); + if (!warningsAsErrors) { + let registerCleanup; + if (typeof registerCleanupFunction != "undefined") { + registerCleanup = registerCleanupFunction; + } else { + registerCleanup = SimpleTest.registerCleanupFunction.bind(SimpleTest); + } + registerCleanup(() => SpecialPowers.setBoolPref(prefName, true)); + } + // In mochitests, setBoolPref is async. + return prefPromise.then(() => {}); +}; diff --git a/testing/mochitest/tests/SimpleTest/LogController.js b/testing/mochitest/tests/SimpleTest/LogController.js new file mode 100644 index 0000000000..059abdcd9b --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/LogController.js @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var LogController = {}; //create the logger object + +LogController.counter = 0; //current log message number +LogController.listeners = []; +LogController.logLevel = { + FATAL: 50, + ERROR: 40, + WARNING: 30, + INFO: 20, + DEBUG: 10, +}; + +/* set minimum logging level */ +LogController.logLevelAtLeast = function(minLevel) { + if (typeof minLevel == "string") { + minLevel = LogController.logLevel[minLevel]; + } + return function(msg) { + var msgLevel = msg.level; + if (typeof msgLevel == "string") { + msgLevel = LogController.logLevel[msgLevel]; + } + return msgLevel >= minLevel; + }; +}; + +/* creates the log message with the given level and info */ +LogController.createLogMessage = function(level, info) { + var msg = {}; + msg.num = LogController.counter; + msg.level = level; + msg.info = info; + msg.timestamp = new Date(); + return msg; +}; + +/* helper method to return a sub-array */ +LogController.extend = function(args, skip) { + var ret = []; + for (var i = skip; i < args.length; i++) { + ret.push(args[i]); + } + return ret; +}; + +/* logs message with given level. Currently used locally by log() and error() */ +LogController.logWithLevel = function(level, message /*, ...*/) { + var msg = LogController.createLogMessage( + level, + LogController.extend(arguments, 1) + ); + LogController.dispatchListeners(msg); + LogController.counter += 1; +}; + +/* log with level INFO */ +LogController.log = function(message /*, ...*/) { + LogController.logWithLevel("INFO", message); +}; + +/* log with level ERROR */ +LogController.error = function(message /*, ...*/) { + LogController.logWithLevel("ERROR", message); +}; + +/* send log message to listeners */ +LogController.dispatchListeners = function(msg) { + for (var k in LogController.listeners) { + var pair = LogController.listeners[k]; + if (pair.ident != k || (pair[0] && !pair[0](msg))) { + continue; + } + pair[1](msg); + } +}; + +/* add a listener to this log given an identifier, a filter (can be null) and the listener object */ +LogController.addListener = function(ident, filter, listener) { + if (typeof filter == "string") { + filter = LogController.logLevelAtLeast(filter); + } else if (filter !== null && typeof filter !== "function") { + throw new Error("Filter must be a string, a function, or null"); + } + var entry = [filter, listener]; + entry.ident = ident; + LogController.listeners[ident] = entry; +}; + +/* remove a listener from this log */ +LogController.removeListener = function(ident) { + delete LogController.listeners[ident]; +}; diff --git a/testing/mochitest/tests/SimpleTest/MemoryStats.js b/testing/mochitest/tests/SimpleTest/MemoryStats.js new file mode 100644 index 0000000000..eb319b2e76 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/MemoryStats.js @@ -0,0 +1,131 @@ +/* -*- js-indent-level: 4; indent-tabs-mode: nil -*- */ +/* vim:set ts=4 sw=4 sts=4 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var MemoryStats = {}; + +/** + * Statistics that we want to retrieve and display after every test is + * done. The keys of this table are intended to be identical to the + * relevant attributes of nsIMemoryReporterManager. However, since + * nsIMemoryReporterManager doesn't necessarily support all these + * statistics in all build configurations, we also use this table to + * tell us whether statistics are supported or not. + */ +var MEM_STAT_UNKNOWN = 0; +var MEM_STAT_UNSUPPORTED = 1; +var MEM_STAT_SUPPORTED = 2; + +MemoryStats._hasMemoryStatistics = {}; +MemoryStats._hasMemoryStatistics.vsize = MEM_STAT_UNKNOWN; +MemoryStats._hasMemoryStatistics.vsizeMaxContiguous = MEM_STAT_UNKNOWN; +MemoryStats._hasMemoryStatistics.residentFast = MEM_STAT_UNKNOWN; +MemoryStats._hasMemoryStatistics.heapAllocated = MEM_STAT_UNKNOWN; + +MemoryStats._getService = function(className, interfaceName) { + var service; + try { + service = Cc[className].getService(Ci[interfaceName]); + } catch (e) { + service = SpecialPowers.Cc[className].getService( + SpecialPowers.Ci[interfaceName] + ); + } + return service; +}; + +MemoryStats._nsIFile = function(pathname) { + var f; + var contractID = "@mozilla.org/file/local;1"; + try { + f = Cc[contractID].createInstance(Ci.nsIFile); + } catch (e) { + f = SpecialPowers.Cc[contractID].createInstance(SpecialPowers.Ci.nsIFile); + } + f.initWithPath(pathname); + return f; +}; + +MemoryStats.constructPathname = function(directory, basename) { + var d = MemoryStats._nsIFile(directory); + d.append(basename); + return d.path; +}; + +MemoryStats.dump = function( + testNumber, + testURL, + dumpOutputDirectory, + dumpAboutMemory, + dumpDMD +) { + // Use dump because treeherder uses --quiet, which drops 'info' + // from the structured logger. + var info = function(message) { + dump(message + "\n"); + }; + + var mrm = MemoryStats._getService( + "@mozilla.org/memory-reporter-manager;1", + "nsIMemoryReporterManager" + ); + var statMessage = ""; + for (var stat in MemoryStats._hasMemoryStatistics) { + var supported = MemoryStats._hasMemoryStatistics[stat]; + var firstAccess = false; + if (supported == MEM_STAT_UNKNOWN) { + firstAccess = true; + try { + void mrm[stat]; + supported = MEM_STAT_SUPPORTED; + } catch (e) { + supported = MEM_STAT_UNSUPPORTED; + } + MemoryStats._hasMemoryStatistics[stat] = supported; + } + if (supported == MEM_STAT_SUPPORTED) { + var sizeInMB = Math.round(mrm[stat] / (1024 * 1024)); + statMessage += " | " + stat + " " + sizeInMB + "MB"; + } else if (firstAccess) { + info( + "MEMORY STAT " + stat + " not supported in this build configuration." + ); + } + } + if (statMessage.length) { + info("MEMORY STAT" + statMessage); + } + + if (dumpAboutMemory) { + var basename = "about-memory-" + testNumber + ".json.gz"; + var dumpfile = MemoryStats.constructPathname(dumpOutputDirectory, basename); + info(testURL + " | MEMDUMP-START " + dumpfile); + let md = MemoryStats._getService( + "@mozilla.org/memory-info-dumper;1", + "nsIMemoryInfoDumper" + ); + md.dumpMemoryReportsToNamedFile( + dumpfile, + function() { + info("TEST-INFO | " + testURL + " | MEMDUMP-END"); + }, + null, + /* anonymize = */ false, + /* minimize memory usage = */ false + ); + } + + if (dumpDMD) { + let md = MemoryStats._getService( + "@mozilla.org/memory-info-dumper;1", + "nsIMemoryInfoDumper" + ); + md.dumpMemoryInfoToTempDir( + String(testNumber), + /* anonymize = */ false, + /* minimize memory usage = */ false + ); + } +}; diff --git a/testing/mochitest/tests/SimpleTest/MockObjects.js b/testing/mochitest/tests/SimpleTest/MockObjects.js new file mode 100644 index 0000000000..5782707c04 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/MockObjects.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Allows registering a mock XPCOM component, that temporarily replaces the + * original one when an object implementing a given ContractID is requested + * using createInstance. + * + * @param aContractID + * The ContractID of the component to replace, for example + * "@mozilla.org/filepicker;1". + * + * @param aReplacementCtor + * The constructor function for the JavaScript object that will be + * created every time createInstance is called. This object must + * implement QueryInterface and provide the XPCOM interfaces required by + * the specified ContractID (for example + * Components.interfaces.nsIFilePicker). + */ + +function MockObjectRegisterer(aContractID, aReplacementCtor) { + this._contractID = aContractID; + this._replacementCtor = aReplacementCtor; +} + +MockObjectRegisterer.prototype = { + /** + * Replaces the current factory with one that returns a new mock object. + * + * After register() has been called, it is mandatory to call unregister() to + * restore the original component. Usually, you should use a try-catch block + * to ensure that unregister() is called. + */ + register: function MOR_register() { + if (this._originalCID) { + throw new Error("Invalid object state when calling register()"); + } + + // Define a factory that creates a new object using the given constructor. + var isChrome = location.protocol == "chrome:"; + var providedConstructor = this._replacementCtor; + this._mockFactory = { + createInstance: function MF_createInstance(aIid) { + var inst = new providedConstructor().QueryInterface(aIid); + if (!isChrome) { + inst = SpecialPowers.wrapCallbackObject(inst); + } + return inst; + }, + }; + if (!isChrome) { + this._mockFactory = SpecialPowers.wrapCallbackObject(this._mockFactory); + } + + var retVal = SpecialPowers.swapFactoryRegistration( + null, + this._contractID, + this._mockFactory + ); + if ("error" in retVal) { + throw new Error("ERROR: " + retVal.error); + } else { + this._originalCID = retVal.originalCID; + } + }, + + /** + * Restores the original factory. + */ + unregister: function MOR_unregister() { + if (!this._originalCID) { + throw new Error("Invalid object state when calling unregister()"); + } + + // Free references to the mock factory. + SpecialPowers.swapFactoryRegistration(this._originalCID, this._contractID); + + // Allow registering a mock factory again later. + this._originalCID = null; + this._mockFactory = null; + }, + + // --- Private methods and properties --- + + /** + * The factory of the component being replaced. + */ + _originalCID: null, + + /** + * The nsIFactory that was automatically generated by this object. + */ + _mockFactory: null, +}; diff --git a/testing/mochitest/tests/SimpleTest/MozillaLogger.js b/testing/mochitest/tests/SimpleTest/MozillaLogger.js new file mode 100644 index 0000000000..07d50572b0 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/MozillaLogger.js @@ -0,0 +1,103 @@ +/** + * MozillaLogger, a base class logger that just logs to stdout. + */ + +"use strict"; + +function formatLogMessage(msg) { + return msg.info.join(" ") + "\n"; +} + +function importMJS(mjs) { + if (typeof ChromeUtils === "object") { + return ChromeUtils.importESModule(mjs); + } + /* globals SpecialPowers */ + return SpecialPowers.ChromeUtils.importESModule(mjs); +} + +// When running in release builds, we get a fake Components object in +// web contexts, so we need to check that the Components object is sane, +// too, not just that it exists. +let haveComponents = + typeof Components === "object" && + typeof Components.Constructor === "function"; + +let CC = (haveComponents + ? Components + : SpecialPowers.wrap(SpecialPowers.Components) +).Constructor; + +let ConverterOutputStream = CC( + "@mozilla.org/intl/converter-output-stream;1", + "nsIConverterOutputStream", + "init" +); + +class MozillaLogger { + get logCallback() { + return msg => { + this.log(formatLogMessage(msg)); + }; + } + + log(msg) { + dump(msg); + } + + close() {} +} + +/** + * MozillaFileLogger, a log listener that can write to a local file. + * intended to be run from chrome space + */ + +/** Init the file logger with the absolute path to the file. + It will create and append if the file already exists **/ +class MozillaFileLogger extends MozillaLogger { + constructor(aPath) { + super(); + + const { FileUtils } = importMJS("resource://gre/modules/FileUtils.sys.mjs"); + + this._file = FileUtils.File(aPath); + this._foStream = FileUtils.openFileOutputStream( + this._file, + FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | FileUtils.MODE_APPEND + ); + + this._converter = ConverterOutputStream(this._foStream, "UTF-8"); + } + + get logCallback() { + return msg => { + if (this._converter) { + var data = formatLogMessage(msg); + this.log(data); + + if (data.includes("SimpleTest FINISH")) { + this.close(); + } + } + }; + } + + log(msg) { + if (this._converter) { + this._converter.writeString(msg); + } + } + + close() { + this._converter.flush(); + this._converter.close(); + + this._foStream = null; + this._converter = null; + this._file = null; + } +} + +this.MozillaLogger = MozillaLogger; +this.MozillaFileLogger = MozillaFileLogger; diff --git a/testing/mochitest/tests/SimpleTest/NativeKeyCodes.js b/testing/mochitest/tests/SimpleTest/NativeKeyCodes.js new file mode 100644 index 0000000000..b256269e71 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/NativeKeyCodes.js @@ -0,0 +1,369 @@ +/** + * This file defines all virtual keycodes for synthesizeNativeKey() of + * EventUtils.js and nsIDOMWindowUtils.sendNativeKeyEvent(). + * These values are defined in each platform's SDK or documents. + */ + +// Windows +// Windows' native key code values may include scan code value which can be +// retrieved with |((code & 0xFFFF0000 >> 16)|. If the value is 0, it will +// be computed with active keyboard layout automatically. +// FYI: Don't define scan code here for printable keys, numeric keys and +// IME keys because they depend on active keyboard layout. +// XXX: Although, ABNT C1 key depends on keyboard layout in strictly speaking. +// However, computing its scan code from the virtual keycode, +// WIN_VK_ABNT_C1, doesn't work fine (computed as 0x0073, "IntlRo"). +// Therefore, we should specify it here explicitly (it should be 0x0056, +// "IntlBackslash"). Fortunately, the key always generates 0x0056 with +// any keyboard layouts as far as I've tested. So, this must be safe to +// test new regressions. + +const WIN_VK_LBUTTON = 0x00000001; +const WIN_VK_RBUTTON = 0x00000002; +const WIN_VK_CANCEL = 0xe0460003; +const WIN_VK_MBUTTON = 0x00000004; +const WIN_VK_XBUTTON1 = 0x00000005; +const WIN_VK_XBUTTON2 = 0x00000006; +const WIN_VK_BACK = 0x000e0008; +const WIN_VK_TAB = 0x000f0009; +const WIN_VK_CLEAR = 0x004c000c; +const WIN_VK_RETURN = 0x001c000d; +const WIN_VK_SHIFT = 0x002a0010; +const WIN_VK_CONTROL = 0x001d0011; +const WIN_VK_MENU = 0x00380012; +const WIN_VK_PAUSE = 0x00450013; +const WIN_VK_CAPITAL = 0x003a0014; +const WIN_VK_KANA = 0x00000015; +const WIN_VK_HANGUEL = 0x00000015; +const WIN_VK_HANGUL = 0x00000015; +const WIN_VK_JUNJA = 0x00000017; +const WIN_VK_FINAL = 0x00000018; +const WIN_VK_HANJA = 0x00000019; +const WIN_VK_KANJI = 0x00000019; +const WIN_VK_ESCAPE = 0x0001001b; +const WIN_VK_CONVERT = 0x0000001c; +const WIN_VK_NONCONVERT = 0x0000001d; +const WIN_VK_ACCEPT = 0x0000001e; +const WIN_VK_MODECHANGE = 0x0000001f; +const WIN_VK_SPACE = 0x00390020; +const WIN_VK_PRIOR = 0xe0490021; +const WIN_VK_NEXT = 0xe0510022; +const WIN_VK_END = 0xe04f0023; +const WIN_VK_HOME = 0xe0470024; +const WIN_VK_LEFT = 0xe04b0025; +const WIN_VK_UP = 0xe0480026; +const WIN_VK_RIGHT = 0xe04d0027; +const WIN_VK_DOWN = 0xe0500028; +const WIN_VK_SELECT = 0x00000029; +const WIN_VK_PRINT = 0x0000002a; +const WIN_VK_EXECUTE = 0x0000002b; +const WIN_VK_SNAPSHOT = 0xe037002c; +const WIN_VK_INSERT = 0xe052002d; +const WIN_VK_DELETE = 0xe053002e; +const WIN_VK_HELP = 0x0000002f; +const WIN_VK_0 = 0x00000030; +const WIN_VK_1 = 0x00000031; +const WIN_VK_2 = 0x00000032; +const WIN_VK_3 = 0x00000033; +const WIN_VK_4 = 0x00000034; +const WIN_VK_5 = 0x00000035; +const WIN_VK_6 = 0x00000036; +const WIN_VK_7 = 0x00000037; +const WIN_VK_8 = 0x00000038; +const WIN_VK_9 = 0x00000039; +const WIN_VK_A = 0x00000041; +const WIN_VK_B = 0x00000042; +const WIN_VK_C = 0x00000043; +const WIN_VK_D = 0x00000044; +const WIN_VK_E = 0x00000045; +const WIN_VK_F = 0x00000046; +const WIN_VK_G = 0x00000047; +const WIN_VK_H = 0x00000048; +const WIN_VK_I = 0x00000049; +const WIN_VK_J = 0x0000004a; +const WIN_VK_K = 0x0000004b; +const WIN_VK_L = 0x0000004c; +const WIN_VK_M = 0x0000004d; +const WIN_VK_N = 0x0000004e; +const WIN_VK_O = 0x0000004f; +const WIN_VK_P = 0x00000050; +const WIN_VK_Q = 0x00000051; +const WIN_VK_R = 0x00000052; +const WIN_VK_S = 0x00000053; +const WIN_VK_T = 0x00000054; +const WIN_VK_U = 0x00000055; +const WIN_VK_V = 0x00000056; +const WIN_VK_W = 0x00000057; +const WIN_VK_X = 0x00000058; +const WIN_VK_Y = 0x00000059; +const WIN_VK_Z = 0x0000005a; +const WIN_VK_LWIN = 0xe05b005b; +const WIN_VK_RWIN = 0xe05c005c; +const WIN_VK_APPS = 0xe05d005d; +const WIN_VK_SLEEP = 0x0000005f; +const WIN_VK_NUMPAD0 = 0x00520060; +const WIN_VK_NUMPAD1 = 0x004f0061; +const WIN_VK_NUMPAD2 = 0x00500062; +const WIN_VK_NUMPAD3 = 0x00510063; +const WIN_VK_NUMPAD4 = 0x004b0064; +const WIN_VK_NUMPAD5 = 0x004c0065; +const WIN_VK_NUMPAD6 = 0x004d0066; +const WIN_VK_NUMPAD7 = 0x00470067; +const WIN_VK_NUMPAD8 = 0x00480068; +const WIN_VK_NUMPAD9 = 0x00490069; +const WIN_VK_MULTIPLY = 0x0037006a; +const WIN_VK_ADD = 0x004e006b; +const WIN_VK_SEPARATOR = 0x0000006c; +const WIN_VK_OEM_NEC_SEPARATE = 0x0000006c; +const WIN_VK_SUBTRACT = 0x004a006d; +const WIN_VK_DECIMAL = 0x0053006e; +const WIN_VK_DIVIDE = 0xe035006f; +const WIN_VK_F1 = 0x003b0070; +const WIN_VK_F2 = 0x003c0071; +const WIN_VK_F3 = 0x003d0072; +const WIN_VK_F4 = 0x003e0073; +const WIN_VK_F5 = 0x003f0074; +const WIN_VK_F6 = 0x00400075; +const WIN_VK_F7 = 0x00410076; +const WIN_VK_F8 = 0x00420077; +const WIN_VK_F9 = 0x00430078; +const WIN_VK_F10 = 0x00440079; +const WIN_VK_F11 = 0x0057007a; +const WIN_VK_F12 = 0x0058007b; +const WIN_VK_F13 = 0x0064007c; +const WIN_VK_F14 = 0x0065007d; +const WIN_VK_F15 = 0x0066007e; +const WIN_VK_F16 = 0x0067007f; +const WIN_VK_F17 = 0x00680080; +const WIN_VK_F18 = 0x00690081; +const WIN_VK_F19 = 0x006a0082; +const WIN_VK_F20 = 0x006b0083; +const WIN_VK_F21 = 0x006c0084; +const WIN_VK_F22 = 0x006d0085; +const WIN_VK_F23 = 0x006e0086; +const WIN_VK_F24 = 0x00760087; +const WIN_VK_NUMLOCK = 0xe0450090; +const WIN_VK_SCROLL = 0x00460091; +const WIN_VK_OEM_FJ_JISHO = 0x00000092; +const WIN_VK_OEM_NEC_EQUAL = 0x00000092; +const WIN_VK_OEM_FJ_MASSHOU = 0x00000093; +const WIN_VK_OEM_FJ_TOUROKU = 0x00000094; +const WIN_VK_OEM_FJ_LOYA = 0x00000095; +const WIN_VK_OEM_FJ_ROYA = 0x00000096; +const WIN_VK_LSHIFT = 0x002a00a0; +const WIN_VK_RSHIFT = 0x003600a1; +const WIN_VK_LCONTROL = 0x001d00a2; +const WIN_VK_RCONTROL = 0xe01d00a3; +const WIN_VK_LMENU = 0x003800a4; +const WIN_VK_RMENU = 0xe03800a5; +const WIN_VK_BROWSER_BACK = 0xe06a00a6; +const WIN_VK_BROWSER_FORWARD = 0xe06900a7; +const WIN_VK_BROWSER_REFRESH = 0xe06700a8; +const WIN_VK_BROWSER_STOP = 0xe06800a9; +const WIN_VK_BROWSER_SEARCH = 0x000000aa; +const WIN_VK_BROWSER_FAVORITES = 0xe06600ab; +const WIN_VK_BROWSER_HOME = 0xe03200ac; +const WIN_VK_VOLUME_MUTE = 0xe02000ad; +const WIN_VK_VOLUME_DOWN = 0xe02e00ae; +const WIN_VK_VOLUME_UP = 0xe03000af; +const WIN_VK_MEDIA_NEXT_TRACK = 0xe01900b0; +const WIN_VK_OEM_FJ_000 = 0x000000b0; +const WIN_VK_MEDIA_PREV_TRACK = 0xe01000b1; +const WIN_VK_OEM_FJ_EUQAL = 0x000000b1; +const WIN_VK_MEDIA_STOP = 0xe02400b2; +const WIN_VK_MEDIA_PLAY_PAUSE = 0xe02200b3; +const WIN_VK_OEM_FJ_00 = 0x000000b3; +const WIN_VK_LAUNCH_MAIL = 0xe06c00b4; +const WIN_VK_LAUNCH_MEDIA_SELECT = 0xe06d00b5; +const WIN_VK_LAUNCH_APP1 = 0xe06b00b6; +const WIN_VK_LAUNCH_APP2 = 0xe02100b7; +const WIN_VK_OEM_1 = 0x000000ba; +const WIN_VK_OEM_PLUS = 0x000000bb; +const WIN_VK_OEM_COMMA = 0x000000bc; +const WIN_VK_OEM_MINUS = 0x000000bd; +const WIN_VK_OEM_PERIOD = 0x000000be; +const WIN_VK_OEM_2 = 0x000000bf; +const WIN_VK_OEM_3 = 0x000000c0; +const WIN_VK_ABNT_C1 = 0x005600c1; +const WIN_VK_ABNT_C2 = 0x000000c2; +const WIN_VK_OEM_4 = 0x000000db; +const WIN_VK_OEM_5 = 0x000000dc; +const WIN_VK_OEM_6 = 0x000000dd; +const WIN_VK_OEM_7 = 0x000000de; +const WIN_VK_OEM_8 = 0x000000df; +const WIN_VK_OEM_NEC_DP1 = 0x000000e0; +const WIN_VK_OEM_AX = 0x000000e1; +const WIN_VK_OEM_NEC_DP2 = 0x000000e1; +const WIN_VK_OEM_102 = 0x000000e2; +const WIN_VK_OEM_NEC_DP3 = 0x000000e2; +const WIN_VK_ICO_HELP = 0x000000e3; +const WIN_VK_OEM_NEC_DP4 = 0x000000e3; +const WIN_VK_ICO_00 = 0x000000e4; +const WIN_VK_PROCESSKEY = 0x000000e5; +const WIN_VK_ICO_CLEAR = 0x000000e6; +const WIN_VK_PACKET = 0x000000e7; +const WIN_VK_ERICSSON_BASE = 0x000000e8; +const WIN_VK_OEM_RESET = 0x000000e9; +const WIN_VK_OEM_JUMP = 0x000000ea; +const WIN_VK_OEM_PA1 = 0x000000eb; +const WIN_VK_OEM_PA2 = 0x000000ec; +const WIN_VK_OEM_PA3 = 0x000000ed; +const WIN_VK_OEM_WSCTRL = 0x000000ee; +const WIN_VK_OEM_CUSEL = 0x000000ef; +const WIN_VK_OEM_ATTN = 0x000000f0; +const WIN_VK_OEM_FINISH = 0x000000f1; +const WIN_VK_OEM_COPY = 0x000000f2; +const WIN_VK_OEM_AUTO = 0x000000f3; +const WIN_VK_OEM_ENLW = 0x000000f4; +const WIN_VK_OEM_BACKTAB = 0x000000f5; +const WIN_VK_ATTN = 0x000000f6; +const WIN_VK_CRSEL = 0x000000f7; +const WIN_VK_EXSEL = 0x000000f8; +const WIN_VK_EREOF = 0x000000f9; +const WIN_VK_PLAY = 0x000000fa; +const WIN_VK_ZOOM = 0x000000fb; +const WIN_VK_NONAME = 0x000000fc; +const WIN_VK_PA1 = 0x000000fd; +const WIN_VK_OEM_CLEAR = 0x000000fe; + +const WIN_VK_NUMPAD_RETURN = 0xe01c000d; +const WIN_VK_NUMPAD_PRIOR = 0x00490021; +const WIN_VK_NUMPAD_NEXT = 0x00510022; +const WIN_VK_NUMPAD_END = 0x004f0023; +const WIN_VK_NUMPAD_HOME = 0x00470024; +const WIN_VK_NUMPAD_LEFT = 0x004b0025; +const WIN_VK_NUMPAD_UP = 0x00480026; +const WIN_VK_NUMPAD_RIGHT = 0x004d0027; +const WIN_VK_NUMPAD_DOWN = 0x00500028; +const WIN_VK_NUMPAD_INSERT = 0x0052002d; +const WIN_VK_NUMPAD_DELETE = 0x0053002e; + +// Mac + +const MAC_VK_ANSI_A = 0x00; +const MAC_VK_ANSI_S = 0x01; +const MAC_VK_ANSI_D = 0x02; +const MAC_VK_ANSI_F = 0x03; +const MAC_VK_ANSI_H = 0x04; +const MAC_VK_ANSI_G = 0x05; +const MAC_VK_ANSI_Z = 0x06; +const MAC_VK_ANSI_X = 0x07; +const MAC_VK_ANSI_C = 0x08; +const MAC_VK_ANSI_V = 0x09; +const MAC_VK_ISO_Section = 0x0a; +const MAC_VK_ANSI_B = 0x0b; +const MAC_VK_ANSI_Q = 0x0c; +const MAC_VK_ANSI_W = 0x0d; +const MAC_VK_ANSI_E = 0x0e; +const MAC_VK_ANSI_R = 0x0f; +const MAC_VK_ANSI_Y = 0x10; +const MAC_VK_ANSI_T = 0x11; +const MAC_VK_ANSI_1 = 0x12; +const MAC_VK_ANSI_2 = 0x13; +const MAC_VK_ANSI_3 = 0x14; +const MAC_VK_ANSI_4 = 0x15; +const MAC_VK_ANSI_6 = 0x16; +const MAC_VK_ANSI_5 = 0x17; +const MAC_VK_ANSI_Equal = 0x18; +const MAC_VK_ANSI_9 = 0x19; +const MAC_VK_ANSI_7 = 0x1a; +const MAC_VK_ANSI_Minus = 0x1b; +const MAC_VK_ANSI_8 = 0x1c; +const MAC_VK_ANSI_0 = 0x1d; +const MAC_VK_ANSI_RightBracket = 0x1e; +const MAC_VK_ANSI_O = 0x1f; +const MAC_VK_ANSI_U = 0x20; +const MAC_VK_ANSI_LeftBracket = 0x21; +const MAC_VK_ANSI_I = 0x22; +const MAC_VK_ANSI_P = 0x23; +const MAC_VK_Return = 0x24; +const MAC_VK_ANSI_L = 0x25; +const MAC_VK_ANSI_J = 0x26; +const MAC_VK_ANSI_Quote = 0x27; +const MAC_VK_ANSI_K = 0x28; +const MAC_VK_ANSI_Semicolon = 0x29; +const MAC_VK_ANSI_Backslash = 0x2a; +const MAC_VK_ANSI_Comma = 0x2b; +const MAC_VK_ANSI_Slash = 0x2c; +const MAC_VK_ANSI_N = 0x2d; +const MAC_VK_ANSI_M = 0x2e; +const MAC_VK_ANSI_Period = 0x2f; +const MAC_VK_Tab = 0x30; +const MAC_VK_Space = 0x31; +const MAC_VK_ANSI_Grave = 0x32; +const MAC_VK_Delete = 0x33; +const MAC_VK_PC_Backspace = 0x33; +const MAC_VK_Powerbook_KeypadEnter = 0x34; +const MAC_VK_Escape = 0x35; +const MAC_VK_RightCommand = 0x36; +const MAC_VK_Command = 0x37; +const MAC_VK_Shift = 0x38; +const MAC_VK_CapsLock = 0x39; +const MAC_VK_Option = 0x3a; +const MAC_VK_Control = 0x3b; +const MAC_VK_RightShift = 0x3c; +const MAC_VK_RightOption = 0x3d; +const MAC_VK_RightControl = 0x3e; +const MAC_VK_Function = 0x3f; +const MAC_VK_F17 = 0x40; +const MAC_VK_ANSI_KeypadDecimal = 0x41; +const MAC_VK_ANSI_KeypadMultiply = 0x43; +const MAC_VK_ANSI_KeypadPlus = 0x45; +const MAC_VK_ANSI_KeypadClear = 0x47; +const MAC_VK_VolumeUp = 0x48; +const MAC_VK_VolumeDown = 0x49; +const MAC_VK_Mute = 0x4a; +const MAC_VK_ANSI_KeypadDivide = 0x4b; +const MAC_VK_ANSI_KeypadEnter = 0x4c; +const MAC_VK_ANSI_KeypadMinus = 0x4e; +const MAC_VK_F18 = 0x4f; +const MAC_VK_F19 = 0x50; +const MAC_VK_ANSI_KeypadEquals = 0x51; +const MAC_VK_ANSI_Keypad0 = 0x52; +const MAC_VK_ANSI_Keypad1 = 0x53; +const MAC_VK_ANSI_Keypad2 = 0x54; +const MAC_VK_ANSI_Keypad3 = 0x55; +const MAC_VK_ANSI_Keypad4 = 0x56; +const MAC_VK_ANSI_Keypad5 = 0x57; +const MAC_VK_ANSI_Keypad6 = 0x58; +const MAC_VK_ANSI_Keypad7 = 0x59; +const MAC_VK_F20 = 0x5a; +const MAC_VK_ANSI_Keypad8 = 0x5b; +const MAC_VK_ANSI_Keypad9 = 0x5c; +const MAC_VK_JIS_Yen = 0x5d; +const MAC_VK_JIS_Underscore = 0x5e; +const MAC_VK_JIS_KeypadComma = 0x5f; +const MAC_VK_F5 = 0x60; +const MAC_VK_F6 = 0x61; +const MAC_VK_F7 = 0x62; +const MAC_VK_F3 = 0x63; +const MAC_VK_F8 = 0x64; +const MAC_VK_F9 = 0x65; +const MAC_VK_JIS_Eisu = 0x66; +const MAC_VK_F11 = 0x67; +const MAC_VK_JIS_Kana = 0x68; +const MAC_VK_F13 = 0x69; +const MAC_VK_PC_PrintScreen = 0x69; +const MAC_VK_F16 = 0x6a; +const MAC_VK_F14 = 0x6b; +const MAC_VK_PC_ScrollLock = 0x6b; +const MAC_VK_F10 = 0x6d; +const MAC_VK_PC_ContextMenu = 0x6e; +const MAC_VK_F12 = 0x6f; +const MAC_VK_F15 = 0x71; +const MAC_VK_PC_Pause = 0x71; +const MAC_VK_Help = 0x72; +const MAC_VK_PC_Insert = 0x72; +const MAC_VK_Home = 0x73; +const MAC_VK_PageUp = 0x74; +const MAC_VK_ForwardDelete = 0x75; +const MAC_VK_PC_Delete = 0x75; +const MAC_VK_F4 = 0x76; +const MAC_VK_End = 0x77; +const MAC_VK_F2 = 0x78; +const MAC_VK_PageDown = 0x79; +const MAC_VK_F1 = 0x7a; +const MAC_VK_LeftArrow = 0x7b; +const MAC_VK_RightArrow = 0x7c; +const MAC_VK_DownArrow = 0x7d; +const MAC_VK_UpArrow = 0x7e; diff --git a/testing/mochitest/tests/SimpleTest/SimpleTest.js b/testing/mochitest/tests/SimpleTest/SimpleTest.js new file mode 100644 index 0000000000..12c8b3f6ea --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/SimpleTest.js @@ -0,0 +1,2189 @@ +/* -*- js-indent-level: 4; tab-width: 4; indent-tabs-mode: nil -*- */ +/* vim:set ts=4 sw=4 sts=4 et: */ + +// Generally gTestPath should be set by the harness. +/* global gTestPath */ + +/** + * SimpleTest framework object. + * @class + */ +var SimpleTest = {}; +var parentRunner = null; + +// Using a try/catch rather than SpecialPowers.Cu.isRemoteProxy() because +// it doesn't cover the case where an iframe is xorigin but fission is +// not enabled. +let isSameOrigin = function(w) { + try { + w.top.TestRunner; + } catch (e) { + if (e instanceof DOMException) { + return false; + } + } + return true; +}; +let isXOrigin = !isSameOrigin(window); + +// In normal test runs, the window that has a TestRunner in its parent is +// the primary window. In single test runs, if there is no parent and there +// is no opener then it is the primary window. +var isSingleTestRun = + parent == window && + !(opener || (window.arguments && window.arguments[0].SimpleTest)); +try { + var isPrimaryTestWindow = + (isXOrigin && parent != window && parent == top) || + (!isXOrigin && (!!parent.TestRunner || isSingleTestRun)); +} catch (e) { + dump( + "TEST-UNEXPECTED-FAIL, Exception caught: " + + e.message + + ", at: " + + e.fileName + + " (" + + e.lineNumber + + "), location: " + + window.location.href + + "\n" + ); +} + +let xOriginRunner = { + init(harnessWindow) { + this.harnessWindow = harnessWindow; + let url = new URL(document.URL); + this.testFile = url.pathname; + this.showTestReport = url.searchParams.get("showTestReport") == "true"; + this.expected = url.searchParams.get("expected"); + }, + callHarnessMethod(applyOn, command, ...params) { + // Message handled by xOriginTestRunnerHandler in TestRunner.js + this.harnessWindow.postMessage( + { + harnessType: "SimpleTest", + applyOn, + command, + params, + }, + "*" + ); + }, + getParameterInfo() { + let url = new URL(document.URL); + return { + currentTestURL: url.searchParams.get("currentTestURL"), + testRoot: url.searchParams.get("testRoot"), + }; + }, + addFailedTest(test) { + this.callHarnessMethod("runner", "addFailedTest", test); + }, + expectAssertions(min, max) { + this.callHarnessMethod("runner", "expectAssertions", min, max); + }, + expectChildProcessCrash() { + this.callHarnessMethod("runner", "expectChildProcessCrash"); + }, + requestLongerTimeout(factor) { + this.callHarnessMethod("runner", "requestLongerTimeout", factor); + }, + _lastAssertionCount: 0, + testFinished(tests) { + var newAssertionCount = SpecialPowers.assertionCount(); + var numAsserts = newAssertionCount - this._lastAssertionCount; + this._lastAssertionCount = newAssertionCount; + this.callHarnessMethod("runner", "addAssertionCount", numAsserts); + this.callHarnessMethod("runner", "testFinished", tests); + }, + structuredLogger: { + info(msg) { + xOriginRunner.callHarnessMethod("logger", "structuredLogger.info", msg); + }, + warning(msg) { + xOriginRunner.callHarnessMethod( + "logger", + "structuredLogger.warning", + msg + ); + }, + error(msg) { + xOriginRunner.callHarnessMethod("logger", "structuredLogger.error", msg); + }, + activateBuffering() { + xOriginRunner.callHarnessMethod( + "logger", + "structuredLogger.activateBuffering" + ); + }, + deactivateBuffering() { + xOriginRunner.callHarnessMethod( + "logger", + "structuredLogger.deactivateBuffering" + ); + }, + testStatus(url, subtest, status, expected, diagnostic, stack) { + xOriginRunner.callHarnessMethod( + "logger", + "structuredLogger.testStatus", + url, + subtest, + status, + expected, + diagnostic, + stack + ); + }, + }, +}; + +// Finds the TestRunner for this test run and the SpecialPowers object (in +// case it is not defined) from a parent/opener window. +// +// Finding the SpecialPowers object is needed when we have ChromePowers in +// harness.xhtml and we need SpecialPowers in the iframe, and also for tests +// like test_focus.xhtml where we open a window which opens another window which +// includes SimpleTest.js. +(function() { + function ancestor(w) { + return w.parent != w + ? w.parent + : w.opener || + (!isXOrigin && + w.arguments && + SpecialPowers.wrap(Window).isInstance(w.arguments[0]) && + w.arguments[0]); + } + + var w = ancestor(window); + while (w && !parentRunner) { + isXOrigin = !isSameOrigin(w); + + if (isXOrigin) { + if (w.parent != w) { + w = w.top; + } + xOriginRunner.init(w); + parentRunner = xOriginRunner; + } + + if (!parentRunner) { + parentRunner = w.TestRunner; + if (!parentRunner && w.wrappedJSObject) { + parentRunner = w.wrappedJSObject.TestRunner; + } + } + w = ancestor(w); + } + + if (parentRunner) { + SimpleTest.harnessParameters = parentRunner.getParameterInfo(); + } +})(); + +/* Helper functions pulled out of various MochiKit modules */ +if (typeof repr == "undefined") { + this.repr = function repr(o) { + if (typeof o == "undefined") { + return "undefined"; + } else if (o === null) { + return "null"; + } + try { + if (typeof o.__repr__ == "function") { + return o.__repr__(); + } else if (typeof o.repr == "function" && o.repr != repr) { + return o.repr(); + } + } catch (e) {} + try { + if ( + typeof o.NAME == "string" && + (o.toString == Function.prototype.toString || + o.toString == Object.prototype.toString) + ) { + return o.NAME; + } + } catch (e) {} + var ostring; + try { + if (o === 0) { + ostring = 1 / o > 0 ? "+0" : "-0"; + } else if (typeof o === "string") { + ostring = JSON.stringify(o); + } else if (Array.isArray(o)) { + ostring = "[" + o.map(val => repr(val)).join(", ") + "]"; + } else { + ostring = o + ""; + } + } catch (e) { + return "[" + typeof o + "]"; + } + if (typeof o == "function") { + o = ostring.replace(/^\s+/, ""); + var idx = o.indexOf("{"); + if (idx != -1) { + o = o.substr(0, idx) + "{...}"; + } + } + return ostring; + }; +} + +/* This returns a function that applies the previously given parameters. + * This is used by SimpleTest.showReport + */ +if (typeof partial == "undefined") { + this.partial = function(func) { + var args = []; + for (let i = 1; i < arguments.length; i++) { + args.push(arguments[i]); + } + return function() { + if (arguments.length) { + for (let i = 1; i < arguments.length; i++) { + args.push(arguments[i]); + } + } + func(args); + }; + }; +} + +if (typeof getElement == "undefined") { + this.getElement = function(id) { + return typeof id == "string" ? document.getElementById(id) : id; + }; + this.$ = this.getElement; +} + +SimpleTest._newCallStack = function(path) { + var rval = function callStackHandler() { + var callStack = callStackHandler.callStack; + for (var i = 0; i < callStack.length; i++) { + if (callStack[i].apply(this, arguments) === false) { + break; + } + } + try { + this[path] = null; + } catch (e) { + // pass + } + }; + rval.callStack = []; + return rval; +}; + +if (typeof addLoadEvent == "undefined") { + this.addLoadEvent = function(func) { + var existing = window.onload; + var regfunc = existing; + if ( + !( + typeof existing == "function" && + typeof existing.callStack == "object" && + existing.callStack !== null + ) + ) { + regfunc = SimpleTest._newCallStack("onload"); + if (typeof existing == "function") { + regfunc.callStack.push(existing); + } + window.onload = regfunc; + } + regfunc.callStack.push(func); + }; +} + +function createEl(type, attrs, html) { + //use createElementNS so the xul/xhtml tests have no issues + var el; + if (!document.body) { + el = document.createElementNS("http://www.w3.org/1999/xhtml", type); + } else { + el = document.createElement(type); + } + if (attrs !== null && attrs !== undefined) { + for (var k in attrs) { + el.setAttribute(k, attrs[k]); + } + } + if (html !== null && html !== undefined) { + el.appendChild(document.createTextNode(html)); + } + return el; +} + +/* lots of tests use this as a helper to get css properties */ +if (typeof computedStyle == "undefined") { + this.computedStyle = function(elem, cssProperty) { + elem = getElement(elem); + if (elem.currentStyle) { + return elem.currentStyle[cssProperty]; + } + if (typeof document.defaultView == "undefined" || document === null) { + return undefined; + } + var style = document.defaultView.getComputedStyle(elem); + if (typeof style == "undefined" || style === null) { + return undefined; + } + + var selectorCase = cssProperty.replace(/([A-Z])/g, "-$1").toLowerCase(); + + return style.getPropertyValue(selectorCase); + }; +} + +SimpleTest._tests = []; +SimpleTest._stopOnLoad = true; +SimpleTest._cleanupFunctions = []; +SimpleTest._timeoutFunctions = []; +SimpleTest._inChaosMode = false; +// When using failure pattern file to filter unexpected issues, +// SimpleTest.expected would be an array of [pattern, expected count], +// and SimpleTest.num_failed would be an array of actual counts which +// has the same length as SimpleTest.expected. +SimpleTest.expected = "pass"; +SimpleTest.num_failed = 0; + +SpecialPowers.setAsDefaultAssertHandler(); + +function usesFailurePatterns() { + return Array.isArray(SimpleTest.expected); +} + +/** + * Checks whether there is any failure pattern matches the given error + * message, and if found, bumps the counter of the failure pattern. + * + * @return {boolean} Whether a matched failure pattern is found. + */ +function recordIfMatchesFailurePattern(name, diag) { + let index = SimpleTest.expected.findIndex(([pat, count]) => { + return ( + pat == null || + (typeof name == "string" && name.includes(pat)) || + (typeof diag == "string" && diag.includes(pat)) + ); + }); + if (index >= 0) { + SimpleTest.num_failed[index]++; + return true; + } + return false; +} + +SimpleTest.setExpected = function() { + if (!parentRunner) { + return; + } + if (!Array.isArray(parentRunner.expected)) { + SimpleTest.expected = parentRunner.expected; + } else { + // Assertions are checked by the runner. + SimpleTest.expected = parentRunner.expected.filter( + ([pat]) => pat != "ASSERTION" + ); + SimpleTest.num_failed = new Array(SimpleTest.expected.length); + SimpleTest.num_failed.fill(0); + } +}; +SimpleTest.setExpected(); + +/** + * Something like assert. + **/ +SimpleTest.ok = function(condition, name) { + if (arguments.length > 2) { + const diag = "Too many arguments passed to `ok(condition, name)`"; + SimpleTest.record(false, name, diag); + } else { + SimpleTest.record(condition, name); + } +}; + +SimpleTest.record = function(condition, name, diag, stack, expected) { + var test = { result: !!condition, name, diag }; + let successInfo; + let failureInfo; + if (SimpleTest.expected == "fail") { + if (!test.result) { + SimpleTest.num_failed++; + test.result = true; + } + successInfo = { + status: "PASS", + expected: "PASS", + message: "TEST-PASS", + }; + failureInfo = { + status: "FAIL", + expected: "FAIL", + message: "TEST-KNOWN-FAIL", + }; + } else if (!test.result && usesFailurePatterns()) { + if (recordIfMatchesFailurePattern(name, diag)) { + test.result = true; + // Add a mark for unexpected failures suppressed by failure pattern. + name = "[suppressed] " + name; + } + successInfo = { + status: "FAIL", + expected: "FAIL", + message: "TEST-KNOWN-FAIL", + }; + failureInfo = { + status: "FAIL", + expected: "PASS", + message: "TEST-UNEXPECTED-FAIL", + }; + } else if (expected == "fail") { + successInfo = { + status: "PASS", + expected: "FAIL", + message: "TEST-UNEXPECTED-PASS", + }; + failureInfo = { + status: "FAIL", + expected: "FAIL", + message: "TEST-KNOWN-FAIL", + }; + } else { + successInfo = { + status: "PASS", + expected: "PASS", + message: "TEST-PASS", + }; + failureInfo = { + status: "FAIL", + expected: "PASS", + message: "TEST-UNEXPECTED-FAIL", + }; + } + + if (condition) { + stack = null; + } else if (!stack) { + stack = new Error().stack + .replace(/^(.*@)http:\/\/mochi.test:8888\/tests\//gm, " $1") + .split("\n"); + stack.splice(0, 1); + stack = stack.join("\n"); + } + SimpleTest._logResult(test, successInfo, failureInfo, stack); + SimpleTest._tests.push(test); +}; + +/** + * Roughly equivalent to ok(Object.is(a, b), name) + **/ +SimpleTest.is = function(a, b, name) { + // Be lazy and use Object.is til we want to test a browser without it. + var pass = Object.is(a, b); + var diag = pass ? "" : "got " + repr(a) + ", expected " + repr(b); + SimpleTest.record(pass, name, diag); +}; + +SimpleTest.isfuzzy = function(a, b, epsilon, name) { + var pass = a >= b - epsilon && a <= b + epsilon; + var diag = pass + ? "" + : "got " + + repr(a) + + ", expected " + + repr(b) + + " epsilon: +/- " + + repr(epsilon); + SimpleTest.record(pass, name, diag); +}; + +SimpleTest.isnot = function(a, b, name) { + var pass = !Object.is(a, b); + var diag = pass ? "" : "didn't expect " + repr(a) + ", but got it"; + SimpleTest.record(pass, name, diag); +}; + +/** + * Check that the function call throws an exception. + */ +SimpleTest.doesThrow = function(fn, name) { + var gotException = false; + try { + fn(); + } catch (ex) { + gotException = true; + } + ok(gotException, name); +}; + +// --------------- Test.Builder/Test.More todo() ----------------- + +SimpleTest.todo = function(condition, name, diag) { + var test = { result: !!condition, name, diag, todo: true }; + if ( + test.result && + usesFailurePatterns() && + recordIfMatchesFailurePattern(name, diag) + ) { + // Flipping the result to false so we don't get unexpected result. There + // is no perfect way here. A known failure can trigger unexpected pass, + // in which case, tagging it as KNOWN-FAIL probably makes more sense than + // marking it PASS. + test.result = false; + // Add a mark for unexpected failures suppressed by failure pattern. + name = "[suppressed] " + name; + } + var successInfo = { + status: "PASS", + expected: "FAIL", + message: "TEST-UNEXPECTED-PASS", + }; + var failureInfo = { + status: "FAIL", + expected: "FAIL", + message: "TEST-KNOWN-FAIL", + }; + SimpleTest._logResult(test, successInfo, failureInfo); + SimpleTest._tests.push(test); +}; + +/* + * Returns the absolute URL to a test data file from where tests + * are served. i.e. the file doesn't necessarely exists where tests + * are executed. + * + * (For android, mochitest are executed on the device, while + * all mochitest html (and others) files are served from the test runner + * slave) + */ +SimpleTest.getTestFileURL = function(path) { + var location = window.location; + // Remove mochitest html file name from the path + var remotePath = location.pathname.replace(/\/[^\/]+?$/, ""); + var url = location.origin + remotePath + "/" + path; + return url; +}; + +SimpleTest._getCurrentTestURL = function() { + return ( + (SimpleTest.harnessParameters && + SimpleTest.harnessParameters.currentTestURL) || + (parentRunner && parentRunner.currentTestURL) || + (typeof gTestPath == "string" && gTestPath) || + "unknown test url" + ); +}; + +SimpleTest._forceLogMessageOutput = false; + +/** + * Force all test messages to be displayed. Only applies for the current test. + */ +SimpleTest.requestCompleteLog = function() { + if (!parentRunner || SimpleTest._forceLogMessageOutput) { + return; + } + + parentRunner.structuredLogger.deactivateBuffering(); + SimpleTest._forceLogMessageOutput = true; + + SimpleTest.registerCleanupFunction(function() { + parentRunner.structuredLogger.activateBuffering(); + SimpleTest._forceLogMessageOutput = false; + }); +}; + +SimpleTest._logResult = function(test, passInfo, failInfo, stack) { + var url = SimpleTest._getCurrentTestURL(); + var result = test.result ? passInfo : failInfo; + var diagnostic = test.diag || null; + // BUGFIX : coercing test.name to a string, because some a11y tests pass an xpconnect object + var subtest = test.name ? String(test.name) : null; + var isError = !test.result == !test.todo; + + if (parentRunner) { + if (!result.status || !result.expected) { + if (diagnostic) { + parentRunner.structuredLogger.info(diagnostic); + } + return; + } + + if (isError) { + parentRunner.addFailedTest(url); + } + + parentRunner.structuredLogger.testStatus( + url, + subtest, + result.status, + result.expected, + diagnostic, + stack + ); + } else if (typeof dump === "function") { + var diagMessage = test.name + (test.diag ? " - " + test.diag : ""); + var debugMsg = [result.message, url, diagMessage].join(" | "); + dump(debugMsg + "\n"); + } else { + // Non-Mozilla browser? Just do nothing. + } +}; + +SimpleTest.info = function(name, message) { + var log = message ? name + " | " + message : name; + if (parentRunner) { + parentRunner.structuredLogger.info(log); + } else { + dump(log + "\n"); + } +}; + +/** + * Copies of is and isnot with the call to ok replaced by a call to todo. + **/ + +SimpleTest.todo_is = function(a, b, name) { + var pass = Object.is(a, b); + var diag = pass + ? repr(a) + " should equal " + repr(b) + : "got " + repr(a) + ", expected " + repr(b); + SimpleTest.todo(pass, name, diag); +}; + +SimpleTest.todo_isnot = function(a, b, name) { + var pass = !Object.is(a, b); + var diag = pass + ? repr(a) + " should not equal " + repr(b) + : "didn't expect " + repr(a) + ", but got it"; + SimpleTest.todo(pass, name, diag); +}; + +/** + * Makes a test report, returns it as a DIV element. + **/ +SimpleTest.report = function() { + var passed = 0; + var failed = 0; + var todo = 0; + + var tallyAndCreateDiv = function(test) { + var cls, msg, div; + var diag = test.diag ? " - " + test.diag : ""; + if (test.todo && !test.result) { + todo++; + cls = "test_todo"; + msg = "todo | " + test.name + diag; + } else if (test.result && !test.todo) { + passed++; + cls = "test_ok"; + msg = "passed | " + test.name + diag; + } else { + failed++; + cls = "test_not_ok"; + msg = "failed | " + test.name + diag; + } + div = createEl("div", { class: cls }, msg); + return div; + }; + var results = []; + for (var d = 0; d < SimpleTest._tests.length; d++) { + results.push(tallyAndCreateDiv(SimpleTest._tests[d])); + } + + var summary_class = + // eslint-disable-next-line no-nested-ternary + failed != 0 ? "some_fail" : passed == 0 ? "todo_only" : "all_pass"; + + var div1 = createEl("div", { class: "tests_report" }); + var div2 = createEl("div", { class: "tests_summary " + summary_class }); + var div3 = createEl("div", { class: "tests_passed" }, "Passed: " + passed); + var div4 = createEl("div", { class: "tests_failed" }, "Failed: " + failed); + var div5 = createEl("div", { class: "tests_todo" }, "Todo: " + todo); + div2.appendChild(div3); + div2.appendChild(div4); + div2.appendChild(div5); + div1.appendChild(div2); + for (var t = 0; t < results.length; t++) { + //iterate in order + div1.appendChild(results[t]); + } + return div1; +}; + +/** + * Toggle element visibility + **/ +SimpleTest.toggle = function(el) { + if (computedStyle(el, "display") == "block") { + el.style.display = "none"; + } else { + el.style.display = "block"; + } +}; + +/** + * Toggle visibility for divs with a specific class. + **/ +SimpleTest.toggleByClass = function(cls, evt) { + var children = document.getElementsByTagName("div"); + var elements = []; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + var clsName = child.className; + if (!clsName) { + continue; + } + var classNames = clsName.split(" "); + for (var j = 0; j < classNames.length; j++) { + if (classNames[j] == cls) { + elements.push(child); + break; + } + } + } + for (var t = 0; t < elements.length; t++) { + //TODO: again, for-in loop over elems seems to break this + SimpleTest.toggle(elements[t]); + } + if (evt) { + evt.preventDefault(); + } +}; + +/** + * Shows the report in the browser + **/ +SimpleTest.showReport = function() { + var togglePassed = createEl("a", { href: "#" }, "Toggle passed checks"); + var toggleFailed = createEl("a", { href: "#" }, "Toggle failed checks"); + var toggleTodo = createEl("a", { href: "#" }, "Toggle todo checks"); + togglePassed.onclick = partial(SimpleTest.toggleByClass, "test_ok"); + toggleFailed.onclick = partial(SimpleTest.toggleByClass, "test_not_ok"); + toggleTodo.onclick = partial(SimpleTest.toggleByClass, "test_todo"); + var body = document.body; // Handles HTML documents + if (!body) { + // Do the XML thing. + body = document.getElementsByTagNameNS( + "http://www.w3.org/1999/xhtml", + "body" + )[0]; + } + var firstChild = body.childNodes[0]; + var addNode; + if (firstChild) { + addNode = function(el) { + body.insertBefore(el, firstChild); + }; + } else { + addNode = function(el) { + body.appendChild(el); + }; + } + addNode(togglePassed); + addNode(createEl("span", null, " ")); + addNode(toggleFailed); + addNode(createEl("span", null, " ")); + addNode(toggleTodo); + addNode(SimpleTest.report()); + // Add a separator from the test content. + addNode(createEl("hr")); +}; + +/** + * Tells SimpleTest to don't finish the test when the document is loaded, + * useful for asynchronous tests. + * + * When SimpleTest.waitForExplicitFinish is called, + * explicit SimpleTest.finish() is required. + **/ +SimpleTest.waitForExplicitFinish = function() { + SimpleTest._stopOnLoad = false; +}; + +/** + * Multiply the timeout the parent runner uses for this test by the + * given factor. + * + * For example, in a test that may take a long time to complete, using + * "SimpleTest.requestLongerTimeout(5)" will give it 5 times as long to + * finish. + * + * @param {Number} factor + * The multiplication factor to use on the timeout for this test. + */ +SimpleTest.requestLongerTimeout = function(factor) { + if (parentRunner) { + parentRunner.requestLongerTimeout(factor); + } else { + dump( + "[SimpleTest.requestLongerTimeout()] ignoring request, maybe you meant to call the global `requestLongerTimeout` instead?\n" + ); + } +}; + +/** + * Note that the given range of assertions is to be expected. When + * this function is not called, 0 assertions are expected. When only + * one argument is given, that number of assertions are expected. + * + * A test where we expect to have assertions (which should largely be a + * transitional mechanism to get assertion counts down from our current + * situation) can call the SimpleTest.expectAssertions() function, with + * either one or two arguments: one argument gives an exact number + * expected, and two arguments give a range. For example, a test might do + * one of the following: + * + * @example + * + * // Currently triggers two assertions (bug NNNNNN). + * SimpleTest.expectAssertions(2); + * + * // Currently triggers one assertion on Mac (bug NNNNNN). + * if (navigator.platform.indexOf("Mac") == 0) { + * SimpleTest.expectAssertions(1); + * } + * + * // Currently triggers two assertions on all platforms (bug NNNNNN), + * // but intermittently triggers two additional assertions (bug NNNNNN) + * // on Windows. + * if (navigator.platform.indexOf("Win") == 0) { + * SimpleTest.expectAssertions(2, 4); + * } else { + * SimpleTest.expectAssertions(2); + * } + * + * // Intermittently triggers up to three assertions (bug NNNNNN). + * SimpleTest.expectAssertions(0, 3); + */ +SimpleTest.expectAssertions = function(min, max) { + if (parentRunner) { + parentRunner.expectAssertions(min, max); + } +}; + +SimpleTest._flakyTimeoutIsOK = false; +SimpleTest._originalSetTimeout = window.setTimeout; +window.setTimeout = function SimpleTest_setTimeoutShim() { + // Don't break tests that are loaded without a parent runner. + if (parentRunner) { + // Right now, we only enable these checks for mochitest-plain. + switch (SimpleTest.harnessParameters.testRoot) { + case "browser": + case "chrome": + case "a11y": + break; + default: + if ( + !SimpleTest._alreadyFinished && + arguments.length > 1 && + arguments[1] > 0 + ) { + if (SimpleTest._flakyTimeoutIsOK) { + SimpleTest.todo( + false, + "The author of the test has indicated that flaky timeouts are expected. Reason: " + + SimpleTest._flakyTimeoutReason + ); + } else { + SimpleTest.ok( + false, + "Test attempted to use a flaky timeout value " + arguments[1] + ); + } + } + } + } + return SimpleTest._originalSetTimeout.apply(window, arguments); +}; + +/** + * Request the framework to allow usage of setTimeout(func, timeout) + * where ``timeout > 0``. This is required to note that the author of + * the test is aware of the inherent flakiness in the test caused by + * that, and asserts that there is no way around using the magic timeout + * value number for some reason. + * + * Use of this function is **STRONGLY** discouraged. Think twice before + * using it. Such magic timeout values could result in intermittent + * failures in your test, and are almost never necessary! + * + * @param {String} reason + * A string representation of the reason why the test needs timeouts. + * + */ +SimpleTest.requestFlakyTimeout = function(reason) { + SimpleTest.is(typeof reason, "string", "A valid string reason is expected"); + SimpleTest.isnot(reason, "", "Reason cannot be empty"); + SimpleTest._flakyTimeoutIsOK = true; + SimpleTest._flakyTimeoutReason = reason; +}; + +/** + * If the page is not yet loaded, waits for the load event. If the page is + * not yet focused, focuses and waits for the window to be focused. + * If the current page is 'about:blank', then the page is assumed to not + * yet be loaded. Pass true for expectBlankPage to not make this assumption + * if you expect a blank page to be present. + * + * The target object should be specified if it is different than 'window'. The + * actual focused window may be a descendant window of aObject. + * + * @param {Window|browser|BrowsingContext} [aObject] + * Optional object to be focused, and may be any of: + * window - a window object to focus + * browser - a <browser>/<iframe> element. The top-level window + * within the frame will be focused. + * browsing context - a browsing context containing a window to focus + * If not specified, defaults to the global 'window'. + * @param {boolean} [expectBlankPage=false] + * True if targetWindow.location is 'about:blank'. + * @param {boolean} [aBlurSubframe=false] + * If true, and a subframe within the window to focus is focused, blur + * it so that the specified window or browsing context will receive + * focus events. + * + * @returns The browsing context that was focused. + */ +SimpleTest.promiseFocus = async function( + aObject, + aExpectBlankPage = false, + aBlurSubframe = false +) { + let browser; + let browsingContext; + let windowToFocus; + + if (!aObject) { + aObject = window; + } + + async function waitForEvent(aTarget, aEventName) { + return new Promise(resolve => { + aTarget.addEventListener(aEventName, resolve, { + capture: true, + once: true, + }); + }); + } + + if (SpecialPowers.wrap(Window).isInstance(aObject)) { + windowToFocus = aObject; + + let isBlank = windowToFocus.location.href == "about:blank"; + if ( + aExpectBlankPage != isBlank || + windowToFocus.document.readyState != "complete" + ) { + info("must wait for load"); + await waitForEvent(windowToFocus, "load"); + } + } else { + if (SpecialPowers.wrap(Element).isInstance(aObject)) { + // assume this is a browser/iframe element + browsingContext = aObject.browsingContext; + } else { + browsingContext = aObject; + } + + browser = + browsingContext == aObject ? aObject.top.embedderElement : aObject; + windowToFocus = browser.ownerGlobal; + } + + if (!windowToFocus.document.hasFocus()) { + info("must wait for focus"); + let focusPromise = waitForEvent(windowToFocus.document, "focus"); + SpecialPowers.focus(windowToFocus); + await focusPromise; + } + + if (browser) { + if (windowToFocus.document.activeElement != browser) { + browser.focus(); + } + + info("must wait for focus in content"); + + // Make sure that the child process thinks it is focused as well. + await SpecialPowers.ensureFocus(browsingContext, aBlurSubframe); + } else { + if (aBlurSubframe) { + SpecialPowers.clearFocus(windowToFocus); + } + + browsingContext = windowToFocus.browsingContext; + } + + // Some tests rely on this delay, likely expecting layout or paint to occur. + await new Promise(resolve => { + SimpleTest.executeSoon(resolve); + }); + + return browsingContext; +}; + +/** + * Version of promiseFocus that uses a callback. For compatibility, + * the callback is passed one argument, the window that was focused. + * If the focused window is not in the same process, null is supplied. + */ +SimpleTest.waitForFocus = function(callback, aObject, expectBlankPage) { + SimpleTest.promiseFocus(aObject, expectBlankPage).then(focusedBC => { + callback(focusedBC?.window); + }); +}; +/* eslint-enable mozilla/use-services */ + +SimpleTest.stripLinebreaksAndWhitespaceAfterTags = function(aString) { + return aString.replace(/(>\s*(\r\n|\n|\r)*\s*)/gm, ">"); +}; + +/* + * `navigator.platform` should include this, when the platform is Windows. + */ +const kPlatformWindows = "Win"; + +/* + * See `SimpleTest.waitForClipboard`. + */ +const kTextHtmlPrefixClipboardDataWindows = + "<html><body>\n<!--StartFragment-->"; + +/* + * See `SimpleTest.waitForClipboard`. + */ +const kTextHtmlSuffixClipboardDataWindows = + "<!--EndFragment-->\n</body>\n</html>"; + +/* + * Polls the clipboard waiting for the expected value. A known value different than + * the expected value is put on the clipboard first (and also polled for) so we + * can be sure the value we get isn't just the expected value because it was already + * on the clipboard. This only uses the global clipboard and only for text/unicode + * values. + * + * @param {String|Function} aExpectedStringOrValidatorFn + * The string value that is expected to be on the clipboard, or a + * validator function getting expected clipboard data and returning a bool. + * If you specify string value, line breakers in clipboard are treated + * as LineFeed. Therefore, you cannot include CarriageReturn to the + * string. + * If you specify string value and expect "text/html" data, this wraps + * the expected value with `kTextHtmlPrefixClipboardDataWindows` and + * `kTextHtmlSuffixClipboardDataWindows` only when it runs on Windows + * because they are appended only by nsDataObj.cpp for Windows. + * https://searchfox.org/mozilla-central/rev/8f7b017a31326515cb467e69eef1f6c965b4f00e/widget/windows/nsDataObj.cpp#1798-1805,1839-1840,1842 + * Therefore, you can specify selected (copied) HTML data simply on any + * platforms. + * @param {Function} aSetupFn + * A function responsible for setting the clipboard to the expected value, + * called after the known value setting succeeds. + * @param {Function} aSuccessFn + * A function called when the expected value is found on the clipboard. + * @param {Function} aFailureFn + * A function called if the expected value isn't found on the clipboard + * within 5s. It can also be called if the known value can't be found. + * @param {String} [aFlavor="text/unicode"] + * The flavor to look for. + * @param {Number} [aTimeout=5000] + * The timeout (in milliseconds) to wait for a clipboard change. + * @param {boolean} [aExpectFailure=false] + * If true, fail if the clipboard contents are modified within the timeout + * interval defined by aTimeout. When aExpectFailure is true, the argument + * aExpectedStringOrValidatorFn must be null, as it won't be used. + * @param {boolean} [aDontInitializeClipboardIfExpectFailure=false] + * If aExpectFailure and this is set to true, this does NOT initialize + * clipboard with random data before running aSetupFn. + */ +SimpleTest.waitForClipboard = function( + aExpectedStringOrValidatorFn, + aSetupFn, + aSuccessFn, + aFailureFn, + aFlavor, + aTimeout, + aExpectFailure, + aDontInitializeClipboardIfExpectFailure +) { + let promise = SimpleTest.promiseClipboardChange( + aExpectedStringOrValidatorFn, + aSetupFn, + aFlavor, + aTimeout, + aExpectFailure, + aDontInitializeClipboardIfExpectFailure + ); + promise.then(aSuccessFn).catch(aFailureFn); +}; + +/** + * Promise-oriented version of waitForClipboard. + */ +SimpleTest.promiseClipboardChange = async function( + aExpectedStringOrValidatorFn, + aSetupFn, + aFlavor, + aTimeout, + aExpectFailure, + aDontInitializeClipboardIfExpectFailure +) { + let requestedFlavor = aFlavor || "text/unicode"; + + // The known value we put on the clipboard before running aSetupFn + let initialVal = "waitForClipboard-known-value-" + Math.random(); + let preExpectedVal = initialVal; + + let inputValidatorFn; + if (aExpectFailure) { + // If we expect failure, the aExpectedStringOrValidatorFn should be null + if (aExpectedStringOrValidatorFn !== null) { + SimpleTest.ok( + false, + "When expecting failure, aExpectedStringOrValidatorFn must be null" + ); + } + + inputValidatorFn = function(aData) { + return aData != initialVal; + }; + // Build a default validator function for common string input. + } else if (typeof aExpectedStringOrValidatorFn == "string") { + if (aExpectedStringOrValidatorFn.includes("\r")) { + throw new Error( + "Use function instead of string to compare raw line breakers in clipboard" + ); + } + if (requestedFlavor === "text/html" && navigator.platform.includes("Win")) { + inputValidatorFn = function(aData) { + return ( + aData.replace(/\r\n?/g, "\n") === + kTextHtmlPrefixClipboardDataWindows + + aExpectedStringOrValidatorFn + + kTextHtmlSuffixClipboardDataWindows + ); + }; + } else { + inputValidatorFn = function(aData) { + return aData.replace(/\r\n?/g, "\n") === aExpectedStringOrValidatorFn; + }; + } + } else { + inputValidatorFn = aExpectedStringOrValidatorFn; + } + + let maxPolls = aTimeout ? aTimeout / 100 : 50; + + async function putAndVerify(operationFn, validatorFn, flavor, expectFailure) { + await operationFn(); + + let data; + for (let i = 0; i < maxPolls; i++) { + data = SpecialPowers.getClipboardData(flavor); + if (validatorFn(data)) { + // Don't show the success message when waiting for preExpectedVal + if (preExpectedVal) { + preExpectedVal = null; + } else { + SimpleTest.ok( + !expectFailure, + "Clipboard has the given value: '" + data + "'" + ); + } + + return data; + } + + // Wait 100ms and check again. + await new Promise(resolve => { + SimpleTest._originalSetTimeout.apply(window, [resolve, 100]); + }); + } + + let errorMsg = `Timed out while polling clipboard for ${ + preExpectedVal ? "initialized" : "requested" + } data, got: ${data}`; + SimpleTest.ok(expectFailure, errorMsg); + if (!expectFailure) { + throw new Error(errorMsg); + } + return data; + } + + if (!aExpectFailure || !aDontInitializeClipboardIfExpectFailure) { + // First we wait for a known value different from the expected one. + SimpleTest.info(`Initializing clipboard with "${preExpectedVal}"...`); + await putAndVerify( + function() { + SpecialPowers.clipboardCopyString(preExpectedVal); + }, + function(aData) { + return aData == preExpectedVal; + }, + "text/unicode", + false + ); + + SimpleTest.info( + "Succeeded initializing clipboard, start requested things..." + ); + } else { + preExpectedVal = null; + } + + return putAndVerify( + aSetupFn, + inputValidatorFn, + requestedFlavor, + aExpectFailure + ); +}; + +/** + * Wait for a condition for a while (actually up to 3s here). + * + * @param {Function} aCond + * A function returns the result of the condition + * @param {Function} aCallback + * A function called after the condition is passed or timeout. + * @param {String} aErrorMsg + * The message displayed when the condition failed to pass + * before timeout. + */ +SimpleTest.waitForCondition = function(aCond, aCallback, aErrorMsg) { + this.promiseWaitForCondition(aCond, aErrorMsg).then(() => aCallback()); +}; +SimpleTest.promiseWaitForCondition = async function(aCond, aErrorMsg) { + for (let tries = 0; tries < 30; ++tries) { + // Wait 100ms between checks. + await new Promise(resolve => { + SimpleTest._originalSetTimeout.apply(window, [resolve, 100]); + }); + + let conditionPassed; + try { + conditionPassed = await aCond(); + } catch (e) { + ok(false, `${e}\n${e.stack}`); + conditionPassed = false; + } + if (conditionPassed) { + return; + } + } + ok(false, aErrorMsg); +}; + +/** + * Executes a function shortly after the call, but lets the caller continue + * working (or finish). + * + * @param {Function} aFunc + * Function to execute soon. + */ +SimpleTest.executeSoon = function(aFunc) { + if ("SpecialPowers" in window) { + return SpecialPowers.executeSoon(aFunc, window); + } + setTimeout(aFunc, 0); + return null; // Avoid warning. +}; + +/** + * Register a cleanup/teardown function (which may be async) to run after all + * tasks have finished, before running the next test. If async (or the function + * returns a promise), the framework will wait for the promise/async function + * to resolve. + * + * @param {Function} aFunc + * The cleanup/teardown function to run. + */ +SimpleTest.registerCleanupFunction = function(aFunc) { + SimpleTest._cleanupFunctions.push(aFunc); +}; + +SimpleTest.registerTimeoutFunction = function(aFunc) { + SimpleTest._timeoutFunctions.push(aFunc); +}; + +SimpleTest.testInChaosMode = function() { + if (SimpleTest._inChaosMode) { + // It's already enabled for this test, don't enter twice + return; + } + SpecialPowers.DOMWindowUtils.enterChaosMode(); + SimpleTest._inChaosMode = true; + // increase timeout here as chaosmode is very slow (i.e. 10x) + // doing 20x as this overwrites anything the tests set + SimpleTest.requestLongerTimeout(20); +}; + +SimpleTest.timeout = async function() { + for (const func of SimpleTest._timeoutFunctions) { + await func(); + } + SimpleTest._timeoutFunctions = []; +}; + +SimpleTest.finishWithFailure = function(msg) { + SimpleTest.ok(false, msg); + SimpleTest.finish(); +}; + +/** + * Finishes the tests. This is automatically called, except when + * SimpleTest.waitForExplicitFinish() has been invoked. + **/ +SimpleTest.finish = function() { + if (SimpleTest._alreadyFinished) { + var err = + "TEST-UNEXPECTED-FAIL | SimpleTest | this test already called finish!"; + if (parentRunner) { + parentRunner.structuredLogger.error(err); + } else { + dump(err + "\n"); + } + } + + if (SimpleTest.expected == "fail" && SimpleTest.num_failed <= 0) { + let msg = "We expected at least one failure"; + let test = { + result: false, + name: "fail-if condition in manifest", + diag: msg, + }; + let successInfo = { + status: "FAIL", + expected: "FAIL", + message: "TEST-KNOWN-FAIL", + }; + let failureInfo = { + status: "PASS", + expected: "FAIL", + message: "TEST-UNEXPECTED-PASS", + }; + SimpleTest._logResult(test, successInfo, failureInfo); + SimpleTest._tests.push(test); + } else if (usesFailurePatterns()) { + SimpleTest.expected.forEach(([pat, expected_count], i) => { + let count = SimpleTest.num_failed[i]; + let diag; + if (expected_count === null && count == 0) { + diag = "expected some failures but got none"; + } else if (expected_count !== null && expected_count != count) { + diag = `expected ${expected_count} failures but got ${count}`; + } else { + return; + } + let name = pat + ? `failure pattern \`${pat}\` in this test` + : "failures in this test"; + let test = { result: false, name, diag }; + let successInfo = { + status: "PASS", + expected: "PASS", + message: "TEST-PASS", + }; + let failureInfo = { + status: "FAIL", + expected: "PASS", + message: "TEST-UNEXPECTED-FAIL", + }; + SimpleTest._logResult(test, successInfo, failureInfo); + SimpleTest._tests.push(test); + }); + } + + SimpleTest._timeoutFunctions = []; + + SimpleTest.testsLength = SimpleTest._tests.length; + + SimpleTest._alreadyFinished = true; + + if (SimpleTest._inChaosMode) { + SpecialPowers.DOMWindowUtils.leaveChaosMode(); + SimpleTest._inChaosMode = false; + } + + var afterCleanup = async function() { + SpecialPowers.removeFiles(); + + if (SpecialPowers.DOMWindowUtils.isTestControllingRefreshes) { + SimpleTest.ok(false, "test left refresh driver under test control"); + SpecialPowers.DOMWindowUtils.restoreNormalRefresh(); + } + if (SimpleTest._expectingUncaughtException) { + SimpleTest.ok( + false, + "expectUncaughtException was called but no uncaught exception was detected!" + ); + } + if (!SimpleTest._tests.length) { + SimpleTest.ok( + false, + "[SimpleTest.finish()] No checks actually run. " + + "(You need to call ok(), is(), or similar " + + "functions at least once. Make sure you use " + + "SimpleTest.waitForExplicitFinish() if you need " + + "it.)" + ); + } + + let workers = await SpecialPowers.registeredServiceWorkers(); + let promise = null; + if (SimpleTest._expectingRegisteredServiceWorker) { + if (workers.length === 0) { + SimpleTest.ok( + false, + "This test is expected to leave a service worker registered" + ); + } + } else if (workers.length) { + let FULL_PROFILE_WORKERS_TO_IGNORE = []; + if (parentRunner.conditionedProfile) { + // Full profile has service workers in the profile, without clearing the profile + // service workers will be leftover, in all my testing youtube is the only one. + FULL_PROFILE_WORKERS_TO_IGNORE = ["https://www.youtube.com/sw.js"]; + } else { + SimpleTest.ok( + false, + "This test left a service worker registered without cleaning it up" + ); + } + + for (let worker of workers) { + if (FULL_PROFILE_WORKERS_TO_IGNORE.includes(worker.scriptSpec)) { + continue; + } + SimpleTest.ok( + false, + `Left over worker: ${worker.scriptSpec} (scope: ${worker.scope})` + ); + } + promise = SpecialPowers.removeAllServiceWorkerData(); + } + + // If we want to wait for removeAllServiceWorkerData to finish, above, + // there's a small chance that spinning the event loop could cause + // SpecialPowers and SimpleTest to go away (e.g. if the test did + // document.open). promise being non-null should be rare (a test would + // have had to already fail by leaving a service worker around), so + // limit the chances of the async wait happening to that case. + function finish() { + if (parentRunner) { + /* We're running in an iframe, and the parent has a TestRunner */ + parentRunner.testFinished(SimpleTest._tests); + } + + if (!parentRunner || parentRunner.showTestReport) { + SpecialPowers.flushPermissions(function() { + SpecialPowers.flushPrefEnv(function() { + SimpleTest.showReport(); + }); + }); + } + } + + if (promise) { + promise.then(finish); + } else { + finish(); + } + }; + + var executeCleanupFunction = function() { + var func = SimpleTest._cleanupFunctions.pop(); + + if (!func) { + afterCleanup(); + return; + } + + var ret; + try { + ret = func(); + } catch (ex) { + SimpleTest.ok(false, "Cleanup function threw exception: " + ex); + } + + if (ret && ret.constructor.name == "Promise") { + ret.then(executeCleanupFunction, ex => + SimpleTest.ok(false, "Cleanup promise rejected: " + ex) + ); + } else { + executeCleanupFunction(); + } + }; + + executeCleanupFunction(); + + SpecialPowers.notifyObservers(null, "test-complete"); +}; + +/** + * Monitor console output from now until endMonitorConsole is called. + * + * Expect to receive all console messages described by the elements of + * ``msgs``, an array, in the order listed in ``msgs``; each element is an + * object which may have any number of the following properties: + * + * message, errorMessage, sourceName, sourceLine, category: string or regexp + * lineNumber, columnNumber: number + * isScriptError, isWarning: boolean + * + * Strings, numbers, and booleans must compare equal to the named + * property of the Nth console message. Regexps must match. Any + * fields present in the message but not in the pattern object are ignored. + * + * In addition to the above properties, elements in ``msgs`` may have a ``forbid`` + * boolean property. When ``forbid`` is true, a failure is logged each time a + * matching message is received. + * + * If ``forbidUnexpectedMsgs`` is true, then the messages received in the console + * must exactly match the non-forbidden messages in ``msgs``; for each received + * message not described by the next element in ``msgs``, a failure is logged. If + * false, then other non-forbidden messages are ignored, but all expected + * messages must still be received. + * + * After endMonitorConsole is called, ``continuation`` will be called + * asynchronously. (Normally, you will want to pass ``SimpleTest.finish`` here.) + * + * It is incorrect to use this function in a test which has not called + * SimpleTest.waitForExplicitFinish. + */ +SimpleTest.monitorConsole = function(continuation, msgs, forbidUnexpectedMsgs) { + if (SimpleTest._stopOnLoad) { + ok(false, "Console monitoring requires use of waitForExplicitFinish."); + } + + function msgMatches(msg, pat) { + for (var k in pat) { + if (!(k in msg)) { + return false; + } + if (pat[k] instanceof RegExp && typeof msg[k] === "string") { + if (!pat[k].test(msg[k])) { + return false; + } + } else if (msg[k] !== pat[k]) { + return false; + } + } + return true; + } + + var forbiddenMsgs = []; + var i = 0; + while (i < msgs.length) { + let pat = msgs[i]; + if ("forbid" in pat) { + var forbid = pat.forbid; + delete pat.forbid; + if (forbid) { + forbiddenMsgs.push(pat); + msgs.splice(i, 1); + continue; + } + } + i++; + } + + var counter = 0; + var assertionLabel = JSON.stringify(msgs); + function listener(msg) { + if (msg.message === "SENTINEL" && !msg.isScriptError) { + is( + counter, + msgs.length, + "monitorConsole | number of messages " + assertionLabel + ); + SimpleTest.executeSoon(continuation); + return; + } + for (let pat of forbiddenMsgs) { + if (msgMatches(msg, pat)) { + ok( + false, + "monitorConsole | observed forbidden message " + JSON.stringify(msg) + ); + return; + } + } + if (counter >= msgs.length) { + var str = "monitorConsole | extra message | " + JSON.stringify(msg); + if (forbidUnexpectedMsgs) { + ok(false, str); + } else { + info(str); + } + return; + } + var matches = msgMatches(msg, msgs[counter]); + if (forbidUnexpectedMsgs) { + ok( + matches, + "monitorConsole | [" + counter + "] must match " + JSON.stringify(msg) + ); + } else { + info( + "monitorConsole | [" + + counter + + "] " + + (matches ? "matched " : "did not match ") + + JSON.stringify(msg) + ); + } + if (matches) { + counter++; + } + } + SpecialPowers.registerConsoleListener(listener); +}; + +/** + * Stop monitoring console output. + */ +SimpleTest.endMonitorConsole = function() { + SpecialPowers.postConsoleSentinel(); +}; + +/** + * Run ``testfn`` synchronously, and monitor its console output. + * + * ``msgs`` is handled as described above for monitorConsole. + * + * After ``testfn`` returns, console monitoring will stop, and ``continuation`` + * will be called asynchronously. + * + */ +SimpleTest.expectConsoleMessages = function(testfn, msgs, continuation) { + SimpleTest.monitorConsole(continuation, msgs); + testfn(); + SimpleTest.executeSoon(SimpleTest.endMonitorConsole); +}; + +/** + * Wrapper around ``expectConsoleMessages`` for the case where the test has + * only one ``testfn`` to run. + */ +SimpleTest.runTestExpectingConsoleMessages = function(testfn, msgs) { + SimpleTest.waitForExplicitFinish(); + SimpleTest.expectConsoleMessages(testfn, msgs, SimpleTest.finish); +}; + +/** + * Indicates to the test framework that the current test expects one or + * more crashes (from plugins or IPC documents), and that the minidumps from + * those crashes should be removed. + */ +SimpleTest.expectChildProcessCrash = function() { + if (parentRunner) { + parentRunner.expectChildProcessCrash(); + } +}; + +/** + * Indicates to the test framework that the next uncaught exception during + * the test is expected, and should not cause a test failure. + */ +SimpleTest.expectUncaughtException = function(aExpecting) { + SimpleTest._expectingUncaughtException = + aExpecting === void 0 || !!aExpecting; +}; + +/** + * Returns whether the test has indicated that it expects an uncaught exception + * to occur. + */ +SimpleTest.isExpectingUncaughtException = function() { + return SimpleTest._expectingUncaughtException; +}; + +/** + * Indicates to the test framework that all of the uncaught exceptions + * during the test are known problems that should be fixed in the future, + * but which should not cause the test to fail currently. + */ +SimpleTest.ignoreAllUncaughtExceptions = function(aIgnoring) { + SimpleTest._ignoringAllUncaughtExceptions = + aIgnoring === void 0 || !!aIgnoring; +}; + +/** + * Returns whether the test has indicated that all uncaught exceptions should be + * ignored. + */ +SimpleTest.isIgnoringAllUncaughtExceptions = function() { + return SimpleTest._ignoringAllUncaughtExceptions; +}; + +/** + * Indicates to the test framework that this test is expected to leave a + * service worker registered when it finishes. + */ +SimpleTest.expectRegisteredServiceWorker = function() { + SimpleTest._expectingRegisteredServiceWorker = true; +}; + +/** + * Resets any state this SimpleTest object has. This is important for + * browser chrome mochitests, which reuse the same SimpleTest object + * across a run. + */ +SimpleTest.reset = function() { + SimpleTest._ignoringAllUncaughtExceptions = false; + SimpleTest._expectingUncaughtException = false; + SimpleTest._expectingRegisteredServiceWorker = false; + SimpleTest._bufferedMessages = []; +}; + +if (isPrimaryTestWindow) { + addLoadEvent(function() { + if (SimpleTest._stopOnLoad) { + SimpleTest.finish(); + } + }); +} + +// --------------- Test.Builder/Test.More isDeeply() ----------------- + +SimpleTest.DNE = { dne: "Does not exist" }; +SimpleTest.LF = "\r\n"; + +SimpleTest._deepCheck = function(e1, e2, stack, seen) { + var ok = false; + if (Object.is(e1, e2)) { + // Handles identical primitives and references. + ok = true; + } else if ( + typeof e1 != "object" || + typeof e2 != "object" || + e1 === null || + e2 === null + ) { + // If either argument is a primitive or function, don't consider the arguments the same. + ok = false; + } else if (e1 == SimpleTest.DNE || e2 == SimpleTest.DNE) { + ok = false; + } else if (SimpleTest.isa(e1, "Array") && SimpleTest.isa(e2, "Array")) { + ok = SimpleTest._eqArray(e1, e2, stack, seen); + } else { + ok = SimpleTest._eqAssoc(e1, e2, stack, seen); + } + return ok; +}; + +SimpleTest._eqArray = function(a1, a2, stack, seen) { + // Return if they're the same object. + if (a1 == a2) { + return true; + } + + // JavaScript objects have no unique identifiers, so we have to store + // references to them all in an array, and then compare the references + // directly. It's slow, but probably won't be much of an issue in + // practice. Start by making a local copy of the array to as to avoid + // confusing a reference seen more than once (such as [a, a]) for a + // circular reference. + for (var j = 0; j < seen.length; j++) { + if (seen[j][0] == a1) { + return seen[j][1] == a2; + } + } + + // If we get here, we haven't seen a1 before, so store it with reference + // to a2. + seen.push([a1, a2]); + + var ok = true; + // Only examines enumerable attributes. Only works for numeric arrays! + // Associative arrays return 0. So call _eqAssoc() for them, instead. + var max = Math.max(a1.length, a2.length); + if (max == 0) { + return SimpleTest._eqAssoc(a1, a2, stack, seen); + } + for (var i = 0; i < max; i++) { + var e1 = i < a1.length ? a1[i] : SimpleTest.DNE; + var e2 = i < a2.length ? a2[i] : SimpleTest.DNE; + stack.push({ type: "Array", idx: i, vals: [e1, e2] }); + ok = SimpleTest._deepCheck(e1, e2, stack, seen); + if (ok) { + stack.pop(); + } else { + break; + } + } + return ok; +}; + +SimpleTest._eqAssoc = function(o1, o2, stack, seen) { + // Return if they're the same object. + if (o1 == o2) { + return true; + } + + // JavaScript objects have no unique identifiers, so we have to store + // references to them all in an array, and then compare the references + // directly. It's slow, but probably won't be much of an issue in + // practice. Start by making a local copy of the array to as to avoid + // confusing a reference seen more than once (such as [a, a]) for a + // circular reference. + seen = seen.slice(0); + for (let j = 0; j < seen.length; j++) { + if (seen[j][0] == o1) { + return seen[j][1] == o2; + } + } + + // If we get here, we haven't seen o1 before, so store it with reference + // to o2. + seen.push([o1, o2]); + + // They should be of the same class. + + var ok = true; + // Only examines enumerable attributes. + var o1Size = 0; + // eslint-disable-next-line no-unused-vars + for (let i in o1) { + o1Size++; + } + var o2Size = 0; + // eslint-disable-next-line no-unused-vars + for (let i in o2) { + o2Size++; + } + var bigger = o1Size > o2Size ? o1 : o2; + for (let i in bigger) { + var e1 = i in o1 ? o1[i] : SimpleTest.DNE; + var e2 = i in o2 ? o2[i] : SimpleTest.DNE; + stack.push({ type: "Object", idx: i, vals: [e1, e2] }); + ok = SimpleTest._deepCheck(e1, e2, stack, seen); + if (ok) { + stack.pop(); + } else { + break; + } + } + return ok; +}; + +SimpleTest._formatStack = function(stack) { + var variable = "$Foo"; + for (let i = 0; i < stack.length; i++) { + var entry = stack[i]; + var type = entry.type; + var idx = entry.idx; + if (idx != null) { + if (type == "Array") { + // Numeric array index. + variable += "[" + idx + "]"; + } else { + // Associative array index. + idx = idx.replace("'", "\\'"); + variable += "['" + idx + "']"; + } + } + } + + var vals = stack[stack.length - 1].vals.slice(0, 2); + var vars = [ + variable.replace("$Foo", "got"), + variable.replace("$Foo", "expected"), + ]; + + var out = "Structures begin differing at:" + SimpleTest.LF; + for (let i = 0; i < vals.length; i++) { + var val = vals[i]; + if (val === SimpleTest.DNE) { + val = "Does not exist"; + } else { + val = repr(val); + } + out += vars[i] + " = " + val + SimpleTest.LF; + } + + return " " + out; +}; + +SimpleTest.isDeeply = function(it, as, name) { + var stack = [{ vals: [it, as] }]; + var seen = []; + if (SimpleTest._deepCheck(it, as, stack, seen)) { + SimpleTest.record(true, name); + } else { + SimpleTest.record(false, name, SimpleTest._formatStack(stack)); + } +}; + +SimpleTest.typeOf = function(object) { + var c = Object.prototype.toString.apply(object); + var name = c.substring(8, c.length - 1); + if (name != "Object") { + return name; + } + // It may be a non-core class. Try to extract the class name from + // the constructor function. This may not work in all implementations. + if (/function ([^(\s]+)/.test(Function.toString.call(object.constructor))) { + return RegExp.$1; + } + // No idea. :-( + return name; +}; + +SimpleTest.isa = function(object, clas) { + return SimpleTest.typeOf(object) == clas; +}; + +// Global symbols: +var ok = SimpleTest.ok; +var record = SimpleTest.record; +var is = SimpleTest.is; +var isfuzzy = SimpleTest.isfuzzy; +var isnot = SimpleTest.isnot; +var todo = SimpleTest.todo; +var todo_is = SimpleTest.todo_is; +var todo_isnot = SimpleTest.todo_isnot; +var isDeeply = SimpleTest.isDeeply; +var info = SimpleTest.info; + +var gOldOnError = window.onerror; +window.onerror = function simpletestOnerror( + errorMsg, + url, + lineNumber, + columnNumber, + originalException +) { + // Log the message. + // XXX Chrome mochitests sometimes trigger this window.onerror handler, + // but there are a number of uncaught JS exceptions from those tests. + // For now, for tests that self identify as having unintentional uncaught + // exceptions, just dump it so that the error is visible but doesn't cause + // a test failure. See bug 652494. + var isExpected = !!SimpleTest._expectingUncaughtException; + var message = (isExpected ? "expected " : "") + "uncaught exception"; + var error = errorMsg + " at "; + try { + error += originalException.stack; + } catch (e) { + // At least use the url+line+column we were given + error += url + ":" + lineNumber + ":" + columnNumber; + } + if (!SimpleTest._ignoringAllUncaughtExceptions) { + // Don't log if SimpleTest.finish() is already called, it would cause failures + if (!SimpleTest._alreadyFinished) { + SimpleTest.record(isExpected, message, error); + } + SimpleTest._expectingUncaughtException = false; + } else { + SimpleTest.todo(false, message + ": " + error); + } + // There is no Components.stack.caller to log. (See bug 511888.) + + // Call previous handler. + if (gOldOnError) { + try { + // Ignore return value: always run default handler. + gOldOnError(errorMsg, url, lineNumber); + } catch (e) { + // Log the error. + SimpleTest.info("Exception thrown by gOldOnError(): " + e); + // Log its stack. + if (e.stack) { + SimpleTest.info("JavaScript error stack:\n" + e.stack); + } + } + } + + if (!SimpleTest._stopOnLoad && !isExpected && !SimpleTest._alreadyFinished) { + // Need to finish() manually here, yet let the test actually end first. + SimpleTest.executeSoon(SimpleTest.finish); + } +}; + +// Lifted from dom/media/test/manifest.js +// Make sure to not touch navigator in here, since we want to push prefs that +// will affect the APIs it exposes, but the set of exposed APIs is determined +// when Navigator.prototype is created. So if we touch navigator before pushing +// the prefs, the APIs it exposes will not take those prefs into account. We +// work around this by using a navigator object from a different global for our +// UA string testing. +var gAndroidSdk = null; +function getAndroidSdk() { + if (gAndroidSdk === null) { + var iframe = document.documentElement.appendChild( + document.createElement("iframe") + ); + iframe.style.display = "none"; + var nav = iframe.contentWindow.navigator; + if ( + !nav.userAgent.includes("Mobile") && + !nav.userAgent.includes("Tablet") + ) { + gAndroidSdk = -1; + } else { + // See nsSystemInfo.cpp, the getProperty('version') returns different value + // on each platforms, so we need to distinguish the android platform. + var versionString = nav.userAgent.includes("Android") + ? "version" + : "sdk_version"; + gAndroidSdk = SpecialPowers.Services.sysinfo.getProperty(versionString); + } + document.documentElement.removeChild(iframe); + } + return gAndroidSdk; +} + +// add_task(generatorFunction): +// Call `add_task(generatorFunction)` for each separate +// asynchronous task in a mochitest. Tasks are run consecutively. +// Before the first task, `SimpleTest.waitForExplicitFinish()` +// will be called automatically, and after the last task, +// `SimpleTest.finish()` will be called. +var add_task = (function() { + // The list of tasks to run. + var task_list = []; + var run_only_this_task = null; + + function isGenerator(value) { + return ( + value && typeof value === "object" && typeof value.next === "function" + ); + } + + // The "add_task" function + return function(generatorFunction, options = { isSetup: false }) { + if (task_list.length === 0) { + // This is the first time add_task has been called. + // First, confirm that SimpleTest is available. + if (!SimpleTest) { + throw new Error("SimpleTest not available."); + } + // Don't stop tests until asynchronous tasks are finished. + SimpleTest.waitForExplicitFinish(); + // Because the client is using add_task for this set of tests, + // we need to spawn a "master task" that calls each task in succesion. + // Use setTimeout to ensure the master task runs after the client + // script finishes. + setTimeout(function nextTick() { + // If we are in a HTML document, we should wait for the document + // to be fully loaded. + // These checks ensure that we are in an HTML document without + // throwing TypeError; also I am told that readyState in XUL documents + // are totally bogus so we don't try to do this there. + if ( + typeof window !== "undefined" && + typeof HTMLDocument !== "undefined" && + window.document instanceof HTMLDocument && + window.document.readyState !== "complete" + ) { + setTimeout(nextTick); + return; + } + + (async () => { + // Allow for a task to be skipped; we need only use the structured logger + // for this, whilst deactivating log buffering to ensure that messages + // are always printed to stdout. + function skipTask(name) { + let logger = parentRunner && parentRunner.structuredLogger; + if (!logger) { + info("add_task | Skipping test " + name); + return; + } + logger.deactivateBuffering(); + logger.testStatus(SimpleTest._getCurrentTestURL(), name, "SKIP"); + logger.warning("add_task | Skipping test " + name); + logger.activateBuffering(); + } + + // We stop the entire test file at the first exception because this + // may mean that the state of subsequent tests may be corrupt. + try { + for (var task of task_list) { + var name = task.name || ""; + if ( + task.__skipMe || + (run_only_this_task && task != run_only_this_task) + ) { + skipTask(name); + continue; + } + const taskInfo = action => + info( + `${ + task.isSetup ? "add_setup" : "add_task" + } | ${action} ${name}` + ); + taskInfo("Entering"); + let result = await task(); + if (isGenerator(result)) { + ok(false, "Task returned a generator"); + } + taskInfo("Leaving"); + } + } catch (ex) { + try { + let serializedEx; + if (ex instanceof Error) { + serializedEx = `${ex}`; + } else { + serializedEx = JSON.stringify(ex); + } + + SimpleTest.record( + false, + serializedEx, + "Should not throw any errors", + ex.stack + ); + } catch (ex2) { + SimpleTest.record( + false, + "(The exception cannot be converted to string.)", + "Should not throw any errors", + ex.stack + ); + } + } + // All tasks are finished. + SimpleTest.finish(); + })(); + }); + } + generatorFunction.skip = () => (generatorFunction.__skipMe = true); + generatorFunction.only = () => (run_only_this_task = generatorFunction); + // Add the task to the list of tasks to run after + // the main thread is finished. + if (options.isSetup) { + generatorFunction.isSetup = true; + let lastSetupIndex = task_list.findLastIndex(t => t.isSetup) + 1; + task_list.splice(lastSetupIndex, 0, generatorFunction); + } else { + task_list.push(generatorFunction); + } + return generatorFunction; + }; +})(); + +// Like add_task, but setup tasks are executed first. +function add_setup(generatorFunction) { + return add_task(generatorFunction, { isSetup: true }); +} + +// Request complete log when using failure patterns so that failure info +// from infra can be useful. +if (usesFailurePatterns()) { + SimpleTest.requestCompleteLog(); +} + +addEventListener("message", async event => { + if (event.data == "SimpleTest:timeout") { + await SimpleTest.timeout(); + SimpleTest.finish(); + } +}); diff --git a/testing/mochitest/tests/SimpleTest/TestRunner.js b/testing/mochitest/tests/SimpleTest/TestRunner.js new file mode 100644 index 0000000000..915ac6cbad --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/TestRunner.js @@ -0,0 +1,1077 @@ +/* -*- js-indent-level: 4; indent-tabs-mode: nil -*- */ +/* + * e10s event dispatcher from content->chrome + * + * type = eventName (QuitApplication) + * data = json object {"filename":filename} <- for LoggerInit + */ + +// This file expects the following files to be loaded. +/* import-globals-from LogController.js */ +/* import-globals-from MemoryStats.js */ +/* import-globals-from MozillaLogger.js */ + +/* eslint-disable no-unsanitized/property */ + +"use strict"; + +const { + StructuredLogger, + StructuredFormatter, +} = SpecialPowers.ChromeUtils.importESModule( + "resource://testing-common/StructuredLog.sys.mjs" +); + +function getElement(id) { + return typeof id == "string" ? document.getElementById(id) : id; +} + +this.$ = this.getElement; + +function contentDispatchEvent(type, data, sync) { + if (typeof data == "undefined") { + data = {}; + } + + var event = new CustomEvent("contentEvent", { + bubbles: true, + detail: { + sync, + type, + data: JSON.stringify(data), + }, + }); + document.dispatchEvent(event); +} + +function contentAsyncEvent(type, data) { + contentDispatchEvent(type, data, 0); +} + +/* Helper Function */ +function extend(obj, /* optional */ skip) { + // Extend an array with an array-like object starting + // from the skip index + if (!skip) { + skip = 0; + } + if (obj) { + var l = obj.length; + var ret = []; + for (var i = skip; i < l; i++) { + ret.push(obj[i]); + } + } + return ret; +} + +function flattenArguments(lst /* ...*/) { + var res = []; + var args = extend(arguments); + while (args.length) { + var o = args.shift(); + if (o && typeof o == "object" && typeof o.length == "number") { + for (var i = o.length - 1; i >= 0; i--) { + args.unshift(o[i]); + } + } else { + res.push(o); + } + } + return res; +} + +function testInXOriginFrame() { + // Check if the test running in an iframe is a cross origin test. + try { + $("testframe").contentWindow.origin; + return false; + } catch (e) { + return true; + } +} + +function testInDifferentProcess() { + // Check if the test running in an iframe that is loaded in a different process. + return SpecialPowers.Cu.isRemoteProxy($("testframe").contentWindow); +} + +/** + * TestRunner: A test runner for SimpleTest + * TODO: + * + * * Avoid moving iframes: That causes reloads on mozilla and opera. + * + * + **/ +var TestRunner = {}; +TestRunner.logEnabled = false; +TestRunner._currentTest = 0; +TestRunner._lastTestFinished = -1; +TestRunner._loopIsRestarting = false; +TestRunner.currentTestURL = ""; +TestRunner.originalTestURL = ""; +TestRunner._urls = []; +TestRunner._lastAssertionCount = 0; +TestRunner._expectedMinAsserts = 0; +TestRunner._expectedMaxAsserts = 0; + +TestRunner.timeout = 300 * 1000; // 5 minutes. +TestRunner.maxTimeouts = 4; // halt testing after too many timeouts +TestRunner.runSlower = false; +TestRunner.dumpOutputDirectory = ""; +TestRunner.dumpAboutMemoryAfterTest = false; +TestRunner.dumpDMDAfterTest = false; +TestRunner.slowestTestTime = 0; +TestRunner.slowestTestURL = ""; +TestRunner.interactiveDebugger = false; +TestRunner.cleanupCrashes = false; +TestRunner.timeoutAsPass = false; +TestRunner.conditionedProfile = false; + +TestRunner._expectingProcessCrash = false; +TestRunner._structuredFormatter = new StructuredFormatter(); + +/** + * Make sure the tests don't hang indefinitely. + **/ +TestRunner._numTimeouts = 0; +TestRunner._currentTestStartTime = new Date().valueOf(); +TestRunner._timeoutFactor = 1; + +/** + * Used to collect code coverage with the js debugger. + */ +TestRunner.jscovDirPrefix = ""; +var coverageCollector = {}; + +function record(succeeded, expectedFail, msg) { + let successInfo; + let failureInfo; + if (expectedFail) { + successInfo = { + status: "PASS", + expected: "FAIL", + message: "TEST-UNEXPECTED-PASS", + }; + failureInfo = { + status: "FAIL", + expected: "FAIL", + message: "TEST-KNOWN-FAIL", + }; + } else { + successInfo = { + status: "PASS", + expected: "PASS", + message: "TEST-PASS", + }; + failureInfo = { + status: "FAIL", + expected: "PASS", + message: "TEST-UNEXPECTED-FAIL", + }; + } + + let result = succeeded ? successInfo : failureInfo; + + TestRunner.structuredLogger.testStatus( + TestRunner.currentTestURL, + msg, + result.status, + result.expected, + "", + "" + ); +} + +TestRunner._checkForHangs = function() { + function reportError(win, msg) { + if (testInXOriginFrame() || "SimpleTest" in win) { + record(false, TestRunner.timeoutAsPass, msg); + } else if ("W3CTest" in win) { + win.W3CTest.logFailure(msg); + } + } + + async function killTest(win) { + if (testInXOriginFrame()) { + win.postMessage("SimpleTest:timeout", "*"); + } else if ("SimpleTest" in win) { + await win.SimpleTest.timeout(); + win.SimpleTest.finish(); + } else if ("W3CTest" in win) { + await win.W3CTest.timeout(); + } + } + + if (TestRunner._currentTest < TestRunner._urls.length) { + var runtime = new Date().valueOf() - TestRunner._currentTestStartTime; + if (runtime >= TestRunner.timeout * TestRunner._timeoutFactor) { + let testIframe = $("testframe"); + var frameWindow = + (!testInXOriginFrame() && testIframe.contentWindow.wrappedJSObject) || + testIframe.contentWindow; + // TODO : Do this in a way that reports that the test ended with a status "TIMEOUT" + reportError(frameWindow, "Test timed out."); + + // If we have too many timeouts, give up. We don't want to wait hours + // for results if some bug causes lots of tests to time out. + if (++TestRunner._numTimeouts >= TestRunner.maxTimeouts) { + TestRunner._haltTests = true; + + TestRunner.currentTestURL = "(SimpleTest/TestRunner.js)"; + reportError( + frameWindow, + TestRunner.maxTimeouts + " test timeouts, giving up." + ); + var skippedTests = TestRunner._urls.length - TestRunner._currentTest; + reportError( + frameWindow, + "Skipping " + skippedTests + " remaining tests." + ); + } + + // Add a little (1 second) delay to ensure automation.py has time to notice + // "Test timed out" log and process it (= take a screenshot). + setTimeout(async function delayedKillTest() { + try { + await killTest(frameWindow); + } catch (e) { + reportError(frameWindow, "Test error: " + e); + } + }, 1000); + + if (TestRunner._haltTests) { + return; + } + } + + setTimeout(TestRunner._checkForHangs, 30000); + } +}; + +TestRunner.requestLongerTimeout = function(factor) { + TestRunner._timeoutFactor = factor; +}; + +/** + * This is used to loop tests + **/ +TestRunner.repeat = 0; +TestRunner._currentLoop = 1; + +TestRunner.expectAssertions = function(min, max) { + if (typeof max == "undefined") { + max = min; + } + if ( + typeof min != "number" || + typeof max != "number" || + min < 0 || + max < min + ) { + throw new Error("bad parameter to expectAssertions"); + } + TestRunner._expectedMinAsserts = min; + TestRunner._expectedMaxAsserts = max; +}; + +/** + * This function is called after generating the summary. + **/ +TestRunner.onComplete = null; + +/** + * Adds a failed test case to a list so we can rerun only the failed tests + **/ +TestRunner._failedTests = {}; +TestRunner._failureFile = ""; + +TestRunner.addFailedTest = function(testName) { + if (TestRunner._failedTests[testName] == undefined) { + TestRunner._failedTests[testName] = ""; + } +}; + +TestRunner.setFailureFile = function(fileName) { + TestRunner._failureFile = fileName; +}; + +TestRunner.generateFailureList = function() { + if (TestRunner._failureFile) { + var failures = new MozillaFileLogger(TestRunner._failureFile); + failures.log(JSON.stringify(TestRunner._failedTests)); + failures.close(); + } +}; + +/** + * If logEnabled is true, this is the logger that will be used. + **/ + +// This delimiter is used to avoid interleaving Mochitest/Gecko logs. +var LOG_DELIMITER = "\ue175\uee31\u2c32\uacbf"; + +// A log callback for StructuredLog.sys.mjs +TestRunner._dumpMessage = function(message) { + var str; + + // This is a directive to python to format these messages + // for compatibility with mozharness. This can be removed + // with the MochitestFormatter (see bug 1045525). + message.js_source = "TestRunner.js"; + if ( + TestRunner.interactiveDebugger && + message.action in TestRunner._structuredFormatter + ) { + str = TestRunner._structuredFormatter[message.action](message); + } else { + str = LOG_DELIMITER + JSON.stringify(message) + LOG_DELIMITER; + } + // BUGFIX: browser-chrome tests don't use LogController + if (Object.keys(LogController.listeners).length !== 0) { + LogController.log(str); + } else { + dump("\n" + str + "\n"); + } + // Checking for error messages + if (message.expected || message.level === "ERROR") { + TestRunner.failureHandler(); + } +}; + +// From https://searchfox.org/mozilla-central/source/testing/modules/StructuredLog.sys.mjs +TestRunner.structuredLogger = new StructuredLogger( + "mochitest", + TestRunner._dumpMessage, + [], + TestRunner +); +TestRunner.structuredLogger.deactivateBuffering = function() { + TestRunner.structuredLogger.logData("buffering_off"); +}; +TestRunner.structuredLogger.activateBuffering = function() { + TestRunner.structuredLogger.logData("buffering_on"); +}; + +TestRunner.log = function(msg) { + if (TestRunner.logEnabled) { + TestRunner.structuredLogger.info(msg); + } else { + dump(msg + "\n"); + } +}; + +TestRunner.error = function(msg) { + if (TestRunner.logEnabled) { + TestRunner.structuredLogger.error(msg); + } else { + dump(msg + "\n"); + TestRunner.failureHandler(); + } +}; + +TestRunner.failureHandler = function() { + if (TestRunner.runUntilFailure) { + TestRunner._haltTests = true; + } + + if (TestRunner.debugOnFailure) { + // You've hit this line because you requested to break into the + // debugger upon a testcase failure on your test run. + // eslint-disable-next-line no-debugger + debugger; + } +}; + +/** + * Toggle element visibility + **/ +TestRunner._toggle = function(el) { + if (el.className == "noshow") { + el.className = ""; + el.style.cssText = ""; + } else { + el.className = "noshow"; + el.style.cssText = "width:0px; height:0px; border:0px;"; + } +}; + +/** + * Creates the iframe that contains a test + **/ +TestRunner._makeIframe = function(url, retry) { + var iframe = $("testframe"); + if ( + url != "about:blank" && + (("hasFocus" in document && !document.hasFocus()) || + ("activeElement" in document && document.activeElement != iframe)) + ) { + contentAsyncEvent("Focus"); + window.focus(); + SpecialPowers.focus(); + iframe.focus(); + if (retry < 3) { + window.setTimeout(function() { + TestRunner._makeIframe(url, retry + 1); + }, 1000); + return; + } + + TestRunner.structuredLogger.info( + "Error: Unable to restore focus, expect failures and timeouts." + ); + } + window.scrollTo(0, $("indicator").offsetTop); + try { + let urlObj = new URL(url); + if (TestRunner.xOriginTests) { + // The test will run in a xorigin iframe, so we pass in additional test params in the + // URL since the content process won't be able to access them from the parentRunner + // directly. + let params = TestRunner.getParameterInfo(); + urlObj.searchParams.append( + "currentTestURL", + urlObj.pathname.replace("/tests/", "") + ); + urlObj.searchParams.append("closeWhenDone", params.closeWhenDone); + urlObj.searchParams.append("showTestReport", TestRunner.showTestReport); + urlObj.searchParams.append("expected", TestRunner.expected); + iframe.src = urlObj.href; + } else { + iframe.src = url; + } + } catch { + // If the provided `url` is not a valid URL (i.e. doesn't include a protocol) + // then the new URL() constructor will raise a TypeError. This is expected in the + // usual case (i.e. non-xorigin iFrame tests) so set the URL in the usual way. + iframe.src = url; + } + iframe.name = url; + iframe.width = "500"; +}; + +/** + * Returns the current test URL. + * We use this to tell whether the test has navigated to another test without + * being finished first. + */ +TestRunner.getLoadedTestURL = function() { + if (!testInXOriginFrame()) { + var prefix = ""; + // handle mochitest-chrome URIs + if ($("testframe").contentWindow.location.protocol == "chrome:") { + prefix = "chrome://mochitests"; + } + return prefix + $("testframe").contentWindow.location.pathname; + } + return TestRunner.currentTestURL; +}; + +TestRunner.setParameterInfo = function(params) { + this._params = params; +}; + +TestRunner.getParameterInfo = function() { + return this._params; +}; + +/** + * Print information about which prefs are set. + * This is used to help validate that the tests are actually + * running in the expected context. + */ +TestRunner.dumpPrefContext = function() { + let prefs = ["fission.autostart"]; + + let message = ["Dumping test context:"]; + prefs.forEach(function formatPref(pref) { + let val = SpecialPowers.getBoolPref(pref); + message.push(pref + "=" + val); + }); + TestRunner.structuredLogger.info(message.join("\n ")); +}; + +/** + * TestRunner entry point. + * + * The arguments are the URLs of the test to be ran. + * + **/ +TestRunner.runTests = function(/*url...*/) { + TestRunner.structuredLogger.info("SimpleTest START"); + TestRunner.dumpPrefContext(); + TestRunner.originalTestURL = $("current-test").innerHTML; + + SpecialPowers.registerProcessCrashObservers(); + + // Initialize code coverage + if (TestRunner.jscovDirPrefix != "") { + var { CoverageCollector } = SpecialPowers.ChromeUtils.importESModule( + "resource://testing-common/CoverageUtils.sys.mjs" + ); + coverageCollector = new CoverageCollector(TestRunner.jscovDirPrefix); + } + + SpecialPowers.requestResetCoverageCounters().then(() => { + TestRunner._urls = flattenArguments(arguments); + + var singleTestRun = this._urls.length <= 1 && TestRunner.repeat <= 1; + TestRunner.showTestReport = singleTestRun; + var frame = $("testframe"); + frame.src = ""; + if (singleTestRun) { + // Can't use document.body because this runs in a XUL doc as well... + var body = document.getElementsByTagName("body")[0]; + body.setAttribute("singletest", "true"); + frame.removeAttribute("scrolling"); + } + TestRunner._checkForHangs(); + TestRunner.runNextTest(); + }); +}; + +/** + * Used for running a set of tests in a loop for debugging purposes + * Takes an array of URLs + **/ +TestRunner.resetTests = function(listURLs) { + TestRunner._currentTest = 0; + // Reset our "Current-test" line - functionality depends on it + $("current-test").innerHTML = TestRunner.originalTestURL; + if (TestRunner.logEnabled) { + TestRunner.structuredLogger.info( + "SimpleTest START Loop " + TestRunner._currentLoop + ); + } + + TestRunner._urls = listURLs; + $("testframe").src = ""; + TestRunner._checkForHangs(); + TestRunner.runNextTest(); +}; + +TestRunner.getNextUrl = function() { + var url = ""; + // sometimes we have a subtest/harness which doesn't use a manifest + if ( + TestRunner._urls[TestRunner._currentTest] instanceof Object && + "test" in TestRunner._urls[TestRunner._currentTest] + ) { + url = TestRunner._urls[TestRunner._currentTest].test.url; + TestRunner.expected = + TestRunner._urls[TestRunner._currentTest].test.expected; + } else { + url = TestRunner._urls[TestRunner._currentTest]; + TestRunner.expected = "pass"; + } + return url; +}; + +/** + * Run the next test. If no test remains, calls onComplete(). + **/ +TestRunner._haltTests = false; +async function _runNextTest() { + if ( + TestRunner._currentTest < TestRunner._urls.length && + !TestRunner._haltTests + ) { + var url = TestRunner.getNextUrl(); + TestRunner.currentTestURL = url; + + $("current-test-path").innerHTML = url; + + TestRunner._currentTestStartTimestamp = SpecialPowers.Cu.now(); + TestRunner._currentTestStartTime = new Date().valueOf(); + TestRunner._timeoutFactor = 1; + TestRunner._expectedMinAsserts = 0; + TestRunner._expectedMaxAsserts = 0; + + TestRunner.structuredLogger.testStart(url); + + if (TestRunner._urls[TestRunner._currentTest].test.allow_xul_xbl) { + await SpecialPowers.pushPermissions([ + { type: "allowXULXBL", allow: true, context: "http://mochi.test:8888" }, + { type: "allowXULXBL", allow: true, context: "http://example.org" }, + ]); + } + TestRunner._makeIframe(url, 0); + } else { + $("current-test").innerHTML = "<b>Finished</b>"; + // Only unload the last test to run if we're running more than one test. + if (TestRunner._urls.length > 1) { + TestRunner._makeIframe("about:blank", 0); + } + + var passCount = parseInt($("pass-count").innerHTML, 10); + var failCount = parseInt($("fail-count").innerHTML, 10); + var todoCount = parseInt($("todo-count").innerHTML, 10); + + if (passCount === 0 && failCount === 0 && todoCount === 0) { + // No |$('testframe').contentWindow|, so manually update: ... + // ... the log, + TestRunner.structuredLogger.error( + "TEST-UNEXPECTED-FAIL | SimpleTest/TestRunner.js | No checks actually run" + ); + // ... the count, + $("fail-count").innerHTML = 1; + // ... the indicator. + var indicator = $("indicator"); + indicator.innerHTML = "Status: Fail (No checks actually run)"; + indicator.style.backgroundColor = "red"; + } + + let e10sMode = SpecialPowers.isMainProcess() ? "non-e10s" : "e10s"; + + TestRunner.structuredLogger.info("TEST-START | Shutdown"); + TestRunner.structuredLogger.info("Passed: " + passCount); + TestRunner.structuredLogger.info("Failed: " + failCount); + TestRunner.structuredLogger.info("Todo: " + todoCount); + TestRunner.structuredLogger.info("Mode: " + e10sMode); + TestRunner.structuredLogger.info( + "Slowest: " + + TestRunner.slowestTestTime + + "ms - " + + TestRunner.slowestTestURL + ); + + // If we are looping, don't send this cause it closes the log file, + // also don't unregister the crash observers until we're done. + if (TestRunner.repeat === 0) { + SpecialPowers.unregisterProcessCrashObservers(); + TestRunner.structuredLogger.info("SimpleTest FINISHED"); + } + + if (TestRunner.repeat === 0 && TestRunner.onComplete) { + TestRunner.onComplete(); + } + + if ( + TestRunner._currentLoop <= TestRunner.repeat && + !TestRunner._haltTests + ) { + TestRunner._currentLoop++; + TestRunner.resetTests(TestRunner._urls); + TestRunner._loopIsRestarting = true; + } else { + // Loops are finished + if (TestRunner.logEnabled) { + TestRunner.structuredLogger.info( + "TEST-INFO | Ran " + TestRunner._currentLoop + " Loops" + ); + TestRunner.structuredLogger.info("SimpleTest FINISHED"); + } + + if (TestRunner.onComplete) { + TestRunner.onComplete(); + } + } + TestRunner.generateFailureList(); + + if (TestRunner.jscovDirPrefix != "") { + coverageCollector.finalize(); + } + } +} +TestRunner.runNextTest = _runNextTest; + +TestRunner.expectChildProcessCrash = function() { + TestRunner._expectingProcessCrash = true; +}; + +/** + * This stub is called by SimpleTest when a test is finished. + **/ +TestRunner.testFinished = function(tests) { + // Need to track subtests recorded here separately or else they'll + // trigger the `result after SimpleTest.finish()` error. + var extraTests = []; + var result = "OK"; + + // Prevent a test from calling finish() multiple times before we + // have a chance to unload it. + if ( + TestRunner._currentTest == TestRunner._lastTestFinished && + !TestRunner._loopIsRestarting + ) { + TestRunner.structuredLogger.testEnd( + TestRunner.currentTestURL, + "ERROR", + "OK", + "called finish() multiple times" + ); + TestRunner.updateUI([{ result: false }]); + return; + } + + if (TestRunner.jscovDirPrefix != "") { + coverageCollector.recordTestCoverage(TestRunner.currentTestURL); + } + + SpecialPowers.requestDumpCoverageCounters().then(() => { + TestRunner._lastTestFinished = TestRunner._currentTest; + TestRunner._loopIsRestarting = false; + + // TODO : replace this by a function that returns the mem data as an object + // that's dumped later with the test_end message + MemoryStats.dump( + TestRunner._currentTest, + TestRunner.currentTestURL, + TestRunner.dumpOutputDirectory, + TestRunner.dumpAboutMemoryAfterTest, + TestRunner.dumpDMDAfterTest + ); + + async function cleanUpCrashDumpFiles() { + if ( + !(await SpecialPowers.removeExpectedCrashDumpFiles( + TestRunner._expectingProcessCrash + )) + ) { + let subtest = "expected-crash-dump-missing"; + TestRunner.structuredLogger.testStatus( + TestRunner.currentTestURL, + subtest, + "ERROR", + "PASS", + "This test did not leave any crash dumps behind, but we were expecting some!" + ); + extraTests.push({ name: subtest, result: false }); + result = "ERROR"; + } + + var unexpectedCrashDumpFiles = await SpecialPowers.findUnexpectedCrashDumpFiles(); + TestRunner._expectingProcessCrash = false; + if (unexpectedCrashDumpFiles.length) { + let subtest = "unexpected-crash-dump-found"; + TestRunner.structuredLogger.testStatus( + TestRunner.currentTestURL, + subtest, + "ERROR", + "PASS", + "This test left crash dumps behind, but we " + + "weren't expecting it to!", + null, + { unexpected_crashdump_files: unexpectedCrashDumpFiles } + ); + extraTests.push({ name: subtest, result: false }); + result = "CRASH"; + unexpectedCrashDumpFiles.sort().forEach(function(aFilename) { + TestRunner.structuredLogger.info( + "Found unexpected crash dump file " + aFilename + "." + ); + }); + } + + if (TestRunner.cleanupCrashes) { + if (await SpecialPowers.removePendingCrashDumpFiles()) { + TestRunner.structuredLogger.info( + "This test left pending crash dumps" + ); + } + } + } + + function runNextTest() { + if (TestRunner.currentTestURL != TestRunner.getLoadedTestURL()) { + TestRunner.structuredLogger.testStatus( + TestRunner.currentTestURL, + TestRunner.getLoadedTestURL(), + "FAIL", + "PASS", + "finished in a non-clean fashion, probably" + + " because it didn't call SimpleTest.finish()", + { loaded_test_url: TestRunner.getLoadedTestURL() } + ); + extraTests.push({ name: "clean-finish", result: false }); + result = result != "CRASH" ? "ERROR" : result; + } + + SpecialPowers.addProfilerMarker( + "TestRunner", + { category: "Test", startTime: TestRunner._currentTestStartTimestamp }, + TestRunner.currentTestURL + ); + var runtime = new Date().valueOf() - TestRunner._currentTestStartTime; + + TestRunner.structuredLogger.testEnd( + TestRunner.currentTestURL, + result, + "OK", + "Finished in " + runtime + "ms", + { runtime } + ); + + if ( + TestRunner.slowestTestTime < runtime && + TestRunner._timeoutFactor >= 1 + ) { + TestRunner.slowestTestTime = runtime; + TestRunner.slowestTestURL = TestRunner.currentTestURL; + } + + TestRunner.updateUI(tests.concat(extraTests)); + + // Don't show the interstitial if we just run one test with no repeats: + if (TestRunner._urls.length == 1 && TestRunner.repeat <= 1) { + TestRunner.testUnloaded(); + return; + } + + var interstitialURL; + if ( + !testInXOriginFrame() && + $("testframe").contentWindow.location.protocol == "chrome:" + ) { + interstitialURL = "tests/SimpleTest/iframe-between-tests.html"; + } else { + interstitialURL = "/tests/SimpleTest/iframe-between-tests.html"; + } + // check if there were test run after SimpleTest.finish, which should never happen + if (!testInXOriginFrame()) { + $("testframe").contentWindow.addEventListener("unload", function() { + var testwin = $("testframe").contentWindow; + if ( + testwin.SimpleTest && + testwin.SimpleTest._tests.length != testwin.SimpleTest.testsLength + ) { + var wrongtestlength = + testwin.SimpleTest._tests.length - testwin.SimpleTest.testsLength; + var wrongtestname = ""; + for (var i = 0; i < wrongtestlength; i++) { + wrongtestname = + testwin.SimpleTest._tests[testwin.SimpleTest.testsLength + i] + .name; + TestRunner.structuredLogger.error( + "TEST-UNEXPECTED-FAIL | " + + TestRunner.currentTestURL + + " logged result after SimpleTest.finish(): " + + wrongtestname + ); + } + TestRunner.updateUI([{ result: false }]); + } + }); + } + TestRunner._makeIframe(interstitialURL, 0); + } + + SpecialPowers.executeAfterFlushingMessageQueue(async function() { + await SpecialPowers.waitForCrashes(TestRunner._expectingProcessCrash); + await cleanUpCrashDumpFiles(); + await SpecialPowers.flushPermissions(); + await SpecialPowers.flushPrefEnv(); + runNextTest(); + }); + }); +}; + +/** + * This stub is called by XOrigin Tests to report assertion count. + **/ +TestRunner._xoriginAssertionCount = 0; +TestRunner.addAssertionCount = function(count) { + if (!testInXOriginFrame()) { + TestRunner.error( + `addAssertionCount should only be called by a cross origin test` + ); + return; + } + + if (testInDifferentProcess()) { + TestRunner._xoriginAssertionCount += count; + } +}; + +TestRunner.testUnloaded = function() { + // If we're in a debug build, check assertion counts. This code is + // similar to the code in Tester_nextTest in browser-test.js used + // for browser-chrome mochitests. + if (SpecialPowers.isDebugBuild) { + var newAssertionCount = + SpecialPowers.assertionCount() + TestRunner._xoriginAssertionCount; + var numAsserts = newAssertionCount - TestRunner._lastAssertionCount; + TestRunner._lastAssertionCount = newAssertionCount; + + var max = TestRunner._expectedMaxAsserts; + var min = TestRunner._expectedMinAsserts; + if (Array.isArray(TestRunner.expected)) { + // Accumulate all assertion counts recorded in the failure pattern file. + let additionalAsserts = TestRunner.expected.reduce( + (acc, [pat, count]) => { + return pat == "ASSERTION" ? acc + count : acc; + }, + 0 + ); + min += additionalAsserts; + max += additionalAsserts; + } + TestRunner.structuredLogger.assertionCount( + TestRunner.currentTestURL, + numAsserts, + min, + max + ); + } + + TestRunner._currentTest++; + if (TestRunner.runSlower) { + setTimeout(TestRunner.runNextTest, 1000); + } else { + TestRunner.runNextTest(); + } +}; + +/** + * Get the results. + */ +TestRunner.countResults = function(tests) { + var nOK = 0; + var nNotOK = 0; + var nTodo = 0; + for (var i = 0; i < tests.length; ++i) { + var test = tests[i]; + if (test.todo && !test.result) { + nTodo++; + } else if (test.result && !test.todo) { + nOK++; + } else { + nNotOK++; + } + } + return { OK: nOK, notOK: nNotOK, todo: nTodo }; +}; + +/** + * Print out table of any error messages found during looped run + */ +TestRunner.displayLoopErrors = function(tableName, tests) { + if (TestRunner.countResults(tests).notOK > 0) { + var table = $(tableName); + var curtest; + if (!table.rows.length) { + //if table headers are not yet generated, make them + var row = table.insertRow(table.rows.length); + var cell = row.insertCell(0); + var textNode = document.createTextNode("Test File Name:"); + cell.appendChild(textNode); + cell = row.insertCell(1); + textNode = document.createTextNode("Test:"); + cell.appendChild(textNode); + cell = row.insertCell(2); + textNode = document.createTextNode("Error message:"); + cell.appendChild(textNode); + } + + //find the broken test + for (var testnum in tests) { + curtest = tests[testnum]; + if ( + !( + (curtest.todo && !curtest.result) || + (curtest.result && !curtest.todo) + ) + ) { + //this is a failed test or the result of todo test. Display the related message + row = table.insertRow(table.rows.length); + cell = row.insertCell(0); + textNode = document.createTextNode(TestRunner.currentTestURL); + cell.appendChild(textNode); + cell = row.insertCell(1); + textNode = document.createTextNode(curtest.name); + cell.appendChild(textNode); + cell = row.insertCell(2); + textNode = document.createTextNode(curtest.diag ? curtest.diag : ""); + cell.appendChild(textNode); + } + } + } +}; + +TestRunner.updateUI = function(tests) { + var results = TestRunner.countResults(tests); + var passCount = parseInt($("pass-count").innerHTML) + results.OK; + var failCount = parseInt($("fail-count").innerHTML) + results.notOK; + var todoCount = parseInt($("todo-count").innerHTML) + results.todo; + $("pass-count").innerHTML = passCount; + $("fail-count").innerHTML = failCount; + $("todo-count").innerHTML = todoCount; + + // Set the top Green/Red bar + var indicator = $("indicator"); + if (failCount > 0) { + indicator.innerHTML = "Status: Fail"; + indicator.style.backgroundColor = "red"; + } else if (passCount > 0) { + indicator.innerHTML = "Status: Pass"; + indicator.style.backgroundColor = "#0d0"; + } else { + indicator.innerHTML = "Status: ToDo"; + indicator.style.backgroundColor = "orange"; + } + + // Set the table values + var trID = "tr-" + $("current-test-path").innerHTML; + var row = $(trID); + + // Only update the row if it actually exists (autoUI) + if (row != null) { + var tds = row.getElementsByTagName("td"); + tds[0].style.backgroundColor = "#0d0"; + tds[0].innerHTML = parseInt(tds[0].innerHTML) + parseInt(results.OK); + tds[1].style.backgroundColor = results.notOK > 0 ? "red" : "#0d0"; + tds[1].innerHTML = parseInt(tds[1].innerHTML) + parseInt(results.notOK); + tds[2].style.backgroundColor = results.todo > 0 ? "orange" : "#0d0"; + tds[2].innerHTML = parseInt(tds[2].innerHTML) + parseInt(results.todo); + } + + //if we ran in a loop, display any found errors + if (TestRunner.repeat > 0) { + TestRunner.displayLoopErrors("fail-table", tests); + } +}; + +// XOrigin Tests +// If "--enable-xorigin-tests" is set, mochitests are run in a cross origin iframe. +// The parent process will run at http://mochi.xorigin-test:8888", and individual +// mochitests will be launched in a cross-origin iframe at http://mochi.test:8888. + +var xOriginDispatchMap = { + runner: TestRunner, + logger: TestRunner.structuredLogger, + addFailedTest: TestRunner.addFailedTest, + expectAssertions: TestRunner.expectAssertions, + expectChildProcessCrash: TestRunner.expectChildProcessCrash, + requestLongerTimeout: TestRunner.requestLongerTimeout, + "structuredLogger.deactivateBuffering": + TestRunner.structuredLogger.deactivateBuffering, + "structuredLogger.activateBuffering": + TestRunner.structuredLogger.activateBuffering, + "structuredLogger.testStatus": TestRunner.structuredLogger.testStatus, + "structuredLogger.info": TestRunner.structuredLogger.info, + "structuredLogger.warning": TestRunner.structuredLogger.warning, + "structuredLogger.error": TestRunner.structuredLogger.error, + testFinished: TestRunner.testFinished, + addAssertionCount: TestRunner.addAssertionCount, +}; + +function xOriginTestRunnerHandler(event) { + if (event.data.harnessType != "SimpleTest") { + return; + } + // Handles messages from xOriginRunner in SimpleTest.js. + if (event.data.command in xOriginDispatchMap) { + xOriginDispatchMap[event.data.command].apply( + xOriginDispatchMap[event.data.applyOn], + event.data.params + ); + } else { + TestRunner.error(`Command ${event.data.command} not found + in xOriginDispatchMap`); + } +} + +TestRunner.setXOriginEventHandler = function() { + window.addEventListener("message", xOriginTestRunnerHandler); +}; diff --git a/testing/mochitest/tests/SimpleTest/WindowSnapshot.js b/testing/mochitest/tests/SimpleTest/WindowSnapshot.js new file mode 100644 index 0000000000..62f1062b36 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/WindowSnapshot.js @@ -0,0 +1,127 @@ +var gWindowUtils; + +try { + gWindowUtils = SpecialPowers.getDOMWindowUtils(window); + if (gWindowUtils && !gWindowUtils.compareCanvases) { + gWindowUtils = null; + } +} catch (e) { + gWindowUtils = null; +} + +function snapshotWindow(win, withCaret) { + return SpecialPowers.snapshotWindow(win, withCaret); +} + +function snapshotRect(win, rect) { + return SpecialPowers.snapshotRect(win, rect); +} + +// If the two snapshots don't compare as expected (true for equal, false for +// unequal), returns their serializations as data URIs. In all cases, returns +// whether the comparison was as expected. +function compareSnapshots(s1, s2, expectEqual, fuzz) { + if (s1.width != s2.width || s1.height != s2.height) { + ok( + false, + "Snapshot canvases are not the same size: " + + s1.width + + "x" + + s1.height + + " vs. " + + s2.width + + "x" + + s2.height + ); + return [false]; + } + var passed = false; + var numDifferentPixels; + var maxDifference = { value: undefined }; + if (gWindowUtils) { + var equal; + try { + numDifferentPixels = gWindowUtils.compareCanvases(s1, s2, maxDifference); + if (!fuzz) { + equal = numDifferentPixels == 0; + } else { + equal = + numDifferentPixels <= fuzz.numDifferentPixels && + maxDifference.value <= fuzz.maxDifference; + } + passed = equal == expectEqual; + } catch (e) { + ok(false, "Exception thrown from compareCanvases: " + e); + } + } + + var s1DataURI, s2DataURI; + if (!passed) { + s1DataURI = s1.toDataURL(); + s2DataURI = s2.toDataURL(); + + if (!gWindowUtils) { + passed = (s1DataURI == s2DataURI) == expectEqual; + } + } + + return [ + passed, + s1DataURI, + s2DataURI, + numDifferentPixels, + maxDifference.value, + ]; +} + +function assertSnapshots(s1, s2, expectEqual, fuzz, s1name, s2name) { + var [ + passed, + s1DataURI, + s2DataURI, + numDifferentPixels, + maxDifference, + ] = compareSnapshots(s1, s2, expectEqual, fuzz); + var sym = expectEqual ? "==" : "!="; + ok(passed, "reftest comparison: " + sym + " " + s1name + " " + s2name); + if (!passed) { + let status = "TEST-UNEXPECTED-FAIL"; + if (usesFailurePatterns() && recordIfMatchesFailurePattern(s1name)) { + status = "TEST-KNOWN-FAIL"; + } + // The language / format in this message should match the failure messages + // displayed by reftest.js's "RecordResult()" method so that log output + // can be parsed by reftest-analyzer.xhtml + var report = + "REFTEST " + + status + + " | " + + s1name + + " | image comparison (" + + sym + + "), max difference: " + + maxDifference + + ", number of differing pixels: " + + numDifferentPixels + + "\n"; + if (expectEqual) { + report += "REFTEST IMAGE 1 (TEST): " + s1DataURI + "\n"; + report += "REFTEST IMAGE 2 (REFERENCE): " + s2DataURI + "\n"; + } else { + report += "REFTEST IMAGE: " + s1DataURI + "\n"; + } + (info || dump)(report); + } + return passed; +} + +function assertWindowPureColor(win, color) { + const snapshot = SpecialPowers.snapshotRect(win); + const canvas = document.createElement("canvas"); + canvas.width = snapshot.width; + canvas.height = snapshot.height; + const context = canvas.getContext("2d"); + context.fillStyle = color; + context.fillRect(0, 0, canvas.width, canvas.height); + assertSnapshots(snapshot, canvas, true, null, "snapshot", color); +} diff --git a/testing/mochitest/tests/SimpleTest/WorkerHandler.js b/testing/mochitest/tests/SimpleTest/WorkerHandler.js new file mode 100644 index 0000000000..1fbe55ab0d --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/WorkerHandler.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Sets the worker message and error event handlers to respond to SimpleTest + * style messages. + */ +function listenForTests(worker, opts = { verbose: true }) { + worker.onerror = function(error) { + error.preventDefault(); + ok(false, "Worker error " + error.message); + }; + worker.onmessage = function(msg) { + if (opts && opts.verbose) { + ok(true, "MAIN: onmessage " + JSON.stringify(msg.data)); + } + switch (msg.data.kind) { + case "is": + SimpleTest.ok( + msg.data.outcome, + msg.data.description + "( " + msg.data.a + " ==? " + msg.data.b + ")" + ); + return; + case "isnot": + SimpleTest.ok( + msg.data.outcome, + msg.data.description + "( " + msg.data.a + " !=? " + msg.data.b + ")" + ); + return; + case "ok": + SimpleTest.ok(msg.data.condition, msg.data.description); + return; + case "info": + SimpleTest.info(msg.data.description); + return; + case "finish": + SimpleTest.finish(); + return; + default: + SimpleTest.ok( + false, + "test_osfile.xul: wrong message " + JSON.stringify(msg.data) + ); + } + }; +} diff --git a/testing/mochitest/tests/SimpleTest/WorkerSimpleTest.js b/testing/mochitest/tests/SimpleTest/WorkerSimpleTest.js new file mode 100644 index 0000000000..ce4848d7af --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/WorkerSimpleTest.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function log(text) { + dump("WORKER " + text + "\n"); +} + +function send(message) { + self.postMessage(message); +} + +function finish() { + send({ kind: "finish" }); +} + +function ok(condition, description) { + send({ kind: "ok", condition: !!condition, description: "" + description }); +} + +function is(a, b, description) { + let outcome = a == b; // Need to decide outcome here, as not everything can be serialized + send({ + kind: "is", + outcome, + description: "" + description, + a: "" + a, + b: "" + b, + }); +} + +function isnot(a, b, description) { + let outcome = a != b; // Need to decide outcome here, as not everything can be serialized + send({ + kind: "isnot", + outcome, + description: "" + description, + a: "" + a, + b: "" + b, + }); +} + +function info(description) { + send({ kind: "info", description: "" + description }); +} diff --git a/testing/mochitest/tests/SimpleTest/iframe-between-tests.html b/testing/mochitest/tests/SimpleTest/iframe-between-tests.html new file mode 100644 index 0000000000..8de879f205 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/iframe-between-tests.html @@ -0,0 +1,17 @@ +<title>iframe for between tests</title> +<!-- + This page exists so that our accounting for assertions correctly + counts assertions that happen while leaving a page. We load this page + after a test finishes, check the assertion counts, and then go on to + load the next. +--> +<script> +window.addEventListener("load", function() { + var runner = (parent.TestRunner || parent.wrappedJSObject.TestRunner); + runner.testUnloaded(); + + if (SpecialPowers) { + SpecialPowers.DOMWindowUtils.runNextCollectorTimer(); + } +}); +</script> diff --git a/testing/mochitest/tests/SimpleTest/moz.build b/testing/mochitest/tests/SimpleTest/moz.build new file mode 100644 index 0000000000..55c54e6255 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/moz.build @@ -0,0 +1,27 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +TEST_HARNESS_FILES.testing.mochitest.tests.SimpleTest += [ + "/docshell/test/chrome/docshell_helpers.js", + "AccessibilityUtils.js", + "ChromeTask.js", + "EventUtils.js", + "ExtensionTestUtils.js", + "iframe-between-tests.html", + "LogController.js", + "MemoryStats.js", + "MockObjects.js", + "MozillaLogger.js", + "NativeKeyCodes.js", + "paint_listener.js", + "setup.js", + "SimpleTest.js", + "test.css", + "TestRunner.js", + "WindowSnapshot.js", + "WorkerHandler.js", + "WorkerSimpleTest.js", +] diff --git a/testing/mochitest/tests/SimpleTest/paint_listener.js b/testing/mochitest/tests/SimpleTest/paint_listener.js new file mode 100644 index 0000000000..475ac28fc9 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/paint_listener.js @@ -0,0 +1,109 @@ +(function() { + var accumulatedRect = null; + var onpaint = []; + var debug = SpecialPowers.getBoolPref("testing.paint_listener.debug", false); + const FlushModes = { + FLUSH: 0, + NOFLUSH: 1, + }; + + function paintListener(event) { + if (event.target != window) { + if (debug) { + dump("got MozAfterPaint for wrong window\n"); + } + return; + } + var clientRect = event.boundingClientRect; + var eventRect; + if (clientRect) { + eventRect = [ + clientRect.left, + clientRect.top, + clientRect.right, + clientRect.bottom, + ]; + } else { + eventRect = [0, 0, 0, 0]; + } + if (debug) { + dump("got MozAfterPaint: " + eventRect.join(",") + "\n"); + } + accumulatedRect = accumulatedRect + ? [ + Math.min(accumulatedRect[0], eventRect[0]), + Math.min(accumulatedRect[1], eventRect[1]), + Math.max(accumulatedRect[2], eventRect[2]), + Math.max(accumulatedRect[3], eventRect[3]), + ] + : eventRect; + if (debug) { + dump("Dispatching " + onpaint.length + " onpaint listeners\n"); + } + while (onpaint.length) { + window.setTimeout(onpaint.pop(), 0); + } + } + window.addEventListener("MozAfterPaint", paintListener); + + function waitForPaints(callback, subdoc, flushMode) { + // Wait until paint suppression has ended + var utils = SpecialPowers.getDOMWindowUtils(window); + if (utils.paintingSuppressed) { + if (debug) { + dump("waiting for paint suppression to end...\n"); + } + window.setTimeout(function() { + waitForPaints(callback, subdoc, flushMode); + }, 0); + return; + } + + // The call to getBoundingClientRect will flush pending layout + // notifications. Sometimes, however, this is undesirable since it can mask + // bugs where the code under test should be performing the flush. + if (flushMode === FlushModes.FLUSH) { + document.documentElement.getBoundingClientRect(); + if (subdoc) { + subdoc.documentElement.getBoundingClientRect(); + } + } + + if (utils.isMozAfterPaintPending) { + if (debug) { + dump("waiting for paint...\n"); + } + onpaint.push(function() { + waitForPaints(callback, subdoc, FlushModes.NOFLUSH); + }); + if (utils.isTestControllingRefreshes) { + utils.advanceTimeAndRefresh(0); + } + return; + } + + if (debug) { + dump("done...\n"); + } + var result = accumulatedRect || [0, 0, 0, 0]; + accumulatedRect = null; + callback.apply(null, result); + } + + window.waitForAllPaintsFlushed = function(callback, subdoc) { + waitForPaints(callback, subdoc, FlushModes.FLUSH); + }; + + window.waitForAllPaints = function(callback) { + waitForPaints(callback, null, FlushModes.NOFLUSH); + }; + + window.promiseAllPaintsDone = function(subdoc = null, flush = false) { + var flushmode = flush ? FlushModes.FLUSH : FlushModes.NOFLUSH; + return new Promise(function(resolve, reject) { + // The callback is given the components of the rect, but resolve() can + // only be given one arg, so we turn it back into an array. + waitForPaints((l, r, t, b) => resolve([l, r, t, b]), subdoc, flushmode); + }); + }; +})(); diff --git a/testing/mochitest/tests/SimpleTest/setup.js b/testing/mochitest/tests/SimpleTest/setup.js new file mode 100644 index 0000000000..6d8b3bf9dc --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/setup.js @@ -0,0 +1,326 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This file expects the following files to be loaded. +/* import-globals-from TestRunner.js */ + +// From the harness: +/* import-globals-from ../../chrome-harness.js */ +/* import-globals-from ../../chunkifyTests.js */ + +// It appears we expect these from one of the MochiKit scripts. +/* global toggleElementClass, removeElementClass, addElementClass, + hasElementClass */ + +TestRunner.logEnabled = true; +TestRunner.logger = LogController; + +if (!("SpecialPowers" in window)) { + dump("SimpleTest setup.js found SpecialPowers unavailable: reloading...\n"); + setTimeout(() => { + window.location.reload(); + }, 1000); +} + +/* Helper function */ +function parseQueryString(encodedString, useArrays) { + // strip a leading '?' from the encoded string + var qstr = + encodedString.length && encodedString[0] == "?" + ? encodedString.substring(1) + : encodedString; + var pairs = qstr.replace(/\+/g, "%20").split(/(\&\;|\&\#38\;|\&|\&)/); + var o = {}; + var decode; + if (typeof decodeURIComponent != "undefined") { + decode = decodeURIComponent; + } else { + decode = unescape; + } + if (useArrays) { + for (var i = 0; i < pairs.length; i++) { + var pair = pairs[i].split("="); + if (pair.length !== 2) { + continue; + } + var name = decode(pair[0]); + var arr = o[name]; + if (!(arr instanceof Array)) { + arr = []; + o[name] = arr; + } + arr.push(decode(pair[1])); + } + } else { + for (i = 0; i < pairs.length; i++) { + pair = pairs[i].split("="); + if (pair.length !== 2) { + continue; + } + o[decode(pair[0])] = decode(pair[1]); + } + } + return o; +} + +// Check the query string for arguments +var params = parseQueryString(location.search.substring(1), true); + +var config = {}; +if (window.readConfig) { + config = readConfig(); +} + +if (config.testRoot == "chrome" || config.testRoot == "a11y") { + for (var p in params) { + // Compare with arrays to find boolean equivalents, since that's what + // |parseQueryString| with useArrays returns. + if (params[p] == [1]) { + config[p] = true; + } else if (params[p] == [0]) { + config[p] = false; + } else { + config[p] = params[p]; + } + } + params = config; + params.baseurl = "chrome://mochitests/content"; +} else if (params.xOriginTests) { + params.baseurl = "http://mochi.test:8888/tests/"; +} else { + params.baseurl = ""; +} + +if (params.testRoot == "browser") { + params.testPrefix = "chrome://mochitests/content/browser/"; +} else if (params.testRoot == "chrome") { + params.testPrefix = "chrome://mochitests/content/chrome/"; +} else if (params.testRoot == "a11y") { + params.testPrefix = "chrome://mochitests/content/a11y/"; +} else if (params.xOriginTests) { + params.testPrefix = "http://mochi.test:8888/tests/"; + params.httpsBaseUrl = "https://example.org:443/tests/"; +} else { + params.testPrefix = "/tests/"; +} + +// set the per-test timeout if specified in the query string +if (params.timeout) { + TestRunner.timeout = parseInt(params.timeout) * 1000; +} + +// log levels for console and logfile +var fileLevel = params.fileLevel || null; +var consoleLevel = params.consoleLevel || null; + +// repeat tells us how many times to repeat the tests +if (params.repeat) { + TestRunner.repeat = params.repeat; +} + +if (params.runUntilFailure) { + TestRunner.runUntilFailure = true; +} + +// closeWhenDone tells us to close the browser when complete +if (params.closeWhenDone) { + TestRunner.onComplete = SpecialPowers.quit.bind(SpecialPowers); +} + +if (params.failureFile) { + TestRunner.setFailureFile(params.failureFile); +} + +// Breaks execution and enters the JS debugger on a test failure +if (params.debugOnFailure) { + TestRunner.debugOnFailure = true; +} + +// logFile to write our results +if (params.logFile) { + var mfl = new MozillaFileLogger(params.logFile); + TestRunner.logger.addListener("mozLogger", fileLevel + "", mfl.logCallback); +} + +// A temporary hack for android 4.0 where Fennec utilizes the pandaboard so much it reboots +if (params.runSlower) { + TestRunner.runSlower = true; +} + +if (params.dumpOutputDirectory) { + TestRunner.dumpOutputDirectory = params.dumpOutputDirectory; +} + +if (params.dumpAboutMemoryAfterTest) { + TestRunner.dumpAboutMemoryAfterTest = true; +} + +if (params.dumpDMDAfterTest) { + TestRunner.dumpDMDAfterTest = true; +} + +if (params.interactiveDebugger) { + TestRunner.interactiveDebugger = true; +} + +if (params.jscovDirPrefix) { + TestRunner.jscovDirPrefix = params.jscovDirPrefix; +} + +if (params.maxTimeouts) { + TestRunner.maxTimeouts = params.maxTimeouts; +} + +if (params.cleanupCrashes) { + TestRunner.cleanupCrashes = true; +} + +if (params.xOriginTests) { + TestRunner.xOriginTests = true; + TestRunner.setXOriginEventHandler(); +} + +if (params.timeoutAsPass) { + TestRunner.timeoutAsPass = true; +} + +if (params.conditionedProfile) { + TestRunner.conditionedProfile = true; +} + +// Log things to the console if appropriate. +TestRunner.logger.addListener("dumpListener", consoleLevel + "", function(msg) { + dump(msg.info.join(" ") + "\n"); +}); + +var gTestList = []; +var RunSet = {}; +RunSet.runall = function(e) { + // Filter tests to include|exclude tests based on data in params.filter. + // This allows for including or excluding tests from the gTestList + // TODO Only used by ipc tests, remove once those are implemented sanely + if (params.testManifest) { + getTestManifest(getTestManifestURL(params.testManifest), params, function( + filter + ) { + gTestList = filterTests(filter, gTestList, params.runOnly); + RunSet.runtests(); + }); + } else { + RunSet.runtests(); + } +}; + +RunSet.runtests = function(e) { + // Which tests we're going to run + var my_tests = gTestList; + + if (params.startAt || params.endAt) { + my_tests = skipTests(my_tests, params.startAt, params.endAt); + } + + if (params.shuffle) { + for (var i = my_tests.length - 1; i > 0; --i) { + var j = Math.floor(Math.random() * i); + var tmp = my_tests[j]; + my_tests[j] = my_tests[i]; + my_tests[i] = tmp; + } + } + TestRunner.setParameterInfo(params); + TestRunner.runTests(my_tests); +}; + +RunSet.reloadAndRunAll = function(e) { + e.preventDefault(); + //window.location.hash = ""; + if (params.autorun) { + window.location.search += ""; + // eslint-disable-next-line no-self-assign + window.location.href = window.location.href; + } else if (window.location.search) { + window.location.href += "&autorun=1"; + } else { + window.location.href += "?autorun=1"; + } +}; + +// UI Stuff +function toggleVisible(elem) { + toggleElementClass("invisible", elem); +} + +function makeVisible(elem) { + removeElementClass(elem, "invisible"); +} + +function makeInvisible(elem) { + addElementClass(elem, "invisible"); +} + +function isVisible(elem) { + // you may also want to check for + // getElement(elem).style.display == "none" + return !hasElementClass(elem, "invisible"); +} + +function toggleNonTests(e) { + e.preventDefault(); + var elems = document.getElementsByClassName("non-test"); + for (var i = "0"; i < elems.length; i++) { + toggleVisible(elems[i]); + } + if (isVisible(elems[0])) { + $("toggleNonTests").innerHTML = "Hide Non-Tests"; + } else { + $("toggleNonTests").innerHTML = "Show Non-Tests"; + } +} + +// hook up our buttons +function hookup() { + if (params.manifestFile) { + getTestManifest( + getTestManifestURL(params.manifestFile), + params, + hookupTests + ); + } else { + hookupTests(gTestList); + } +} + +function hookupTests(testList) { + if (testList.length) { + gTestList = testList; + } else { + gTestList = []; + for (var obj in testList) { + gTestList.push(testList[obj]); + } + } + + document.getElementById("runtests").onclick = RunSet.reloadAndRunAll; + document.getElementById("toggleNonTests").onclick = toggleNonTests; + // run automatically if autorun specified + if (params.autorun) { + RunSet.runall(); + } +} + +function getTestManifestURL(path) { + // The test manifest url scheme should be the same protocol as the containing + // window... unless it's not http(s) + if ( + window.location.protocol == "http:" || + window.location.protocol == "https:" + ) { + return window.location.protocol + "//" + window.location.host + "/" + path; + } + return "http://mochi.test:8888/" + path; +} diff --git a/testing/mochitest/tests/SimpleTest/test.css b/testing/mochitest/tests/SimpleTest/test.css new file mode 100644 index 0000000000..357070e8ae --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/test.css @@ -0,0 +1,39 @@ +.test_ok { + color: #0d0; + display: none; +} + +.test_not_ok { + color: red; + display: block; +} + +.test_todo { + /* color: orange; */ + display: block; +} + +.test_ok, .test_not_ok, .test_todo { + border-bottom-width: 2px; + border-bottom-style: solid; + border-bottom-color: black; +} + +.all_pass { + background-color: #0d0; +} + +.some_fail { + background-color: red; +} + +.todo_only { + background-color: orange; +} + +.tests_report { + border-width: 2px; + border-style: solid; + width: 20em; + display: table; +} |