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