Bug 1535430 - Updates views for HTML about:addons r=mixedpuppy,flod
authorMark Striemer <mstriemer@mozilla.com>
Fri, 03 May 2019 14:14:11 +0000
changeset 531300 871aff9c35e95c2d7754b8cef7c14a7155204f15
parent 531299 9de7d7669f41a5df9c61b753767a5713aa9b965d
child 531301 b78f80a0ee086ce4e28035c2954b9219c21f63aa
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy, flod
bugs1535430
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 1535430 - Updates views for HTML about:addons r=mixedpuppy,flod Differential Revision: https://phabricator.services.mozilla.com/D29267
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
toolkit/mozapps/extensions/test/browser/browser_html_updates.js
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -385,8 +385,11 @@ install-update-button = Update
 
 # This is the tooltip text for the private browsing badge in about:addons. The
 # badge is the private browsing icon included next to the extension's name.
 addon-badge-private-browsing-allowed =
     .title = Allowed in private windows
 addon-detail-private-browsing-help = When allowed, the extension will have access to your online activities while private browsing. <a data-l10n-name="learn-more">Learn more</a>
 addon-detail-private-browsing-allow = Allow
 addon-detail-private-browsing-disallow = Don’t Allow
+
+available-updates-heading = Available Updates
+recent-updates-heading = Recent Updates
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -2,17 +2,17 @@
 <html>
   <head>
     <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
     <link rel="stylesheet" href="chrome://mozapps/content/extensions/aboutaddons.css">
 
     <link rel="localization" href="branding/brand.ftl">
     <link rel="localization" href="toolkit/about/aboutAddons.ftl">
 
-    <script src="chrome://global/content/contentAreaUtils.js"></script>
+    <script src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"></script>
     <script src="chrome://mozapps/content/extensions/aboutaddons.js"></script>
   </head>
   <body>
     <div id="main">
     </div>
 
     <template name="addon-options">
       <panel-list>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -15,16 +15,18 @@ XPCOMUtils.defineLazyModuleGetters(this,
 
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "allowPrivateBrowsingByDefault",
   "extensions.allowPrivateBrowsingByDefault", true);
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "SUPPORT_URL", "app.support.baseURL",
   "", null, val => Services.urlFormatter.formatURL(val));
 
