summaryrefslogtreecommitdiffstats
path: root/testing/modules/XPCShellContentUtils.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'testing/modules/XPCShellContentUtils.sys.mjs')
-rw-r--r--testing/modules/XPCShellContentUtils.sys.mjs479
1 files changed, 479 insertions, 0 deletions
diff --git a/testing/modules/XPCShellContentUtils.sys.mjs b/testing/modules/XPCShellContentUtils.sys.mjs
new file mode 100644
index 0000000000..21ac5dbae4
--- /dev/null
+++ b/testing/modules/XPCShellContentUtils.sys.mjs
@@ -0,0 +1,479 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+// Windowless browsers can create documents that rely on XUL Custom Elements:
+ChromeUtils.importESModule(
+ "resource://gre/modules/CustomElementsListener.sys.mjs"
+);
+
+// Need to import ActorManagerParent.sys.mjs so that the actors are initialized
+// before running extension XPCShell tests.
+ChromeUtils.importESModule("resource://gre/modules/ActorManagerParent.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContentTask: "resource://testing-common/ContentTask.sys.mjs",
+ HttpServer: "resource://testing-common/httpd.sys.mjs",
+ SpecialPowersParent: "resource://testing-common/SpecialPowersParent.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ proxyService: [
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService",
+ ],
+});
+
+const { promiseDocumentLoaded, promiseEvent, promiseObserved } = ExtensionUtils;
+
+var gRemoteContentScripts = Services.appinfo.browserTabsRemoteAutostart;
+const REMOTE_CONTENT_SUBFRAMES = Services.appinfo.fissionAutostart;
+
+function frameScript() {
+ // We need to make sure that the ExtensionPolicy service has been initialized
+ // as it sets up the observers that inject extension content scripts.
+ Cc["@mozilla.org/addons/policy-service;1"].getService();
+
+ Services.obs.notifyObservers(this, "tab-content-frameloader-created");
+
+ // eslint-disable-next-line mozilla/balanced-listeners, no-undef
+ addEventListener(
+ "MozHeapMinimize",
+ () => {
+ Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
+ },
+ true,
+ true
+ );
+}
+
+let kungFuDeathGrip = new Set();
+function promiseBrowserLoaded(browser, url, redirectUrl) {
+ url = url && Services.io.newURI(url);
+ redirectUrl = redirectUrl && Services.io.newURI(redirectUrl);
+
+ return new Promise(resolve => {
+ const listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsISupportsWeakReference",
+ "nsIWebProgressListener",
+ ]),
+
+ onStateChange(webProgress, request, stateFlags, statusCode) {
+ request.QueryInterface(Ci.nsIChannel);
+
+ let requestURI =
+ request.originalURI ||
+ webProgress.DOMWindow.document.documentURIObject;
+ if (
+ webProgress.isTopLevel &&
+ (url?.equals(requestURI) || redirectUrl?.equals(requestURI)) &&
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP
+ ) {
+ resolve();
+ kungFuDeathGrip.delete(listener);
+ browser.removeProgressListener(listener);
+ }
+ },
+ };
+
+ // addProgressListener only supports weak references, so we need to
+ // use one. But we also need to make sure it stays alive until we're
+ // done with it, so thunk away a strong reference to keep it alive.
+ kungFuDeathGrip.add(listener);
+ browser.addProgressListener(
+ listener,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW
+ );
+ });
+}
+
+class ContentPage {
+ constructor(
+ remote = gRemoteContentScripts,
+ remoteSubframes = REMOTE_CONTENT_SUBFRAMES,
+ extension = null,
+ privateBrowsing = false,
+ userContextId = undefined
+ ) {
+ this.remote = remote;
+
+ // If an extension has been passed, overwrite remote
+ // with extension.remote to be sure that the ContentPage
+ // will have the same remoteness of the extension.
+ if (extension) {
+ this.remote = extension.remote;
+ }
+
+ this.remoteSubframes = this.remote && remoteSubframes;
+ this.extension = extension;
+ this.privateBrowsing = privateBrowsing;
+ this.userContextId = userContextId;
+
+ this.browserReady = this._initBrowser();
+ }
+
+ async _initBrowser() {
+ let chromeFlags = 0;
+ if (this.remote) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW;
+ }
+ if (this.remoteSubframes) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW;
+ }
+ if (this.privateBrowsing) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW;
+ }
+ this.windowlessBrowser = Services.appShell.createWindowlessBrowser(
+ true,
+ chromeFlags
+ );
+
+ let system = Services.scriptSecurityManager.getSystemPrincipal();
+
+ let chromeShell = this.windowlessBrowser.docShell.QueryInterface(
+ Ci.nsIWebNavigation
+ );
+
+ chromeShell.createAboutBlankDocumentViewer(system, system);
+ this.windowlessBrowser.browsingContext.useGlobalHistory = false;
+ let loadURIOptions = {
+ triggeringPrincipal: system,
+ };
+ chromeShell.loadURI(
+ Services.io.newURI("chrome://extensions/content/dummy.xhtml"),
+ loadURIOptions
+ );
+
+ await promiseObserved(
+ "chrome-document-global-created",
+ win => win.document == chromeShell.document
+ );
+
+ let chromeDoc = await promiseDocumentLoaded(chromeShell.document);
+
+ let { SpecialPowers } = chromeDoc.ownerGlobal;
+ SpecialPowers.xpcshellScope = XPCShellContentUtils.currentScope;
+ SpecialPowers.setAsDefaultAssertHandler();
+
+ let browser = chromeDoc.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("nodefaultsrc", "true");
+ if (this.userContextId) {
+ browser.setAttribute("usercontextid", this.userContextId);
+ }
+
+ if (this.extension?.remote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", "extension");
+ }
+
+ // Ensure that the extension is loaded into the correct
+ // BrowsingContextGroupID by default.
+ if (this.extension) {
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ this.extension.browsingContextGroupId
+ );
+ }
+
+ let awaitFrameLoader = Promise.resolve();
+ if (this.remote) {
+ awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
+ browser.setAttribute("remote", "true");
+
+ browser.setAttribute("maychangeremoteness", "true");
+ browser.addEventListener(
+ "DidChangeBrowserRemoteness",
+ this.didChangeBrowserRemoteness.bind(this)
+ );
+ }
+
+ chromeDoc.documentElement.appendChild(browser);
+
+ // Forcibly flush layout so that we get a pres shell soon enough, see
+ // bug 1274775.
+ browser.getBoundingClientRect();
+
+ await awaitFrameLoader;
+
+ this.browser = browser;
+
+ this.loadFrameScript(frameScript);
+
+ return browser;
+ }
+
+ get browsingContext() {
+ return this.browser.browsingContext;
+ }
+
+ get SpecialPowers() {
+ return this.browser.ownerGlobal.SpecialPowers;
+ }
+
+ loadFrameScript(func) {
+ let frameScript = `data:text/javascript,(${encodeURI(func)}).call(this)`;
+ this.browser.messageManager.loadFrameScript(frameScript, true, true);
+ }
+
+ addFrameScriptHelper(func) {
+ let frameScript = `data:text/javascript,${encodeURI(func)}`;
+ this.browser.messageManager.loadFrameScript(frameScript, false, true);
+ }
+
+ didChangeBrowserRemoteness(event) {
+ // XXX: Tests can load their own additional frame scripts, so we may need to
+ // track all scripts that have been loaded, and reload them here?
+ this.loadFrameScript(frameScript);
+ }
+
+ async loadURL(url, redirectUrl = undefined) {
+ await this.browserReady;
+
+ this.browser.fixupAndLoadURIString(url, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ return promiseBrowserLoaded(this.browser, url, redirectUrl);
+ }
+
+ async fetch(...args) {
+ return this.spawn(args, async (url, options) => {
+ let resp = await this.content.fetch(url, options);
+ return resp.text();
+ });
+ }
+
+ spawn(params, task) {
+ return this.SpecialPowers.spawn(this.browser, params, task);
+ }
+
+ // Like spawn(), but uses the legacy ContentTask infrastructure rather than
+ // SpecialPowers. Exists only because the author of the SpecialPowers
+ // migration did not have the time to fix all of the legacy users who relied
+ // on the old semantics.
+ //
+ // DO NOT USE IN NEW CODE
+ legacySpawn(params, task) {
+ lazy.ContentTask.setTestScope(XPCShellContentUtils.currentScope);
+
+ return lazy.ContentTask.spawn(this.browser, params, task);
+ }
+
+ async close() {
+ await this.browserReady;
+
+ let { messageManager } = this.browser;
+
+ this.browser.removeEventListener(
+ "DidChangeBrowserRemoteness",
+ this.didChangeBrowserRemoteness.bind(this)
+ );
+ this.browser = null;
+
+ this.windowlessBrowser.close();
+ this.windowlessBrowser = null;
+
+ await lazy.TestUtils.topicObserved(
+ "message-manager-disconnect",
+ subject => subject === messageManager
+ );
+ }
+}
+
+export var XPCShellContentUtils = {
+ currentScope: null,
+ fetchScopes: new Map(),
+
+ initCommon(scope) {
+ this.currentScope = scope;
+
+ // We need to load at least one frame script into every message
+ // manager to ensure that the scriptable wrapper for its global gets
+ // created before we try to access it externally. If we don't, we
+ // fail sanity checks on debug builds the first time we try to
+ // create a wrapper, because we should never have a global without a
+ // cached wrapper.
+ Services.mm.loadFrameScript("data:text/javascript,//", true, true);
+
+ scope.registerCleanupFunction(() => {
+ this.currentScope = null;
+
+ return Promise.all(
+ Array.from(this.fetchScopes.values(), promise =>
+ promise.then(scope => scope.close())
+ )
+ );
+ });
+ },
+
+ init(scope) {
+ // QuotaManager crashes if it doesn't have a profile.
+ scope.do_get_profile();
+
+ this.initCommon(scope);
+
+ lazy.SpecialPowersParent.registerActor();
+ },
+
+ initMochitest(scope) {
+ this.initCommon(scope);
+ },
+
+ ensureInitialized(scope) {
+ if (!this.currentScope) {
+ if (scope.do_get_profile) {
+ this.init(scope);
+ } else {
+ this.initMochitest(scope);
+ }
+ }
+ },
+
+ /**
+ * Creates a new HttpServer for testing, and begins listening on the
+ * specified port. Automatically shuts down the server when the test
+ * unit ends.
+ *
+ * @param {object} [options = {}]
+ * The options object.
+ * @param {integer} [options.port = -1]
+ * The port to listen on. If omitted, listen on a random
+ * port. The latter is the preferred behavior.
+ * @param {sequence<string>?} [options.hosts = null]
+ * A set of hosts to accept connections to. Support for this is
+ * implemented using a proxy filter.
+ *
+ * @returns {HttpServer}
+ * The HTTP server instance.
+ */
+ createHttpServer({ port = -1, hosts } = {}) {
+ let server = new lazy.HttpServer();
+ server.start(port);
+
+ if (hosts) {
+ const hostsSet = new Set();
+ const serverHost = "localhost";
+ const serverPort = server.identity.primaryPort;
+
+ for (let host of hosts) {
+ if (host.startsWith("[") && host.endsWith("]")) {
+ // HttpServer expects IPv6 addresses in bracket notation, but the
+ // proxy filter uses nsIURI.host, which does not have brackets.
+ hostsSet.add(host.slice(1, -1));
+ } else {
+ hostsSet.add(host);
+ }
+ server.identity.add("http", host, 80);
+ }
+
+ const proxyFilter = {
+ proxyInfo: lazy.proxyService.newProxyInfo(
+ "http",
+ serverHost,
+ serverPort,
+ "",
+ "",
+ 0,
+ 4096,
+ null
+ ),
+
+ applyFilter(channel, defaultProxyInfo, callback) {
+ if (hostsSet.has(channel.URI.host)) {
+ callback.onProxyFilterResult(this.proxyInfo);
+ } else {
+ callback.onProxyFilterResult(defaultProxyInfo);
+ }
+ },
+ };
+
+ lazy.proxyService.registerChannelFilter(proxyFilter, 0);
+ this.currentScope.registerCleanupFunction(() => {
+ lazy.proxyService.unregisterChannelFilter(proxyFilter);
+ });
+ }
+
+ this.currentScope.registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ server.stop(resolve);
+ });
+ });
+
+ return server;
+ },
+
+ registerJSON(server, path, obj) {
+ server.registerPathHandler(path, (request, response) => {
+ response.setHeader("content-type", "application/json", true);
+ response.write(JSON.stringify(obj));
+ });
+ },
+
+ async fetch(origin, url, options) {
+ let fetchScopePromise = this.fetchScopes.get(origin);
+ if (!fetchScopePromise) {
+ fetchScopePromise = this.loadContentPage(origin);
+ this.fetchScopes.set(origin, fetchScopePromise);
+ }
+
+ let fetchScope = await fetchScopePromise;
+ return fetchScope.fetch(url, options);
+ },
+
+ /**
+ * Loads a content page into a hidden docShell.
+ *
+ * @param {string} url
+ * The URL to load.
+ * @param {object} [options = {}]
+ * @param {ExtensionWrapper} [options.extension]
+ * If passed, load the URL as an extension page for the given
+ * extension.
+ * @param {boolean} [options.remote]
+ * If true, load the URL in a content process. If false, load
+ * it in the parent process.
+ * @param {boolean} [options.remoteSubframes]
+ * If true, load cross-origin frames in separate content processes.
+ * This is ignored if |options.remote| is false.
+ * @param {string} [options.redirectUrl]
+ * An optional URL that the initial page is expected to
+ * redirect to.
+ *
+ * @returns {ContentPage}
+ */
+ loadContentPage(
+ url,
+ {
+ extension = undefined,
+ remote = undefined,
+ remoteSubframes = undefined,
+ redirectUrl = undefined,
+ privateBrowsing = false,
+ userContextId = undefined,
+ } = {}
+ ) {
+ let contentPage = new ContentPage(
+ remote,
+ remoteSubframes,
+ extension && extension.extension,
+ privateBrowsing,
+ userContextId
+ );
+
+ return contentPage.loadURL(url, redirectUrl).then(() => {
+ return contentPage;
+ });
+ },
+};