No bug - tagging b4d3227540c9ebc43d64aac6168fdca7019c22d8 with FIREFOX_BETA_126_BASE...
[gecko.git] / testing / modules / TestUtils.sys.mjs
blob82e954e305b2d7aaf985adfe23e1ab8735c7cd1a
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/. */
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  */
19 import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
21 const ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"].getService(
22   Ci.nsIConsoleAPIStorage
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   },
36   waitForTick() {
37     return new Promise(resolve => this.executeSoon(resolve));
38   },
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    * @resolves 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;
71           resolve(message);
72         } catch (ex) {
73           ConsoleAPIStorage.removeLogEventListener(observe);
74           checkFn = null;
75           removed = true;
76           reject(ex);
77         }
78       }
80       ConsoleAPIStorage.addLogEventListener(
81         observe,
82         Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
83       );
85       TestUtils.promiseTestFinished?.then(() => {
86         if (removed) {
87           return;
88         }
90         ConsoleAPIStorage.removeLogEventListener(observe);
91         let text =
92           "Console message observer not removed before the end of test";
93         reject(text);
94       });
95     });
96   },
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     }
113     ConsoleAPIStorage.addLogEventListener(
114       observe,
115       Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
116     );
118     return async () => {
119       await TestUtils.waitForTick();
120       ConsoleAPIStorage.removeLogEventListener(observe);
121       return messages;
122     };
123   },
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}
141    * @resolves The array [subject, data] from the observed notification.
142    */
143   topicObserved(topic, checkFn) {
144     let startTime = Cu.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);
173       TestUtils.promiseTestFinished?.then(() => {
174         if (removed) {
175           return;
176         }
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   },
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}
206    * @resolves 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   },
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   },
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 = Cu.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         }
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         }
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       }
339       TestUtils.promiseTestFinished?.then(() => {
340         if (!timeoutId) {
341           return;
342         }
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       });
354       TestUtils.executeSoon(tryOnce);
355     });
356   },
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   },
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 --appname=dist"
375       );
376     }
377   },