Bug 1544928 - Support pending uninstall in html about:addons. r=mstriemer,flod
authorLuca Greco <lgreco@mozilla.com>
Mon, 06 May 2019 18:38:21 +0000
changeset 472751 362072ce25dbe0311da24e701c293416c2eda3ec
parent 472750 8f6b0e4004271985c3e14d28e9f3c608dc1d498e
child 472752 407fc9248a911ea70e74e44efa709b63af804607
push id35978
push usershindli@mozilla.com
push dateTue, 07 May 2019 09:44:39 +0000
treeherdermozilla-central@7aee5a30dd15 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmstriemer, flod
bugs1544928
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1544928 - Support pending uninstall in html about:addons. r=mstriemer,flod Differential Revision: https://phabricator.services.mozilla.com/D29121
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/abuse-reports.js
toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
toolkit/mozapps/extensions/test/browser/head.js
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -372,16 +372,23 @@ addon-name-disabled = { $name } (disable
 # Variables:
 #   $numberOfReviews (number) - The number of reviews received
 addon-detail-reviews-link =
     { $numberOfReviews ->
         [one] { $numberOfReviews } review
        *[other] { $numberOfReviews } reviews
     }
 
+## Pending uninstall message bar
+
+# Variables:
+#   $addon (string) - Name of the add-on
+pending-uninstall-description = <span data-l10n-name="addon-name">{ $addon }</span> has been removed.
+pending-uninstall-undo-button = Undo
+
 addon-detail-updates-label = Allow automatic updates
 addon-detail-updates-radio-default = Default
 addon-detail-updates-radio-on = On
 addon-detail-updates-radio-off = Off
 addon-detail-update-check-label = Check for Updates
 install-update-button = Update
 
 # This is the tooltip text for the private browsing badge in about:addons. The
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -10,20 +10,25 @@
 }
 
 #abuse-reports-messages {
   margin-inline-start: 28px;
   max-width: var(--section-width);
 }
 
 /* The margin between message bars. */
-#abuse-reports-messages > * {
+message-bar-stack > * {
   margin-bottom: 8px;
 }
 
+/* The margin between pending message-bar-stack and the section heading. */
+addon-list message-bar-stack.pending-uninstall {
+  margin-bottom: 12px;
+}
+
 /* List sections */
 
 .list-section-heading {
   font-size: 17px;
   font-weight: 600;
   margin-block: 16px;
 }
 
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -107,16 +107,21 @@ async function isAllowedInPrivateBrowsin
   let perms = await ExtensionPermissions.get(addon.id);
   return perms.permissions.includes(PRIVATE_BROWSING_PERM_NAME);
 }
 
 function hasPermission(addon, permission) {
   return !!(addon.permissions & PERMISSION_MASKS[permission]);
 }
 
+function isPending(addon, action) {
+  const amAction = AddonManager["PENDING_" + action.toUpperCase()];
+  return !!(addon.pendingOperations & amAction);
+}
+
 /**
  * This function is set in initialize() by the parent about:addons window. It
  * is a helper for gViewController.loadView().
  *
  * @param {string} type The view type to load.
  * @param {string} param The (optional) param for the view.
  */
 let loadViewFn;
@@ -763,17 +768,17 @@ class AddonCard extends HTMLElement {
               Services.scriptSecurityManager.createNullPrincipal({}),
           });
           break;
         case "remove":
           {
             this.panel.hide();
             let response = windowRoot.ownerGlobal.promptRemoveExtension(addon);
             if (response == 0) {
-              await addon.uninstall();
+              await addon.uninstall(true);
               this.sendEvent("remove");
             } else {
               this.sendEvent("remove-cancelled");
             }
           }
           break;
         case "expand":
           loadViewFn("detail", this.addon.id);
