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