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 = []; }