tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

TestUtils.sys.mjs (12695B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /**
      6 * Contains a limited number of testing functions that are commonly used in a
      7 * wide variety of situations, for example waiting for an event loop tick or an
      8 * observer notification.
      9 *
     10 * More complex functions are likely to belong to a separate test-only module.
     11 * Examples include Assert.sys.mjs for generic assertions, FileTestUtils.sys.mjs
     12 * to work with local files and their contents, and BrowserTestUtils.sys.mjs to
     13 * work with browser windows and tabs.
     14 *
     15 * Individual components also offer testing functions to other components, for
     16 * example LoginTestUtils.sys.mjs.
     17 */
     18 
     19 import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
     20 
     21 const ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"].getService(
     22  Ci.nsIConsoleAPIStorage
     23 );
     24 
     25 /**
     26 * TestUtils provides generally useful test utilities.
     27 * It can be used from mochitests, browser mochitests and xpcshell tests alike.
     28 *
     29 * @class
     30 */
     31 export var TestUtils = {
     32  executeSoon(callbackFn) {
     33    Services.tm.dispatchToMainThread(callbackFn);
     34  },
     35 
     36  waitForTick() {
     37    return new Promise(resolve => this.executeSoon(resolve));
     38  },
     39 
     40  /**
     41   * Waits for a console message matching the specified check function to be
     42   * observed.
     43   *
     44   * @param {function} checkFn [optional]
     45   *        Called with the message as its argument, should return true if the
     46   *        notification is the expected one, or false if it should be ignored
     47   *        and listening should continue.
     48   *
     49   * Note: Because this function is intended for testing, any error in checkFn
     50   *       will cause the returned promise to be rejected instead of waiting for
     51   *       the next notification, since this is probably a bug in the test.
     52   *
     53   * @return {Promise}
     54   *   Resolved with the message from the observed notification.
     55   */
     56  consoleMessageObserved(checkFn) {
     57    return new Promise((resolve, reject) => {
     58      let removed = false;
     59      function observe(message) {
     60        try {
     61          if (checkFn && !checkFn(message)) {
     62            return;
     63          }
     64          ConsoleAPIStorage.removeLogEventListener(observe);
     65          // checkFn could reference objects that need to be destroyed before
     66          // the end of the test, so avoid keeping a reference to it after the
     67          // promise resolves.
     68          checkFn = null;
     69          removed = true;
     70 
     71          resolve(message);
     72        } catch (ex) {
     73          ConsoleAPIStorage.removeLogEventListener(observe);
     74          checkFn = null;
     75          removed = true;
     76          reject(ex);
     77        }
     78      }
     79 
     80      ConsoleAPIStorage.addLogEventListener(
     81        observe,
     82        Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
     83      );
     84 
     85      TestUtils.promiseTestFinished?.then(() => {
     86        if (removed) {
     87          return;
     88        }
     89 
     90        ConsoleAPIStorage.removeLogEventListener(observe);
     91        let text =
     92          "Console message observer not removed before the end of test";
     93        reject(text);
     94      });
     95    });
     96  },
     97 
     98  /**
     99   * Listens for any console messages (logged via console.*) and returns them
    100   * when the returned function is called.
    101   *
    102   * @returns {function}
    103   *   Returns an async function that when called will wait for a tick, then stop
    104   *   listening to any more console messages and finally will return the
    105   *   messages that have been received.
    106   */
    107  listenForConsoleMessages() {
    108    let messages = [];
    109    function observe(message) {
    110      messages.push(message);
    111    }
    112 
    113    ConsoleAPIStorage.addLogEventListener(
    114      observe,
    115      Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
    116    );
    117 
    118    return async () => {
    119      await TestUtils.waitForTick();
    120      ConsoleAPIStorage.removeLogEventListener(observe);
    121      return messages;
    122    };
    123  },
    124 
    125  /**
    126   * Waits for the specified topic to be observed.
    127   *
    128   * @param {string} topic
    129   *        The topic to observe.
    130   * @param {function} checkFn [optional]
    131   *        Called with (subject, data) as arguments, should return true if the
    132   *        notification is the expected one, or false if it should be ignored
    133   *        and listening should continue. If not specified, the first
    134   *        notification for the specified topic resolves the returned promise.
    135   *
    136   * Note: Because this function is intended for testing, any error in checkFn
    137   *       will cause the returned promise to be rejected instead of waiting for
    138   *       the next notification, since this is probably a bug in the test.
    139   *
    140   * @return {Promise<[nsISupports, string]>}
    141   *   Resolved with the array ``[subject, data]`` from the observed notification.
    142   */
    143  topicObserved(topic, checkFn) {
    144    let startTime = ChromeUtils.now();
    145    return new Promise((resolve, reject) => {
    146      let removed = false;
    147      function observer(subject, topic, data) {
    148        try {
    149          if (checkFn && !checkFn(subject, data)) {
    150            return;
    151          }
    152          Services.obs.removeObserver(observer, topic);
    153          // checkFn could reference objects that need to be destroyed before
    154          // the end of the test, so avoid keeping a reference to it after the
    155          // promise resolves.
    156          checkFn = null;
    157          removed = true;
    158          ChromeUtils.addProfilerMarker(
    159            "TestUtils",
    160            { startTime, category: "Test" },
    161            "topicObserved: " + topic
    162          );
    163          resolve([subject, data]);
    164        } catch (ex) {
    165          Services.obs.removeObserver(observer, topic);
    166          checkFn = null;
    167          removed = true;
    168          reject(ex);
    169        }
    170      }
    171      Services.obs.addObserver(observer, topic);
    172 
    173      TestUtils.promiseTestFinished?.then(() => {
    174        if (removed) {
    175          return;
    176        }
    177 
    178        Services.obs.removeObserver(observer, topic);
    179        let text = topic + " observer not removed before the end of test";
    180        reject(text);
    181        ChromeUtils.addProfilerMarker(
    182          "TestUtils",
    183          { startTime, category: "Test" },
    184          "topicObserved: " + text
    185        );
    186      });
    187    });
    188  },
    189 
    190  /**
    191   * Waits for the specified preference to be change.
    192   *
    193   * @param {string} prefName
    194   *        The pref to observe.
    195   * @param {function} checkFn [optional]
    196   *        Called with the new preference value as argument, should return true if the
    197   *        notification is the expected one, or false if it should be ignored
    198   *        and listening should continue. If not specified, the first
    199   *        notification for the specified topic resolves the returned promise.
    200   *
    201   * Note: Because this function is intended for testing, any error in checkFn
    202   *       will cause the returned promise to be rejected instead of waiting for
    203   *       the next notification, since this is probably a bug in the test.
    204   *
    205   * @return {Promise<number|string|boolean>}
    206   *   The value of the preference.
    207   */
    208  waitForPrefChange(prefName, checkFn) {
    209    return new Promise((resolve, reject) => {
    210      Services.prefs.addObserver(prefName, function observer() {
    211        try {
    212          let prefValue = null;
    213          switch (Services.prefs.getPrefType(prefName)) {
    214            case Services.prefs.PREF_STRING:
    215              prefValue = Services.prefs.getStringPref(prefName);
    216              break;
    217            case Services.prefs.PREF_INT:
    218              prefValue = Services.prefs.getIntPref(prefName);
    219              break;
    220            case Services.prefs.PREF_BOOL:
    221              prefValue = Services.prefs.getBoolPref(prefName);
    222              break;
    223          }
    224          if (checkFn && !checkFn(prefValue)) {
    225            return;
    226          }
    227          Services.prefs.removeObserver(prefName, observer);
    228          resolve(prefValue);
    229        } catch (ex) {
    230          Services.prefs.removeObserver(prefName, observer);
    231          reject(ex);
    232        }
    233      });
    234    });
    235  },
    236 
    237  /**
    238   * Takes a screenshot of an area and returns it as a data URL.
    239   *
    240   * @param eltOrRect {Element|Rect}
    241   *        The DOM node or rect ({left, top, width, height}) to screenshot.
    242   * @param win {Window}
    243   *        The current window.
    244   */
    245  screenshotArea(eltOrRect, win) {
    246    if (Element.isInstance(eltOrRect)) {
    247      eltOrRect = eltOrRect.getBoundingClientRect();
    248    }
    249    let { left, top, width, height } = eltOrRect;
    250    let canvas = win.document.createElementNS(
    251      "http://www.w3.org/1999/xhtml",
    252      "canvas"
    253    );
    254    let ctx = canvas.getContext("2d");
    255    let ratio = win.devicePixelRatio;
    256    canvas.width = width * ratio;
    257    canvas.height = height * ratio;
    258    ctx.scale(ratio, ratio);
    259    ctx.drawWindow(win, left, top, width, height, "#fff");
    260    return canvas.toDataURL();
    261  },
    262 
    263  /**
    264   * Will poll a condition function until it returns true.
    265   *
    266   * @param condition
    267   *        A condition function that must return true or false. If the
    268   *        condition ever throws, this function fails and rejects the
    269   *        returned promise. The function can be an async function.
    270   * @param msg
    271   *        A message used to describe the condition being waited for.
    272   *        This message will be used to reject the promise should the
    273   *        wait fail. It is also used to add a profiler marker.
    274   * @param interval
    275   *        The time interval to poll the condition function. Defaults
    276   *        to 100ms.
    277   * @param maxTries
    278   *        The number of times to poll before giving up and rejecting
    279   *        if the condition has not yet returned true. Defaults to 50
    280   *        (~5 seconds for 100ms intervals)
    281   * @return Promise
    282   *        Resolves with the return value of the condition function.
    283   *        Rejects if timeout is exceeded or condition ever throws.
    284   *
    285   * NOTE: This is intentionally not using setInterval, using setTimeout
    286   * instead. setInterval is not promise-safe.
    287   */
    288  waitForCondition(condition, msg, interval = 100, maxTries = 50) {
    289    let startTime = ChromeUtils.now();
    290    return new Promise((resolve, reject) => {
    291      let tries = 0;
    292      let timeoutId = 0;
    293      async function tryOnce() {
    294        timeoutId = 0;
    295        if (tries >= maxTries) {
    296          msg += ` - timed out after ${maxTries} tries.`;
    297          ChromeUtils.addProfilerMarker(
    298            "TestUtils",
    299            { startTime, category: "Test" },
    300            `waitForCondition - ${msg}`
    301          );
    302          condition = null;
    303          reject(msg);
    304          return;
    305        }
    306 
    307        let conditionPassed = false;
    308        try {
    309          conditionPassed = await condition();
    310        } catch (e) {
    311          ChromeUtils.addProfilerMarker(
    312            "TestUtils",
    313            { startTime, category: "Test" },
    314            `waitForCondition - ${msg}`
    315          );
    316          msg += ` - threw exception: ${e}`;
    317          condition = null;
    318          reject(msg);
    319          return;
    320        }
    321 
    322        if (conditionPassed) {
    323          ChromeUtils.addProfilerMarker(
    324            "TestUtils",
    325            { startTime, category: "Test" },
    326            `waitForCondition succeeded after ${tries} retries - ${msg}`
    327          );
    328          // Avoid keeping a reference to the condition function after the
    329          // promise resolves, as this function could itself reference objects
    330          // that should be GC'ed before the end of the test.
    331          condition = null;
    332          resolve(conditionPassed);
    333          return;
    334        }
    335        tries++;
    336        timeoutId = setTimeout(tryOnce, interval);
    337      }
    338 
    339      TestUtils.promiseTestFinished?.then(() => {
    340        if (!timeoutId) {
    341          return;
    342        }
    343 
    344        clearTimeout(timeoutId);
    345        msg += " - still pending at the end of the test";
    346        ChromeUtils.addProfilerMarker(
    347          "TestUtils",
    348          { startTime, category: "Test" },
    349          `waitForCondition - ${msg}`
    350        );
    351        reject("waitForCondition timer - " + msg);
    352      });
    353 
    354      TestUtils.executeSoon(tryOnce);
    355    });
    356  },
    357 
    358  shuffle(array) {
    359    let results = [];
    360    for (let i = 0; i < array.length; ++i) {
    361      let randomIndex = Math.floor(Math.random() * (i + 1));
    362      results[i] = results[randomIndex];
    363      results[randomIndex] = array[i];
    364    }
    365    return results;
    366  },
    367 
    368  assertPackagedBuild() {
    369    const omniJa = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
    370    omniJa.append("omni.ja");
    371    if (!omniJa.exists()) {
    372      throw new Error(
    373        "This test requires a packaged build, " +
    374          "run 'mach package' and then use --app-binary=$OBJDIR/dist/firefox/firefox"
    375      );
    376    }
    377  },
    378 };