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";
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",
21 let BASE_MANIFEST = Object.freeze({
22 browser_specific_settings: Object.freeze({
23 gecko: Object.freeze({
34 class ExtensionWrapper {
35 /** @type {AddonWrapper} */
37 /** @type {Promise<AddonWrapper>} */
39 /** @type {nsIFile[]} */
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;
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") {
69 "Extension left running at test shutdown"
72 } else if (this.state == "unloading") {
76 "Extension not fully unloaded at test shutdown"
83 this.id = extension.id;
84 this.attachExtension(extension);
89 // This method should be implemented in subclasses which need to
90 // perform cleanup when destroyed.
93 attachExtension(extension) {
94 if (extension === 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();
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`);
118 clearMessageQueues() {
119 if (this.messageQueue.size) {
120 let names = Array.from(this.messageQueue, ([msg]) => msg);
121 this.testScope.equal(
122 JSON.stringify(names),
124 "message queue is empty"
126 this.messageQueue.clear();
128 if (this.messageAwaiter.size) {
129 let names = Array.from(this.messageAwaiter.keys());
130 this.testScope.equal(
131 JSON.stringify(names),
133 "no tasks awaiting on messages"
135 for (let promise of this.messageAwaiter.values()) {
138 this.messageAwaiter.clear();
142 handleResult(kind, pass, msg, expected, actual) {
147 `${msg} - Expected: ${expected}, Actual: ${actual}`
152 this.testScope.info(msg);
156 this.testScope.ok(pass, msg);
160 this.testScope.ok(pass, msg);
161 this.testResolve(msg);
166 handleMessage(kind, msg, ...args) {
167 let handler = this.messageHandler.get(msg);
171 this.messageQueue.add([msg, ...args]);
172 this.checkMessages();
177 return this.startupPromise;
180 awaitBackgroundStarted() {
181 if (!this.extension.manifest.background) {
182 throw new Error("Extension has no background");
186 this.extension.promiseBackgroundStarted(),
191 if (this.state != "uninitialized") {
192 throw new Error("Extension already started");
194 this.state = "pending";
196 await lazy.ExtensionTestCommon.setIncognitoOverride(this.extension);
198 this.startupPromise = this.extension.startup().then(
200 this.state = "running";
205 this.state = "failed";
207 return Promise.reject(error);
211 return this.startupPromise;
215 if (this.state != "running") {
216 throw new Error("Extension not running");
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
224 await this.addonPromise;
228 await this.addon.uninstall();
230 await this.extension.shutdown();
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(
238 "testing-uninstalled-addon",
239 this.addon ? this.addon.id : this.extension.id
243 this.state = "unloaded";
247 * This method sends the message to force-sleep the background scripts.
249 * @returns {Promise} resolves after the background is asleep and listeners primed.
251 terminateBackground(...args) {
252 return this.extension.terminateBackground(...args);
256 return this.extension.wakeupBackground();
259 sendMessage(...args) {
260 this.extension.testMessage(...args);
264 return this.testDone.then(actual => {
266 this.testScope.equal(actual, msg, "test result correct");
273 for (let message of this.messageQueue) {
274 let [msg, ...args] = message;
276 let listener = this.messageAwaiter.get(msg);
278 this.messageQueue.delete(message);
279 this.messageAwaiter.delete(msg);
281 listener.resolve(...args);
287 checkDuplicateListeners(msg) {
288 if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) {
289 throw new Error("only one message handler allowed");
294 return new Promise((resolve, reject) => {
295 this.checkDuplicateListeners(msg);
297 this.messageAwaiter.set(msg, { resolve, reject });
298 this.checkMessages();
302 onMessage(msg, callback) {
303 this.checkDuplicateListeners(msg);
304 this.messageHandler.set(msg, callback);
308 class AOMExtensionWrapper extends ExtensionWrapper {
309 constructor(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);
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);
339 if (this.state !== "restarting") {
340 this.startupPromise = new Promise(resolve => {
341 this.resolveStartup = resolve;
342 }).then(async result => {
343 await this.addonPromise;
347 this.state = "restarting";
351 if (addon.id === this.id) {
352 this.setRestarting();
356 onInstalling(addon) {
357 if (addon.id === this.id) {
358 this.setRestarting();
363 if (addon.id === this.id) {
368 onUninstalled(addon) {
369 if (addon.id === this.id) {
374 onEvent(kind, ...args) {
376 case "addon-manager-started":
377 if (this.state === "uninitialized") {
378 // startup() not called yet, ignore AddonManager startup notification.
381 this.addonPromise = lazy.AddonManager.getAddonByID(this.id).then(
384 this.addonPromise = null;
388 case "addon-manager-shutdown":
389 if (this.state === "uninitialized") {
394 this.setRestarting();
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";
410 let [extension] = args;
411 if (extension.id === this.id && this.state !== "restarting") {
412 this.state = "unloaded";
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(
426 "testing-installed-addon",
430 this.resolveStartup(extension);
437 async _flushCache() {
438 if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) {
439 let file = this.extension.rootURI.JARFile.QueryInterface(
442 await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {
449 return this.addon && this.addon.version;
453 await this._flushCache();
454 return super.unload();
458 * Override for subclasses which don't set an ID in the constructor.
460 * @param {nsIURI} _uri
461 * @param {string} _id
463 maybeSetID(_uri, _id) {}
466 class InstallableWrapper extends AOMExtensionWrapper {
467 constructor(testScope, xpiFile, addonData = {}) {
471 this.addonData = addonData;
472 this.installType = addonData.useAddonManager || "temporary";
473 this.installTelemetryInfo = addonData.amInstallTelemetryInfo;
475 this.cleanupFiles = [xpiFile];
481 for (let file of this.cleanupFiles.splice(0)) {
483 Services.obs.notifyObservers(file, "flush-cache-entry");
491 maybeSetID(uri, id) {
494 uri instanceof Ci.nsIJARURI &&
495 uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file)
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) {
507 let { id } = addonData.manifest.browser_specific_settings.gecko;
509 return lazy.ExtensionTestCommon.setIncognitoOverride({
516 "Extension ID is required for setting incognito permission."
521 async _install(xpiFile) {
522 await this._setIncognitoOverride();
524 if (this.installType === "temporary") {
525 return lazy.AddonManager.installTemporaryAddon(xpiFile)
530 return this.startupPromise;
533 this.state = "unloaded";
534 return Promise.reject(e);
536 } else if (this.installType === "permanent") {
537 return lazy.AddonManager.getInstallForFile(
540 this.installTelemetryInfo
543 onDownloadFailed: () => {
544 this.state = "unloaded";
545 this.resolveStartup(Promise.reject(new Error("Install failed")));
547 onInstallFailed: () => {
548 this.state = "unloaded";
549 this.resolveStartup(Promise.reject(new Error("Install failed")));
551 onInstallEnded: (install, newAddon) => {
552 this.id = newAddon.id;
553 this.addon = newAddon;
557 install.addListener(listener);
560 return this.startupPromise;
566 if (this.state != "uninitialized") {
567 throw new Error("Extension already started");
570 this.state = "pending";
571 this.startupPromise = new Promise(resolve => {
572 this.resolveStartup = resolve;
575 return this._install(this.file);
578 async upgrade(data) {
579 this.startupPromise = new Promise(resolve => {
580 this.resolveStartup = resolve;
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);
594 class ExternallyInstalledWrapper extends AOMExtensionWrapper {
595 constructor(testScope, id) {
599 this.startupPromise = new Promise(resolve => {
600 this.resolveStartup = resolve;
603 this.state = "restarting";
607 export var ExtensionTestUtils = {
610 get testAssertions() {
611 return lazy.ExtensionTestCommon.testAssertions;
614 // Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled
615 // from mochitest-plain tests.
616 getBackgroundServiceWorkerEnabled() {
617 return lazy.ExtensionTestCommon.getBackgroundServiceWorkerEnabled();
620 // A test helper used to check if the pref "extension.backgroundServiceWorker.forceInTestExtension"
622 isInBackgroundServiceWorkerTests() {
623 return lazy.ExtensionTestCommon.isInBackgroundServiceWorkerTests();
626 async normalizeManifest(
628 manifestType = "manifest.WebExtensionManifest",
629 baseManifest = BASE_MANIFEST
631 await lazy.Management.lazyInit();
633 manifest = Object.assign({}, baseManifest, manifest);
638 manifestVersion: manifest.manifest_version,
647 let normalized = lazy.Schemas.normalize(manifest, manifestType, context);
648 normalized.errors = errors;
658 XPCShellContentUtils.ensureInitialized(scope);
660 this.currentScope = scope;
662 this.profileDir = scope.do_get_profile();
664 let tmpD = this.profileDir.clone();
666 tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY);
669 getFile(prop, persistent) {
670 persistent.value = false;
671 if (prop == "TmpD") {
677 QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
679 Services.dirsvc.registerProvider(dirProvider);
681 scope.registerCleanupFunction(() => {
687 Services.dirsvc.unregisterProvider(dirProvider);
689 this.currentScope = null;
693 addonManagerStarted: false,
696 lazy.AddonTestUtils.createAppInfo(
697 "xpcshell@tests.mozilla.org",
704 startAddonManager() {
705 if (this.addonManagerStarted) {
708 this.addonManagerStarted = true;
711 return lazy.AddonTestUtils.promiseStartupManager();
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);
721 let xpiFile = lazy.ExtensionTestCommon.generateXPI(data);
723 return this.loadExtensionXPI(xpiFile, data);
726 let extension = lazy.ExtensionTestCommon.generate(data);
728 return new ExtensionWrapper(this.currentScope, extension);
731 loadExtensionXPI(xpiFile, data) {
732 return new InstallableWrapper(this.currentScope, xpiFile, data);
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);
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);
751 /** @param {[origin: string, url: string, options: object]} args */
752 async fetch(...args) {
753 return XPCShellContentUtils.fetch(...args);
757 * Loads a content page into a hidden docShell.
759 * @param {string} url
761 * @param {object} [options = {}]
762 * @param {ExtensionWrapper} [options.extension]
763 * If passed, load the URL as an extension page for the given
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
775 * @returns {XPCShellContentUtils.ContentPage}
777 loadContentPage(url, options) {
778 return XPCShellContentUtils.loadContentPage(url, options);