Bug 1885565 - Part 1: Add mozac_ic_avatar_circle_24 to ui-icons r=android-reviewers...
[gecko.git] / toolkit / components / extensions / ExtensionXPCShellUtils.sys.mjs
blob27323dc8b342b33365bf7137b376dfbd574b07f5
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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
8 import { XPCShellContentUtils } from "resource://testing-common/XPCShellContentUtils.sys.mjs";
10 const lazy = {};
12 ChromeUtils.defineESModuleGetters(lazy, {
13   AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
14   AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
15   ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
16   FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
17   Management: "resource://gre/modules/Extension.sys.mjs",
18   Schemas: "resource://gre/modules/Schemas.sys.mjs",
19 });
21 let BASE_MANIFEST = Object.freeze({
22   browser_specific_settings: Object.freeze({
23     gecko: Object.freeze({
24       id: "test@web.ext",
25     }),
26   }),
28   manifest_version: 2,
30   name: "name",
31   version: "0",
32 });
34 class ExtensionWrapper {
35   /** @type {AddonWrapper} */
36   addon;
37   /** @type {Promise<AddonWrapper>} */
38   addonPromise;
39   /** @type {nsIFile[]} */
40   cleanupFiles;
42   constructor(testScope, extension = null) {
43     this.testScope = testScope;
45     this.extension = null;
47     this.handleResult = this.handleResult.bind(this);
48     this.handleMessage = this.handleMessage.bind(this);
50     this.state = "uninitialized";
52     this.testResolve = null;
53     this.testDone = new Promise(resolve => {
54       this.testResolve = resolve;
55     });
57     this.messageHandler = new Map();
58     this.messageAwaiter = new Map();
60     this.messageQueue = new Set();
62     this.testScope.registerCleanupFunction(() => {
63       this.clearMessageQueues();
65       if (this.state == "pending" || this.state == "running") {
66         this.testScope.equal(
67           this.state,
68           "unloaded",
69           "Extension left running at test shutdown"
70         );
71         return this.unload();
72       } else if (this.state == "unloading") {
73         this.testScope.equal(
74           this.state,
75           "unloaded",
76           "Extension not fully unloaded at test shutdown"
77         );
78       }
79       this.destroy();
80     });
82     if (extension) {
83       this.id = extension.id;
84       this.attachExtension(extension);
85     }
86   }
88   destroy() {
89     // This method should be implemented in subclasses which need to
90     // perform cleanup when destroyed.
91   }
93   attachExtension(extension) {
94     if (extension === this.extension) {
95       return;
96     }
98     if (this.extension) {
99       this.extension.off("test-eq", this.handleResult);
100       this.extension.off("test-log", this.handleResult);
101       this.extension.off("test-result", this.handleResult);
102       this.extension.off("test-done", this.handleResult);
103       this.extension.off("test-message", this.handleMessage);
104       this.clearMessageQueues();
105     }
106     this.uuid = extension.uuid;
107     this.extension = extension;
109     extension.on("test-eq", this.handleResult);
110     extension.on("test-log", this.handleResult);
111     extension.on("test-result", this.handleResult);
112     extension.on("test-done", this.handleResult);
113     extension.on("test-message", this.handleMessage);
115     this.testScope.info(`Extension attached`);
116   }
118   clearMessageQueues() {
119     if (this.messageQueue.size) {
120       let names = Array.from(this.messageQueue, ([msg]) => msg);
121       this.testScope.equal(
122         JSON.stringify(names),
123         "[]",
124         "message queue is empty"
125       );
126       this.messageQueue.clear();
127     }
128     if (this.messageAwaiter.size) {
129       let names = Array.from(this.messageAwaiter.keys());
130       this.testScope.equal(
131         JSON.stringify(names),
132         "[]",
133         "no tasks awaiting on messages"
134       );
135       for (let promise of this.messageAwaiter.values()) {
136         promise.reject();
137       }
138       this.messageAwaiter.clear();
139     }
140   }
142   handleResult(kind, pass, msg, expected, actual) {
143     switch (kind) {
144       case "test-eq":
145         this.testScope.ok(
146           pass,
147           `${msg} - Expected: ${expected}, Actual: ${actual}`
148         );
149         break;
151       case "test-log":
152         this.testScope.info(msg);
153         break;
155       case "test-result":
156         this.testScope.ok(pass, msg);
157         break;
159       case "test-done":
160         this.testScope.ok(pass, msg);
161         this.testResolve(msg);
162         break;
163     }
164   }
166   handleMessage(kind, msg, ...args) {
167     let handler = this.messageHandler.get(msg);
168     if (handler) {
169       handler(...args);
170     } else {
171       this.messageQueue.add([msg, ...args]);
172       this.checkMessages();
173     }
174   }
176   awaitStartup() {
177     return this.startupPromise;
178   }
180   awaitBackgroundStarted() {
181     if (!this.extension.manifest.background) {
182       throw new Error("Extension has no background");
183     }
184     return Promise.all([
185       this.startupPromise,
186       this.extension.promiseBackgroundStarted(),
187     ]);
188   }
190   async startup() {
191     if (this.state != "uninitialized") {
192       throw new Error("Extension already started");
193     }
194     this.state = "pending";
196     await lazy.ExtensionTestCommon.setIncognitoOverride(this.extension);
198     this.startupPromise = this.extension.startup().then(
199       result => {
200         this.state = "running";
202         return result;
203       },
204       error => {
205         this.state = "failed";
207         return Promise.reject(error);
208       }
209     );
211     return this.startupPromise;
212   }
214   async unload() {
215     if (this.state != "running") {
216       throw new Error("Extension not running");
217     }
218     this.state = "unloading";
220     if (this.addonPromise) {
221       // If addonPromise is still pending resolution, wait for it to make sure
222       // that add-ons that are installed through the AddonManager are properly
223       // uninstalled.
224       await this.addonPromise;
225     }
227     if (this.addon) {
228       await this.addon.uninstall();
229     } else {
230       await this.extension.shutdown();
231     }
233     if (AppConstants.platform === "android") {
234       // We need a way to notify the embedding layer that an extension has been
235       // uninstalled, so that the java layer can be updated too.
236       Services.obs.notifyObservers(
237         null,
238         "testing-uninstalled-addon",
239         this.addon ? this.addon.id : this.extension.id
240       );
241     }
243     this.state = "unloaded";
244   }
246   /**
247    * This method sends the message to force-sleep the background scripts.
248    *
249    * @returns {Promise} resolves after the background is asleep and listeners primed.
250    */
251   terminateBackground(...args) {
252     return this.extension.terminateBackground(...args);
253   }
255   wakeupBackground() {
256     return this.extension.wakeupBackground();
257   }
259   sendMessage(...args) {
260     this.extension.testMessage(...args);
261   }
263   awaitFinish(msg) {
264     return this.testDone.then(actual => {
265       if (msg) {
266         this.testScope.equal(actual, msg, "test result correct");
267       }
268       return actual;
269     });
270   }
272   checkMessages() {
273     for (let message of this.messageQueue) {
274       let [msg, ...args] = message;
276       let listener = this.messageAwaiter.get(msg);
277       if (listener) {
278         this.messageQueue.delete(message);
279         this.messageAwaiter.delete(msg);
281         listener.resolve(...args);
282         return;
283       }
284     }
285   }
287   checkDuplicateListeners(msg) {
288     if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) {
289       throw new Error("only one message handler allowed");
290     }
291   }
293   awaitMessage(msg) {
294     return new Promise((resolve, reject) => {
295       this.checkDuplicateListeners(msg);
297       this.messageAwaiter.set(msg, { resolve, reject });
298       this.checkMessages();
299     });
300   }
302   onMessage(msg, callback) {
303     this.checkDuplicateListeners(msg);
304     this.messageHandler.set(msg, callback);
305   }
308 class AOMExtensionWrapper extends ExtensionWrapper {
309   constructor(testScope) {
310     super(testScope);
312     this.onEvent = this.onEvent.bind(this);
314     lazy.Management.on("ready", this.onEvent);
315     lazy.Management.on("shutdown", this.onEvent);
316     lazy.Management.on("startup", this.onEvent);
318     lazy.AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
319     lazy.AddonTestUtils.on("addon-manager-started", this.onEvent);
321     lazy.AddonManager.addAddonListener(this);
322   }
324   destroy() {
325     this.id = null;
326     this.addon = null;
328     lazy.Management.off("ready", this.onEvent);
329     lazy.Management.off("shutdown", this.onEvent);
330     lazy.Management.off("startup", this.onEvent);
332     lazy.AddonTestUtils.off("addon-manager-shutdown", this.onEvent);
333     lazy.AddonTestUtils.off("addon-manager-started", this.onEvent);
335     lazy.AddonManager.removeAddonListener(this);
336   }
338   setRestarting() {
339     if (this.state !== "restarting") {
340       this.startupPromise = new Promise(resolve => {
341         this.resolveStartup = resolve;
342       }).then(async result => {
343         await this.addonPromise;
344         return result;
345       });
346     }
347     this.state = "restarting";
348   }
350   onEnabling(addon) {
351     if (addon.id === this.id) {
352       this.setRestarting();
353     }
354   }
356   onInstalling(addon) {
357     if (addon.id === this.id) {
358       this.setRestarting();
359     }
360   }
362   onInstalled(addon) {
363     if (addon.id === this.id) {
364       this.addon = addon;
365     }
366   }
368   onUninstalled(addon) {
369     if (addon.id === this.id) {
370       this.destroy();
371     }
372   }
374   onEvent(kind, ...args) {
375     switch (kind) {
376       case "addon-manager-started":
377         if (this.state === "uninitialized") {
378           // startup() not called yet, ignore AddonManager startup notification.
379           return;
380         }
381         this.addonPromise = lazy.AddonManager.getAddonByID(this.id).then(
382           addon => {
383             this.addon = addon;
384             this.addonPromise = null;
385           }
386         );
387       // FALLTHROUGH
388       case "addon-manager-shutdown":
389         if (this.state === "uninitialized") {
390           return;
391         }
392         this.addon = null;
394         this.setRestarting();
395         break;
397       case "startup": {
398         let [extension] = args;
400         this.maybeSetID(extension.rootURI, extension.id);
402         if (extension.id === this.id) {
403           this.attachExtension(extension);
404           this.state = "pending";
405         }
406         break;
407       }
409       case "shutdown": {
410         let [extension] = args;
411         if (extension.id === this.id && this.state !== "restarting") {
412           this.state = "unloaded";
413         }
414         break;
415       }
417       case "ready": {
418         let [extension] = args;
419         if (extension.id === this.id) {
420           this.state = "running";
421           if (AppConstants.platform === "android") {
422             // We need a way to notify the embedding layer that a new extension
423             // has been installed, so that the java layer can be updated too.
424             Services.obs.notifyObservers(
425               null,
426               "testing-installed-addon",
427               extension.id
428             );
429           }
430           this.resolveStartup(extension);
431         }
432         break;
433       }
434     }
435   }
437   async _flushCache() {
438     if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) {
439       let file = this.extension.rootURI.JARFile.QueryInterface(
440         Ci.nsIFileURL
441       ).file;
442       await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {
443         path: file.path,
444       });
445     }
446   }
448   get version() {
449     return this.addon && this.addon.version;
450   }
452   async unload() {
453     await this._flushCache();
454     return super.unload();
455   }
457   /**
458    * Override for subclasses which don't set an ID in the constructor.
459    *
460    * @param {nsIURI} _uri
461    * @param {string} _id
462    */
463   maybeSetID(_uri, _id) {}
466 class InstallableWrapper extends AOMExtensionWrapper {
467   constructor(testScope, xpiFile, addonData = {}) {
468     super(testScope);
470     this.file = xpiFile;
471     this.addonData = addonData;
472     this.installType = addonData.useAddonManager || "temporary";
473     this.installTelemetryInfo = addonData.amInstallTelemetryInfo;
475     this.cleanupFiles = [xpiFile];
476   }
478   destroy() {
479     super.destroy();
481     for (let file of this.cleanupFiles.splice(0)) {
482       try {
483         Services.obs.notifyObservers(file, "flush-cache-entry");
484         file.remove(false);
485       } catch (e) {
486         Cu.reportError(e);
487       }
488     }
489   }
491   maybeSetID(uri, id) {
492     if (
493       !this.id &&
494       uri instanceof Ci.nsIJARURI &&
495       uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file)
496     ) {
497       this.id = id;
498     }
499   }
501   _setIncognitoOverride() {
502     // this.id is not set yet so grab it from the manifest data to set
503     // the incognito permission.
504     let { addonData } = this;
505     if (addonData && addonData.incognitoOverride) {
506       try {
507         let { id } = addonData.manifest.browser_specific_settings.gecko;
508         if (id) {
509           return lazy.ExtensionTestCommon.setIncognitoOverride({
510             id,
511             addonData,
512           });
513         }
514       } catch (e) {}
515       throw new Error(
516         "Extension ID is required for setting incognito permission."
517       );
518     }
519   }
521   async _install(xpiFile) {
522     await this._setIncognitoOverride();
524     if (this.installType === "temporary") {
525       return lazy.AddonManager.installTemporaryAddon(xpiFile)
526         .then(addon => {
527           this.id = addon.id;
528           this.addon = addon;
530           return this.startupPromise;
531         })
532         .catch(e => {
533           this.state = "unloaded";
534           return Promise.reject(e);
535         });
536     } else if (this.installType === "permanent") {
537       return lazy.AddonManager.getInstallForFile(
538         xpiFile,
539         null,
540         this.installTelemetryInfo
541       ).then(install => {
542         let listener = {
543           onDownloadFailed: () => {
544             this.state = "unloaded";
545             this.resolveStartup(Promise.reject(new Error("Install failed")));
546           },
547           onInstallFailed: () => {
548             this.state = "unloaded";
549             this.resolveStartup(Promise.reject(new Error("Install failed")));
550           },
551           onInstallEnded: (install, newAddon) => {
552             this.id = newAddon.id;
553             this.addon = newAddon;
554           },
555         };
557         install.addListener(listener);
558         install.install();
560         return this.startupPromise;
561       });
562     }
563   }
565   startup() {
566     if (this.state != "uninitialized") {
567       throw new Error("Extension already started");
568     }
570     this.state = "pending";
571     this.startupPromise = new Promise(resolve => {
572       this.resolveStartup = resolve;
573     });
575     return this._install(this.file);
576   }
578   async upgrade(data) {
579     this.startupPromise = new Promise(resolve => {
580       this.resolveStartup = resolve;
581     });
582     this.state = "restarting";
584     await this._flushCache();
586     let xpiFile = lazy.ExtensionTestCommon.generateXPI(data);
588     this.cleanupFiles.push(xpiFile);
590     return this._install(xpiFile);
591   }
594 class ExternallyInstalledWrapper extends AOMExtensionWrapper {
595   constructor(testScope, id) {
596     super(testScope);
598     this.id = id;
599     this.startupPromise = new Promise(resolve => {
600       this.resolveStartup = resolve;
601     });
603     this.state = "restarting";
604   }
607 export var ExtensionTestUtils = {
608   BASE_MANIFEST,
610   get testAssertions() {
611     return lazy.ExtensionTestCommon.testAssertions;
612   },
614   // Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled
615   // from mochitest-plain tests.
616   getBackgroundServiceWorkerEnabled() {
617     return lazy.ExtensionTestCommon.getBackgroundServiceWorkerEnabled();
618   },
620   // A test helper used to check if the pref "extension.backgroundServiceWorker.forceInTestExtension"
621   // is set to true.
622   isInBackgroundServiceWorkerTests() {
623     return lazy.ExtensionTestCommon.isInBackgroundServiceWorkerTests();
624   },
626   async normalizeManifest(
627     manifest,
628     manifestType = "manifest.WebExtensionManifest",
629     baseManifest = BASE_MANIFEST
630   ) {
631     await lazy.Management.lazyInit();
633     manifest = Object.assign({}, baseManifest, manifest);
635     let errors = [];
636     let context = {
637       url: null,
638       manifestVersion: manifest.manifest_version,
640       logError: error => {
641         errors.push(error);
642       },
644       preprocessors: {},
645     };
647     let normalized = lazy.Schemas.normalize(manifest, manifestType, context);
648     normalized.errors = errors;
650     return normalized;
651   },
653   currentScope: null,
655   profileDir: null,
657   init(scope) {
658     XPCShellContentUtils.ensureInitialized(scope);
660     this.currentScope = scope;
662     this.profileDir = scope.do_get_profile();
664     let tmpD = this.profileDir.clone();
665     tmpD.append("tmp");
666     tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY);
668     let dirProvider = {
669       getFile(prop, persistent) {
670         persistent.value = false;
671         if (prop == "TmpD") {
672           return tmpD.clone();
673         }
674         return null;
675       },
677       QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
678     };
679     Services.dirsvc.registerProvider(dirProvider);
681     scope.registerCleanupFunction(() => {
682       try {
683         tmpD.remove(true);
684       } catch (e) {
685         Cu.reportError(e);
686       }
687       Services.dirsvc.unregisterProvider(dirProvider);
689       this.currentScope = null;
690     });
691   },
693   addonManagerStarted: false,
695   mockAppInfo() {
696     lazy.AddonTestUtils.createAppInfo(
697       "xpcshell@tests.mozilla.org",
698       "XPCShell",
699       "48",
700       "48"
701     );
702   },
704   startAddonManager() {
705     if (this.addonManagerStarted) {
706       return;
707     }
708     this.addonManagerStarted = true;
709     this.mockAppInfo();
711     return lazy.AddonTestUtils.promiseStartupManager();
712   },
714   loadExtension(data) {
715     if (data.useAddonManager) {
716       // If we're using incognitoOverride, we'll need to ensure
717       // an ID is available before generating the XPI.
718       if (data.incognitoOverride) {
719         lazy.ExtensionTestCommon.setExtensionID(data);
720       }
721       let xpiFile = lazy.ExtensionTestCommon.generateXPI(data);
723       return this.loadExtensionXPI(xpiFile, data);
724     }
726     let extension = lazy.ExtensionTestCommon.generate(data);
728     return new ExtensionWrapper(this.currentScope, extension);
729   },
731   loadExtensionXPI(xpiFile, data) {
732     return new InstallableWrapper(this.currentScope, xpiFile, data);
733   },
735   // Create a wrapper for a webextension that will be installed
736   // by some external process (e.g., Normandy)
737   expectExtension(id) {
738     return new ExternallyInstalledWrapper(this.currentScope, id);
739   },
741   failOnSchemaWarnings(warningsAsErrors = true) {
742     let prefName = "extensions.webextensions.warnings-as-errors";
743     Services.prefs.setBoolPref(prefName, warningsAsErrors);
744     if (!warningsAsErrors) {
745       this.currentScope.registerCleanupFunction(() => {
746         Services.prefs.setBoolPref(prefName, true);
747       });
748     }
749   },
751   /** @param {[origin: string, url: string, options: object]} args */
752   async fetch(...args) {
753     return XPCShellContentUtils.fetch(...args);
754   },
756   /**
757    * Loads a content page into a hidden docShell.
758    *
759    * @param {string} url
760    *        The URL to load.
761    * @param {object} [options = {}]
762    * @param {ExtensionWrapper} [options.extension]
763    *        If passed, load the URL as an extension page for the given
764    *        extension.
765    * @param {boolean} [options.remote]
766    *        If true, load the URL in a content process. If false, load
767    *        it in the parent process.
768    * @param {boolean} [options.remoteSubframes]
769    *        If true, load cross-origin frames in separate content processes.
770    *        This is ignored if |options.remote| is false.
771    * @param {string} [options.redirectUrl]
772    *        An optional URL that the initial page is expected to
773    *        redirect to.
774    *
775    * @returns {XPCShellContentUtils.ContentPage}
776    */
777   loadContentPage(url, options) {
778     return XPCShellContentUtils.loadContentPage(url, options);
779   },