+const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds)
+
 const PLUGIN_ICON_URL = "chrome://global/skin/plugins/pluginGeneric.svg";
 const PERMISSION_MASKS = {
   "ask-to-activate": AddonManager.PERM_CAN_ASK_TO_ACTIVATE,
   enable: AddonManager.PERM_CAN_ENABLE,
   "always-activate": AddonManager.PERM_CAN_ENABLE,
   disable: AddonManager.PERM_CAN_DISABLE,
   "never-activate": AddonManager.PERM_CAN_DISABLE,
   uninstall: AddonManager.PERM_CAN_UNINSTALL,
@@ -109,16 +111,17 @@ function hasPermission(addon, permission
 /**
  * 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;
+let setCategoryFn;
 
 let _templates = {};
 
 /**
  * Import a template from the main document.
  */
 function importTemplate(name) {
   if (!_templates.hasOwnProperty(name)) {
@@ -1036,32 +1039,35 @@ class AddonList extends HTMLElement {
     return aAddon.name.localeCompare(bAddon.name);
   }
 
   async getAddons() {
     if (!this.type) {
       throw new Error(`type must be set to find add-ons`);
     }
 
-    // Find everything matching our type.
-    let addons = await AddonManager.getAddonsByTypes([this.type]);
-
-    // Sort by name.
-    addons.sort(this.sortByFn);
+    // Find everything matching our type, null will find all types.
+    let type = this.type == "all" ? null : [this.type];
+    let addons = await AddonManager.getAddonsByTypes(type);
 
     // 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);
       }
     }
 
+    // Sort the add-ons in each section.
+    for (let section of sectionedAddons) {
+      section.sort(this.sortByFn);
+    }
+
     return sectionedAddons;
   }
 
   createSectionHeading(headingIndex) {
     let {headingId} = this.sections[headingIndex];
     let heading = document.createElement("h2");
     heading.classList.add("list-section-heading");
     document.l10n.setAttributes(heading, headingId);
@@ -1089,17 +1095,17 @@ class AddonList extends HTMLElement {
     let insertBefore = Array.from(sectionCards).find(
       otherCard => this.sortByFn(card.addon, otherCard.addon) < 0);
     // This will append if insertBefore is null.
     section.insertBefore(card, insertBefore || null);
   }
 
   addAddon(addon) {
     // Only insert add-ons of the right type.
-    if (addon.type != this.type) {
+    if (addon.type != this.type && this.type != "all") {
       this.sendEvent("skip-add", "type-mismatch");
       return;
     }
 
     let insertSection = this.sections.findIndex(
       ({filterFn}) => filterFn(addon));
 
     // Don't add the add-on if it doesn't go in a section.
@@ -1245,37 +1251,81 @@ class DetailView {
     this.id = param;
     this.root = root;
   }
 
   async render() {
     let addon = await AddonManager.getAddonByID(this.id);
     let card = document.createElement("addon-card");
 
+    // Ensure the category for this add-on type is selected.
+    setCategoryFn(addon.type);
+
     // Go back to the list view when the add-on is removed.
     card.addEventListener("remove", () => loadViewFn("list", addon.type));
 
     card.setAddon(addon);
     card.expand();
     await card.render();
 
     this.root.textContent = "";
     this.root.appendChild(card);
   }
 }
 
+class UpdatesView {
+  constructor({param, root}) {
+    this.root = root;
+    this.param = param;
+  }
+
+  async render() {
+    let list = document.createElement("addon-list");
+    list.type = "all";
+    if (this.param == "available") {
+      list.setSections([{
+        headingId: "available-updates-heading",
+        filterFn: addon => addon.updateInstall,
+      }]);
+    } else if (this.param == "recent") {
+      list.sortByFn = (a, b) => {
+        if (a.updateDate > b.updateDate) {
+          return -1;
+        }
+        if (a.updateDate < b.updateDate) {
+          return 1;
+        }
+        return 0;
+      };
+      let updateLimit = new Date() - UPDATES_RECENT_TIMESPAN;
+      list.setSections([{
+        headingId: "recent-updates-heading",
+        filterFn: addon =>
+          !addon.hidden && addon.updateDate && addon.updateDate > updateLimit,
+      }]);
+    } else {
+      throw new Error(`Unknown updates view ${this.param}`);
+    }
+
+    await list.render();
+    this.root.textContent = "";
+    this.root.appendChild(list);
+  }
+}
+
 // Generic view management.
 let root = null;
 
 /**
  * Called from extensions.js once, when about:addons is loading.
  */
 function initialize(opts) {
   root = document.getElementById("main");
   loadViewFn = opts.loadViewFn;
+  setCategoryFn = opts.setCategoryFn;
   AddonCardListenerHandler.startup();
   window.addEventListener("unload", () => {
     // Clear out the root node so the disconnectedCallback will trigger
     // properly and all of the custom elements can cleanup.
     root.textContent = "";
     AddonCardListenerHandler.shutdown();
   }, {once: true});
 }
@@ -1285,14 +1335,16 @@ function initialize(opts) {
  * resolve once the view has been updated to conform with other about:addons
  * views.
  */
 async function show(type, param) {
   if (type == "list") {
     await new ListView({param, root}).render();
   } else if (type == "detail") {
     await new DetailView({param, root}).render();
+  } else if (type == "updates") {
+    await new UpdatesView({param, root}).render();
   }
 }
 
 function hide() {
   root.textContent = "";
 }
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -730,25 +730,29 @@ var gViewController = {
   initialize() {
     this.viewPort = document.getElementById("view-port");
     this.headeredViews = document.getElementById("headered-views");
     this.headeredViewsDeck = document.getElementById("headered-views-content");
     this.backButton = document.getElementById("go-back");
 
     this.viewObjects.discover = gDiscoverView;
     this.viewObjects.legacy = gLegacyView;
-    this.viewObjects.updates = gUpdatesView;
     this.viewObjects.shortcuts = gShortcutsView;
 
     if (useHtmlViews) {
       this.viewObjects.list = htmlView("list");
       this.viewObjects.detail = htmlView("detail");
+      this.viewObjects.updates = htmlView("updates");
+      // gUpdatesView still handles when the Available Updates category is
+      // shown. Include it in viewObjects so it gets initialized and shutdown.
+      this.viewObjects._availableUpdatesSidebar = gUpdatesView;
     } else {
       this.viewObjects.list = gListView;
       this.viewObjects.detail = gDetailView;
+      this.viewObjects.updates = gUpdatesView;
     }
 
     for (let type in this.viewObjects) {
       let view = this.viewObjects[type];
       view.initialize();
     }
 
     window.controllers.appendController(this);
@@ -3832,24 +3836,32 @@ var gBrowser = {
     }
   }, UPDATE_POSITION_DELAY);
 
   window.addEventListener("scroll", () => {
     updatePositionTask.arm();
   }, true);
 }
 
+const addonTypes = new Set([
+  "extension", "theme", "plugin", "dictionary", "locale",
+]);
 const htmlViewOpts = {
   loadViewFn(type, param) {
     let viewId = `addons://${type}`;
     if (param) {
       viewId += "/" + encodeURIComponent(param);
     }
     gViewController.loadView(viewId);
   },
+  setCategoryFn(name) {
+    if (addonTypes.has(name)) {
+      gCategories.select(`addons://list/${name}`);
+    }
+  },
 };
 
 // View wrappers for the HTML version of about:addons. These delegate to an
 // HTML browser that renders the actual views.
 let htmlBrowser;
 let htmlBrowserLoaded;
 function getHtmlBrowser() {
   if (!htmlBrowser) {
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -74,16 +74,17 @@ skip-if = os == "linux" && !debug # Bug 
 skip-if = true # Bug 1449071 - Frequent failures
 [browser_globalwarnings.js]
 [browser_gmpProvider.js]
 skip-if = os == 'linux' && !debug # Bug 1398766
 [browser_html_detail_view.js]
 [browser_html_list_view.js]
 [browser_html_plugins.js]
 skip-if = (os == 'win' && processor == 'aarch64') # aarch64 has no plugin support, bug 1525174 and 1547495
+[browser_html_recent_updates.js]
 [browser_html_updates.js]
 [browser_inlinesettings_browser.js]
 skip-if = os == 'mac' || os == 'linux' # Bug 1483347
 [browser_installssl.js]
 skip-if = verify
 [browser_interaction_telemetry.js]
 [browser_langpack_signing.js]
 [browser_legacy.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
@@ -0,0 +1,123 @@
+/* eslint max-len: ["error", 80] */
+let gProvider;
+
+function dateHoursAgo(hours) {
+  let date = new Date();
+  date.setTime(date.getTime() - hours * 3600000);
+  return date;
+}
+
+add_task(async function enableHtmlViews() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.htmlaboutaddons.enabled", true]],
+  });
+
+  gProvider = new MockProvider();
+  gProvider.createAddons([{
+    id: "addon-today-2@mochi.test",
+    name: "Updated today two",
+    creator: {name: "The creator"},
+    version: "3.3",
+    type: "extension",
+    updateDate: dateHoursAgo(6),
+  }, {
+    id: "addon-today-3@mochi.test",
+    name: "Updated today three",
+    creator: {name: "The creator"},
+    version: "3.3",
+    type: "extension",
+    updateDate: dateHoursAgo(9),
+  }, {
+    id: "addon-today-1@mochi.test",
+    name: "Updated today",
+    creator: {name: "The creator"},
+    version: "3.1",
+    type: "extension",
+    updateDate: dateHoursAgo(1),
+  }, {
+    id: "addon-yesterday-1@mochi.test",
+    name: "Updated yesterday one",
+    creator: {name: "The creator"},
+    version: "3.3",
+    type: "extension",
+    updateDate: dateHoursAgo(15),
+  }, {
+    id: "addon-earlier@mochi.test",
+    name: "Updated earlier",
+    creator: {name: "The creator"},
+    version: "3.3",
+    type: "extension",
+    updateDate: dateHoursAgo(49),
+  }, {
+    id: "addon-yesterday-2@mochi.test",
+    name: "Updated yesterday",
+    creator: {name: "The creator"},
+    version: "3.3",
+    type: "extension",
+    updateDate: dateHoursAgo(24),
+  }, {
+    id: "addon-lastweek@mochi.test",
+    name: "Updated last week",
+    creator: {name: "The creator"},
+    version: "3.3",
+    type: "extension",
+    updateDate: dateHoursAgo(192),
+  }]);
+});
+
+add_task(async function testRecentUpdatesList() {
+  // Load extension view first so we can mock the startOfDay property.
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+  let managerDoc = win.managerWindow.document;
+
+  // Load the recent updates view.
+  let loaded = waitForViewLoad(win);
+  managerDoc.getElementById("utils-viewUpdates").doCommand();
+  await loaded;
+
+  // Find all the add-on ids.
+  let list = doc.querySelector("addon-list");
+  let addonsInOrder = () =>
+    Array.from(list.querySelectorAll("addon-card"))
+      .map(card => card.addon.id)
+      .filter(id => id.endsWith("@mochi.test"));
+
+  // Verify that the add-ons are in the right order.
+  Assert.deepEqual(addonsInOrder(), [
+    "addon-today-1@mochi.test", "addon-today-2@mochi.test",
+    "addon-today-3@mochi.test", "addon-yesterday-1@mochi.test",
+    "addon-yesterday-2@mochi.test",
+  ], "The add-ons are in the right order");
+
+  // Install a new extension.
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      name: "New extension",
+      applications: {gecko: {id: "new@mochi.test"}},
+    },
+    useAddonManager: "temporary",
+  });
+  let added = BrowserTestUtils.waitForEvent(list, "add");
+  await extension.startup();
+  await added;
+
+  // The new extension should now be at the top of the list.
+  Assert.deepEqual(addonsInOrder(), [
+    "new@mochi.test", "addon-today-1@mochi.test", "addon-today-2@mochi.test",
+    "addon-today-3@mochi.test", "addon-yesterday-1@mochi.test",
+    "addon-yesterday-2@mochi.test",
+  ], "The new add-on went to the top");
+
+  // Open the detail view for the new add-on.
+  let card = list.querySelector('addon-card[addon-id="new@mochi.test"]');
+  loaded = waitForViewLoad(win);
+  card.querySelector('[action="expand"]').click();
+  await loaded;
+
+  is(win.managerWindow.gCategories.selected, "addons://list/extension",
+     "The extensions category is selected");
+
+  await closeView(win);
+  await extension.unload();
+});
--- a/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
@@ -103,72 +103,76 @@ add_task(async function testChangeAutoUp
   updated = BrowserTestUtils.waitForEvent(card, "update");
   AddonManager.autoUpdateDefault = true;
   await updated;
 
   await closeView(win);
   await extension.unload();
 });
 
