browser_head.js (12639B)
1 /** 2 * This file contains common functionality for ServiceWorker browser tests. 3 * 4 * Note that the normal auto-import mechanics for browser mochitests only 5 * handles "head.js", but we currently store all of our different varieties of 6 * mochitest in a single directory, which potentially results in a collision 7 * for similar heuristics for xpcshell. 8 * 9 * Many of the storage-related helpers in this file come from: 10 * https://searchfox.org/mozilla-central/source/dom/localstorage/test/unit/head.js 11 */ 12 13 // To use this file, explicitly import it via: 14 // 15 // Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/dom/serviceworkers/test/browser_head.js", this); 16 17 // Find the current parent directory of the test context we're being loaded into 18 // such that one can do `${originNoTrailingSlash}/${DIR_PATH}/file_in_dir.foo`. 19 const DIR_PATH = getRootDirectory(gTestPath) 20 .replace("chrome://mochitests/content/", "") 21 .slice(0, -1); 22 23 const SWM = Cc["@mozilla.org/serviceworkers/manager;1"].getService( 24 Ci.nsIServiceWorkerManager 25 ); 26 27 // The expected minimum usage for an origin that has any Cache API storage in 28 // use. Currently, the DB uses a page size of 4k and a minimum growth size of 29 // 32k and has enough tables/indices for this to use 15 pages (61440) which 30 // rounds up to 64k. However, we also have to allow for the incremental 31 // vacuum heuristic only firing if we have more than the growth increment's 32 // worth of free pages, so we need to set the threshold at 32k * 3. 33 const kMinimumOriginUsageBytes = 98304; 34 35 function getPrincipal(url, attrs) { 36 const uri = Services.io.newURI(url); 37 if (!attrs) { 38 attrs = {}; 39 } 40 return Services.scriptSecurityManager.createContentPrincipal(uri, attrs); 41 } 42 43 async function _qm_requestFinished(request) { 44 await new Promise(function (resolve) { 45 request.callback = function () { 46 resolve(); 47 }; 48 }); 49 50 if (request.resultCode !== Cr.NS_OK) { 51 throw new RequestError(request.resultCode, request.resultName); 52 } 53 54 return request.result; 55 } 56 57 async function qm_reset_storage() { 58 return new Promise(resolve => { 59 let request = Services.qms.reset(); 60 request.callback = resolve; 61 }); 62 } 63 64 async function get_qm_origin_usage(origin) { 65 return new Promise(resolve => { 66 const principal = 67 Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin); 68 Services.qms.getUsageForPrincipal(principal, request => { 69 info(`QM says usage of ${origin} is ${request.result.usage}`); 70 resolve(request.result.usage); 71 }); 72 }); 73 } 74 75 /** 76 * Clear the group associated with the given origin via nsIClearDataService. We 77 * are using nsIClearDataService here because nsIQuotaManagerService doesn't 78 * (directly) provide a means of clearing a group. 79 */ 80 async function clear_qm_origin_group_via_clearData(origin) { 81 const uri = Services.io.newURI(origin); 82 const baseDomain = Services.eTLD.getBaseDomain(uri); 83 info(`Clearing storage on domain ${baseDomain} (from origin ${origin})`); 84 85 // Initiate group clearing and wait for it. 86 await new Promise((resolve, reject) => { 87 Services.clearData.deleteDataFromSite( 88 baseDomain, 89 {}, 90 false, 91 Services.clearData.CLEAR_DOM_QUOTA, 92 failedFlags => { 93 if (failedFlags) { 94 reject(failedFlags); 95 } else { 96 resolve(); 97 } 98 } 99 ); 100 }); 101 } 102 103 /** 104 * Look up the nsIServiceWorkerRegistrationInfo for a given SW descriptor. 105 */ 106 function swm_lookup_reg(swDesc) { 107 // Scopes always include the full origin. 108 const fullScope = `${swDesc.origin}/${DIR_PATH}/${swDesc.scope}`; 109 const principal = getPrincipal(fullScope); 110 111 const reg = SWM.getRegistrationByPrincipal(principal, fullScope); 112 113 return reg; 114 } 115 116 /** 117 * Install a ServiceWorker according to the provided descriptor by opening a 118 * fresh tab that waits for the installed worker to be active and then closes 119 * the tab. Returns the`nsIServiceWorkerRegistrationInfo` corresponding to the 120 * registration. 121 * 122 * The descriptor may have the following properties: 123 * - scope: Optional. This is usually a relative path for tests and because 124 * there are (security) checks if the scope is more generic than the page 125 * URL, you almost never would want to specify an absolute scope here. 126 * - script: The script, which usually just wants to be a relative path. 127 * - origin: Requred, the origin (which should not include a trailing slash). 128 */ 129 async function install_sw(swDesc) { 130 info( 131 `Installing ServiceWorker ${swDesc.script} at ${swDesc.scope} on origin ${swDesc.origin}` 132 ); 133 const pageUrlStr = `${swDesc.origin}/${DIR_PATH}/empty_with_utils.html`; 134 135 await BrowserTestUtils.withNewTab( 136 { 137 gBrowser, 138 url: pageUrlStr, 139 }, 140 async browser => { 141 await SpecialPowers.spawn( 142 browser, 143 [{ swScript: swDesc.script, swScope: swDesc.scope }], 144 async function ({ swScript, swScope }) { 145 await content.wrappedJSObject.registerAndWaitForActive( 146 swScript, 147 swScope 148 ); 149 } 150 ); 151 } 152 ); 153 info(`ServiceWorker installed`); 154 155 return swm_lookup_reg(swDesc); 156 } 157 158 async function createMessagingHelperTab(origin, channelName) { 159 const pageUrlStr = `${origin}/${DIR_PATH}/empty_with_utils.html`; 160 161 let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrlStr); 162 163 // For hygiene reasons we make sure the helper establishes its message channel 164 // in a task distinct from any of the tasks that will initiate events. 165 await SpecialPowers.spawn(tab.linkedBrowser, [channelName], channelName => { 166 content.wrappedJSObject.setupMessagingChannel(channelName); 167 }); 168 169 return { 170 async postMessageScopeAndWaitFor(scope, messageToSend, messageToWaitFor) { 171 info( 172 `Sending message to SW scope ${scope} via helper page: ${messageToSend}` 173 ); 174 info(`Waiting for message via helper page: ${messageToWaitFor}`); 175 await SpecialPowers.spawn( 176 tab.linkedBrowser, 177 [channelName, scope, messageToSend, messageToWaitFor], 178 async (channelName, scope, messageToSend, messageToWaitFor) => { 179 await content.wrappedJSObject.postMessageScopeAndWaitFor( 180 channelName, 181 scope, 182 messageToSend, 183 messageToWaitFor 184 ); 185 } 186 ); 187 ok(true, "Expected message received"); 188 }, 189 190 async broadcastAndWaitFor(messageToBroadcast, messageToWaitFor) { 191 info(`Sending messageToBroadcast via helper page: ${messageToBroadcast}`); 192 info(`Waiting for message via helper page: ${messageToWaitFor}`); 193 await SpecialPowers.spawn( 194 tab.linkedBrowser, 195 [channelName, messageToBroadcast, messageToWaitFor], 196 async (channelName, messageToBroadcast, messageToWaitFor) => { 197 await content.wrappedJSObject.broadcastAndWaitFor( 198 channelName, 199 messageToBroadcast, 200 messageToWaitFor 201 ); 202 } 203 ); 204 ok(true, "Expected message received"); 205 }, 206 207 async updateScopeAndWaitFor(scope, messageToWaitFor) { 208 info(`Updating scope ${scope} via helper page`); 209 info(`Waiting for message via helper page: ${messageToWaitFor}`); 210 await SpecialPowers.spawn( 211 tab.linkedBrowser, 212 [channelName, scope, messageToWaitFor], 213 async (channelName, scope, messageToWaitFor) => { 214 await content.wrappedJSObject.updateScopeAndWaitFor( 215 channelName, 216 scope, 217 messageToWaitFor 218 ); 219 } 220 ); 221 ok(true, "Expected message received"); 222 }, 223 224 async closeHelperTab() { 225 await BrowserTestUtils.removeTab(tab); 226 tab = null; 227 }, 228 }; 229 } 230 231 /** 232 * Consume storage in the given origin by storing randomly generated Blobs into 233 * Cache API storage and IndexedDB storage. We use both APIs in order to 234 * ensure that data clearing wipes both QM clients. 235 * 236 * Randomly generated Blobs means Blobs with literally random content. This is 237 * done to compensate for the Cache API using snappy for compression. 238 */ 239 async function consume_storage(origin, storageDesc) { 240 info(`Consuming storage on origin ${origin}`); 241 const pageUrlStr = `${origin}/${DIR_PATH}/empty_with_utils.html`; 242 243 await BrowserTestUtils.withNewTab( 244 { 245 gBrowser, 246 url: pageUrlStr, 247 }, 248 async browser => { 249 await SpecialPowers.spawn( 250 browser, 251 [storageDesc], 252 async function ({ cacheBytes, idbBytes }) { 253 await content.wrappedJSObject.fillStorage(cacheBytes, idbBytes); 254 } 255 ); 256 } 257 ); 258 } 259 260 // Check if the origin is effectively empty, but allowing for the minimum size 261 // Cache API database to be present. 262 function is_minimum_origin_usage(originUsageBytes) { 263 return originUsageBytes <= kMinimumOriginUsageBytes; 264 } 265 266 /** 267 * Perform a navigation, waiting until the navigation stops, then returning 268 * the `textContent` of the body node. The expectation is this will be used 269 * with ServiceWorkers that return a body that indicates the ServiceWorker 270 * provided the result (possibly derived from the request) versus if 271 * interception didn't happen. 272 */ 273 async function navigate_and_get_body(swDesc, debugTag) { 274 let pageUrlStr = `${swDesc.origin}/${DIR_PATH}/${swDesc.scope}`; 275 if (debugTag) { 276 pageUrlStr += "?" + debugTag; 277 } 278 info(`Navigating to ${pageUrlStr}`); 279 280 const tabResult = await BrowserTestUtils.withNewTab( 281 { 282 gBrowser, 283 url: pageUrlStr, 284 // In the event of an aborted navigation, the load event will never 285 // happen... 286 waitForLoad: false, 287 // ...but the stop will. 288 waitForStateStop: true, 289 }, 290 async browser => { 291 info(` Tab opened, querying body content.`); 292 const spawnResult = await SpecialPowers.spawn(browser, [], function () { 293 const controlled = !!content.navigator.serviceWorker.controller; 294 // Special-case about: URL's. 295 let loc = content.document.documentURI; 296 if (loc.startsWith("about:")) { 297 // about:neterror is parameterized by query string, so truncate that 298 // off because our tests just care if we're seeing the neterror page. 299 const idxQuestion = loc.indexOf("?"); 300 if (idxQuestion !== -1) { 301 loc = loc.substring(0, idxQuestion); 302 } 303 return { controlled, body: loc }; 304 } 305 return { 306 controlled, 307 body: content.document?.body?.textContent?.trim(), 308 }; 309 }); 310 311 return spawnResult; 312 } 313 ); 314 315 return tabResult; 316 } 317 318 function waitForIframeLoad(iframe) { 319 return new Promise(function (resolve) { 320 iframe.onload = resolve; 321 }); 322 } 323 324 function waitForRegister(scope, callback) { 325 return new Promise(function (resolve) { 326 let listener = { 327 onRegister(registration) { 328 if (registration.scope !== scope) { 329 return; 330 } 331 SWM.removeListener(listener); 332 resolve(callback ? callback(registration) : registration); 333 }, 334 }; 335 SWM.addListener(listener); 336 }); 337 } 338 339 function waitForUnregister(scope) { 340 return new Promise(function (resolve) { 341 let listener = { 342 onUnregister(registration) { 343 if (registration.scope !== scope) { 344 return; 345 } 346 SWM.removeListener(listener); 347 resolve(registration); 348 }, 349 }; 350 SWM.addListener(listener); 351 }); 352 } 353 354 // Be careful using this helper function, please make sure QuotaUsageCheck must 355 // happen, otherwise test would be stucked in this function. 356 function waitForQuotaUsageCheckFinish(scope) { 357 return new Promise(function (resolve) { 358 let listener = { 359 onQuotaUsageCheckFinish(registration) { 360 if (registration.scope !== scope) { 361 return; 362 } 363 SWM.removeListener(listener); 364 resolve(registration); 365 }, 366 }; 367 SWM.addListener(listener); 368 }); 369 } 370 371 function waitForServiceWorkerRegistrationChange(registration, callback) { 372 return new Promise(function (resolve) { 373 let listener = { 374 onChange() { 375 registration.removeListener(listener); 376 if (callback) { 377 callback(); 378 } 379 resolve(callback ? callback() : undefined); 380 }, 381 }; 382 registration.addListener(listener); 383 }); 384 } 385 386 function waitForServiceWorkerShutdown() { 387 return new Promise(function (resolve) { 388 let observer = { 389 observe(subject, topic, data) { 390 if (topic !== "service-worker-shutdown") { 391 return; 392 } 393 SpecialPowers.removeObserver(observer, "service-worker-shutdown"); 394 resolve(); 395 }, 396 }; 397 SpecialPowers.addObserver(observer, "service-worker-shutdown"); 398 }); 399 }