XPCShellContentUtils.sys.mjs (14978B)
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set sts=2 sw=2 et tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; 8 9 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 10 11 // Windowless browsers can create documents that rely on XUL Custom Elements: 12 ChromeUtils.importESModule( 13 "resource://gre/modules/CustomElementsListener.sys.mjs" 14 ); 15 16 // Need to import ActorManagerParent.sys.mjs so that the actors are initialized 17 // before running extension XPCShell tests. 18 ChromeUtils.importESModule("resource://gre/modules/ActorManagerParent.sys.mjs"); 19 20 const lazy = {}; 21 22 ChromeUtils.defineESModuleGetters(lazy, { 23 ContentTask: "resource://testing-common/ContentTask.sys.mjs", 24 HttpServer: "resource://testing-common/httpd.sys.mjs", 25 SpecialPowersParent: "resource://testing-common/SpecialPowersParent.sys.mjs", 26 SpecialPowersForProcess: 27 "resource://testing-common/SpecialPowersProcessActor.sys.mjs", 28 TestUtils: "resource://testing-common/TestUtils.sys.mjs", 29 }); 30 31 XPCOMUtils.defineLazyServiceGetters(lazy, { 32 proxyService: [ 33 "@mozilla.org/network/protocol-proxy-service;1", 34 Ci.nsIProtocolProxyService, 35 ], 36 }); 37 38 const { promiseDocumentLoaded, promiseEvent, promiseObserved } = ExtensionUtils; 39 40 var gRemoteContentScripts = Services.appinfo.browserTabsRemoteAutostart; 41 const REMOTE_CONTENT_SUBFRAMES = Services.appinfo.fissionAutostart; 42 43 function frameScript() { 44 // We need to make sure that the ExtensionPolicy service has been initialized 45 // as it sets up the observers that inject extension content scripts. 46 Cc["@mozilla.org/addons/policy-service;1"].getService(); 47 48 Services.obs.notifyObservers(this, "tab-content-frameloader-created"); 49 50 // eslint-disable-next-line mozilla/balanced-listeners, no-undef 51 addEventListener( 52 "MozHeapMinimize", 53 () => { 54 Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize"); 55 }, 56 true, 57 true 58 ); 59 } 60 61 let kungFuDeathGrip = new Set(); 62 function promiseBrowserLoaded(browser, url, redirectUrl) { 63 url = url && Services.io.newURI(url); 64 redirectUrl = redirectUrl && Services.io.newURI(redirectUrl); 65 66 return new Promise(resolve => { 67 const listener = { 68 QueryInterface: ChromeUtils.generateQI([ 69 "nsISupportsWeakReference", 70 "nsIWebProgressListener", 71 ]), 72 73 onStateChange(webProgress, request, stateFlags) { 74 request.QueryInterface(Ci.nsIChannel); 75 76 let requestURI = 77 request.originalURI || 78 webProgress.DOMWindow.document.documentURIObject; 79 if ( 80 webProgress.isTopLevel && 81 (url?.equals(requestURI) || redirectUrl?.equals(requestURI)) && 82 stateFlags & Ci.nsIWebProgressListener.STATE_STOP 83 ) { 84 resolve(); 85 kungFuDeathGrip.delete(listener); 86 browser.removeProgressListener(listener); 87 } 88 }, 89 }; 90 91 // addProgressListener only supports weak references, so we need to 92 // use one. But we also need to make sure it stays alive until we're 93 // done with it, so thunk away a strong reference to keep it alive. 94 kungFuDeathGrip.add(listener); 95 browser.addProgressListener( 96 listener, 97 Ci.nsIWebProgress.NOTIFY_STATE_WINDOW 98 ); 99 }); 100 } 101 102 export class ContentPage { 103 constructor( 104 remote = gRemoteContentScripts, 105 remoteSubframes = REMOTE_CONTENT_SUBFRAMES, 106 extension = null, 107 privateBrowsing = false, 108 userContextId = undefined 109 ) { 110 this.remote = remote; 111 112 // If an extension has been passed, overwrite remote 113 // with extension.remote to be sure that the ContentPage 114 // will have the same remoteness of the extension. 115 if (extension) { 116 this.remote = extension.remote; 117 } 118 119 this.remoteSubframes = this.remote && remoteSubframes; 120 this.extension = extension; 121 this.privateBrowsing = privateBrowsing; 122 this.userContextId = userContextId; 123 124 this.browserReady = this._initBrowser(); 125 } 126 127 async _initBrowser() { 128 let chromeFlags = 0; 129 if (this.remote) { 130 chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW; 131 } 132 if (this.remoteSubframes) { 133 chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW; 134 } 135 if (this.privateBrowsing) { 136 chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW; 137 } 138 this.windowlessBrowser = Services.appShell.createWindowlessBrowser( 139 true, 140 chromeFlags 141 ); 142 143 let system = Services.scriptSecurityManager.getSystemPrincipal(); 144 145 let chromeShell = this.windowlessBrowser.docShell.QueryInterface( 146 Ci.nsIWebNavigation 147 ); 148 149 this.windowlessBrowser.browsingContext.useGlobalHistory = false; 150 let loadURIOptions = { 151 triggeringPrincipal: system, 152 }; 153 chromeShell.loadURI( 154 Services.io.newURI("chrome://extensions/content/dummy.xhtml"), 155 loadURIOptions 156 ); 157 158 await promiseObserved( 159 "chrome-document-global-created", 160 win => win.document == chromeShell.document 161 ); 162 163 let chromeDoc = await promiseDocumentLoaded(chromeShell.document); 164 165 let { SpecialPowers } = chromeDoc.ownerGlobal; 166 SpecialPowers.xpcshellScope = XPCShellContentUtils.currentScope; 167 SpecialPowers.setAsDefaultAssertHandler(); 168 169 let browser = chromeDoc.createXULElement("browser"); 170 browser.setAttribute("type", "content"); 171 browser.setAttribute("disableglobalhistory", "true"); 172 browser.setAttribute("messagemanagergroup", "webext-browsers"); 173 browser.setAttribute("nodefaultsrc", "true"); 174 if (this.userContextId) { 175 browser.setAttribute("usercontextid", this.userContextId); 176 } 177 178 if (this.extension?.remote) { 179 browser.setAttribute("remote", "true"); 180 browser.setAttribute("remoteType", "extension"); 181 } 182 183 // Ensure that the extension is loaded into the correct 184 // BrowsingContextGroupID by default. 185 if (this.extension) { 186 browser.setAttribute( 187 "initialBrowsingContextGroupId", 188 this.extension.browsingContextGroupId 189 ); 190 } 191 192 let awaitFrameLoader = Promise.resolve(); 193 if (this.remote) { 194 awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated"); 195 browser.setAttribute("remote", "true"); 196 197 browser.setAttribute("maychangeremoteness", "true"); 198 browser.addEventListener( 199 "DidChangeBrowserRemoteness", 200 this.didChangeBrowserRemoteness.bind(this) 201 ); 202 } 203 204 chromeDoc.documentElement.appendChild(browser); 205 206 // Forcibly flush layout so that we get a pres shell soon enough, see 207 // bug 1274775. 208 browser.getBoundingClientRect(); 209 210 await awaitFrameLoader; 211 212 this.browser = browser; 213 214 this.loadFrameScript(frameScript); 215 216 return browser; 217 } 218 219 get browsingContext() { 220 return this.browser.browsingContext; 221 } 222 223 get SpecialPowers() { 224 return this.browser.ownerGlobal.SpecialPowers; 225 } 226 227 loadFrameScript(func) { 228 let frameScript = `data:text/javascript,(${encodeURI(func)}).call(this)`; 229 this.browser.messageManager.loadFrameScript(frameScript, true, true); 230 } 231 232 addFrameScriptHelper(func) { 233 let frameScript = `data:text/javascript,${encodeURI(func)}`; 234 this.browser.messageManager.loadFrameScript(frameScript, false, true); 235 } 236 237 didChangeBrowserRemoteness() { 238 // XXX: Tests can load their own additional frame scripts, so we may need to 239 // track all scripts that have been loaded, and reload them here? 240 this.loadFrameScript(frameScript); 241 } 242 243 async loadURL(url, redirectUrl = undefined) { 244 await this.browserReady; 245 246 let browserLoadedPromise = promiseBrowserLoaded( 247 this.browser, 248 url, 249 redirectUrl 250 ); 251 this.browser.fixupAndLoadURIString(url, { 252 triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), 253 }); 254 return browserLoadedPromise; 255 } 256 257 async fetch(...args) { 258 return this.spawn(args, async (url, options) => { 259 let resp = await this.content.fetch(url, options); 260 return resp.text(); 261 }); 262 } 263 264 spawn(params, task) { 265 return this.SpecialPowers.spawn(this.browser, params, task); 266 } 267 268 // Get a SpecialPowersForProcess instance associated with the content process 269 // of the currently loaded page. This allows callers to spawn() tasks that 270 // outlive the page (for as long as the page's process is around). 271 getCurrentContentProcessSpecialPowers() { 272 const testScope = XPCShellContentUtils.currentScope; 273 const domProcess = this.browsingContext.currentWindowGlobal.domProcess; 274 return new lazy.SpecialPowersForProcess(testScope, domProcess); 275 } 276 277 // Like spawn(), but uses the legacy ContentTask infrastructure rather than 278 // SpecialPowers. Exists only because the author of the SpecialPowers 279 // migration did not have the time to fix all of the legacy users who relied 280 // on the old semantics. 281 // 282 // DO NOT USE IN NEW CODE 283 legacySpawn(params, task) { 284 lazy.ContentTask.setTestScope(XPCShellContentUtils.currentScope); 285 286 return lazy.ContentTask.spawn(this.browser, params, task); 287 } 288 289 async close() { 290 await this.browserReady; 291 292 let { messageManager } = this.browser; 293 294 this.browser.removeEventListener( 295 "DidChangeBrowserRemoteness", 296 this.didChangeBrowserRemoteness.bind(this) 297 ); 298 this.browser = null; 299 300 this.windowlessBrowser.close(); 301 this.windowlessBrowser = null; 302 303 await lazy.TestUtils.topicObserved( 304 "message-manager-disconnect", 305 subject => subject === messageManager 306 ); 307 } 308 } 309 310 export var XPCShellContentUtils = { 311 currentScope: null, 312 fetchScopes: new Map(), 313 314 initCommon(scope) { 315 this.currentScope = scope; 316 317 // We need to load at least one frame script into every message 318 // manager to ensure that the scriptable wrapper for its global gets 319 // created before we try to access it externally. If we don't, we 320 // fail sanity checks on debug builds the first time we try to 321 // create a wrapper, because we should never have a global without a 322 // cached wrapper. 323 Services.mm.loadFrameScript("data:text/javascript,//", true, true); 324 325 scope.registerCleanupFunction(() => { 326 this.currentScope = null; 327 328 return Promise.all( 329 Array.from(this.fetchScopes.values(), promise => 330 promise.then(scope => scope.close()) 331 ) 332 ); 333 }); 334 }, 335 336 init(scope) { 337 // QuotaManager crashes if it doesn't have a profile. 338 scope.do_get_profile(); 339 340 this.initCommon(scope); 341 342 lazy.SpecialPowersParent.registerActor(); 343 }, 344 345 initMochitest(scope) { 346 this.initCommon(scope); 347 }, 348 349 ensureInitialized(scope) { 350 if (!this.currentScope) { 351 if (scope.do_get_profile) { 352 this.init(scope); 353 } else { 354 this.initMochitest(scope); 355 } 356 } 357 }, 358 359 /** 360 * Creates a new HttpServer for testing, and begins listening on the 361 * specified port. Automatically shuts down the server when the test 362 * unit ends. 363 * 364 * @param {object} [options = {}] 365 * The options object. 366 * @param {integer} [options.port = -1] 367 * The port to listen on. If omitted, listen on a random 368 * port. The latter is the preferred behavior. 369 * @param {sequence<string>?} [options.hosts = null] 370 * A set of hosts to accept connections to. Support for this is 371 * implemented using a proxy filter. 372 * 373 * @returns {HttpServer} 374 * The HTTP server instance. 375 */ 376 createHttpServer({ port = -1, hosts } = {}) { 377 let server = new lazy.HttpServer(); 378 server.start(port); 379 380 if (hosts) { 381 const hostsSet = new Set(); 382 const serverHost = "localhost"; 383 const serverPort = server.identity.primaryPort; 384 385 for (let host of hosts) { 386 if (host.startsWith("[") && host.endsWith("]")) { 387 // HttpServer expects IPv6 addresses in bracket notation, but the 388 // proxy filter uses nsIURI.host, which does not have brackets. 389 hostsSet.add(host.slice(1, -1)); 390 } else { 391 hostsSet.add(host); 392 } 393 server.identity.add("http", host, 80); 394 } 395 396 const proxyFilter = { 397 proxyInfo: lazy.proxyService.newProxyInfo( 398 "http", 399 serverHost, 400 serverPort, 401 "", 402 "", 403 0, 404 4096, 405 null 406 ), 407 408 applyFilter(channel, defaultProxyInfo, callback) { 409 if (hostsSet.has(channel.URI.host)) { 410 callback.onProxyFilterResult(this.proxyInfo); 411 } else { 412 callback.onProxyFilterResult(defaultProxyInfo); 413 } 414 }, 415 }; 416 417 lazy.proxyService.registerChannelFilter(proxyFilter, 0); 418 this.currentScope.registerCleanupFunction(() => { 419 lazy.proxyService.unregisterChannelFilter(proxyFilter); 420 }); 421 } 422 423 this.currentScope.registerCleanupFunction(() => { 424 return new Promise(resolve => { 425 server.stop(resolve); 426 }); 427 }); 428 429 return server; 430 }, 431 432 registerJSON(server, path, obj) { 433 server.registerPathHandler(path, (request, response) => { 434 response.setHeader("content-type", "application/json", true); 435 response.write(JSON.stringify(obj)); 436 }); 437 }, 438 439 async fetch(origin, url, options) { 440 let fetchScopePromise = this.fetchScopes.get(origin); 441 if (!fetchScopePromise) { 442 fetchScopePromise = this.loadContentPage(origin); 443 this.fetchScopes.set(origin, fetchScopePromise); 444 } 445 446 let fetchScope = await fetchScopePromise; 447 return fetchScope.fetch(url, options); 448 }, 449 450 /** 451 * Loads a content page into a hidden docShell. 452 * 453 * @param {string} url 454 * The URL to load. 455 * @param {object} [options = {}] 456 * @param {ExtensionWrapper} [options.extension] 457 * If passed, load the URL as an extension page for the given 458 * extension. 459 * @param {boolean} [options.remote] 460 * If true, load the URL in a content process. If false, load 461 * it in the parent process. 462 * @param {boolean} [options.remoteSubframes] 463 * If true, load cross-origin frames in separate content processes. 464 * This is ignored if |options.remote| is false. 465 * @param {string} [options.redirectUrl] 466 * An optional URL that the initial page is expected to 467 * redirect to. 468 * 469 * @returns {ContentPage} 470 */ 471 loadContentPage( 472 url, 473 { 474 extension = undefined, 475 remote = undefined, 476 remoteSubframes = undefined, 477 redirectUrl = undefined, 478 privateBrowsing = false, 479 userContextId = undefined, 480 } = {} 481 ) { 482 let contentPage = new ContentPage( 483 remote, 484 remoteSubframes, 485 extension && extension.extension, 486 privateBrowsing, 487 userContextId 488 ); 489 490 return contentPage.loadURL(url, redirectUrl).then(() => { 491 return contentPage; 492 }); 493 }, 494 };