diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs | 776 |
1 files changed, 776 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs b/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs new file mode 100644 index 0000000000..4fc640ff39 --- /dev/null +++ b/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs @@ -0,0 +1,776 @@ +/* -*- 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCShellContentUtils } from "resource://testing-common/XPCShellContentUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +let BASE_MANIFEST = Object.freeze({ + browser_specific_settings: Object.freeze({ + gecko: Object.freeze({ + id: "test@web.ext", + }), + }), + + manifest_version: 2, + + name: "name", + version: "0", +}); + +class ExtensionWrapper { + constructor(testScope, extension = null) { + this.testScope = testScope; + + this.extension = null; + + this.handleResult = this.handleResult.bind(this); + this.handleMessage = this.handleMessage.bind(this); + + this.state = "uninitialized"; + + this.testResolve = null; + this.testDone = new Promise(resolve => { + this.testResolve = resolve; + }); + + this.messageHandler = new Map(); + this.messageAwaiter = new Map(); + + this.messageQueue = new Set(); + + this.testScope.registerCleanupFunction(() => { + this.clearMessageQueues(); + + if (this.state == "pending" || this.state == "running") { + this.testScope.equal( + this.state, + "unloaded", + "Extension left running at test shutdown" + ); + return this.unload(); + } else if (this.state == "unloading") { + this.testScope.equal( + this.state, + "unloaded", + "Extension not fully unloaded at test shutdown" + ); + } + this.destroy(); + }); + + if (extension) { + this.id = extension.id; + this.attachExtension(extension); + } + } + + destroy() { + // This method should be implemented in subclasses which need to + // perform cleanup when destroyed. + } + + attachExtension(extension) { + if (extension === this.extension) { + return; + } + + if (this.extension) { + this.extension.off("test-eq", this.handleResult); + this.extension.off("test-log", this.handleResult); + this.extension.off("test-result", this.handleResult); + this.extension.off("test-done", this.handleResult); + this.extension.off("test-message", this.handleMessage); + this.clearMessageQueues(); + } + this.uuid = extension.uuid; + this.extension = extension; + + extension.on("test-eq", this.handleResult); + extension.on("test-log", this.handleResult); + extension.on("test-result", this.handleResult); + extension.on("test-done", this.handleResult); + extension.on("test-message", this.handleMessage); + + this.testScope.info(`Extension attached`); + } + + clearMessageQueues() { + if (this.messageQueue.size) { + let names = Array.from(this.messageQueue, ([msg]) => msg); + this.testScope.equal( + JSON.stringify(names), + "[]", + "message queue is empty" + ); + this.messageQueue.clear(); + } + if (this.messageAwaiter.size) { + let names = Array.from(this.messageAwaiter.keys()); + this.testScope.equal( + JSON.stringify(names), + "[]", + "no tasks awaiting on messages" + ); + for (let promise of this.messageAwaiter.values()) { + promise.reject(); + } + this.messageAwaiter.clear(); + } + } + + handleResult(kind, pass, msg, expected, actual) { + switch (kind) { + case "test-eq": + this.testScope.ok( + pass, + `${msg} - Expected: ${expected}, Actual: ${actual}` + ); + break; + + case "test-log": + this.testScope.info(msg); + break; + + case "test-result": + this.testScope.ok(pass, msg); + break; + + case "test-done": + this.testScope.ok(pass, msg); + this.testResolve(msg); + break; + } + } + + handleMessage(kind, msg, ...args) { + let handler = this.messageHandler.get(msg); + if (handler) { + handler(...args); + } else { + this.messageQueue.add([msg, ...args]); + this.checkMessages(); + } + } + + awaitStartup() { + return this.startupPromise; + } + + awaitBackgroundStarted() { + if (!this.extension.manifest.background) { + throw new Error("Extension has no background"); + } + return Promise.all([ + this.startupPromise, + this.extension.promiseBackgroundStarted(), + ]); + } + + async startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + this.state = "pending"; + + await lazy.ExtensionTestCommon.setIncognitoOverride(this.extension); + + this.startupPromise = this.extension.startup().then( + result => { + this.state = "running"; + + return result; + }, + error => { + this.state = "failed"; + + return Promise.reject(error); + } + ); + + return this.startupPromise; + } + + async unload() { + if (this.state != "running") { + throw new Error("Extension not running"); + } + this.state = "unloading"; + + if (this.addonPromise) { + // If addonPromise is still pending resolution, wait for it to make sure + // that add-ons that are installed through the AddonManager are properly + // uninstalled. + await this.addonPromise; + } + + if (this.addon) { + await this.addon.uninstall(); + } else { + await this.extension.shutdown(); + } + + if (AppConstants.platform === "android") { + // We need a way to notify the embedding layer that an extension has been + // uninstalled, so that the java layer can be updated too. + Services.obs.notifyObservers( + null, + "testing-uninstalled-addon", + this.addon ? this.addon.id : this.extension.id + ); + } + + this.state = "unloaded"; + } + + /** + * This method sends the message to force-sleep the background scripts. + * + * @returns {Promise} resolves after the background is asleep and listeners primed. + */ + terminateBackground(...args) { + return this.extension.terminateBackground(...args); + } + + wakeupBackground() { + return this.extension.wakeupBackground(); + } + + sendMessage(...args) { + this.extension.testMessage(...args); + } + + awaitFinish(msg) { + return this.testDone.then(actual => { + if (msg) { + this.testScope.equal(actual, msg, "test result correct"); + } + return actual; + }); + } + + checkMessages() { + for (let message of this.messageQueue) { + let [msg, ...args] = message; + + let listener = this.messageAwaiter.get(msg); + if (listener) { + this.messageQueue.delete(message); + this.messageAwaiter.delete(msg); + + listener.resolve(...args); + return; + } + } + } + + checkDuplicateListeners(msg) { + if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) { + throw new Error("only one message handler allowed"); + } + } + + awaitMessage(msg) { + return new Promise((resolve, reject) => { + this.checkDuplicateListeners(msg); + + this.messageAwaiter.set(msg, { resolve, reject }); + this.checkMessages(); + }); + } + + onMessage(msg, callback) { + this.checkDuplicateListeners(msg); + this.messageHandler.set(msg, callback); + } +} + +class AOMExtensionWrapper extends ExtensionWrapper { + constructor(testScope) { + super(testScope); + + this.onEvent = this.onEvent.bind(this); + + lazy.Management.on("ready", this.onEvent); + lazy.Management.on("shutdown", this.onEvent); + lazy.Management.on("startup", this.onEvent); + + lazy.AddonTestUtils.on("addon-manager-shutdown", this.onEvent); + lazy.AddonTestUtils.on("addon-manager-started", this.onEvent); + + lazy.AddonManager.addAddonListener(this); + } + + destroy() { + this.id = null; + this.addon = null; + + lazy.Management.off("ready", this.onEvent); + lazy.Management.off("shutdown", this.onEvent); + lazy.Management.off("startup", this.onEvent); + + lazy.AddonTestUtils.off("addon-manager-shutdown", this.onEvent); + lazy.AddonTestUtils.off("addon-manager-started", this.onEvent); + + lazy.AddonManager.removeAddonListener(this); + } + + setRestarting() { + if (this.state !== "restarting") { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }).then(async result => { + await this.addonPromise; + return result; + }); + } + this.state = "restarting"; + } + + onEnabling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalled(addon) { + if (addon.id === this.id) { + this.addon = addon; + } + } + + onUninstalled(addon) { + if (addon.id === this.id) { + this.destroy(); + } + } + + onEvent(kind, ...args) { + switch (kind) { + case "addon-manager-started": + if (this.state === "uninitialized") { + // startup() not called yet, ignore AddonManager startup notification. + return; + } + this.addonPromise = lazy.AddonManager.getAddonByID(this.id).then( + addon => { + this.addon = addon; + this.addonPromise = null; + } + ); + // FALLTHROUGH + case "addon-manager-shutdown": + if (this.state === "uninitialized") { + return; + } + this.addon = null; + + this.setRestarting(); + break; + + case "startup": { + let [extension] = args; + + this.maybeSetID(extension.rootURI, extension.id); + + if (extension.id === this.id) { + this.attachExtension(extension); + this.state = "pending"; + } + break; + } + + case "shutdown": { + let [extension] = args; + if (extension.id === this.id && this.state !== "restarting") { + this.state = "unloaded"; + } + break; + } + + case "ready": { + let [extension] = args; + if (extension.id === this.id) { + this.state = "running"; + if (AppConstants.platform === "android") { + // We need a way to notify the embedding layer that a new extension + // has been installed, so that the java layer can be updated too. + Services.obs.notifyObservers( + null, + "testing-installed-addon", + extension.id + ); + } + this.resolveStartup(extension); + } + break; + } + } + } + + async _flushCache() { + if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) { + let file = this.extension.rootURI.JARFile.QueryInterface( + Ci.nsIFileURL + ).file; + await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", { + path: file.path, + }); + } + } + + get version() { + return this.addon && this.addon.version; + } + + async unload() { + await this._flushCache(); + return super.unload(); + } + + async upgrade(data) { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + this.state = "restarting"; + + await this._flushCache(); + + let xpiFile = lazy.ExtensionTestCommon.generateXPI(data); + + this.cleanupFiles.push(xpiFile); + + return this._install(xpiFile); + } +} + +class InstallableWrapper extends AOMExtensionWrapper { + constructor(testScope, xpiFile, addonData = {}) { + super(testScope); + + this.file = xpiFile; + this.addonData = addonData; + this.installType = addonData.useAddonManager || "temporary"; + this.installTelemetryInfo = addonData.amInstallTelemetryInfo; + + this.cleanupFiles = [xpiFile]; + } + + destroy() { + super.destroy(); + + for (let file of this.cleanupFiles.splice(0)) { + try { + Services.obs.notifyObservers(file, "flush-cache-entry"); + file.remove(false); + } catch (e) { + Cu.reportError(e); + } + } + } + + maybeSetID(uri, id) { + if ( + !this.id && + uri instanceof Ci.nsIJARURI && + uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file) + ) { + this.id = id; + } + } + + _setIncognitoOverride() { + // this.id is not set yet so grab it from the manifest data to set + // the incognito permission. + let { addonData } = this; + if (addonData && addonData.incognitoOverride) { + try { + let { id } = addonData.manifest.browser_specific_settings.gecko; + if (id) { + return lazy.ExtensionTestCommon.setIncognitoOverride({ + id, + addonData, + }); + } + } catch (e) {} + throw new Error( + "Extension ID is required for setting incognito permission." + ); + } + } + + async _install(xpiFile) { + await this._setIncognitoOverride(); + + if (this.installType === "temporary") { + return lazy.AddonManager.installTemporaryAddon(xpiFile) + .then(addon => { + this.id = addon.id; + this.addon = addon; + + return this.startupPromise; + }) + .catch(e => { + this.state = "unloaded"; + return Promise.reject(e); + }); + } else if (this.installType === "permanent") { + return lazy.AddonManager.getInstallForFile( + xpiFile, + null, + this.installTelemetryInfo + ).then(install => { + let listener = { + onDownloadFailed: () => { + this.state = "unloaded"; + this.resolveStartup(Promise.reject(new Error("Install failed"))); + }, + onInstallFailed: () => { + this.state = "unloaded"; + this.resolveStartup(Promise.reject(new Error("Install failed"))); + }, + onInstallEnded: (install, newAddon) => { + this.id = newAddon.id; + this.addon = newAddon; + }, + }; + + install.addListener(listener); + install.install(); + + return this.startupPromise; + }); + } + } + + startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + + this.state = "pending"; + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + + return this._install(this.file); + } +} + +class ExternallyInstalledWrapper extends AOMExtensionWrapper { + constructor(testScope, id) { + super(testScope); + + this.id = id; + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + + this.state = "restarting"; + } + + maybeSetID(uri, id) {} +} + +export var ExtensionTestUtils = { + BASE_MANIFEST, + + get testAssertions() { + return lazy.ExtensionTestCommon.testAssertions; + }, + + // Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled + // from mochitest-plain tests. + getBackgroundServiceWorkerEnabled() { + return lazy.ExtensionTestCommon.getBackgroundServiceWorkerEnabled(); + }, + + // A test helper used to check if the pref "extension.backgroundServiceWorker.forceInTestExtension" + // is set to true. + isInBackgroundServiceWorkerTests() { + return lazy.ExtensionTestCommon.isInBackgroundServiceWorkerTests(); + }, + + async normalizeManifest( + manifest, + manifestType = "manifest.WebExtensionManifest", + baseManifest = BASE_MANIFEST + ) { + await lazy.Management.lazyInit(); + + manifest = Object.assign({}, baseManifest, manifest); + + let errors = []; + let context = { + url: null, + manifestVersion: manifest.manifest_version, + + logError: error => { + errors.push(error); + }, + + preprocessors: {}, + }; + + let normalized = lazy.Schemas.normalize(manifest, manifestType, context); + normalized.errors = errors; + + return normalized; + }, + + currentScope: null, + + profileDir: null, + + init(scope) { + XPCShellContentUtils.ensureInitialized(scope); + + this.currentScope = scope; + + this.profileDir = scope.do_get_profile(); + + let tmpD = this.profileDir.clone(); + tmpD.append("tmp"); + tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY); + + let dirProvider = { + getFile(prop, persistent) { + persistent.value = false; + if (prop == "TmpD") { + return tmpD.clone(); + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]), + }; + Services.dirsvc.registerProvider(dirProvider); + + scope.registerCleanupFunction(() => { + try { + tmpD.remove(true); + } catch (e) { + Cu.reportError(e); + } + Services.dirsvc.unregisterProvider(dirProvider); + + this.currentScope = null; + }); + }, + + addonManagerStarted: false, + + mockAppInfo() { + lazy.AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "48", + "48" + ); + }, + + startAddonManager() { + if (this.addonManagerStarted) { + return; + } + this.addonManagerStarted = true; + this.mockAppInfo(); + + return lazy.AddonTestUtils.promiseStartupManager(); + }, + + loadExtension(data) { + if (data.useAddonManager) { + // If we're using incognitoOverride, we'll need to ensure + // an ID is available before generating the XPI. + if (data.incognitoOverride) { + lazy.ExtensionTestCommon.setExtensionID(data); + } + let xpiFile = lazy.ExtensionTestCommon.generateXPI(data); + + return this.loadExtensionXPI(xpiFile, data); + } + + let extension = lazy.ExtensionTestCommon.generate(data); + + return new ExtensionWrapper(this.currentScope, extension); + }, + + loadExtensionXPI(xpiFile, data) { + return new InstallableWrapper(this.currentScope, xpiFile, data); + }, + + // Create a wrapper for a webextension that will be installed + // by some external process (e.g., Normandy) + expectExtension(id) { + return new ExternallyInstalledWrapper(this.currentScope, id); + }, + + failOnSchemaWarnings(warningsAsErrors = true) { + let prefName = "extensions.webextensions.warnings-as-errors"; + Services.prefs.setBoolPref(prefName, warningsAsErrors); + if (!warningsAsErrors) { + this.currentScope.registerCleanupFunction(() => { + Services.prefs.setBoolPref(prefName, true); + }); + } + }, + + get remoteContentScripts() { + return XPCShellContentUtils.remoteContentScripts; + }, + + set remoteContentScripts(val) { + XPCShellContentUtils.remoteContentScripts = val; + }, + + async fetch(...args) { + return XPCShellContentUtils.fetch(...args); + }, + + /** + * 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. + * @param {...any} args + * Extra parameters to ensure compatibility + * + * @returns {ContentPage} + */ + loadContentPage(url, options, ...args) { + return XPCShellContentUtils.loadContentPage(url, options, ...args); + }, +}; |