summaryrefslogtreecommitdiffstats
path: root/browser/base/content/test/webextensions/head.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/base/content/test/webextensions/head.js699
1 files changed, 699 insertions, 0 deletions
diff --git a/browser/base/content/test/webextensions/head.js b/browser/base/content/test/webextensions/head.js
new file mode 100644
index 0000000000..11dc18acdd
--- /dev/null
+++ b/browser/base/content/test/webextensions/head.js
@@ -0,0 +1,699 @@
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonTestUtils",
+ "resource://testing-common/AddonTestUtils.jsm"
+);
+
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+
+var { ExtensionsUI } = ChromeUtils.import(
+ "resource:///modules/ExtensionsUI.jsm"
+);
+XPCOMUtils.defineLazyGetter(this, "Management", () => {
+ // eslint-disable-next-line no-shadow
+ const { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm"
+ );
+ return Management;
+});
+
+let { CustomizableUITestUtils } = ChromeUtils.import(
+ "resource://testing-common/CustomizableUITestUtils.jsm"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+const { PermissionTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PermissionTestUtils.jsm"
+);
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name) {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstElementChild);
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function promiseAppMenuNotificationShown(id) {
+ const { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+ );
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = AppMenuNotifications.activeNotification;
+ if (!notification) {
+ return;
+ }
+
+ is(notification.id, id, `${id} notification shown`);
+ ok(PanelUI.isNotificationPanelOpen, "notification panel open");
+
+ PanelUI.notificationPanel.removeEventListener("popupshown", popupshown);
+
+ let popupnotificationID = PanelUI._getPopupId(notification);
+ let popupnotification = document.getElementById(popupnotificationID);
+
+ resolve(popupnotification);
+ }
+ PanelUI.notificationPanel.addEventListener("popupshown", popupshown);
+ });
+}
+
+/**
+ * Wait for a specific install event to fire for a given addon
+ *
+ * @param {AddonWrapper} addon
+ * The addon to watch for an event on
+ * @param {string}
+ * The name of the event to watch for (e.g., onInstallEnded)
+ *
+ * @returns {Promise}
+ * Resolves when the event triggers with the first argument
+ * to the event handler as the resolution value.
+ */
+function promiseInstallEvent(addon, event) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener[event] = (install, arg) => {
+ if (install.addon.id == addon.id) {
+ AddonManager.removeInstallListener(listener);
+ resolve(arg);
+ }
+ };
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+/**
+ * Install an (xpi packaged) extension
+ *
+ * @param {string} url
+ * URL of the .xpi file to install
+ * @param {Object?} installTelemetryInfo
+ * an optional object that contains additional details used by the telemetry events.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has been installed with the Addon
+ * object as the resolution value.
+ */
+async function promiseInstallAddon(url, telemetryInfo) {
+ let install = await AddonManager.getInstallForURL(url, { telemetryInfo });
+ install.install();
+
+ let addon = await new Promise(resolve => {
+ install.addListener({
+ onInstallEnded(_install, _addon) {
+ resolve(_addon);
+ },
+ });
+ });
+
+ if (addon.isWebExtension) {
+ await new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+ }
+
+ return addon;
+}
+
+/**
+ * Wait for an update to the given webextension to complete.
+ * (This does not actually perform an update, it just watches for
+ * the events that occur as a result of an update.)
+ *
+ * @param {AddonWrapper} addon
+ * The addon to be updated.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has ben updated.
+ */
+async function waitForUpdate(addon) {
+ let installPromise = promiseInstallEvent(addon, "onInstallEnded");
+ let readyPromise = new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+
+ let [newAddon] = await Promise.all([installPromise, readyPromise]);
+ return newAddon;
+}
+
+function waitAboutAddonsViewLoaded(doc) {
+ return BrowserTestUtils.waitForEvent(doc, "view-loaded");
+}
+
+/**
+ * Trigger an action from the page options menu.
+ */
+function triggerPageOptionsAction(win, action) {
+ win.document.querySelector(`#page-options [action="${action}"]`).click();
+}
+
+function isDefaultIcon(icon) {
+ return icon == "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+}
+
+/**
+ * Check the contents of an individual permission string.
+ * This function is fairly specific to the use here and probably not
+ * suitable for re-use elsewhere...
+ *
+ * @param {string} string
+ * The string value to check (i.e., pulled from the DOM)
+ * @param {string} key
+ * The key in browser.properties for the localized string to
+ * compare with.
+ * @param {string|null} param
+ * Optional string to substitute for %S in the localized string.
+ * @param {string} msg
+ * The message to be emitted as part of the actual test.
+ */
+function checkPermissionString(string, key, param, msg) {
+ let localizedString = param
+ ? gBrowserBundle.formatStringFromName(key, [param])
+ : gBrowserBundle.GetStringFromName(key);
+
+ // If this is a parameterized string and the parameter isn't given,
+ // just do a simple comparison of the text before and after the %S
+ if (localizedString.includes("%S")) {
+ let i = localizedString.indexOf("%S");
+ ok(string.startsWith(localizedString.slice(0, i)), msg);
+ ok(string.endsWith(localizedString.slice(i + 2)), msg);
+ } else {
+ is(string, localizedString, msg);
+ }
+}
+
+/**
+ * Check the contents of a permission popup notification
+ *
+ * @param {Window} panel
+ * The popup window.
+ * @param {string|regexp|function} checkIcon
+ * The icon expected to appear in the notification. If this is a
+ * string, it must match the icon url exactly. If it is a
+ * regular expression it is tested against the icon url, and if
+ * it is a function, it is called with the icon url and returns
+ * true if the url is correct.
+ * @param {array} permissions
+ * The expected entries in the permissions list. Each element
+ * in this array is itself a 2-element array with the string key
+ * for the item (e.g., "webextPerms.description.foo") and an
+ * optional formatting parameter.
+ * @param {boolean} sideloaded
+ * Whether the notification is for a sideloaded extenion.
+ */
+function checkNotification(panel, checkIcon, permissions, sideloaded) {
+ let icon = panel.getAttribute("icon");
+ let ul = document.getElementById("addon-webext-perm-list");
+ let singleDataEl = document.getElementById("addon-webext-perm-single-entry");
+ let learnMoreLink = document.getElementById("addon-webext-perm-info");
+
+ if (checkIcon instanceof RegExp) {
+ ok(
+ checkIcon.test(icon),
+ `Notification icon is correct ${JSON.stringify(icon)} ~= ${checkIcon}`
+ );
+ } else if (typeof checkIcon == "function") {
+ ok(checkIcon(icon), "Notification icon is correct");
+ } else {
+ is(icon, checkIcon, "Notification icon is correct");
+ }
+
+ let description = panel.querySelector(".popup-notification-description")
+ .textContent;
+ let expectedDescription = "webextPerms.header";
+ if (permissions.length) {
+ expectedDescription += "WithPerms";
+ }
+ if (sideloaded) {
+ expectedDescription = "webextPerms.sideloadHeader";
+ }
+ checkPermissionString(
+ description,
+ expectedDescription,
+ undefined,
+ `Description is the expected one`
+ );
+ is(
+ learnMoreLink.hidden,
+ !permissions.length,
+ "Permissions learn more is hidden if there are no permissions"
+ );
+
+ if (!permissions.length) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !(ul.childElementCount || singleDataEl.textContent),
+ "Permission list and single permission element have no entries"
+ );
+ } else if (permissions.length === 1) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(!ul.childElementCount, "Permission list has no entries");
+ ok(singleDataEl.textContent, "Single permission data label has been set");
+ } else {
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !singleDataEl.textContent,
+ "Single permission data label has not been set"
+ );
+ for (let i in permissions) {
+ let [key, param] = permissions[i];
+ checkPermissionString(
+ ul.children[i].textContent,
+ key,
+ param,
+ `Permission number ${i + 1} is correct`
+ );
+ }
+ }
+}
+
+/**
+ * Test that install-time permission prompts work for a given
+ * installation method.
+ *
+ * @param {Function} installFn
+ * Callable that takes the name of an xpi file to install and
+ * starts to install it. Should return a Promise that resolves
+ * when the install is finished or rejects if the install is canceled.
+ * @param {string} telemetryBase
+ * If supplied, the base type for telemetry events that should be
+ * recorded for this install method.
+ *
+ * @returns {Promise}
+ */
+async function testInstallMethod(installFn, telemetryBase) {
+ const PERMS_XPI = "browser_webext_permissions.xpi";
+ const NO_PERMS_XPI = "browser_webext_nopermissions.xpi";
+ const ID = "permissions@test.mozilla.org";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+
+ if (telemetryBase !== undefined) {
+ hookExtensionsTelemetry();
+ }
+
+ let testURI = makeURI("https://example.com/");
+ PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION);
+ registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install"));
+
+ async function runOnce(filename, cancel) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ let installPromise = new Promise(resolve => {
+ let listener = {
+ onDownloadCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallEnded() {
+ AddonManager.removeInstallListener(listener);
+ resolve(true);
+ },
+
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+
+ let installMethodPromise = installFn(filename);
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ if (filename == PERMS_XPI) {
+ // The icon should come from the extension, don't bother with the precise
+ // path, just make sure we've got a jar url pointing to the right path
+ // inside the jar.
+ checkNotification(panel, /^jar:file:\/\/.*\/icon\.png$/, [
+ ["webextPerms.hostDescription.wildcard", "wildcard.domain"],
+ ["webextPerms.hostDescription.oneSite", "singlehost.domain"],
+ ["webextPerms.description.nativeMessaging"],
+ // The below permissions are deliberately in this order as permissions
+ // are sorted alphabetically by the permission string to match AMO.
+ ["webextPerms.description.history"],
+ ["webextPerms.description.tabs"],
+ ]);
+ } else if (filename == NO_PERMS_XPI) {
+ checkNotification(panel, isDefaultIcon, []);
+ }
+
+ if (cancel) {
+ panel.secondaryButton.click();
+ try {
+ await installMethodPromise;
+ } catch (err) {}
+ } else {
+ // Look for post-install notification
+ let postInstallPromise = promiseAppMenuNotificationShown(
+ "addon-installed"
+ );
+ panel.button.click();
+
+ // Press OK on the post-install notification
+ panel = await postInstallPromise;
+ panel.button.click();
+
+ await installMethodPromise;
+ }
+
+ let result = await installPromise;
+ let addon = await AddonManager.getAddonByID(ID);
+ if (cancel) {
+ ok(!result, "Installation was cancelled");
+ is(addon, null, "Extension is not installed");
+ } else {
+ ok(result, "Installation completed");
+ isnot(addon, null, "Extension is installed");
+ await addon.uninstall();
+ }
+
+ BrowserTestUtils.removeTab(tab);
+ }
+
+ // A few different tests for each installation method:
+ // 1. Start installation of an extension that requests no permissions,
+ // verify the notification contents, then cancel the install
+ await runOnce(NO_PERMS_XPI, true);
+
+ // 2. Same as #1 but with an extension that requests some permissions.
+ await runOnce(PERMS_XPI, true);
+
+ // 3. Repeat with the same extension from step 2 but this time,
+ // accept the permissions to install the extension. (Then uninstall
+ // the extension to clean up.)
+ await runOnce(PERMS_XPI, false);
+
+ if (telemetryBase !== undefined) {
+ // Should see 2 canceled installs followed by 1 successful install
+ // for this method.
+ expectTelemetry([
+ `${telemetryBase}Rejected`,
+ `${telemetryBase}Rejected`,
+ `${telemetryBase}Accepted`,
+ ]);
+ }
+
+ await SpecialPowers.popPrefEnv();
+}
+
+// Helper function to test a specific scenario for interactive updates.
+// `checkFn` is a callable that triggers a check for updates.
+// `autoUpdate` specifies whether the test should be run with
+// updates applied automatically or not.
+async function interactiveUpdateTest(autoUpdate, checkFn) {
+ AddonTestUtils.initMochitest(this);
+
+ const ID = "update2@tests.mozilla.org";
+ const FAKE_INSTALL_SOURCE = "fake-install-source";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+
+ ["extensions.update.autoUpdateDefault", autoUpdate],
+
+ // Point updates to the local mochitest server
+ ["extensions.update.url", `${BASE}/browser_webext_update.json`],
+ ],
+ });
+
+ AddonTestUtils.hookAMTelemetryEvents();
+
+ // Trigger an update check, manually applying the update if we're testing
+ // without auto-update.
+ async function triggerUpdate(win, addon) {
+ let manualUpdatePromise;
+ if (!autoUpdate) {
+ manualUpdatePromise = new Promise(resolve => {
+ let listener = {
+ onNewInstall() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+ }
+
+ let promise = checkFn(win, addon);
+
+ if (manualUpdatePromise) {
+ await manualUpdatePromise;
+
+ let doc = win.document;
+ if (win.gViewController.currentViewId !== "addons://updates/available") {
+ let showUpdatesBtn = doc.querySelector("addon-updates-message").button;
+ await TestUtils.waitForCondition(() => {
+ return !showUpdatesBtn.hidden;
+ }, "Wait for show updates button");
+ let viewChanged = waitAboutAddonsViewLoaded(doc);
+ showUpdatesBtn.click();
+ await viewChanged;
+ }
+ let card = await TestUtils.waitForCondition(() => {
+ return doc.querySelector(`addon-card[addon-id="${ID}"]`);
+ }, `Wait addon card for "${ID}"`);
+ let updateBtn = card.querySelector('panel-item[action="install-update"]');
+ ok(updateBtn, `Found update button for "${ID}"`);
+ updateBtn.click();
+ }
+
+ return { promise };
+ }
+
+ // Navigate away from the starting page to force about:addons to load
+ // in a new tab during the tests below.
+ BrowserTestUtils.loadURI(gBrowser.selectedBrowser, "about:mozilla");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(`${BASE}/browser_webext_update1.xpi`, {
+ source: FAKE_INSTALL_SOURCE,
+ });
+ ok(addon, "Addon was installed");
+ is(addon.version, "1.0", "Version 1 of the addon is installed");
+
+ let win = await BrowserOpenAddonsMgr("addons://list/extension");
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ // Trigger an update check
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let { promise: checkPromise } = await triggerUpdate(win, addon);
+ let panel = await popupPromise;
+
+ // Click the cancel button, wait to see the cancel event
+ let cancelPromise = promiseInstallEvent(addon, "onInstallCancelled");
+ panel.secondaryButton.click();
+ await cancelPromise;
+
+ addon = await AddonManager.getAddonByID(ID);
+ is(addon.version, "1.0", "Should still be running the old version");
+
+ // Make sure the update check is completely finished.
+ await checkPromise;
+
+ // Trigger a new update check
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ checkPromise = (await triggerUpdate(win, addon)).promise;
+
+ // This time, accept the upgrade
+ let updatePromise = waitForUpdate(addon);
+ panel = await popupPromise;
+ panel.button.click();
+
+ addon = await updatePromise;
+ is(addon.version, "2.0", "Should have upgraded");
+
+ await checkPromise;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ const collectedUpdateEvents = AddonTestUtils.getAMTelemetryEvents().filter(
+ evt => {
+ return evt.method === "update";
+ }
+ );
+
+ Assert.deepEqual(
+ collectedUpdateEvents.map(evt => evt.extra.step),
+ [
+ // First update is cancelled on the permission prompt.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "cancelled",
+ // Second update is expected to be completed.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "completed",
+ ],
+ "Got the expected sequence on update telemetry events"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.addon_id === ID),
+ "Every update telemetry event should have the expected addon_id extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(
+ evt => evt.extra.source === FAKE_INSTALL_SOURCE
+ ),
+ "Every update telemetry event should have the expected source extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.updated_from === "user"),
+ "Every update telemetry event should have the update_from extra var 'user'"
+ );
+
+ let hasPermissionsExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "permissions_prompt";
+ })
+ .every(evt => {
+ return Number.isInteger(parseInt(evt.extra.num_strings, 10));
+ });
+
+ ok(
+ hasPermissionsExtras,
+ "Every 'permissions_prompt' update telemetry event should have the permissions extra vars"
+ );
+
+ let hasDownloadTimeExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "download_completed";
+ })
+ .every(evt => {
+ const download_time = parseInt(evt.extra.download_time, 10);
+ return !isNaN(download_time) && download_time > 0;
+ });
+
+ ok(
+ hasDownloadTimeExtras,
+ "Every 'download_completed' update telemetry event should have a download_time extra vars"
+ );
+}
+
+// The tests in this directory install a bunch of extensions but they
+// need to uninstall them before exiting, as a stray leftover extension
+// after one test can foul up subsequent tests.
+// So, add a task to run before any tests that grabs a list of all the
+// add-ons that are pre-installed in the test environment and then checks
+// the list of installed add-ons at the end of the test to make sure no
+// new add-ons have been added.
+// Individual tests can store a cleanup function in the testCleanup global
+// to ensure it gets called before the final check is performed.
+let testCleanup;
+add_setup(async function head_setup() {
+ let addons = await AddonManager.getAllAddons();
+ let existingAddons = new Set(addons.map(a => a.id));
+
+ registerCleanupFunction(async function() {
+ if (testCleanup) {
+ await testCleanup();
+ testCleanup = null;
+ }
+
+ for (let addon of await AddonManager.getAllAddons()) {
+ // Builtin search extensions may have been installed by SearchService
+ // during the test run, ignore those.
+ if (
+ !existingAddons.has(addon.id) &&
+ !(addon.isBuiltin && addon.id.endsWith("@search.mozilla.org"))
+ ) {
+ ok(
+ false,
+ `Addon ${addon.id} was left installed at the end of the test`
+ );
+ await addon.uninstall();
+ }
+ }
+ });
+});
+
+let collectedTelemetry = [];
+function hookExtensionsTelemetry() {
+ let originalHistogram = ExtensionsUI.histogram;
+ ExtensionsUI.histogram = {
+ add(value) {
+ collectedTelemetry.push(value);
+ },
+ };
+ registerCleanupFunction(() => {
+ is(
+ collectedTelemetry.length,
+ 0,
+ "No unexamined telemetry after test is finished"
+ );
+ ExtensionsUI.histogram = originalHistogram;
+ });
+}
+
+function expectTelemetry(values) {
+ Assert.deepEqual(values, collectedTelemetry);
+ collectedTelemetry = [];
+}