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 = ["ExtensionTestUtils"];
9
10const { ActorManagerParent } = ChromeUtils.import(
11  "resource://gre/modules/ActorManagerParent.jsm"
12);
13const { ExtensionUtils } = ChromeUtils.import(
14  "resource://gre/modules/ExtensionUtils.jsm"
15);
16const { XPCOMUtils } = ChromeUtils.import(
17  "resource://gre/modules/XPCOMUtils.jsm"
18);
19const { AppConstants } = ChromeUtils.import(
20  "resource://gre/modules/AppConstants.jsm"
21);
22
23// Windowless browsers can create documents that rely on XUL Custom Elements:
24ChromeUtils.import("resource://gre/modules/CustomElementsListener.jsm", null);
25
26ChromeUtils.defineModuleGetter(
27  this,
28  "AddonManager",
29  "resource://gre/modules/AddonManager.jsm"
30);
31ChromeUtils.defineModuleGetter(
32  this,
33  "AddonTestUtils",
34  "resource://testing-common/AddonTestUtils.jsm"
35);
36ChromeUtils.defineModuleGetter(
37  this,
38  "ContentTask",
39  "resource://testing-common/ContentTask.jsm"
40);
41ChromeUtils.defineModuleGetter(
42  this,
43  "ExtensionTestCommon",
44  "resource://testing-common/ExtensionTestCommon.jsm"
45);
46ChromeUtils.defineModuleGetter(
47  this,
48  "FileUtils",
49  "resource://gre/modules/FileUtils.jsm"
50);
51ChromeUtils.defineModuleGetter(
52  this,
53  "MessageChannel",
54  "resource://gre/modules/MessageChannel.jsm"
55);
56ChromeUtils.defineModuleGetter(
57  this,
58  "Schemas",
59  "resource://gre/modules/Schemas.jsm"
60);
61ChromeUtils.defineModuleGetter(
62  this,
63  "Services",
64  "resource://gre/modules/Services.jsm"
65);
66ChromeUtils.defineModuleGetter(
67  this,
68  "TestUtils",
69  "resource://testing-common/TestUtils.jsm"
70);
71
72XPCOMUtils.defineLazyGetter(this, "Management", () => {
73  const { Management } = ChromeUtils.import(
74    "resource://gre/modules/Extension.jsm",
75    null
76  );
77  return Management;
78});
79
80Services.mm.loadFrameScript(
81  "chrome://global/content/browser-content.js",
82  true,
83  true
84);
85
86ActorManagerParent.flush();
87
88/* exported ExtensionTestUtils */
89
90const { promiseDocumentLoaded, promiseEvent, promiseObserved } = ExtensionUtils;
91
92var REMOTE_CONTENT_SCRIPTS = Services.prefs.getBoolPref(
93  "browser.tabs.remote.autostart",
94  false
95);
96
97let BASE_MANIFEST = Object.freeze({
98  applications: Object.freeze({
99    gecko: Object.freeze({
100      id: "test@web.ext",
101    }),
102  }),
103
104  manifest_version: 2,
105
106  name: "name",
107  version: "0",
108});
109
110function frameScript() {
111  const { MessageChannel } = ChromeUtils.import(
112    "resource://gre/modules/MessageChannel.jsm"
113  );
114  const { Services } = ChromeUtils.import(
115    "resource://gre/modules/Services.jsm"
116  );
117
118  Services.obs.notifyObservers(this, "tab-content-frameloader-created");
119
120  const messageListener = {
121    async receiveMessage({ target, messageName, recipient, data, name }) {
122      /* globals content */
123      let resp = await content.fetch(data.url, data.options);
124      return resp.text();
125    },
126  };
127  MessageChannel.addListener(this, "Test:Fetch", messageListener);
128
129  // eslint-disable-next-line mozilla/balanced-listeners, no-undef
130  addEventListener(
131    "MozHeapMinimize",
132    () => {
133      Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
134    },
135    true,
136    true
137  );
138}
139
140let kungFuDeathGrip = new Set();
141function promiseBrowserLoaded(browser, url, redirectUrl) {
142  url = url && Services.io.newURI(url);
143  redirectUrl = redirectUrl && Services.io.newURI(redirectUrl);
144
145  return new Promise(resolve => {
146    const listener = {
147      QueryInterface: ChromeUtils.generateQI([
148        Ci.nsISupportsWeakReference,
149        Ci.nsIWebProgressListener,
150      ]),
151
152      onStateChange(webProgress, request, stateFlags, statusCode) {
153        request.QueryInterface(Ci.nsIChannel);
154
155        let requestURI =
156          request.originalURI ||
157          webProgress.DOMWindow.document.documentURIObject;
158        if (
159          webProgress.isTopLevel &&
160          (url?.equals(requestURI) || redirectUrl?.equals(requestURI)) &&
161          stateFlags & Ci.nsIWebProgressListener.STATE_STOP
162        ) {
163          resolve();
164          kungFuDeathGrip.delete(listener);
165          browser.removeProgressListener(listener);
166        }
167      },
168    };
169
170    // addProgressListener only supports weak references, so we need to
171    // use one. But we also need to make sure it stays alive until we're
172    // done with it, so thunk away a strong reference to keep it alive.
173    kungFuDeathGrip.add(listener);
174    browser.addProgressListener(
175      listener,
176      Ci.nsIWebProgress.NOTIFY_STATE_WINDOW
177    );
178  });
179}
180
181class ContentPage {
182  constructor(
183    remote = REMOTE_CONTENT_SCRIPTS,
184    extension = null,
185    privateBrowsing = false,
186    userContextId = undefined
187  ) {
188    this.remote = remote;
189    this.extension = extension;
190    this.privateBrowsing = privateBrowsing;
191    this.userContextId = userContextId;
192
193    this.browserReady = this._initBrowser();
194  }
195
196  async _initBrowser() {
197    this.windowlessBrowser = Services.appShell.createWindowlessBrowser(true);
198
199    if (this.privateBrowsing) {
200      let loadContext = this.windowlessBrowser.docShell.QueryInterface(
201        Ci.nsILoadContext
202      );
203      loadContext.usePrivateBrowsing = true;
204    }
205
206    let system = Services.scriptSecurityManager.getSystemPrincipal();
207
208    let chromeShell = this.windowlessBrowser.docShell.QueryInterface(
209      Ci.nsIWebNavigation
210    );
211
212    chromeShell.createAboutBlankContentViewer(system, system);
213    this.windowlessBrowser.browsingContext.useGlobalHistory = false;
214    let loadURIOptions = {
215      triggeringPrincipal: system,
216    };
217    chromeShell.loadURI(
218      "chrome://extensions/content/dummy.xhtml",
219      loadURIOptions
220    );
221
222    await promiseObserved(
223      "chrome-document-global-created",
224      win => win.document == chromeShell.document
225    );
226
227    let chromeDoc = await promiseDocumentLoaded(chromeShell.document);
228
229    let browser = chromeDoc.createXULElement("browser");
230    browser.setAttribute("type", "content");
231    browser.setAttribute("disableglobalhistory", "true");
232    if (this.userContextId) {
233      browser.setAttribute("usercontextid", this.userContextId);
234    }
235
236    if (this.extension && this.extension.remote) {
237      this.remote = true;
238      browser.setAttribute("remote", "true");
239      browser.setAttribute("remoteType", "extension");
240      browser.sameProcessAsFrameLoader = this.extension.groupFrameLoader;
241    }
242
243    let awaitFrameLoader = Promise.resolve();
244    if (this.remote) {
245      awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
246      browser.setAttribute("remote", "true");
247    }
248
249    chromeDoc.documentElement.appendChild(browser);
250
251    await awaitFrameLoader;
252    this.browser = browser;
253
254    this.loadFrameScript(frameScript);
255
256    return browser;
257  }
258
259  sendMessage(msg, data) {
260    return MessageChannel.sendMessage(this.browser.messageManager, msg, data);
261  }
262
263  loadFrameScript(func) {
264    let frameScript = `data:text/javascript,(${encodeURI(func)}).call(this)`;
265    this.browser.messageManager.loadFrameScript(frameScript, true, true);
266  }
267
268  addFrameScriptHelper(func) {
269    let frameScript = `data:text/javascript,${encodeURI(func)}`;
270    this.browser.messageManager.loadFrameScript(frameScript, false, true);
271  }
272
273  async loadURL(url, redirectUrl = undefined) {
274    await this.browserReady;
275
276    this.browser.loadURI(url, {
277      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
278    });
279    return promiseBrowserLoaded(this.browser, url, redirectUrl);
280  }
281
282  async fetch(url, options) {
283    return this.sendMessage("Test:Fetch", { url, options });
284  }
285
286  spawn(params, task) {
287    return ContentTask.spawn(this.browser, params, task);
288  }
289
290  async close() {
291    await this.browserReady;
292
293    let { messageManager } = this.browser;
294
295    this.browser = null;
296
297    this.windowlessBrowser.close();
298    this.windowlessBrowser = null;
299
300    await TestUtils.topicObserved(
301      "message-manager-disconnect",
302      subject => subject === messageManager
303    );
304  }
305}
306
307class ExtensionWrapper {
308  constructor(testScope, extension = null) {
309    this.testScope = testScope;
310
311    this.extension = null;
312
313    this.handleResult = this.handleResult.bind(this);
314    this.handleMessage = this.handleMessage.bind(this);
315
316    this.state = "uninitialized";
317
318    this.testResolve = null;
319    this.testDone = new Promise(resolve => {
320      this.testResolve = resolve;
321    });
322
323    this.messageHandler = new Map();
324    this.messageAwaiter = new Map();
325
326    this.messageQueue = new Set();
327
328    this.testScope.registerCleanupFunction(() => {
329      this.clearMessageQueues();
330
331      if (this.state == "pending" || this.state == "running") {
332        this.testScope.equal(
333          this.state,
334          "unloaded",
335          "Extension left running at test shutdown"
336        );
337        return this.unload();
338      } else if (this.state == "unloading") {
339        this.testScope.equal(
340          this.state,
341          "unloaded",
342          "Extension not fully unloaded at test shutdown"
343        );
344      }
345      this.destroy();
346    });
347
348    if (extension) {
349      this.id = extension.id;
350      this.attachExtension(extension);
351    }
352  }
353
354  destroy() {
355    // This method should be implemented in subclasses which need to
356    // perform cleanup when destroyed.
357  }
358
359  attachExtension(extension) {
360    if (extension === this.extension) {
361      return;
362    }
363
364    if (this.extension) {
365      this.extension.off("test-eq", this.handleResult);
366      this.extension.off("test-log", this.handleResult);
367      this.extension.off("test-result", this.handleResult);
368      this.extension.off("test-done", this.handleResult);
369      this.extension.off("test-message", this.handleMessage);
370      this.clearMessageQueues();
371    }
372    this.uuid = extension.uuid;
373    this.extension = extension;
374
375    extension.on("test-eq", this.handleResult);
376    extension.on("test-log", this.handleResult);
377    extension.on("test-result", this.handleResult);
378    extension.on("test-done", this.handleResult);
379    extension.on("test-message", this.handleMessage);
380
381    this.testScope.info(`Extension attached`);
382  }
383
384  clearMessageQueues() {
385    if (this.messageQueue.size) {
386      let names = Array.from(this.messageQueue, ([msg]) => msg);
387      this.testScope.equal(
388        JSON.stringify(names),
389        "[]",
390        "message queue is empty"
391      );
392      this.messageQueue.clear();
393    }
394    if (this.messageAwaiter.size) {
395      let names = Array.from(this.messageAwaiter.keys());
396      this.testScope.equal(
397        JSON.stringify(names),
398        "[]",
399        "no tasks awaiting on messages"
400      );
401      for (let promise of this.messageAwaiter.values()) {
402        promise.reject();
403      }
404      this.messageAwaiter.clear();
405    }
406  }
407
408  handleResult(kind, pass, msg, expected, actual) {
409    switch (kind) {
410      case "test-eq":
411        this.testScope.ok(
412          pass,
413          `${msg} - Expected: ${expected}, Actual: ${actual}`
414        );
415        break;
416
417      case "test-log":
418        this.testScope.info(msg);
419        break;
420
421      case "test-result":
422        this.testScope.ok(pass, msg);
423        break;
424
425      case "test-done":
426        this.testScope.ok(pass, msg);
427        this.testResolve(msg);
428        break;
429    }
430  }
431
432  handleMessage(kind, msg, ...args) {
433    let handler = this.messageHandler.get(msg);
434    if (handler) {
435      handler(...args);
436    } else {
437      this.messageQueue.add([msg, ...args]);
438      this.checkMessages();
439    }
440  }
441
442  awaitStartup() {
443    return this.startupPromise;
444  }
445
446  async startup() {
447    if (this.state != "uninitialized") {
448      throw new Error("Extension already started");
449    }
450    this.state = "pending";
451
452    await ExtensionTestCommon.setIncognitoOverride(this.extension);
453
454    this.startupPromise = this.extension.startup().then(
455      result => {
456        this.state = "running";
457
458        return result;
459      },
460      error => {
461        this.state = "failed";
462
463        return Promise.reject(error);
464      }
465    );
466
467    return this.startupPromise;
468  }
469
470  async unload() {
471    if (this.state != "running") {
472      throw new Error("Extension not running");
473    }
474    this.state = "unloading";
475
476    if (this.addonPromise) {
477      // If addonPromise is still pending resolution, wait for it to make sure
478      // that add-ons that are installed through the AddonManager are properly
479      // uninstalled.
480      await this.addonPromise;
481    }
482
483    if (this.addon) {
484      await this.addon.uninstall();
485    } else {
486      await this.extension.shutdown();
487    }
488
489    if (AppConstants.platform === "android") {
490      // We need a way to notify the embedding layer that an extension has been
491      // uninstalled, so that the java layer can be updated too.
492      Services.obs.notifyObservers(
493        null,
494        "testing-uninstalled-addon",
495        this.addon ? this.addon.id : this.extension.id
496      );
497    }
498
499    this.state = "unloaded";
500  }
501
502  /*
503   * This method marks the extension unloading without actually calling
504   * shutdown, since shutting down a MockExtension causes it to be uninstalled.
505   *
506   * Normally you shouldn't need to use this unless you need to test something
507   * that requires a restart, such as updates.
508   */
509  markUnloaded() {
510    if (this.state != "running") {
511      throw new Error("Extension not running");
512    }
513    this.state = "unloaded";
514
515    return Promise.resolve();
516  }
517
518  sendMessage(...args) {
519    this.extension.testMessage(...args);
520  }
521
522  awaitFinish(msg) {
523    return this.testDone.then(actual => {
524      if (msg) {
525        this.testScope.equal(actual, msg, "test result correct");
526      }
527      return actual;
528    });
529  }
530
531  checkMessages() {
532    for (let message of this.messageQueue) {
533      let [msg, ...args] = message;
534
535      let listener = this.messageAwaiter.get(msg);
536      if (listener) {
537        this.messageQueue.delete(message);
538        this.messageAwaiter.delete(msg);
539
540        listener.resolve(...args);
541        return;
542      }
543    }
544  }
545
546  checkDuplicateListeners(msg) {
547    if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) {
548      throw new Error("only one message handler allowed");
549    }
550  }
551
552  awaitMessage(msg) {
553    return new Promise((resolve, reject) => {
554      this.checkDuplicateListeners(msg);
555
556      this.messageAwaiter.set(msg, { resolve, reject });
557      this.checkMessages();
558    });
559  }
560
561  onMessage(msg, callback) {
562    this.checkDuplicateListeners(msg);
563    this.messageHandler.set(msg, callback);
564  }
565}
566
567class AOMExtensionWrapper extends ExtensionWrapper {
568  constructor(testScope) {
569    super(testScope);
570
571    this.onEvent = this.onEvent.bind(this);
572
573    Management.on("ready", this.onEvent);
574    Management.on("shutdown", this.onEvent);
575    Management.on("startup", this.onEvent);
576
577    AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
578    AddonTestUtils.on("addon-manager-started", this.onEvent);
579
580    AddonManager.addAddonListener(this);
581  }
582
583  destroy() {
584    this.id = null;
585    this.addon = null;
586
587    Management.off("ready", this.onEvent);
588    Management.off("shutdown", this.onEvent);
589    Management.off("startup", this.onEvent);
590
591    AddonTestUtils.off("addon-manager-shutdown", this.onEvent);
592    AddonTestUtils.off("addon-manager-started", this.onEvent);
593
594    AddonManager.removeAddonListener(this);
595  }
596
597  setRestarting() {
598    if (this.state !== "restarting") {
599      this.startupPromise = new Promise(resolve => {
600        this.resolveStartup = resolve;
601      }).then(async result => {
602        await this.addonPromise;
603        return result;
604      });
605    }
606    this.state = "restarting";
607  }
608
609  onEnabling(addon) {
610    if (addon.id === this.id) {
611      this.setRestarting();
612    }
613  }
614
615  onInstalling(addon) {
616    if (addon.id === this.id) {
617      this.setRestarting();
618    }
619  }
620
621  onInstalled(addon) {
622    if (addon.id === this.id) {
623      this.addon = addon;
624    }
625  }
626
627  onUninstalled(addon) {
628    if (addon.id === this.id) {
629      this.destroy();
630    }
631  }
632
633  onEvent(kind, ...args) {
634    switch (kind) {
635      case "addon-manager-started":
636        if (this.state === "uninitialized") {
637          // startup() not called yet, ignore AddonManager startup notification.
638          return;
639        }
640        this.addonPromise = AddonManager.getAddonByID(this.id).then(addon => {
641          this.addon = addon;
642          this.addonPromise = null;
643        });
644      // FALLTHROUGH
645      case "addon-manager-shutdown":
646        if (this.state === "uninitialized") {
647          return;
648        }
649        this.addon = null;
650
651        this.setRestarting();
652        break;
653
654      case "startup": {
655        let [extension] = args;
656
657        this.maybeSetID(extension.rootURI, extension.id);
658
659        if (extension.id === this.id) {
660          this.attachExtension(extension);
661          this.state = "pending";
662        }
663        break;
664      }
665
666      case "shutdown": {
667        let [extension] = args;
668        if (extension.id === this.id && this.state !== "restarting") {
669          this.state = "unloaded";
670        }
671        break;
672      }
673
674      case "ready": {
675        let [extension] = args;
676        if (extension.id === this.id) {
677          this.state = "running";
678          if (AppConstants.platform === "android") {
679            // We need a way to notify the embedding layer that a new extension
680            // has been installed, so that the java layer can be updated too.
681            Services.obs.notifyObservers(
682              null,
683              "testing-installed-addon",
684              extension.id
685            );
686          }
687          this.resolveStartup(extension);
688        }
689        break;
690      }
691    }
692  }
693
694  async _flushCache() {
695    if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) {
696      let file = this.extension.rootURI.JARFile.QueryInterface(Ci.nsIFileURL)
697        .file;
698      await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {
699        path: file.path,
700      });
701    }
702  }
703
704  get version() {
705    return this.addon && this.addon.version;
706  }
707
708  async unload() {
709    await this._flushCache();
710    return super.unload();
711  }
712
713  async upgrade(data) {
714    this.startupPromise = new Promise(resolve => {
715      this.resolveStartup = resolve;
716    });
717    this.state = "restarting";
718
719    await this._flushCache();
720
721    let xpiFile = ExtensionTestCommon.generateXPI(data);
722
723    this.cleanupFiles.push(xpiFile);
724
725    return this._install(xpiFile);
726  }
727}
728
729class InstallableWrapper extends AOMExtensionWrapper {
730  constructor(testScope, xpiFile, addonData = {}) {
731    super(testScope);
732
733    this.file = xpiFile;
734    this.addonData = addonData;
735    this.installType = addonData.useAddonManager || "temporary";
736    this.installTelemetryInfo = addonData.amInstallTelemetryInfo;
737
738    this.cleanupFiles = [xpiFile];
739  }
740
741  destroy() {
742    super.destroy();
743
744    for (let file of this.cleanupFiles.splice(0)) {
745      try {
746        Services.obs.notifyObservers(file, "flush-cache-entry");
747        file.remove(false);
748      } catch (e) {
749        Cu.reportError(e);
750      }
751    }
752  }
753
754  maybeSetID(uri, id) {
755    if (
756      !this.id &&
757      uri instanceof Ci.nsIJARURI &&
758      uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file)
759    ) {
760      this.id = id;
761    }
762  }
763
764  _setIncognitoOverride() {
765    // this.id is not set yet so grab it from the manifest data to set
766    // the incognito permission.
767    let { addonData } = this;
768    if (addonData && addonData.incognitoOverride) {
769      try {
770        let { id } = addonData.manifest.applications.gecko;
771        if (id) {
772          return ExtensionTestCommon.setIncognitoOverride({ id, addonData });
773        }
774      } catch (e) {}
775      throw new Error(
776        "Extension ID is required for setting incognito permission."
777      );
778    }
779  }
780
781  async _install(xpiFile) {
782    // Timing here is different than in MockExtension so we need to handle
783    // incognitoOverride early.
784    await this._setIncognitoOverride();
785
786    if (this.installType === "temporary") {
787      return AddonManager.installTemporaryAddon(xpiFile)
788        .then(addon => {
789          this.id = addon.id;
790          this.addon = addon;
791
792          return this.startupPromise;
793        })
794        .catch(e => {
795          this.state = "unloaded";
796          return Promise.reject(e);
797        });
798    } else if (this.installType === "permanent") {
799      return AddonManager.getInstallForFile(
800        xpiFile,
801        null,
802        this.installTelemetryInfo
803      ).then(install => {
804        let listener = {
805          onInstallFailed: () => {
806            this.state = "unloaded";
807            this.resolveStartup(Promise.reject(new Error("Install failed")));
808          },
809          onInstallEnded: (install, newAddon) => {
810            this.id = newAddon.id;
811            this.addon = newAddon;
812          },
813        };
814
815        install.addListener(listener);
816        install.install();
817
818        return this.startupPromise;
819      });
820    }
821  }
822
823  startup() {
824    if (this.state != "uninitialized") {
825      throw new Error("Extension already started");
826    }
827
828    this.state = "pending";
829    this.startupPromise = new Promise(resolve => {
830      this.resolveStartup = resolve;
831    });
832
833    return this._install(this.file);
834  }
835}
836
837class ExternallyInstalledWrapper extends AOMExtensionWrapper {
838  constructor(testScope, id) {
839    super(testScope);
840
841    this.id = id;
842    this.startupPromise = new Promise(resolve => {
843      this.resolveStartup = resolve;
844    });
845
846    this.state = "restarting";
847  }
848
849  maybeSetID(uri, id) {}
850}
851
852var ExtensionTestUtils = {
853  BASE_MANIFEST,
854
855  async normalizeManifest(
856    manifest,
857    manifestType = "manifest.WebExtensionManifest",
858    baseManifest = BASE_MANIFEST
859  ) {
860    await Management.lazyInit();
861
862    let errors = [];
863    let context = {
864      url: null,
865
866      logError: error => {
867        errors.push(error);
868      },
869
870      preprocessors: {},
871    };
872
873    manifest = Object.assign({}, baseManifest, manifest);
874
875    let normalized = Schemas.normalize(manifest, manifestType, context);
876    normalized.errors = errors;
877
878    return normalized;
879  },
880
881  currentScope: null,
882
883  profileDir: null,
884
885  init(scope) {
886    this.currentScope = scope;
887
888    this.profileDir = scope.do_get_profile();
889
890    this.fetchScopes = new Map();
891
892    // We need to load at least one frame script into every message
893    // manager to ensure that the scriptable wrapper for its global gets
894    // created before we try to access it externally. If we don't, we
895    // fail sanity checks on debug builds the first time we try to
896    // create a wrapper, because we should never have a global without a
897    // cached wrapper.
898    Services.mm.loadFrameScript("data:text/javascript,//", true, true);
899
900    let tmpD = this.profileDir.clone();
901    tmpD.append("tmp");
902    tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
903
904    let dirProvider = {
905      getFile(prop, persistent) {
906        persistent.value = false;
907        if (prop == "TmpD") {
908          return tmpD.clone();
909        }
910        return null;
911      },
912
913      QueryInterface: ChromeUtils.generateQI([Ci.nsIDirectoryServiceProvider]),
914    };
915    Services.dirsvc.registerProvider(dirProvider);
916
917    scope.registerCleanupFunction(() => {
918      try {
919        tmpD.remove(true);
920      } catch (e) {
921        Cu.reportError(e);
922      }
923      Services.dirsvc.unregisterProvider(dirProvider);
924
925      this.currentScope = null;
926
927      return Promise.all(
928        Array.from(this.fetchScopes.values(), promise =>
929          promise.then(scope => scope.close())
930        )
931      );
932    });
933  },
934
935  addonManagerStarted: false,
936
937  mockAppInfo() {
938    AddonTestUtils.createAppInfo(
939      "xpcshell@tests.mozilla.org",
940      "XPCShell",
941      "48",
942      "48"
943    );
944  },
945
946  startAddonManager() {
947    if (this.addonManagerStarted) {
948      return;
949    }
950    this.addonManagerStarted = true;
951    this.mockAppInfo();
952
953    return AddonTestUtils.promiseStartupManager();
954  },
955
956  loadExtension(data) {
957    if (data.useAddonManager) {
958      // If we're using incognitoOverride, we'll need to ensure
959      // an ID is available before generating the XPI.
960      if (data.incognitoOverride) {
961        ExtensionTestCommon.setExtensionID(data);
962      }
963      let xpiFile = ExtensionTestCommon.generateXPI(data);
964
965      return this.loadExtensionXPI(xpiFile, data);
966    }
967
968    let extension = ExtensionTestCommon.generate(data);
969
970    return new ExtensionWrapper(this.currentScope, extension);
971  },
972
973  loadExtensionXPI(xpiFile, data) {
974    return new InstallableWrapper(this.currentScope, xpiFile, data);
975  },
976
977  // Create a wrapper for a webextension that will be installed
978  // by some external process (e.g., Normandy)
979  expectExtension(id) {
980    return new ExternallyInstalledWrapper(this.currentScope, id);
981  },
982
983  failOnSchemaWarnings(warningsAsErrors = true) {
984    let prefName = "extensions.webextensions.warnings-as-errors";
985    Services.prefs.setBoolPref(prefName, warningsAsErrors);
986    if (!warningsAsErrors) {
987      this.currentScope.registerCleanupFunction(() => {
988        Services.prefs.setBoolPref(prefName, true);
989      });
990    }
991  },
992
993  get remoteContentScripts() {
994    return REMOTE_CONTENT_SCRIPTS;
995  },
996
997  set remoteContentScripts(val) {
998    REMOTE_CONTENT_SCRIPTS = !!val;
999  },
1000
1001  async fetch(origin, url, options) {
1002    let fetchScopePromise = this.fetchScopes.get(origin);
1003    if (!fetchScopePromise) {
1004      fetchScopePromise = this.loadContentPage(origin);
1005      this.fetchScopes.set(origin, fetchScopePromise);
1006    }
1007
1008    let fetchScope = await fetchScopePromise;
1009    return fetchScope.sendMessage("Test:Fetch", { url, options });
1010  },
1011
1012  /**
1013   * Loads a content page into a hidden docShell.
1014   *
1015   * @param {string} url
1016   *        The URL to load.
1017   * @param {object} [options = {}]
1018   * @param {ExtensionWrapper} [options.extension]
1019   *        If passed, load the URL as an extension page for the given
1020   *        extension.
1021   * @param {boolean} [options.remote]
1022   *        If true, load the URL in a content process. If false, load
1023   *        it in the parent process.
1024   * @param {string} [options.redirectUrl]
1025   *        An optional URL that the initial page is expected to
1026   *        redirect to.
1027   *
1028   * @returns {ContentPage}
1029   */
1030  loadContentPage(
1031    url,
1032    {
1033      extension = undefined,
1034      remote = undefined,
1035      redirectUrl = undefined,
1036      privateBrowsing = false,
1037      userContextId = undefined,
1038    } = {}
1039  ) {
1040    ContentTask.setTestScope(this.currentScope);
1041
1042    let contentPage = new ContentPage(
1043      remote,
1044      extension && extension.extension,
1045      privateBrowsing,
1046      userContextId
1047    );
1048
1049    return contentPage.loadURL(url, redirectUrl).then(() => {
1050      return contentPage;
1051    });
1052  },
1053};
1054