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