summaryrefslogtreecommitdiffstats
path: root/testing/modules/TestUtils.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'testing/modules/TestUtils.sys.mjs')
-rw-r--r--testing/modules/TestUtils.sys.mjs382
1 files changed, 382 insertions, 0 deletions
diff --git a/testing/modules/TestUtils.sys.mjs b/testing/modules/TestUtils.sys.mjs
new file mode 100644
index 0000000000..c3daacbceb
--- /dev/null
+++ b/testing/modules/TestUtils.sys.mjs
@@ -0,0 +1,382 @@
+/* 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/. */
+
+/**
+ * Contains a limited number of testing functions that are commonly used in a
+ * wide variety of situations, for example waiting for an event loop tick or an
+ * observer notification.
+ *
+ * More complex functions are likely to belong to a separate test-only module.
+ * Examples include Assert.sys.mjs for generic assertions, FileTestUtils.sys.mjs
+ * to work with local files and their contents, and BrowserTestUtils.sys.mjs to
+ * work with browser windows and tabs.
+ *
+ * Individual components also offer testing functions to other components, for
+ * example LoginTestUtils.jsm.
+ */
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"].getService(
+ Ci.nsIConsoleAPIStorage
+);
+
+/**
+ * TestUtils provides generally useful test utilities.
+ * It can be used from mochitests, browser mochitests and xpcshell tests alike.
+ *
+ * @class
+ */
+export var TestUtils = {
+ executeSoon(callbackFn) {
+ Services.tm.dispatchToMainThread(callbackFn);
+ },
+
+ waitForTick() {
+ return new Promise(resolve => this.executeSoon(resolve));
+ },
+
+ /**
+ * Waits for a console message matching the specified check function to be
+ * observed.
+ *
+ * @param {function} checkFn [optional]
+ * Called with the message as its argument, should return true if the
+ * notification is the expected one, or false if it should be ignored
+ * and listening should continue.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @return {Promise}
+ * @resolves The message from the observed notification.
+ */
+ consoleMessageObserved(checkFn) {
+ return new Promise((resolve, reject) => {
+ let removed = false;
+ function observe(message) {
+ try {
+ if (checkFn && !checkFn(message)) {
+ return;
+ }
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ // checkFn could reference objects that need to be destroyed before
+ // the end of the test, so avoid keeping a reference to it after the
+ // promise resolves.
+ checkFn = null;
+ removed = true;
+
+ resolve(message);
+ } catch (ex) {
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ checkFn = null;
+ removed = true;
+ reject(ex);
+ }
+ }
+
+ ConsoleAPIStorage.addLogEventListener(
+ observe,
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+
+ TestUtils.promiseTestFinished?.then(() => {
+ if (removed) {
+ return;
+ }
+
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ let text =
+ "Console message observer not removed before the end of test";
+ reject(text);
+ });
+ });
+ },
+
+ /**
+ * Listens for any console messages (logged via console.*) and returns them
+ * when the returned function is called.
+ *
+ * @returns {function}
+ * Returns an async function that when called will wait for a tick, then stop
+ * listening to any more console messages and finally will return the
+ * messages that have been received.
+ */
+ listenForConsoleMessages() {
+ let messages = [];
+ function observe(message) {
+ messages.push(message);
+ }
+
+ ConsoleAPIStorage.addLogEventListener(
+ observe,
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+
+ return async () => {
+ await TestUtils.waitForTick();
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ return messages;
+ };
+ },
+
+ /**
+ * Waits for the specified topic to be observed.
+ *
+ * @param {string} topic
+ * The topic to observe.
+ * @param {function} checkFn [optional]
+ * Called with (subject, data) as arguments, should return true if the
+ * notification is the expected one, or false if it should be ignored
+ * and listening should continue. If not specified, the first
+ * notification for the specified topic resolves the returned promise.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @return {Promise}
+ * @resolves The array [subject, data] from the observed notification.
+ */
+ topicObserved(topic, checkFn) {
+ let startTime = Cu.now();
+ return new Promise((resolve, reject) => {
+ let removed = false;
+ function observer(subject, topic, data) {
+ try {
+ if (checkFn && !checkFn(subject, data)) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ // checkFn could reference objects that need to be destroyed before
+ // the end of the test, so avoid keeping a reference to it after the
+ // promise resolves.
+ checkFn = null;
+ removed = true;
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ "topicObserved: " + topic
+ );
+ resolve([subject, data]);
+ } catch (ex) {
+ Services.obs.removeObserver(observer, topic);
+ checkFn = null;
+ removed = true;
+ reject(ex);
+ }
+ }
+ Services.obs.addObserver(observer, topic);
+
+ TestUtils.promiseTestFinished?.then(() => {
+ if (removed) {
+ return;
+ }
+
+ Services.obs.removeObserver(observer, topic);
+ let text = topic + " observer not removed before the end of test";
+ reject(text);
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ "topicObserved: " + text
+ );
+ });
+ });
+ },
+
+ /**
+ * Waits for the specified preference to be change.
+ *
+ * @param {string} prefName
+ * The pref to observe.
+ * @param {function} checkFn [optional]
+ * Called with the new preference value as argument, should return true if the
+ * notification is the expected one, or false if it should be ignored
+ * and listening should continue. If not specified, the first
+ * notification for the specified topic resolves the returned promise.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @return {Promise}
+ * @resolves The value of the preference.
+ */
+ waitForPrefChange(prefName, checkFn) {
+ return new Promise((resolve, reject) => {
+ Services.prefs.addObserver(prefName, function observer(
+ subject,
+ topic,
+ data
+ ) {
+ try {
+ let prefValue = null;
+ switch (Services.prefs.getPrefType(prefName)) {
+ case Services.prefs.PREF_STRING:
+ prefValue = Services.prefs.getStringPref(prefName);
+ break;
+ case Services.prefs.PREF_INT:
+ prefValue = Services.prefs.getIntPref(prefName);
+ break;
+ case Services.prefs.PREF_BOOL:
+ prefValue = Services.prefs.getBoolPref(prefName);
+ break;
+ }
+ if (checkFn && !checkFn(prefValue)) {
+ return;
+ }
+ Services.prefs.removeObserver(prefName, observer);
+ resolve(prefValue);
+ } catch (ex) {
+ Services.prefs.removeObserver(prefName, observer);
+ reject(ex);
+ }
+ });
+ });
+ },
+
+ /**
+ * Takes a screenshot of an area and returns it as a data URL.
+ *
+ * @param eltOrRect {Element|Rect}
+ * The DOM node or rect ({left, top, width, height}) to screenshot.
+ * @param win {Window}
+ * The current window.
+ */
+ screenshotArea(eltOrRect, win) {
+ if (Element.isInstance(eltOrRect)) {
+ eltOrRect = eltOrRect.getBoundingClientRect();
+ }
+ let { left, top, width, height } = eltOrRect;
+ let canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ let ctx = canvas.getContext("2d");
+ let ratio = win.devicePixelRatio;
+ canvas.width = width * ratio;
+ canvas.height = height * ratio;
+ ctx.scale(ratio, ratio);
+ ctx.drawWindow(win, left, top, width, height, "#fff");
+ return canvas.toDataURL();
+ },
+
+ /**
+ * Will poll a condition function until it returns true.
+ *
+ * @param condition
+ * A condition function that must return true or false. If the
+ * condition ever throws, this function fails and rejects the
+ * returned promise. The function can be an async function.
+ * @param msg
+ * A message used to describe the condition being waited for.
+ * This message will be used to reject the promise should the
+ * wait fail. It is also used to add a profiler marker.
+ * @param interval
+ * The time interval to poll the condition function. Defaults
+ * to 100ms.
+ * @param maxTries
+ * The number of times to poll before giving up and rejecting
+ * if the condition has not yet returned true. Defaults to 50
+ * (~5 seconds for 100ms intervals)
+ * @return Promise
+ * Resolves with the return value of the condition function.
+ * Rejects if timeout is exceeded or condition ever throws.
+ *
+ * NOTE: This is intentionally not using setInterval, using setTimeout
+ * instead. setInterval is not promise-safe.
+ */
+ waitForCondition(condition, msg, interval = 100, maxTries = 50) {
+ let startTime = Cu.now();
+ return new Promise((resolve, reject) => {
+ let tries = 0;
+ let timeoutId = 0;
+ async function tryOnce() {
+ timeoutId = 0;
+ if (tries >= maxTries) {
+ msg += ` - timed out after ${maxTries} tries.`;
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ condition = null;
+ reject(msg);
+ return;
+ }
+
+ let conditionPassed = false;
+ try {
+ conditionPassed = await condition();
+ } catch (e) {
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ msg += ` - threw exception: ${e}`;
+ condition = null;
+ reject(msg);
+ return;
+ }
+
+ if (conditionPassed) {
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition succeeded after ${tries} retries - ${msg}`
+ );
+ // Avoid keeping a reference to the condition function after the
+ // promise resolves, as this function could itself reference objects
+ // that should be GC'ed before the end of the test.
+ condition = null;
+ resolve(conditionPassed);
+ return;
+ }
+ tries++;
+ timeoutId = setTimeout(tryOnce, interval);
+ }
+
+ TestUtils.promiseTestFinished?.then(() => {
+ if (!timeoutId) {
+ return;
+ }
+
+ clearTimeout(timeoutId);
+ msg += " - still pending at the end of the test";
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ reject("waitForCondition timer - " + msg);
+ });
+
+ TestUtils.executeSoon(tryOnce);
+ });
+ },
+
+ shuffle(array) {
+ let results = [];
+ for (let i = 0; i < array.length; ++i) {
+ let randomIndex = Math.floor(Math.random() * (i + 1));
+ results[i] = results[randomIndex];
+ results[randomIndex] = array[i];
+ }
+ return results;
+ },
+
+ assertPackagedBuild() {
+ const omniJa = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+ omniJa.append("omni.ja");
+ if (!omniJa.exists()) {
+ throw new Error(
+ "This test requires a packaged build, " +
+ "run 'mach package' and then use --appname=dist"
+ );
+ }
+ },
+};