-async function setupExtensionWithUpdate() {
+async function setupExtensionWithUpdate(id) {
   await SpecialPowers.pushPrefEnv({
     set: [["extensions.checkUpdateSecurity", false]],
   });
 
   let server = AddonTestUtils.createHttpServer();
   let serverHost = `http://localhost:${server.identity.primaryPort}`;
+  let updatesPath = `/ext-updates-${id}.json`;
 
   let baseManifest = {
     name: "Updates",
     applications: {
       gecko: {
-        id: "update@mochi.test",
-        update_url: `${serverHost}/ext-updates.json`,
+        id,
+        update_url: serverHost + updatesPath,
       },
     },
   };
 
   let updateXpi = AddonTestUtils.createTempWebExtensionFile({
     manifest: {
       ...baseManifest,
       version: "2",
     },
   });
-  server.registerFile("/update-2.xpi", updateXpi);
-  AddonTestUtils.registerJSON(server, "/ext-updates.json", {
+  let xpiFilename = `/update-${id}.xpi`;
+  server.registerFile(xpiFilename, updateXpi);
+  AddonTestUtils.registerJSON(server, updatesPath, {
     addons: {
-      "update@mochi.test": {
+      [id]: {
         updates: [
-          {version: "2", update_link: `${serverHost}/update-2.xpi`},
+          {version: "2", update_link: serverHost + xpiFilename},
         ],
       },
     },
   });
 
-  return ExtensionTestUtils.loadExtension({
+  let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       ...baseManifest,
       version: "1",
     },
     // Use permanent so the add-on can be updated.
     useAddonManager: "permanent",
   });
+  await extension.startup();
+  return extension;
 }
 
 function disableAutoUpdates(card) {
   // Check button should be hidden.
   let updateCheckButton = card.querySelector('button[action="update-check"]');
   ok(updateCheckButton.hidden, "The button is initially hidden");
 
   // Disable updates, update check button is now visible.
   card.querySelector('input[name="autoupdate"][value="0"]').click();
   ok(!updateCheckButton.hidden, "The button is now visible");
 
   // There shouldn't be an update shown to the user.
-  assertUpdateState(card, false);
+  assertUpdateState({card, shown: false});
 }
 
 function checkForUpdate(card, expected) {
   let updateCheckButton = card.querySelector('button[action="update-check"]');
   let updateFound = BrowserTestUtils.waitForEvent(card, expected);
   updateCheckButton.click();
   return updateFound;
 }
@@ -176,82 +180,84 @@ function checkForUpdate(card, expected) 
 function installUpdate(card, expected) {
   // Install the update.
   let updateInstalled = BrowserTestUtils.waitForEvent(card, expected);
   let updated = BrowserTestUtils.waitForEvent(card, "update");
   card.querySelector('panel-item[action="install-update"]').click();
   return Promise.all([updateInstalled, updated]);
 }
 
-function assertUpdateState(card, shown) {
+function assertUpdateState({card, shown, expanded = true}) {
   let menuButton = card.querySelector(".more-options-button");
   ok(menuButton.classList.contains("more-options-button-badged") == shown,
      "The menu button is badged");
   let installButton = card.querySelector('panel-item[action="install-update"]');
   ok(installButton.hidden != shown,
      `The install button is ${shown ? "hidden" : "shown"}`);
-  let updateCheckButton = card.querySelector('button[action="update-check"]');
-  ok(updateCheckButton.hidden == shown,
-     `The update check button is ${shown ? "hidden" : "shown"}`);
+  if (expanded) {
+    let updateCheckButton = card.querySelector('button[action="update-check"]');
+    ok(updateCheckButton.hidden == shown,
+      `The update check button is ${shown ? "hidden" : "shown"}`);
+  }
 }
 
 add_task(async function testUpdateAvailable() {
-  let extension = await setupExtensionWithUpdate();
-  await extension.startup();
+  let id = "update@mochi.test";
+  let extension = await setupExtensionWithUpdate(id);
 
   let win = await loadInitialView("extension");
   let doc = win.document;
 
-  await loadDetailView(win, "update@mochi.test");
+  await loadDetailView(win, id);
 
   let card = doc.querySelector("addon-card");
 
   // Disable updates and then check.
   disableAutoUpdates(card);
   await checkForUpdate(card, "update-found");
 
   // There should now be an update.
-  assertUpdateState(card, true);
+  assertUpdateState({card, shown: true});
 
   // The version was 1.
   let versionRow = card.querySelector(".addon-detail-row-version");
   is(versionRow.lastChild.textContent, "1", "The version started as 1");
 
   await installUpdate(card, "update-installed");
 
   // The version is now 2.
   versionRow = card.querySelector(".addon-detail-row-version");
   is(versionRow.lastChild.textContent, "2", "The version has updated");
 
   // No update is shown again.
-  assertUpdateState(card, false);
+  assertUpdateState({card, shown: false});
 
   // Check for updates again, there shouldn't be an update.
   await checkForUpdate(card, "no-update");
 
   await closeView(win);
   await extension.unload();
 });
 
 add_task(async function testUpdateCancelled() {
-  let extension = await setupExtensionWithUpdate();
-  await extension.startup();
+  let id = "update@mochi.test";
+  let extension = await setupExtensionWithUpdate(id);
 
   let win = await loadInitialView("extension");
   let doc = win.document;
 
   await loadDetailView(win, "update@mochi.test");
   let card = doc.querySelector("addon-card");
 
   // Disable updates and then check.
   disableAutoUpdates(card);
   await checkForUpdate(card, "update-found");
 
   // There should now be an update.
-  assertUpdateState(card, true);
+  assertUpdateState({card, shown: true});
 
   // The add-on starts as version 1.
   let versionRow = card.querySelector(".addon-detail-row-version");
   is(versionRow.lastChild.textContent, "1", "The version started as 1");
 
   // Force the install to be cancelled.
   let install = card.updateInstall;
   ok(install, "There was an install found");
@@ -259,13 +265,101 @@ add_task(async function testUpdateCancel
 
   await installUpdate(card, "update-cancelled");
 
   // The add-on is still version 1.
   versionRow = card.querySelector(".addon-detail-row-version");
   is(versionRow.lastChild.textContent, "1", "The version hasn't changed");
 
   // The update has been removed.
-  assertUpdateState(card, false);
+  assertUpdateState({card, shown: false});
 
   await closeView(win);
   await extension.unload();
 });
+
+add_task(async function testAvailableUpdates() {
+  let ids = ["update1@mochi.test", "update2@mochi.test", "update3@mochi.test"];
+  let addons = await Promise.all(ids.map(id => setupExtensionWithUpdate(id)));
+
+  // Disable global add-on updates.
+  AddonManager.autoUpdateDefault = false;
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  let managerDoc = win.managerWindow.document;
+  let {gCategories} = win.managerWindow;
+  let availableCat = gCategories.get("addons://updates/available");
+
+  ok(availableCat.hidden, "Available updates is hidden");
+  is(availableCat.badgeCount, 0, "There are no updates");
+
+  // Check for all updates.
+  let updatesFound = TestUtils.topicObserved("EM-update-check-finished");
+  managerDoc.getElementById("utils-updateNow").doCommand();
+  await updatesFound;
+
+  // Wait for the available updates count to finalize, it's async.
+  await BrowserTestUtils.waitForCondition(() => availableCat.badgeCount == 3);
+
+  // The category shows the correct update count.
+  ok(!availableCat.hidden, "Available updates is visible");
+  is(availableCat.badgeCount, 3, "There are 3 updates");
+
+  // Go to the available updates page.
+  let loaded = waitForViewLoad(win);
+  availableCat.click();
+  await loaded;
+
+  // Check the updates are shown.
+  let cards = doc.querySelectorAll("addon-card");
+  is(cards.length, 3, "There are 3 cards");
+
+  // Each card should have an update.
+  for (let card of cards) {
+    assertUpdateState({card, shown: true, expanded: false});
+  }
+
+  // Check the detail page for the first add-on.
+  await loadDetailView(win, ids[0]);
+  is(gCategories.selected, "addons://list/extension",
+     "The extensions category is selected");
+
+  // Go back to the last view.
+  loaded = waitForViewLoad(win);
+  managerDoc.getElementById("go-back").click();
+  await loaded;
+
+  // We're back on the updates view.
+  is(gCategories.selected, "addons://updates/available",
+     "The available updates category is selected");
+
+  // Find the cards again.
+  cards = doc.querySelectorAll("addon-card");
+  is(cards.length, 3, "There are 3 cards");
+
+  // Install the first update.
+  await installUpdate(cards[0], "update-installed");
+  assertUpdateState({card: cards[0], shown: false, expanded: false});
+
+  // The count goes down but the card stays.
+  is(availableCat.badgeCount, 2, "There are only 2 updates now");
+  is(doc.querySelectorAll("addon-card").length, 3,
+     "All 3 cards are still visible on the updates page");
+
+  // Install the other two updates.
+  await installUpdate(cards[1], "update-installed");
+  assertUpdateState({card: cards[1], shown: false, expanded: false});
+  await installUpdate(cards[2], "update-installed");
+  assertUpdateState({card: cards[2], shown: false, expanded: false});
+
+  // The count goes down but the card stays.
+  is(availableCat.badgeCount, 0, "There are no more updates");
+  is(doc.querySelectorAll("addon-card").length, 3,
+     "All 3 cards are still visible on the updates page");
+
+  // Enable global add-on updates again.
+  AddonManager.autoUpdateDefault = true;
+
+  await closeView(win);
+  await Promise.all(addons.map(addon => addon.unload()));
+});