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