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 };