@@ -1009,16 +1014,17 @@ customElements.define("addon-card", Addo
  *      filterFn: addon => !addon.isSystem,
  *    }]);
  *    document.body.appendChild(list);
  */
 class AddonList extends HTMLElement {
   constructor() {
     super();
     this.sections = [];
+    this.pendingUninstallAddons = new Set();
   }
 
   async connectedCallback() {
     // Register the listener and get the add-ons, these operations should
     // happpen as close to each other as possible.
     this.registerListener();
     // Don't render again if we were rendered prior to being inserted.
     if (this.children.length == 0) {
@@ -1026,16 +1032,21 @@ class AddonList extends HTMLElement {
       this.render();
     }
   }
 
   disconnectedCallback() {
     // Remove content and stop listening until this is connected again.
     this.textContent = "";
     this.removeListener();
+
+    // Process any pending uninstall related to this list.
+    for (const addon of this.pendingUninstallAddons) {
+      addon.uninstall();
+    }
   }
 
   /**
    * Configure the sections in the list.
    *
    * @param {object[]} sections
    *        The options for the section. Each entry in the array should have:
    *          headingId: The fluent id for the section's heading.
@@ -1067,16 +1078,20 @@ class AddonList extends HTMLElement {
   getCards(section) {
     return section.querySelectorAll("addon-card");
   }
 
   getCard(addon) {
     return this.querySelector(`addon-card[addon-id="${addon.id}"]`);
   }
 
+  getPendingUninstallBar(addon) {
+    return this.querySelector(`message-bar[addon-id="${addon.id}"]`);
+  }
+
   sortByFn(aAddon, bAddon) {
     return aAddon.name.localeCompare(bAddon.name);
   }
 
   async getAddons() {
     if (!this.type) {
       throw new Error(`type must be set to find add-ons`);
     }
@@ -1087,27 +1102,72 @@ class AddonList extends HTMLElement {
 
     // Put the add-ons into the sections, an add-on goes in the first section
     // that it matches the filterFn for. It might not go in any section.
     let sectionedAddons = this.sections.map(() => []);
     for (let addon of addons) {
       let index = this.sections.findIndex(({filterFn}) => filterFn(addon));
       if (index != -1) {
         sectionedAddons[index].push(addon);
+      } else if (isPending(addon, "uninstall")) {
+        // A second tab may be opened on "about:addons" (or Firefox may
+        // have crashed) while there are still "pending uninstall" add-ons.
+        // Ensure to list them in the pendingUninstall message-bar-stack
+        // when the AddonList is initially rendered.
+        this.pendingUninstallAddons.add(addon);
       }
     }
 
     // Sort the add-ons in each section.
     for (let section of sectionedAddons) {
       section.sort(this.sortByFn);
     }
 
     return sectionedAddons;
   }
 
+  createPendingUninstallStack() {
+    const stack = document.createElement("message-bar-stack");
+    stack.setAttribute("class", "pending-uninstall");
+    stack.setAttribute("reverse", "");
+    return stack;
+ }
+
+  addPendingUninstallBar(addon) {
+    const stack = this.pendingUninstallStack;
+    const mb = document.createElement("message-bar");
+    mb.setAttribute("addon-id", addon.id);
+    mb.setAttribute("type", "generic");
+
+    const addonName = document.createElement("span");
+    addonName.setAttribute("data-l10n-name", "addon-name");
+    const message = document.createElement("span");
+    message.append(addonName);
+    const undo = document.createElement("button");
+    undo.setAttribute("action", "undo");
+    undo.addEventListener("click", () => {
+      addon.cancelUninstall();
+    });
+
+    document.l10n.setAttributes(message, "pending-uninstall-description", {
+      "addon": addon.name,
+    });
+    document.l10n.setAttributes(undo, "pending-uninstall-undo-button");
+
+    mb.append(message, undo);
+    stack.append(mb);
+  }
+
+  removePendingUninstallBar(addon) {
+    const messagebar = this.getPendingUninstallBar(addon);
+    if (messagebar) {
+      messagebar.remove();
+    }
+  }
+
   createSectionHeading(headingIndex) {
     let {headingId} = this.sections[headingIndex];
     let heading = document.createElement("h2");
     heading.classList.add("list-section-heading");
     document.l10n.setAttributes(heading, headingId);
     return heading;
   }
 
@@ -1212,19 +1272,26 @@ class AddonList extends HTMLElement {
     return section;
   }
 
   async render() {
     this.textContent = "";
 
     let sectionedAddons = await this.getAddons();
 
-    // Render the sections.
     let frag = document.createDocumentFragment();
 
+    // Render the pending uninstall message-bar-stack.
+    this.pendingUninstallStack = this.createPendingUninstallStack();
+    for (let addon of this.pendingUninstallAddons) {
+      this.addPendingUninstallBar(addon);
+    }
+    frag.appendChild(this.pendingUninstallStack);
+
+    // Render the sections.
     for (let i = 0; i < sectionedAddons.length; i++) {
       this.sections[i].node = this.renderSection(sectionedAddons[i], i);
       frag.appendChild(this.sections[i].node);
     }
 
     // Make sure fluent has set all the strings before we render. This will
     // avoid the height changing as strings go from 0 height to having text.
     await document.l10n.translateFragment(frag);
@@ -1234,52 +1301,70 @@ class AddonList extends HTMLElement {
   registerListener() {
     AddonManager.addAddonListener(this);
   }
 
   removeListener() {
     AddonManager.removeAddonListener(this);
   }
 
+  onOperationCancelled(addon) {
+    if (this.pendingUninstallAddons.has(addon) &&
+        !isPending(addon, "uninstall")) {
+      this.pendingUninstallAddons.delete(addon);
+      this.removePendingUninstallBar(addon);
+    }
+    this.updateAddon(addon);
+  }
+
   onEnabled(addon) {
     this.updateAddon(addon);
   }
 
   onDisabled(addon) {
     this.updateAddon(addon);
   }
 
+  onUninstalling(addon) {
+    this.pendingUninstallAddons.add(addon);
+    this.addPendingUninstallBar(addon);
+    this.updateAddon(addon);
+  }
+
   onInstalled(addon) {
     if (this.querySelector(`addon-card[addon-id="${addon.id}"]`)) {
       return;
     }
     this.addAddon(addon);
   }
 
   onUninstalled(addon) {
+    this.removePendingUninstallBar(addon);
     this.removeAddon(addon);
   }
 }
 customElements.define("addon-list", AddonList);
 
 class ListView {
   constructor({param, root}) {
     this.type = param;
     this.root = root;
   }
 
   async render() {
     let list = document.createElement("addon-list");
     list.type = this.type;
     list.setSections([{
       headingId: "addons-enabled-heading",
-      filterFn: addon => !addon.hidden && addon.isActive,
+      filterFn: addon => !addon.hidden && addon.isActive &&
+                         !isPending(addon, "uninstall"),
     }, {
       headingId: "addons-disabled-heading",
-      filterFn: addon => !addon.hidden && !addon.isActive,
+      filterFn: addon => !addon.hidden && !addon.isActive &&
+                         !isPending(addon, "uninstall"),
     }]);
 
     await list.render();
     this.root.textContent = "";
     this.root.appendChild(list);
   }
 }
 
--- a/toolkit/mozapps/extensions/content/abuse-reports.js
+++ b/toolkit/mozapps/extensions/content/abuse-reports.js
@@ -138,17 +138,17 @@ async function submitReport({report, rea
     const mbInfo = createReportMessageBar(barId, {
       addonId, addonName, addonType,
     }, {
       onaction: (action) => {
         mbInfo.remove();
         // action "keep" doesn't require any further action,
         // just handle "remove".
         if (action === "remove") {
-          report.addon.uninstall();
+          report.addon.uninstall(true);
         }
       },
     });
   } catch (err) {
     // Log the complete error in the console.
     console.error("Error submitting abuse report for", addonId, err);
     mbSubmitting.remove();
     // The report has a submission error, create a error message bar which
--- a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -1,16 +1,21 @@
 /* eslint max-len: ["error", 80] */
 
+const {AddonTestUtils} =
+  ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", {});
+
 const {ExtensionPermissions} =
   ChromeUtils.import("resource://gre/modules/ExtensionPermissions.jsm", {});
 
 let gProvider;
 let promptService;
 
+AddonTestUtils.initMochitest(this);
+
 function getAddonCard(doc, addonId) {
   return doc.querySelector(`addon-card[addon-id="${addonId}"]`);
 }
 
 function checkLabel(row, name) {
   let id;
   if (name == "private-browsing") {
     // This id is carried over from the old about:addons.
@@ -200,43 +205,60 @@ add_task(async function testDetailOperat
 
   // The (disabled) text should be shown now.
   Assert.deepEqual(
     doc.l10n.getAttributes(name),
     {id: "addon-name-disabled", args: {name: "Test"}},
     "The name is updated to the disabled text");
 
   // Enable the add-on.
+  let extensionStarted = AddonTestUtils.promiseWebExtensionStartup(
+    "test@mochi.test");
   disableToggled = BrowserTestUtils.waitForEvent(card, "update");
   disableButton.click();
-  await disableToggled;
+  await Promise.all([disableToggled, extensionStarted]);
 
   // Name is just the add-on name again.
   is(name.textContent, "Test", "The name is reset when enabled");
   is(doc.l10n.getAttributes(name).id, "", "There is no l10n name");
 
   // Remove but cancel.
   let cancelled = BrowserTestUtils.waitForEvent(card, "remove-cancelled");
   removeButton.click();
   await cancelled;
 
-
   // Remove the extension.
   let viewChanged = waitForViewLoad(win);
   // Tell the mock prompt service that the prompt was accepted.
   promptService._response = 0;
   removeButton.click();
   await viewChanged;
 
   // We're on the list view now and there's no card for this extension.
-  ok(doc.querySelector("addon-list"), "There's an addon-list now");
+  const addonList = doc.querySelector("addon-list");
+  ok(addonList, "There's an addon-list now");
   ok(!getAddonCard(doc, "test@mochi.test"),
      "The extension no longer has a card");
   let addon = await AddonManager.getAddonByID("test@mochi.test");
-  ok(!addon, "The extension can't be found now");
+  ok(addon && !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+     "The addon is pending uninstall");
+
+  // Ensure that a pending uninstall bar has been created for the
+  // pending uninstall extension, and pressing the undo button will
+  // refresh the list and render a card to the re-enabled extension.
+  assertHasPendingUninstalls(addonList, 1);
+  assertHasPendingUninstallAddon(addonList, addon);
+
+  extensionStarted = AddonTestUtils.promiseWebExtensionStartup(addon.id);
+  await testUndoPendingUninstall(addonList, addon);
+  info("Wait for the pending uninstall addon complete restart");
+  await extensionStarted;
+
+  card = getAddonCard(doc, addon.id);
+  ok(card, "Addon card rendered after clicking pending uninstall undo button");
 
   await closeView(win);
   await extension.unload();
 });
 
 add_task(async function testFullDetails() {
   let win = await loadInitialView("extension");
   let doc = win.document;
--- a/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
@@ -1,10 +1,16 @@
 /* eslint max-len: ["error", 80] */
 
+const {
+  AddonTestUtils,
+} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
+AddonTestUtils.initMochitest(this);
+
 let promptService;
 
 const SECTION_INDEXES = {
   enabled: 0,
   disabled: 1,
 };
 function getSection(doc, type) {
   return doc.querySelector(`section[section="${SECTION_INDEXES[type]}"]`);
@@ -116,20 +122,99 @@ add_task(async function testExtensionLis
 
   let removed = BrowserTestUtils.waitForEvent(list, "remove");
   // Tell the mock prompt service that the prompt was accepted.
   promptService._response = 0;
   removeButton.click();
   await removed;
 
   addon = await AddonManager.getAddonByID("test@mochi.test");
-  ok(!addon, "The addon is not longer found");
+  ok(addon && !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+     "The addon is pending uninstall");
+
+  // Ensure that a pending uninstall bar has been created for the
+  // pending uninstall extension, and pressing the undo button will
+  // refresh the list and render a card to the re-enabled extension.
+  assertHasPendingUninstalls(list, 1);
+  assertHasPendingUninstallAddon(list, addon);
+
+  // Add a second pending uninstall extension.
+  info("Install a second test extension and wait for addon card rendered");
+  let added = BrowserTestUtils.waitForEvent(list, "add");
+  const extension2 = ExtensionTestUtils.loadExtension({
+    manifest: {
+      name: "Test extension 2",
+      applications: {gecko: {id: "test-2@mochi.test"}},
+      icons: {
+        32: "test-icon.png",
+      },
+    },
+    useAddonManager: "temporary",
+  });
+  await extension2.startup();
+
+  await added;
+  ok(getCardByAddonId(list, extension2.id),
+     "Got a card added for the second extension");
+
+  info("Uninstall the second test extension and wait for addon card removed");
+  removed = BrowserTestUtils.waitForEvent(list, "remove");
+  const addon2 = await AddonManager.getAddonByID(extension2.id);
+  addon2.uninstall(true);
+  await removed;
+
+  ok(!getCardByAddonId(list, extension2.id),
+     "Addon card for the second extension removed");
+
+  assertHasPendingUninstalls(list, 2);
+  assertHasPendingUninstallAddon(list, addon2);
 
+  // Addon2 was enabled before entering the pending uninstall state,
+  // wait for its startup after pressing undo.
+  let addon2Started = AddonTestUtils.promiseWebExtensionStartup(addon2.id);
+  await testUndoPendingUninstall(list, addon);
+  await testUndoPendingUninstall(list, addon2);
+  info("Wait for the second pending uninstal add-ons startup");
+  await addon2Started;
+
+  await extension2.unload();
   await extension.unload();
+
+  // Install a third addon to verify that is being fully removed once the
+  // about:addons page is closed.
+  const xpi = AddonTestUtils.createTempWebExtensionFile({
+    manifest: {
+      name: "Test extension 3",
+      applications: {gecko: {id: "test-3@mochi.test"}},
+      icons: {
+        32: "test-icon.png",
+      },
+    },
+  });
+
+  added = BrowserTestUtils.waitForEvent(list, "add");
+  const addon3 = await AddonManager.installTemporaryAddon(xpi);
+  await added;
+  ok(getCardByAddonId(list, addon3.id),
+     "Addon card for the third extension added");
+
+  removed = BrowserTestUtils.waitForEvent(list, "remove");
+  addon3.uninstall(true);
+  await removed;
+  ok(!getCardByAddonId(list, addon3.id),
+     "Addon card for the third extension removed");
+
+  assertHasPendingUninstalls(list, 1);
+  ok(addon3 && !!(addon3.pendingOperations & AddonManager.PENDING_UNINSTALL),
+     "The third addon is pending uninstall");
+
   await closeView(win);
+
+  ok(!await AddonManager.getAddonByID(addon3.id),
+     "The third addon has been fully uninstalled");
 });
 
 add_task(async function testMouseSupport() {
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       name: "Test extension",
       applications: {gecko: {id: "test@mochi.test"}},
     },
--- a/toolkit/mozapps/extensions/test/browser/head.js
+++ b/toolkit/mozapps/extensions/test/browser/head.js
@@ -1496,8 +1496,43 @@ function mockPromptService() {
     confirmEx: () => promptService._response,
   };
   Services.prompt = promptService;
   registerCleanupFunction(() => {
     Services.prompt = prompt;
   });
   return promptService;
 }
+
+function assertHasPendingUninstalls(addonList, expectedPendingUninstallsCount) {
+  const pendingUninstalls = addonList
+    .querySelector("message-bar-stack.pending-uninstall");
+  ok(pendingUninstalls,
+     "Got a pending-uninstall message-bar-stack");
+  is(pendingUninstalls.childElementCount, expectedPendingUninstallsCount,
+     "Got a message bar in the pending-uninstall message-bar-stack");
+}
+
+function assertHasPendingUninstallAddon(addonList, addon) {
+  const pendingUninstalls = addonList
+    .querySelector("message-bar-stack.pending-uninstall");
+  const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
+  ok(addonPendingUninstall,
+     "Got expected message-bar for the pending uninstall test extension");
+  is(addonPendingUninstall.parentNode, pendingUninstalls,
+     "pending uninstall bar should be part of the message-bar-stack");
+  is(addonPendingUninstall.getAttribute("addon-id"), addon.id,
+     "Got expected addon-id attribute on the pending uninstall message-bar");
+}
+
+async function testUndoPendingUninstall(addonList, addon) {
+  const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
+  const undoButton = addonPendingUninstall.querySelector("button[action=undo]");
+  ok(undoButton, "Got undo action button in the pending uninstall message-bar");
+
+  info("Clicking the pending uninstall undo button and wait for addon card rendered");
+  const updated = BrowserTestUtils.waitForEvent(addonList, "add") ;
+  undoButton.click();
+  await updated;
+
+  ok(addon && !(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+     "The addon pending uninstall cancelled");
+}