Bug 1546248 - Add discopane to about:addons HTML view r=mstriemer,flod
☠☠ backed out by 2f3479842f91 ☠ ☠
authorRob Wu <rob@robwu.nl>
Mon, 06 May 2019 10:41:10 +0000
changeset 531516 afb54f703345de55f066e105ab254fa80526c3de
parent 531515 4ae270b9ed1d22d2246944990122c2792055b98c
child 531517 0258f553e7213c5057745ac2737543b6379b31b7
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)
reviewersmstriemer, flod
bugs1546248
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 1546248 - Add discopane to about:addons HTML view r=mstriemer,flod The api_response.json test file is the response from https://addons-dev.allizom.org/api/v4/discovery/?lang=en-US It has not been modified, except for being prettified using `json_pp`. Differential Revision: https://phabricator.services.mozilla.com/D28436
browser/app/profile/firefox.js
toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/internal/AddonRepository.jsm
toolkit/mozapps/extensions/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
toolkit/mozapps/extensions/test/browser/discovery/api_response.json
toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -39,16 +39,17 @@ pref("extensions.webextOptionalPermissio
 // Preferences for AMO integration
 pref("extensions.getAddons.cache.enabled", true);
 pref("extensions.getAddons.get.url", "https://services.addons.mozilla.org/api/v3/addons/search/?guid=%IDS%&lang=%LOCALE%");
 pref("extensions.getAddons.compatOverides.url", "https://services.addons.mozilla.org/api/v3/addons/compat-override/?guid=%IDS%&lang=%LOCALE%");
 pref("extensions.getAddons.search.browseURL", "https://addons.mozilla.org/%LOCALE%/firefox/search?q=%TERMS%&platform=%OS%&appver=%VERSION%");
 pref("extensions.webservice.discoverURL", "https://discovery.addons.mozilla.org/%LOCALE%/firefox/discovery/pane/%VERSION%/%OS%/%COMPATIBILITY_MODE%");
 pref("extensions.getAddons.link.url", "https://addons.mozilla.org/%LOCALE%/firefox/");
 pref("extensions.getAddons.langpacks.url", "https://services.addons.mozilla.org/api/v3/addons/language-tools/?app=firefox&type=language&appversion=%VERSION%");
+pref("extensions.getAddons.discovery.api_url", "https://services.addons.mozilla.org/api/v4/discovery/?lang=%LOCALE%");
 
 pref("extensions.update.autoUpdateDefault", true);
 
 // Check AUS for system add-on updates.
 pref("extensions.systemAddon.update.url", "https://aus5.mozilla.org/update/3/SystemAddons/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml");
 pref("extensions.systemAddon.update.enabled", true);
 
 // Disable add-ons that are not installed by the user in all scopes by default.
--- a/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
+++ b/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
@@ -100,16 +100,18 @@ type.service.name=Services
 type.legacy.name=Legacy Extensions
 type.unsupported.name=Unsupported
 
 #LOCALIZATION NOTE(legacyWarning.description) %S is the brandShortName
 legacyWarning.description=Missing something? Some extensions are no longer supported by %S.
 #LOCALIZATION NOTE(legacyThemeWarning.description) %S is the brandShortName
 legacyThemeWarning.description=Missing something? Some themes are no longer supported by %S.
 
+#LOCALIZATION NOTE(listHeading.discover) %S is the brandShortName
+listHeading.discover=Personalize Your %S
 listHeading.extension=Manage Your Extensions
 listHeading.shortcuts=Manage Extension Shortcuts
 listHeading.theme=Manage Your Themes
 listHeading.plugin=Manage Your Plugins
 listHeading.locale=Manage Your Languages
 listHeading.dictionary=Manage Your Dictionaries
 
 searchLabel.extension=Find more extensions
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -338,16 +338,41 @@ shortcuts-card-expand-button =
         *[other] Show { $numberToShow } More
     }
 
 shortcuts-card-collapse-button = Show Less
 
 go-back-button =
     .tooltiptext = Go back
 
+## Recommended add-ons page
+
+# Explanatory introduction to the list of recommended add-ons. The action word
+# ("recommends") in the final sentence is a link to external documentation.
+discopane-intro =
+    Extensions and themes are like apps for your browser, and they let you
+    protect passwords, download videos, find deals, block annoying ads, change
+    how your browser looks, and much more. These small software programs are
+    often developed by a third party. Here’s a selection { -brand-product-name }
+    <a data-l10n-name="learn-more-trigger">recommends</a> for exceptional
+    security, performance, and functionality.
+
+privacy-policy = Privacy Policy
+
+# Refers to the author of an add-on, shown below the name of the add-on.
+# Variables:
+#   $author (string) - The name of the add-on developer.
+created-by-author = by <a data-l10n-name="author">{ $author }</a>
+install-extension-button = Add to { -brand-product-name }
+install-theme-button = Install Theme
+# The label of the button that appears after installing an add-on. Upon click,
+# the detailed add-on view is opened, from where the add-on can be managed.
+manage-addon-button = Manage
+find-more-addons = Find more add-ons
+
 ## Add-on actions
 remove-addon-button = Remove
 disable-addon-button = Disable
 enable-addon-button = Enable
 expand-addon-button = More Options
 
 addons-enabled-heading = Enabled
 addons-disabled-heading = Disabled
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -53,16 +53,17 @@ addon-list .addon.card {
   width: var(--addon-icon-size);
   height: var(--addon-icon-size);
   margin-inline-end: 16px;
   -moz-context-properties: fill;
   fill: currentColor;
 }
 
 .card-contents {
+  word-break: break-word;
   flex-grow: 1;
   display: flex;
   flex-direction: column;
 }
 
 .card-actions {
   flex-shrink: 0;
 }
@@ -115,16 +116,84 @@ addon-card:not([expanded]) .addon-descri
 
 .more-options-menu {
   position: relative;
   /* Add some negative margin to account for the button's padding */
   margin-top: -10px;
   margin-inline-end: -8px;
 }
 
+/* Discopane extensions to the add-on card */
+
+recommended-addon-card .addon-name {
+  display: flex;
+}
+
+recommended-addon-card .addon-description:not(:empty) {
+  margin-top: 0.5em;
+}
+
+.disco-card-head {
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.disco-addon-author {
+  font-size: 12px;
+  font-weight: normal;
+}
+
+.disco-cta-button {
+  font-size: 14px;
+  flex-shrink: 0;
+  flex-grow: 0;
+  align-self: baseline;
+}
+
+.disco-cta-button[action="install-addon"]::before {
+  content: "+";
+  padding-inline-end: 4px;
+}
+
+.discopane-notice {
+  margin: 0.5em 0;
+}
+
+.discopane-notice-content {
+  align-items: center;
+  display: flex;
+  width: 100%;
+}
+
+.discopane-notice-content > span {
+  flex-grow: 1;
+}
+
+.discopane-notice-content > button {
+  flex-grow: 0;
+  flex-shrink: 0;
+}
+
+.discopane-footer {
+  text-align: center;
+}
+
+.discopane-footer > * {
+  margin-top: 30px;
+}
+
+.discopane-privacy-policy-link {
+  font-size: small;
+}
+
+addon-details {
+  color: var(--grey-60);
+}
+
 .addon-detail-description {
   margin: 16px 0;
 }
 
 .addon-detail-contribute {
   padding: var(--card-padding);
   border: 1px solid var(--grey-90-a20);
   border-radius: var(--panel-border-radius);
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -50,16 +50,32 @@
           </div>
           <div class="more-options-menu">
             <button class="more-options-button ghost-button" action="more-options"></button>
           </div>
         </div>
       </div>
     </template>
 
+    <template name="addon-name-container-in-disco-card">
+      <div class="disco-card-head">
+        <span class="disco-addon-name"></span>
+        <span class="disco-addon-author"><a data-l10n-name="author" target="_blank"></a></span>
+      </div>
+      <button class="disco-cta-button primary" action="install-addon"></button>
+      <button class="disco-cta-button" data-l10n-id="manage-addon-button" action="manage-addon"></button>
+    </template>
+
+    <template name="addon-description-in-disco-card">
+      <div>
+        <strong class="disco-description-intro"></strong>
+        <span class="disco-description-main"></span>
+      </div>
+    </template>
+
     <template name="addon-details">
       <div class="addon-detail-description"></div>
       <div class="addon-detail-contribute">
         <label data-l10n-id="detail-contributions-description"></label>
         <button
           class="addon-detail-contribute-button"
           action="contribute"
           data-l10n-id="detail-contributions-button"
@@ -134,10 +150,38 @@
       </div>
       <div class="arrow bottom"></div>
     </template>
 
     <template name="panel-item">
       <link rel="stylesheet" href="chrome://mozapps/content/extensions/panel-item.css">
       <button><slot></slot></button>
     </template>
+
+    <template name="discopane">
+      <header>
+        <p>
+          <span data-l10n-id="discopane-intro">
+            <a
+              class="discopane-intro-learn-more-link"
+              data-l10n-name="learn-more-trigger"
+              target="_blank">
+            </a>
+          </span>
+        </p>
+      </header>
+      <recommended-addon-list></recommended-addon-list>
+      <footer class="discopane-footer">
+        <div>
+          <button class="primary" action="open-amo" data-l10n-id="find-more-addons"></button>
+        </div>
+        <div>
+          <a
+            class="discopane-privacy-policy-link"
+            data-l10n-id="privacy-policy"
+            href="https://www.mozilla.org/privacy/firefox/?utm_source=firefox-browser&amp;utm_medium=firefox-browser&amp;utm_content=privacy-policy-link#addons"
+            target="_blank"
+          ></a>
+        </div>
+      </footer>
+    </template>
   </body>
 </html>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -5,17 +5,20 @@
 /* exported initialize, hide, show */
 /* import-globals-from aboutaddonsCommon.js */
 /* global windowRoot */
 
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
+  AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
+  ClientID: "resource://gre/modules/ClientID.jsm",
   ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
+  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
 });
 
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "allowPrivateBrowsingByDefault",
   "extensions.allowPrivateBrowsingByDefault", true);
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "SUPPORT_URL", "app.support.baseURL",
   "", null, val => Services.urlFormatter.formatURL(val));
@@ -28,16 +31,19 @@ const PERMISSION_MASKS = {
   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,
   upgrade: AddonManager.PERM_CAN_UPGRADE,
 };
 
+const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
+const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled";
+const PREF_TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled";
 const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
 const PRIVATE_BROWSING_PERMS =
   {permissions: [PRIVATE_BROWSING_PERM_NAME], origins: []};
 
 const AddonCardListenerHandler = {
   ADDON_EVENTS: new Set([
     "onDisabled", "onEnabled", "onInstalled", "onPropertyChanged",
     "onUninstalled",
@@ -142,16 +148,94 @@ function nl2br(text) {
       frag.appendChild(document.createElement("br"));
     }
     frag.appendChild(new Text(part));
     hasAppended = true;
   }
   return frag;
 }
 
+// A wrapper around an item from the "results" array from AMO's discovery API.
+// See https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+class DiscoAddonWrapper {
+  constructor(details) {
+    // Reuse AddonRepository._parseAddon to have the AMO response parsing logic
+    // in one place.
+    let repositoryAddon = AddonRepository._parseAddon(details.addon);
+
+    // Note: Any property used by RecommendedAddonCard should appear here.
+    // The property names and values should have the same semantics as
+    // AddonWrapper, to ease the reuse of helper functions in this file.
+    this.id = repositoryAddon.id;
+    this.type = repositoryAddon.type;
+    this.name = repositoryAddon.name;
+    this.screenshots = repositoryAddon.screenshots;
+    this.sourceURI = repositoryAddon.sourceURI;
+    this.creator = repositoryAddon.creator;
+
+    this.editorialHeading = details.heading_text;
+    this.editorialDescription = details.description_text;
+    this.iconURL = details.addon.icon_url;
+    this.amoListingUrl = details.addon.url;
+  }
+}
+
+/**
+ * A helper to retrieve the list of recommended add-ons via AMO's discovery API.
+ */
+var DiscoveryAPI = {
+  /**
+   * Fetch the list of recommended add-ons. The results are cached.
+   *
+   * Pending requests are coalesced, so there is only one request at any given
+   * time. If a request fails, the pending promises are rejected, but a new
+   * call will result in a new request.
+   *
+   * @returns {Promise<DiscoAddonWrapper[]>}
+   */
+  async getResults() {
+    if (!this._resultPromise) {
+      this._resultPromise = this._fetchRecommendedAddons()
+        .catch(e => {
+          // Delete the pending promise, so _fetchRecommendedAddons can be
+          // called again at the next property access.
+          delete this._resultPromise;
+          Cu.reportError(e);
+          throw e;
+        });
+    }
+    return this._resultPromise;
+  },
+
+  get clientIdDiscoveryEnabled() {
+    // These prefs match Discovery.jsm for enabling clientId cookies.
+    return Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) &&
+           Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) &&
+           !PrivateBrowsingUtils.isContentWindowPrivate(window);
+  },
+
+  async _fetchRecommendedAddons() {
+    let discoveryApiUrl =
+      new URL(Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL));
+
+    if (DiscoveryAPI.clientIdDiscoveryEnabled) {
+      let clientId = await ClientID.getClientIdHash();
+      discoveryApiUrl.searchParams.set("telemetry-client-id", clientId);
+    }
+    let res = await fetch(discoveryApiUrl.href, {
+      credentials: "omit",
+    });
+    if (!res.ok) {
+      throw new Error(`Failed to fetch recommended add-ons, ${res.status}`);
+    }
+    let {results} = await res.json();
+    return results.map(details => new DiscoAddonWrapper(details));
+  },
+};
+
 class PanelList extends HTMLElement {
   static get observedAttributes() {
     return ["open"];
   }
 
   constructor() {
     super();
     this.attachShadow({mode: "open"});
@@ -956,16 +1040,169 @@ class AddonCard extends HTMLElement {
 
   sendEvent(name, detail) {
     this.dispatchEvent(new CustomEvent(name, {detail}));
   }
 }
 customElements.define("addon-card", AddonCard);
 
 /**
+ * A child element of `<recommended-addon-list>`. It should be initialized
+ * by calling `setDiscoAddon()` first. Call `setAddon(addon)` if it has been
+ * installed, and call `setAddon(null)` upon uninstall.
+ *
+ *    let discoAddon = new DiscoAddonWrapper({ ... });
+ *    let card = document.createElement("recommended-addon-card");
+ *    card.setDiscoAddon(discoAddon);
+ *    document.body.appendChild(card);
+ *
+ *    AddonManager.getAddonsByID(discoAddon.id)
+ *      .then(addon => card.setAddon(addon));
+ */
+class RecommendedAddonCard extends HTMLElement {
+  /**
+   * @param {DiscoAddonWrapper} addon
+   *        The details of the add-on that should be rendered in the card.
+   */
+  setDiscoAddon(addon) {
+    this.addonId = addon.id;
+
+    // Save the information so we can install.
+    this.discoAddon = addon;
+
+    let card = importTemplate("card").firstElementChild;
+    let heading = card.querySelector(".addon-name-container");
+    heading.textContent = "";
+    heading.append(importTemplate("addon-name-container-in-disco-card"));
+    card.querySelector(".more-options-menu").remove();
+
+    this.setCardContent(card, addon);
+    if (addon.type != "theme") {
+      card.querySelector(".addon-description")
+        .append(importTemplate("addon-description-in-disco-card"));
+      this.setCardDescription(card, addon);
+    }
+    this.registerButtons(card, addon);
+
+    this.textContent = "";
+    this.append(card);
+
+    // We initially assume that the add-on is not installed.
+    this.setAddon(null);
+  }
+
+  /**
+   * Fills in all static parts of the card.
+   *
+   * @param {HTMLElement} card
+   *        The primary content of this card.
+   * @param {DiscoAddonWrapper} addon
+   */
+  setCardContent(card, addon) {
+    // Set the icon.
+    if (addon.type == "theme") {
+      card.querySelector(".addon-icon").hidden = true;
+    } else {
+      card.querySelector(".addon-icon").src =
+        AddonManager.getPreferredIconURL(addon, 32, window);
+    }
+
+    // Set the theme preview.
+    let preview = card.querySelector(".card-heading-image");
+    preview.hidden = true;
+    if (addon.type == "theme") {
+      let screenshot =
+        AddonCard.prototype.screenshotForImg.call({addon}, preview);
+      if (screenshot) {
+        preview.src = screenshot.url;
+        preview.hidden = false;
+      }
+    }
+
+    // Set the name.
+    card.querySelector(".disco-addon-name").textContent = addon.name;
+
+    // Set the author name and link to AMO.
+    if (addon.creator) {
+      let authorInfo = card.querySelector(".disco-addon-author");
+      document.l10n.setAttributes(authorInfo, "created-by-author", {
+        author: addon.creator.name,
+      });
+      // This is intentionally a link to the add-on listing instead of the
+      // author page, because the add-on listing provides more relevant info.
+      authorInfo.querySelector("a").href = addon.amoListingUrl;
+      authorInfo.hidden = false;
+    }
+  }
+
+  setCardDescription(card, addon) {
+    // Set the description. Note that this is the editorial description, not
+    // the add-on's original description that would normally appear on a card.
+    card.querySelector(".disco-description-main")
+      .textContent = addon.editorialDescription;
+    if (addon.editorialHeading) {
+      card.querySelector(".disco-description-intro").textContent =
+        addon.editorialHeading;
+    }
+
+    // TODO: Append ratings and user count to description.
+  }
+
+  registerButtons(card, addon) {
+    let installButton = card.querySelector("[action='install-addon']");
+    if (addon.type == "theme") {
+      document.l10n.setAttributes(installButton, "install-theme-button");
+    } else {
+      document.l10n.setAttributes(installButton, "install-extension-button");
+    }
+
+    this.addEventListener("click", this);
+  }
+
+  handleEvent(event) {
+    let action = event.target.getAttribute("action");
+    switch (action) {
+      case "install-addon":
+        this.installDiscoAddon();
+        break;
+      case "manage-addon":
+        loadViewFn("detail", this.addonId);
+        break;
+    }
+  }
+
+  async installDiscoAddon() {
+    let addon = this.discoAddon;
+    let url = addon.sourceURI.spec;
+    let install = await AddonManager.getInstallForURL(url, {
+      name: addon.name,
+      telemetryInfo: {source: "disco"},
+    });
+    // We are hosted in a <browser> in about:addons, but we can just use the
+    // main tab's browser since all of it is using the system principal.
+    let browser = window.docShell.chromeEventHandler;
+    AddonManager.installAddonFromWebpage("application/x-xpinstall", browser,
+      Services.scriptSecurityManager.getSystemPrincipal(), install);
+  }
+
+  /**
+   * @param {AddonWrapper|null} addon
+   *        The add-on that has been installed; null if it has been removed.
+   */
+  setAddon(addon) {
+    let card = this.firstElementChild;
+    card.querySelector("[action='install-addon']").hidden = !!addon;
+    card.querySelector("[action='manage-addon']").hidden = !addon;
+
+    this.dispatchEvent(new CustomEvent("disco-card-updated")); // For testing.
+  }
+}
+customElements.define("recommended-addon-card", RecommendedAddonCard);
+
+/**
  * A list view for add-ons of a certain type. It should be initialized with the
  * type of add-on to render and have section data set before being connected to
  * the document.
  *
  *    let list = document.createElement("addon-list");
  *    list.type = "plugin";
  *    list.setSections([{
  *      headingId: "plugin-section-heading",
@@ -1218,16 +1455,125 @@ class AddonList extends HTMLElement {
   }
 
   onUninstalled(addon) {
     this.removeAddon(addon);
   }
 }
 customElements.define("addon-list", AddonList);
 
+class RecommendedAddonList extends HTMLElement {
+  connectedCallback() {
+    if (this.isConnected) {
+      this.loadCardsIfNeeded();
+      this.updateCardsWithAddonManager();
+    }
+    AddonManager.addAddonListener(this);
+  }
+
+  disconnectedCallback() {
+    AddonManager.removeAddonListener(this);
+  }
+
+  onInstalled(addon) {
+    let card = this.getCardById(addon.id);
+    if (card) {
+      card.setAddon(addon);
+    }
+  }
+
+  onUninstalled(addon) {
+    let card = this.getCardById(addon.id);
+    if (card) {
+      card.setAddon(null);
+    }
+  }
+
+  getCardById(addonId) {
+    for (let card of this.children) {
+      if (card.addonId === addonId) {
+        return card;
+      }
+    }
+    return null;
+  }
+
+  async updateCardsWithAddonManager() {
+    let cards = Array.from(this.children);
+    let addonIds = cards.map(card => card.addonId);
+    let addons = await AddonManager.getAddonsByIDs(addonIds);
+    for (let [i, card] of cards.entries()) {
+      let addon = addons[i];
+      card.setAddon(addon);
+      if (addon) {
+        // Already installed, move card to end.
+        this.append(card);
+      }
+    }
+  }
+
+  async loadCardsIfNeeded() {
+    // Use promise as guard. Also used by tests to detect when load completes.
+    if (!this.cardsReady) {
+      this.cardsReady = this._loadCards();
+    }
+    return this.cardsReady;
+  }
+
+  async _loadCards() {
+    let recommendedAddons;
+    try {
+      recommendedAddons = await DiscoveryAPI.getResults();
+    } catch (e) {
+      return;
+    }
+
+    let frag = document.createDocumentFragment();
+    for (let addon of recommendedAddons) {
+      let card = document.createElement("recommended-addon-card");
+      card.setDiscoAddon(addon);
+      frag.append(card);
+    }
+    this.append(frag);
+    this.updateCardsWithAddonManager();
+  }
+}
+customElements.define("recommended-addon-list", RecommendedAddonList);
+
+class DiscoveryPane extends HTMLElement {
+  render() {
+    this.append(importTemplate("discopane"));
+    this.querySelector(".discopane-intro-learn-more-link").href =
+      Services.urlFormatter.formatURLPref("app.support.baseURL") +
+      "recommended-extensions-program";
+
+    this.addEventListener("click", this);
+
+    // Hide footer until the cards is loaded, to prevent the content from
+    // suddenly shifting when the user attempts to interact with it.
+    let footer = this.querySelector("footer");
+    footer.hidden = true;
+    this.querySelector("recommended-addon-list").loadCardsIfNeeded()
+      .finally(() => { footer.hidden = false; });
+  }
+
+  handleEvent(event) {
+    let action = event.target.getAttribute("action");
+    switch (action) {
+      case "open-amo":
+        windowRoot.ownerGlobal.openTrustedLinkIn(
+          Services.urlFormatter.formatURLPref("extensions.getAddons.link.url"),
+          "tab");
+        break;
+    }
+  }
+}
+
+customElements.define("discovery-pane", DiscoveryPane);
+
 class ListView {
   constructor({param, root}) {
     this.type = param;
     this.root = root;
   }
 
   async render() {
     let list = document.createElement("addon-list");
@@ -1306,16 +1652,24 @@ class UpdatesView {
     }
 
     await list.render();
     this.root.textContent = "";
     this.root.appendChild(list);
   }
 }
 
+class DiscoveryView {
+  render() {
+    let discopane = document.createElement("discovery-pane");
+    discopane.render();
+    return discopane;
+  }
+}
+
 // Generic view management.
 let root = null;
 
 /**
  * Called from extensions.js once, when about:addons is loading.
  */
 function initialize(opts) {
   root = document.getElementById("main");
@@ -1335,16 +1689,24 @@ 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 == "discover") {
+    let discoverView = new DiscoveryView();
+    let elem = discoverView.render();
+    await document.l10n.translateFragment(elem);
+    root.textContent = "";
+    root.append(elem);
   } else if (type == "updates") {
     await new UpdatesView({param, root}).render();
+  } else {
+    throw new Error(`Unknown view type: ${type}`);
   }
 }
 
 function hide() {
   root.textContent = "";
 }
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -728,30 +728,31 @@ var gViewController = {
   backButton: null,
 
   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.shortcuts = gShortcutsView;
 
     if (useHtmlViews) {
       this.viewObjects.list = htmlView("list");
       this.viewObjects.detail = htmlView("detail");
+      this.viewObjects.discover = htmlView("discover");
       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.discover = gDiscoverView;
       this.viewObjects.updates = gUpdatesView;
     }
 
     for (let type in this.viewObjects) {
       let view = this.viewObjects[type];
       view.initialize();
     }
 
@@ -912,16 +913,20 @@ var gViewController = {
 
     let headingName = document.getElementById("heading-name");
     let headingLabel;
     try {
       headingLabel = gStrings.ext.GetStringFromName(`listHeading.${view.param}`);
     } catch (e) {
       // Some views don't have a label, like the updates view.
       headingLabel = "";
+      if (view.type == "discover") {
+        headingLabel = gStrings.ext.formatStringFromName(
+          "listHeading.discover", [gStrings.brandShortName], 1);
+      }
     }
     headingName.textContent = headingLabel;
     setSearchLabel(view.param);
 
 
     if (aViewId == aPreviousView)
       this.currentViewObj.refresh(view.param, ++this.currentViewRequest, aState);
     else
--- a/toolkit/mozapps/extensions/internal/AddonRepository.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonRepository.jsm
@@ -624,16 +624,17 @@ var AddonRepository = {
 
     addon.description = convertHTMLToPlainText(aEntry.summary);
     addon.fullDescription = convertHTMLToPlainText(aEntry.description);
 
     addon.weeklyDownloads = aEntry.weekly_downloads;
 
     switch (aEntry.type) {
       case "persona":
+      case "statictheme":
         addon.type = "theme";
         break;
 
       case "language":
         addon.type = "locale";
         break;
 
       default:
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -4,16 +4,18 @@ support-files =
   addons/browser_dragdrop1.xpi
   addons/browser_dragdrop2.xpi
   addons/browser_dragdrop_incompat.xpi
   addons/browser_installssl.xpi
   addons/browser_theme.xpi
   addons/options_signed.xpi
   addons/options_signed/*
   addon_prefs.xul
+  discovery/api_response.json
+  discovery/small-1x1.png
   discovery.html
   head.js
   more_options.xul
   options.xul
   plugin_test.html
   redirect.sjs
   releaseNotes.xhtml
   blockNoPlugins.xml
@@ -71,16 +73,18 @@ skip-if = os == "linux" && !debug # Bug 
 [browser_dragdrop_incompat.js]
 [browser_extension_sideloading_permission.js]
 [browser_file_xpi_no_process_switch.js]
 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_discover_view.js]
+[browser_html_discover_view_clientid.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]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
@@ -0,0 +1,656 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const {
+  AddonTestUtils,
+} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
+const {
+  ExtensionUtils: {
+    getUniqueId,
+    promiseEvent,
+    promiseObserved,
+  },
+}  = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+// The response to the discovery API, as documented at:
+// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+//
+// The test is designed to easily verify whether the discopane works with the
+// latest AMO API, by replacing API_RESPONSE_FILE's content with latest AMO API
+// response, e.g. from https://addons.allizom.org/api/v4/discovery/?lang=en-US
+// The response must contain at least one theme, and one extension.
+const API_RESPONSE_FILE = RELATIVE_DIR + "discovery/api_response.json";
+
+const AMO_TEST_HOST = "addons.example.com";
+
+const ArrayBufferInputStream =
+  Components.Constructor("@mozilla.org/io/arraybuffer-input-stream;1",
+                         "nsIArrayBufferInputStream", "setData");
+
+AddonTestUtils.initMochitest(this);
+
+// `result` is an element in the `results` array from AMO's discovery API,
+// stored in API_RESPONSE_FILE.
+function getTestExpectationFromApiResult(result) {
+  return {
+    typeIsTheme: result.addon.type === "statictheme",
+    addonName: result.addon.name,
+    authorName: result.addon.authors[0].name,
+    editorialHead: result.heading_text,
+    editorialBody: result.description_text,
+  };
+}
+
+/**
+ * An internal server to support testing against the AMO API.
+ *
+ * // Start serving at http://example.com
+ * let amoServer = new MockAPIServer("example.com");
+ *
+ * // Define the responses to be mocked:
+ * amoServer.setResponseToFile("file", "path/to/real/file.txt"); // or nsIFile.
+ * amoServer.setResponseToText(".txt", "actual content of file");
+ *
+ * // Suspend and resume responses from the server:
+ * amoServer.blockNextResponses();
+ * amoServer.unblockResponses();
+ *
+ * // Check request counters.
+ * Assert.deepEqual(amoServer.requestCounters, {file: 1, ".txt": 1})
+ *
+ * // Unregister the server, so that a new MockAPIServer can be constructed at
+ * // the next test.
+ * await amoServer.unregister();
+ */
+class MockAPIServer {
+  constructor(host) {
+    this._resources = new Map();
+    this.requestCounters = {};
+
+    if (!MockAPIServer._servers) {
+      MockAPIServer._servers = new Map();
+    }
+    if (!MockAPIServer._servers.has(host)) {
+      MockAPIServer._servers.set(host,
+        AddonTestUtils.createHttpServer({hosts: [host]}));
+    }
+    this.server = MockAPIServer._servers.get(host);
+    this.server.registerPrefixHandler("/", this);
+  }
+
+  unregister() {
+    // We cannot use server.stop() because AddonTestUtils.createHttpServer takes
+    // care of that and does not expect callers to stop the server.
+    // Do the next best thing, i.e. unregistering the request handler.
+    this.server.registerPrefixHandler("/", null);
+    this.server = null;
+  }
+
+  async setResponseToFile(pathSuffix, filepath) {
+    if (filepath instanceof Ci.nsIFile) {
+      filepath = filepath.path;
+    } else {
+      filepath = RELATIVE_DIR + filepath;
+    }
+    this._resources.set(pathSuffix, (await OS.File.read(filepath)).buffer);
+  }
+
+  setResponseToText(pathSuffix, text) {
+    this._resources.set(pathSuffix, new TextEncoder().encode(text).buffer);
+  }
+
+  blockNextResponses() {
+    this._unblockPromise = new Promise(resolve => {
+      this.unblockResponses = resolve;
+    });
+  }
+
+  unblockResponses(responseText) {
+    throw new Error("You need to call blockNextResponses first!");
+  }
+
+  // nsIHttpRequestHandler::handle
+  async handle(request, response) {
+    let body = this._getResourceForPath(request.path);
+    ok(body, `Must have response for: ${request.path}`);
+
+    response.setHeader("Cache-Control", "no-cache");
+    response.processAsync();
+    await this._unblockPromise;
+
+    let binStream = new ArrayBufferInputStream(body, 0, body.byteLength);
+    response.bodyOutputStream.writeFrom(binStream, body.byteLength);
+    response.finish();
+  }
+
+  _getResourceForPath(path) {
+    for (let [suffix, body] of this._resources) {
+      if (path.endsWith(suffix)) {
+        this.requestCounters[suffix] = (this.requestCounters[suffix] || 0) + 1;
+        return body;
+      }
+    }
+    return null;
+  }
+}
+
+// Creates a server and register |apiText| as the response to the discovery API
+// for use with the discopane.
+async function createAMOServer(apiText) {
+  // Replace all URLs in the API response so that our server will intercept
+  // requests to those URLs. And include a unique number in them to ensure that
+  // every occurring URL will result in a new request.
+  apiText = apiText.replace(
+    /"https?:\/\/[^\/"]+/g,
+    () => `"http://${AMO_TEST_HOST}/${getUniqueId()}}`);
+
+  let amoServer = new MockAPIServer(AMO_TEST_HOST);
+  amoServer.setResponseToText("discoapi", apiText);
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.getAddons.discovery.api_url",
+           `http://${AMO_TEST_HOST}/discoapi`]],
+  });
+  return amoServer;
+}
+
+// Retrieve the list of visible action elements inside a document or container.
+function getVisibleActions(documentOrElement) {
+  return Array.from(documentOrElement.querySelectorAll("[action]"))
+    .filter(elem => elem.offsetWidth && elem.offsetHeight);
+}
+
+function getActionName(actionElement) {
+  return actionElement.getAttribute("action");
+}
+
+function getDiscoveryElement(win) {
+  return win.document.querySelector("discovery-pane");
+}
+
+function getCardContainer(win) {
+  return getDiscoveryElement(win).querySelector("recommended-addon-list");
+}
+
+function getCardByAddonId(win, addonId) {
+  for (let card of win.document.querySelectorAll("recommended-addon-card")) {
+    if (card.addonId === addonId) {
+      return card;
+    }
+  }
+  return null;
+}
+
+// Wait until the current `<discovery-pane>` element has finished loading its
+// cards. This can be used after the cards have been loaded.
+function promiseDiscopaneUpdate(win) {
+  let {cardsReady} = getCardContainer(win);
+  ok(cardsReady, "Discovery cards should have started to initialize");
+  return cardsReady;
+}
+
+// Switch to a different view so we can switch back to the discopane later.
+async function switchToNonDiscoView(win) {
+  // Listeners registered while the discopane was the active view continue to be
+  // active when the view switches to the extensions list, because both views
+  // share the same document.
+  let loaded = waitForViewLoad(win);
+  win.managerWindow.gViewController.loadView("addons://list/extensions");
+  await loaded;
+  ok(win.document.querySelector("addon-list"),
+     "Should be at the extension list view");
+}
+
+// Switch to the discopane and wait until it has fully rendered, including any
+// cards from the discovery API.
+async function switchToDiscoView(win) {
+  is(getDiscoveryElement(win), null,
+     "Cannot switch to discopane when the discopane is already shown");
+  let loaded = waitForViewLoad(win);
+  win.managerWindow.gViewController.loadView("addons://discover/");
+  await loaded;
+  await promiseDiscopaneUpdate(win);
+}
+
+// Wait until all images in the DOM have successfully loaded.
+// There must be at least one `<img>` in the document.
+// Returns the number of loaded images.
+async function waitForAllImagesLoaded(win) {
+  let imgs = Array.from(win.document.querySelectorAll("img[src]"));
+  function areAllImagesLoaded() {
+    let loadCount = imgs.filter(img => img.naturalWidth).length;
+    info(`Loaded ${loadCount} out of ${imgs.length} images`);
+    return loadCount === imgs.length;
+  }
+  if (!areAllImagesLoaded()) {
+    await promiseEvent(win.document, "load", true, areAllImagesLoaded);
+  }
+  return imgs.length;
+}
+
+function checkEqualFloat(a, b, message) {
+  let epsilon = 0.1;
+  Assert.less(Math.abs(a - b), epsilon, `${message} - ${a} vs ${b}`);
+}
+
+/**
+ * Checks whether all given elements have equivalent geometry.
+ *
+ * @param {HTMLElement[]} elements
+ *        The elements whose dimensions are checked. The first element is used
+ *        as a reference of how an element is supposed to look like.
+ * @param {String[]} dimensions
+ *        An array of dimension names that should be checked. Any of:
+ *        "left", "right", "top", "bottom", "height", "width".
+ * @param {string} desc
+ *        Description of the test.
+ */
+function checkEqualGeometry(elements, dimensions, desc) {
+  let reference = elements[0].getBoundingClientRect();
+  for (let [i, element] of elements.entries()) {
+    if (i === 0) {
+      // Skip reference element.
+      continue;
+    }
+    let test = element.getBoundingClientRect();
+    for (let d of dimensions) {
+      checkEqualFloat(test[d], reference[d], `${desc}: elements[${i}].${d}`);
+    }
+  }
+}
+
+// A helper that waits until an installation has been requested from `amoServer`
+// and proceeds with approving the installation.
+async function promiseAddonInstall(amoServer, extensionData) {
+  let description = extensionData.manifest.description;
+  let xpiFile = AddonTestUtils.createTempWebExtensionFile(extensionData);
+  amoServer.setResponseToFile("xpi", xpiFile);
+
+  let addonId = extensionData.manifest.applications.gecko.id;
+  let installedPromise =
+    waitAppMenuNotificationShown("addon-installed", addonId, true);
+
+  if (!extensionData.manifest.theme) {
+    info(`${description}: Waiting for permission prompt`);
+    // Extensions have install prompts.
+    let panel = await promisePopupNotificationShown("addon-webext-permissions");
+    panel.button.click();
+  } else {
+    info(`${description}: Waiting for install prompt`);
+    let panel =
+      await promisePopupNotificationShown("addon-install-confirmation");
+    panel.button.click();
+  }
+
+  info("Waiting for post-install doorhanger");
+  await installedPromise;
+
+  let addon = await AddonManager.getAddonByID(addonId);
+  Assert.deepEqual(addon.installTelemetryInfo, {
+    // This is the expected source because before the HTML-based discopane,
+    // "disco" was already used to mark installs from the AMO-hosted discopane.
+    source: "disco",
+  }, "The installed add-on should have the expected telemetry info");
+}
+
+// Install an add-on by clicking on the card.
+// The promise resolves once the card has been updated.
+async function testCardInstall(card) {
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["install-addon"],
+    "Should have an Install button before install");
+
+  let installButton =
+    card.querySelector("[data-l10n-id='install-extension-button']") ||
+    card.querySelector("[data-l10n-id='install-theme-button']");
+
+  let updatePromise = promiseEvent(card, "disco-card-updated");
+  installButton.click();
+  await updatePromise;
+
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["manage-addon"],
+    "Should have a Manage button after install");
+}
+
+// Uninstall the add-on (not via the card, since it has no uninstall button).
+// The promise resolves once the card has been updated.
+async function testAddonUninstall(card) {
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["manage-addon"],
+    "Should have a Manage button before uninstall");
+
+  let addon = await AddonManager.getAddonByID(card.addonId);
+
+  let updatePromise = promiseEvent(card, "disco-card-updated");
+  await addon.uninstall();
+  await updatePromise;
+
+  Assert.deepEqual(
+    getVisibleActions(card).map(getActionName),
+    ["install-addon"],
+    "Should have an Install button after uninstall");
+}
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["extensions.htmlaboutaddons.enabled", true],
+    ],
+  });
+});
+
+// Test that the discopane can be loaded and that meaningful results are shown.
+// This relies on response data from the AMO API, stored in API_RESPONSE_FILE.
+add_task(async function discopane_with_real_api_data() {
+  const apiText = await OS.File.read(API_RESPONSE_FILE, {encoding: "utf-8"});
+  const amoServer = await createAMOServer(apiText);
+
+  const apiResultArray = JSON.parse(apiText).results;
+  ok(apiResultArray.length, `Mock has ${Array.length} results`);
+
+  // Map images to a valid image file, so that waitForAllImagesLoaded finishes.
+  amoServer.setResponseToFile("png", "discovery/small-1x1.png");
+
+  amoServer.blockNextResponses();
+  let win = await loadInitialView("discover");
+
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    [],
+    "The AMO button should be invisible when the AMO API hasn't responded");
+
+  amoServer.unblockResponses();
+  await promiseDiscopaneUpdate(win);
+
+  let actionElements = getVisibleActions(win.document);
+  Assert.deepEqual(
+    actionElements.map(getActionName),
+    [
+      // Expecting an install button for every result.
+      ...new Array(apiResultArray.length).fill("install-addon"),
+      "open-amo",
+    ],
+    "All add-on cards should be rendered, with AMO button at the end.");
+
+  await waitForAllImagesLoaded(win);
+
+  // Check position of install buttons.
+  {
+    let installThemeButtons = actionElements.filter(
+      e => e.matches("[data-l10n-id='install-theme-button']"));
+    let installExtensionButtons = actionElements.filter(
+      e => e.matches("[data-l10n-id='install-extension-button']"));
+
+    ok(installThemeButtons.length, "There must be at least one theme");
+    ok(installExtensionButtons.length, "There must be at least one extension");
+    is(installThemeButtons.length + installExtensionButtons.length,
+       apiResultArray.length,
+      "The only install buttons are for extensions and themes.");
+
+    checkEqualGeometry(installThemeButtons,
+                       ["left", "right", "width", "height"],
+                       "Geometry of theme install buttons should be equal");
+    checkEqualGeometry(installExtensionButtons,
+                       ["left", "right", "width", "height"],
+                       "Geometry of extension install buttons should be equal");
+    // The width/left offset may differ due to different button labels.
+    checkEqualGeometry(
+      [installThemeButtons[0], installExtensionButtons[0]],
+      ["right", "height"],
+      "Extension and theme install buttons should be aligned at the right.");
+  }
+
+  // Check that the cards have the expected content.
+  let cards =
+    Array.from(win.document.querySelectorAll("recommended-addon-card"));
+  is(cards.length, apiResultArray.length, "Every API result has a card");
+  for (let [i, card] of cards.entries()) {
+    let expectations = getTestExpectationFromApiResult(apiResultArray[i]);
+    info(`Expectations for card ${i}: ${JSON.stringify(expectations)}`);
+
+    let checkContent = (selector, expectation) => {
+      let text = card.querySelector(selector).textContent;
+      is(text, expectation, `Content of selector "${selector}"`);
+    };
+    checkContent(".disco-addon-name", expectations.addonName);
+    checkContent(".disco-addon-author [data-l10n-name='author']",
+                 expectations.authorName);
+
+    let actions = getVisibleActions(card);
+    is(actions.length, 1, "Card should only have one install button");
+    let installButton = actions[0];
+    if (expectations.typeIsTheme) {
+      // Theme button + screenshot
+      ok(installButton.matches("[data-l10n-id='install-theme-button'"),
+         "Has theme install button");
+      ok(card.querySelector(".card-heading-image").offsetWidth,
+         "Preview image must be visible");
+    } else {
+      // Extension button + extended description.
+      ok(installButton.matches("[data-l10n-id='install-extension-button'"),
+         "Has extension install button");
+      checkContent(".disco-description-intro", expectations.editorialHead);
+      checkContent(".disco-description-main", expectations.editorialBody);
+    }
+  }
+
+  Assert.deepEqual(amoServer.requestCounters, {
+    // The discovery API should be fetched only once.
+    discoapi: 1,
+    // Every card has either one extension icon, or one theme preview.
+    png: apiResultArray.length,
+  }, "request counters for discopane load with AMO API data");
+
+  await closeView(win);
+  amoServer.unregister();
+});
+
+// Test whether extensions and themes can be installed from the discopane.
+// Also checks that items in the list do not change position after installation,
+// and that they are shown at the bottom of the list when the discopane is
+// reopened.
+add_task(async function install_from_discopane() {
+  const apiText = await OS.File.read(API_RESPONSE_FILE, {encoding: "utf-8"});
+  const apiResultArray = JSON.parse(apiText).results;
+  let getAddonIdByAMOAddonType =
+    type => apiResultArray.find(r => r.addon.type === type).addon.guid;
+  const FIRST_EXTENSION_ID = getAddonIdByAMOAddonType("extension");
+  const FIRST_THEME_ID = getAddonIdByAMOAddonType("statictheme");
+
+  let amoServer = await createAMOServer(apiText);
+  // Map images to a valid image file, so that waitForAllImagesLoaded finishes.
+  amoServer.setResponseToFile("png", "discovery/small-1x1.png");
+
+  let win = await loadInitialView("discover");
+  await promiseDiscopaneUpdate(win);
+  let imageCount = await waitForAllImagesLoaded(win);
+
+  // Test extension install.
+  let installExtensionPromise = promiseAddonInstall(amoServer, {
+    manifest: {
+      name: "My Awesome Add-on",
+      description: "Test extension install button",
+      applications: {gecko: {id: FIRST_EXTENSION_ID}},
+      permissions: ["<all_urls>"],
+    },
+  });
+  await testCardInstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+  await installExtensionPromise;
+
+  // Test theme install.
+  let installThemePromise = promiseAddonInstall(amoServer, {
+    manifest: {
+      name: "My Fancy Theme",
+      description: "Test theme install button",
+      applications: {gecko: {id: FIRST_THEME_ID}},
+      theme: {
+        colors: {
+          tab_selected: "red",
+        },
+      },
+    },
+  });
+  let promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+  await testCardInstall(getCardByAddonId(win, FIRST_THEME_ID));
+  await installThemePromise;
+  await promiseThemeChange;
+
+  // After installing, the cards should have manage buttons instead of install
+  // buttons. The cards should still be at the top of the pane (and not be
+  // moved to the bottom).
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    [
+      "manage-addon",
+      "manage-addon",
+      ...new Array(apiResultArray.length - 2).fill("install-addon"),
+      "open-amo",
+    ],
+    "The Install buttons should be replaced with Manage buttons");
+
+  Assert.deepEqual(amoServer.requestCounters, {
+    discoapi: 1,
+    png: imageCount,
+    xpi: 2,
+  }, "Request counters after add-on installs");
+
+  // End of the testing installation from a card.
+  // Now we are going to force an updated rendering and check that the cards are
+  // in the expected order, and then test uninstallation of the above add-ons.
+
+  // Force the pane to render again.
+  await switchToNonDiscoView(win);
+  await switchToDiscoView(win);
+  await waitForAllImagesLoaded(win);
+
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    [
+      ...new Array(apiResultArray.length - 2).fill("install-addon"),
+      "manage-addon",
+      "manage-addon",
+      "open-amo",
+    ],
+    "Already-installed add-ons should be rendered at the end of the list");
+
+  // The images may or may not have been loaded from the cache; we don't care.
+  amoServer.requestCounters = {}; // Reset counters.
+
+  await testAddonUninstall(getCardByAddonId(win, FIRST_THEME_ID));
+  await testAddonUninstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+
+  Assert.deepEqual(amoServer.requestCounters, {
+  }, "Should not trigger new requests when an add-on is uninstalled");
+
+  await closeView(win);
+  amoServer.unregister();
+});
+
+// Tests that the page is able to switch views while the discopane is loading,
+// without inadvertently replacing the page when the request finishes.
+add_task(async function discopane_navigate_while_loading() {
+  let amoServer = await createAMOServer(`{"results": []}`);
+
+  amoServer.blockNextResponses();
+  let win = await loadInitialView("discover");
+
+  let updatePromise = promiseDiscopaneUpdate(win);
+  let didUpdateDiscopane = false;
+  updatePromise.then(() => { didUpdateDiscopane = true; });
+
+  // Switch views while the request is pending.
+  await switchToNonDiscoView(win);
+
+  is(didUpdateDiscopane, false,
+     "discopane should still not be updated because the request is blocked");
+  is(getDiscoveryElement(win), null,
+     "Discopane should be removed after switching to the extension list");
+
+  // Release pending requests, to verify that completing the request will not
+  // cause changes to the visible view. The updatePromise will still resolve
+  // though, because the event is dispatched to the removed `<discovery-pane>`.
+  amoServer.unblockResponses();
+
+  await updatePromise;
+  ok(win.document.querySelector("addon-list"),
+     "Should still be at the extension list view");
+  is(getDiscoveryElement(win), null,
+     "Discopane should not be in the document when it is not the active view");
+
+  Assert.deepEqual(amoServer.requestCounters, {
+    discoapi: 1,
+  }, "discovery API should be requested once");
+
+  await closeView(win);
+  amoServer.unregister();
+});
+
+// Tests that invalid responses are handled correctly and not cached.
+// Also verifies that the response is cached as long as the page is active,
+// but not when the page is fully reloaded.
+add_task(async function discopane_cache_api_responses() {
+  const INVALID_RESPONSE_BODY = `{"This is some": invalid} JSON`;
+  let amoServer = await createAMOServer(INVALID_RESPONSE_BODY);
+
+  let expectedErrMsg;
+  try {
+    JSON.parse(INVALID_RESPONSE_BODY);
+    ok(false, "JSON.parse should have thrown");
+  } catch (e) {
+    expectedErrMsg = e.message;
+  }
+
+  let invalidResponseHandledPromise = new Promise(resolve => {
+    Services.console.registerListener(function listener(msg) {
+      if (msg.message.includes(expectedErrMsg)) {
+        resolve();
+        Services.console.unregisterListener(listener);
+      }
+    });
+  });
+
+  let win = await loadInitialView("discover"); // Request #1
+  await promiseDiscopaneUpdate(win);
+
+  info("Waiting for expected error");
+  await invalidResponseHandledPromise;
+
+  Assert.deepEqual(
+    getVisibleActions(win.document).map(getActionName),
+    ["open-amo"],
+    "The AMO button should be visible even when the response was invalid");
+
+  // Change to a valid response, so that the next response will be cached.
+  amoServer.setResponseToText("discoapi", `{"results": []}`);
+
+  await switchToNonDiscoView(win);
+  await switchToDiscoView(win); // Request #2
+
+  Assert.deepEqual(amoServer.requestCounters, {
+    discoapi: 2,
+  }, "Should fetch new data because an invalid response should not be cached");
+
+  amoServer.requestCounters = {}; // Reset counters.
+
+  await switchToNonDiscoView(win);
+  await switchToDiscoView(win);
+  await closeView(win);
+
+  Assert.deepEqual(amoServer.requestCounters, {
+  }, "The previous response was valid and should have been reused");
+
+  // Now open a new about:addons page and verify that a new API request is sent.
+  let anotherWin = await loadInitialView("discover");
+  await promiseDiscopaneUpdate(anotherWin);
+  await closeView(anotherWin);
+
+  Assert.deepEqual(amoServer.requestCounters, {
+    discoapi: 1,
+  }, "discovery API should be requested again");
+
+  amoServer.unregister();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
@@ -0,0 +1,84 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const {ClientID} = ChromeUtils.import("resource://gre/modules/ClientID.jsm");
+
+const {
+  AddonTestUtils,
+} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const serverBaseUrl = `http://localhost:${server.identity.primaryPort}/`;
+
+// Before a discovery API request is triggered, this method should be called.
+// Resolves with the value of the "telemetry-client-id" query parameter.
+async function promiseOneDiscoveryApiRequest() {
+  return new Promise(resolve => {
+    let requestCount = 0;
+    // Overwrite previous request handler, if any.
+    server.registerPathHandler("/discoapi", (request, response) => {
+      is(++requestCount, 1, "Expecting one discovery API request");
+      response.write(`{"results": []}`);
+      let searchParams = new URLSearchParams(request.queryString);
+      let clientId = searchParams.get("telemetry-client-id");
+      resolve(clientId);
+    });
+  });
+}
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      // Enable clientid - see Discovery.jsm for the first two prefs.
+      ["browser.discovery.enabled", true],
+      ["datareporting.healthreport.uploadEnabled", true],
+      ["extensions.getAddons.discovery.api_url", `${serverBaseUrl}discoapi`],
+      ["extensions.htmlaboutaddons.enabled", true],
+    ],
+  });
+});
+
+// Test that the clientid is passed to the API when enabled via prefs.
+add_task(async function clientid_enabled() {
+  let EXPECTED_CLIENT_ID = await ClientID.getClientIdHash();
+  ok(EXPECTED_CLIENT_ID, "ClientID should be available");
+
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let win = await loadInitialView("discover");
+  is(await requestPromise, EXPECTED_CLIENT_ID,
+     "Moz-Client-Id should be set when telemetry & discovery are enabled");
+  await closeView(win);
+});
+
+// Test that the clientid is not sent when disabled via prefs.
+add_task(async function clientid_disabled() {
+  // Temporarily override the prefs that we had set in setup.
+  await SpecialPowers.pushPrefEnv({
+    set: [["browser.discovery.enabled", false]],
+  });
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let win = await loadInitialView("discover");
+  is(await requestPromise, null,
+     "Moz-Client-Id should not be sent when discovery is disabled");
+  await closeView(win);
+  await SpecialPowers.popPrefEnv();
+});
+
+// Test that the clientid is not sent from private windows.
+add_task(async function clientid_from_private_window() {
+  let privateWindow =
+    await BrowserTestUtils.openNewBrowserWindow({private: true});
+
+  let requestPromise = promiseOneDiscoveryApiRequest();
+  let managerWindow =
+    await open_manager("addons://discover/", null, null, null, privateWindow);
+  ok(PrivateBrowsingUtils.isContentWindowPrivate(managerWindow),
+     "Addon-manager is in a private window");
+
+  is(await requestPromise, null,
+     "Moz-Client-Id should not be sent in private windows");
+
+  await close_manager(managerWindow);
+  await BrowserTestUtils.closeWindow(privateWindow);
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/api_response.json
@@ -0,0 +1,797 @@
+{
+   "results" : [
+      {
+         "heading_text" : "Tigers Matter ** DON'T DELTE ME**",
+         "description_text" : "",
+         "addon" : {
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{e0d2e13b-2e07-49d5-9574-eb0227482320}",
+            "authors" : [
+               {
+                  "id" : 7804538,
+                  "name" : "Sondergaard",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/7/7804/7804538.png?modified=1392125542",
+                  "username" : "EatingStick",
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/7804538/"
+               }
+            ],
+            "previews" : [
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183758.png?modified=1555593109",
+                  "image_size" : [
+                     680,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183758.png?modified=1555593109",
+                  "id" : 183758,
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "caption" : null
+               },
+               {
+                  "id" : 183768,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183768.png?modified=1555593111",
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183768.png?modified=1555593111",
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ]
+               },
+               {
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183777.png?modified=1555593112",
+                  "id" : 183777,
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183777.png?modified=1555593112",
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ]
+               }
+            ],
+            "name" : "Tigers Matter ** DON'T DELTE ME**",
+            "id" : 496012,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/tigers-matter/",
+            "type" : "statictheme",
+            "ratings" : {
+               "average" : 4.7636,
+               "text_count" : 55,
+               "count" : 55,
+               "bayesian_average" : 4.75672
+            },
+            "slug" : "tigers-matter",
+            "average_daily_users" : 1,
+            "current_version" : {
+               "compatibility" : {
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "53.0"
+                  },
+                  "android" : {
+                     "max" : "*",
+                     "min" : "65.0"
+                  }
+               },
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1655900,
+               "files" : [
+                  {
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/376561/tigers_matter_dont_delte_me-2.0-an+fx.xpi?src=",
+                     "created" : "2019-04-18T13:11:48Z",
+                     "size" : 86337,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "is_mozilla_signed_extension" : false,
+                     "permissions" : [],
+                     "hash" : "sha256:ebeb6e4f40ceafbc4affc5bc9a182ed44ae410d71b8c5f9c547f8d45863e0c37",
+                     "platform" : "all",
+                     "id" : 376561
+                  }
+               ]
+            }
+         },
+         "description" : "",
+         "is_recommendation" : false,
+         "heading" : "Tigers Matter ** DON&#39;T DELTE ME** <span>by <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/tigers-matter/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Sondergaard</a></span>"
+      },
+      {
+         "heading" : "Customize new tab pages <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/awesome-screenshot-plus-/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Awesome Screenshot Plus - Capture, Annotate &amp; More by Diigo Inc.</a> </span>",
+         "is_recommendation" : false,
+         "addon" : {
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/awesome-screenshot-plus-/",
+            "type" : "extension",
+            "ratings" : {
+               "count" : 848,
+               "bayesian_average" : 3.87925,
+               "average" : 3.8797,
+               "text_count" : 842
+            },
+            "slug" : "awesome-screenshot-plus-",
+            "average_daily_users" : 1,
+            "current_version" : {
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1532816,
+               "files" : [
+                  {
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/253549/awesome_screenshot_plus-7-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "size" : 4196,
+                     "created" : "2017-09-01T13:31:17Z",
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "is_mozilla_signed_extension" : false,
+                     "permissions" : [],
+                     "hash" : "sha256:4cd8e9b7e89f61e6855d01c73c5c05920c1e0e91f3ae0f45adbb4bd9919f59d7",
+                     "platform" : "all",
+                     "id" : 253549
+                  }
+               ],
+               "compatibility" : {
+                  "android" : {
+                     "min" : "48.0",
+                     "max" : "*"
+                  },
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "48.0"
+                  }
+               }
+            },
+            "authors" : [
+               {
+                  "username" : "diigo-inc",
+                  "name" : "Diigo Inc.",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/0/6/6724.png?modified=1554393597",
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/6724/",
+                  "id" : 6724
+               }
+            ],
+            "icon_url" : "https://addons-dev-cdn.allizom.org/user-media/addon_icons/287/287841-64.png?modified=mcrushed",
+            "guid" : "jid0-GXjLLfbCoAx0LcltEdFrEkQdQPI@jetpack",
+            "previews" : [
+               {
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54638.png?modified=1543388383",
+                  "id" : 54638,
+                  "image_size" : [
+                     625,
+                     525
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54638.png?modified=1543388383",
+                  "caption" : "Capture and annotate a page",
+                  "thumbnail_size" : [
+                     571,
+                     480
+                  ]
+               },
+               {
+                  "caption" : "Crop selected area",
+                  "thumbnail_size" : [
+                     571,
+                     480
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54639.png?modified=1543388385",
+                  "image_size" : [
+                     625,
+                     525
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54639.png?modified=1543388385",
+                  "id" : 54639
+               },
+               {
+                  "caption" : "Save as a local file or upload to get a sharable link",
+                  "thumbnail_size" : [
+                     640,
+                     234
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54641.png?modified=1543388385",
+                  "image_size" : [
+                     700,
+                     256
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54641.png?modified=1543388385",
+                  "id" : 54641
+               }
+            ],
+            "name" : "Awesome Screenshot Plus - Capture, Annotate & More",
+            "id" : 287841
+         },
+         "description" : "<blockquote>Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines and text, blur sensitive info, one-click upload to share. And more! Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines</blockquote>",
+         "heading_text" : "Customize new tab pages  with Awesome Screenshot Plus - Capture, Annotate & More ",
+         "description_text" : "Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines and text, blur sensitive info, one-click upload to share. And more! Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines"
+      },
+      {
+         "heading_text" : "Perform better as an admin  with Admin Assistant ",
+         "description_text" : "Help Admins in their daily work",
+         "addon" : {
+            "slug" : "amo-admin-assistant-test",
+            "average_daily_users" : 0,
+            "current_version" : {
+               "files" : [
+                  {
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/255370/amo_admin_assistant-4.2-fx.xpi?src=",
+                     "size" : 16016,
+                     "created" : "2018-08-21T16:49:21Z",
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "is_mozilla_signed_extension" : false,
+                     "permissions" : [
+                        "tabs",
+                        "https://addons-internal.prod.mozaws.net/*",
+                        "https://dxr.mozilla.org/addons/*"
+                     ],
+                     "hash" : "sha256:cd28c841a6daf8a2e3c94b0773b373fec0213404b70074309326cfc75e6725d3",
+                     "platform" : "all",
+                     "id" : 255370
+                  }
+               ],
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1534709,
+               "compatibility" : {
+                  "firefox" : {
+                     "min" : "45.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/amo-admin-assistant-test/",
+            "ratings" : {
+               "bayesian_average" : 0,
+               "count" : 0,
+               "text_count" : 0,
+               "average" : 0
+            },
+            "type" : "extension",
+            "id" : 496168,
+            "guid" : "aaa-test-icon@xulforge.com",
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "authors" : [
+               {
+                  "id" : 4230,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/4230/",
+                  "username" : "jorge-villalobos",
+                  "name" : "Jorge Villalobos",
+                  "picture_url" : null
+               }
+            ],
+            "previews" : [],
+            "name" : "AMO Admin Assistant Test"
+         },
+         "description" : "<blockquote>Help Admins in their daily work</blockquote>",
+         "is_recommendation" : false,
+         "heading" : "Perform better as an admin <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/amo-admin-assistant-test/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Admin Assistant by Jorge Villalobos</a> </span>"
+      },
+      {
+         "addon" : {
+            "authors" : [
+               {
+                  "name" : "LexaDev",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10640/10640485.png?modified=1554812253",
+                  "username" : "LexaSV",
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10640485/",
+                  "id" : 10640485
+               }
+            ],
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{f9b9cdd3-91ae-476e-9c21-d5ecfce9889f}",
+            "previews" : [
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183694.png?modified=1555593096",
+                  "image_size" : [
+                     680,
+                     92
+                  ],
+                  "id" : 183694,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183694.png?modified=1555593096",
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "caption" : null
+               },
+               {
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183699.png?modified=1555593097",
+                  "id" : 183699,
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183699.png?modified=1555593097",
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ]
+               },
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183703.png?modified=1555593098",
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183703.png?modified=1555593098",
+                  "id" : 183703,
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ]
+               }
+            ],
+            "name" : "iarba",
+            "id" : 495969,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/iarba/",
+            "ratings" : {
+               "bayesian_average" : 4.86128,
+               "count" : 10,
+               "text_count" : 10,
+               "average" : 4.9
+            },
+            "type" : "statictheme",
+            "slug" : "iarba",
+            "current_version" : {
+               "files" : [
+                  {
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/376535/iarba-2.0-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "size" : 895804,
+                     "created" : "2019-04-18T13:11:35Z",
+                     "is_mozilla_signed_extension" : false,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "id" : 376535,
+                     "permissions" : [],
+                     "platform" : "all",
+                     "hash" : "sha256:d7ecbdfa8ba56c5d08129c867e68b02ffc8c6000a7f7f85d85d2a558045babfa"
+                  }
+               ],
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1655874,
+               "compatibility" : {
+                  "android" : {
+                     "min" : "65.0",
+                     "max" : "*"
+                  },
+                  "firefox" : {
+                     "min" : "53.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "average_daily_users" : 1
+         },
+         "description" : "",
+         "heading_text" : "Custom heading  for a theme",
+         "description_text" : "",
+         "heading" : "Custom heading  for a theme",
+         "is_recommendation" : false
+      },
+      {
+         "description_text" : "Get international weather forecasts",
+         "heading_text" : "Have a nice day   with Forcastfox ",
+         "description" : "<blockquote>Get international weather forecasts</blockquote>",
+         "addon" : {
+            "id" : 502855,
+            "authors" : [
+               {
+                  "id" : 10641527,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10641527/",
+                  "name" : "Amoga-dev",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641527.png?modified=1555333028",
+                  "username" : "Amoga_dev_REST"
+               }
+            ],
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "forecastfox@s3_fix_version",
+            "previews" : [],
+            "name" : "Forecastfox (fix version)",
+            "slug" : "forecastfox-fix-version",
+            "current_version" : {
+               "id" : 1541667,
+               "is_strict_compatibility_enabled" : false,
+               "files" : [
+                  {
+                     "permissions" : [
+                        "activeTab",
+                        "tabs",
+                        "background",
+                        "storage",
+                        "webRequest",
+                        "webRequestBlocking",
+                        "<all_urls>",
+                        "http://www.s3blog.org/geolocation.html*",
+                        "https://embed.windy.com/embed2.html*"
+                     ],
+                     "platform" : "all",
+                     "hash" : "sha256:89e4de4ce86005c57b0197f671e86936aaf843ebd5751caae02cad4991ccbf0a",
+                     "id" : 262328,
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "is_mozilla_signed_extension" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262328/forecastfox_fix_version-4.20-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "created" : "2019-01-16T07:54:26Z",
+                     "size" : 1331686
+                  }
+               ],
+               "compatibility" : {
+                  "android" : {
+                     "min" : "51.0",
+                     "max" : "*"
+                  },
+                  "firefox" : {
+                     "min" : "51.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "average_daily_users" : 0,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/forecastfox-fix-version/",
+            "type" : "extension",
+            "ratings" : {
+               "count" : 0,
+               "bayesian_average" : 0,
+               "average" : 0,
+               "text_count" : 0
+            }
+         },
+         "is_recommendation" : false,
+         "heading" : "Have a nice day  <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/forecastfox-fix-version/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Forcastfox by Amoga-dev</a> </span>"
+      },
+      {
+         "description_text" : "A test extension from webext-generator.",
+         "heading_text" : "...because cats are awesome  with Tabby Cat ",
+         "description" : "<blockquote>A test extension from webext-generator.</blockquote>",
+         "addon" : {
+            "name" : "tabby cat",
+            "previews" : [],
+            "guid" : "{1ed4b641-bac7-4492-b304-6ddc01f538ae}",
+            "icon_url" : "https://addons-dev-cdn.allizom.org/user-media/addon_icons/502/502774-64.png?modified=f289a992",
+            "authors" : [
+               {
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10641572/",
+                  "username" : "AdminUserTestDev1",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641572.png?modified=1555675110",
+                  "name" : "úþÿ Ψ Φ ֎",
+                  "id" : 10641572
+               }
+            ],
+            "id" : 502774,
+            "ratings" : {
+               "bayesian_average" : 0,
+               "count" : 0,
+               "text_count" : 0,
+               "average" : 0
+            },
+            "type" : "extension",
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/tabby-catextension/",
+            "current_version" : {
+               "compatibility" : {
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "48.0"
+                  },
+                  "android" : {
+                     "max" : "*",
+                     "min" : "48.0"
+                  }
+               },
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1541570,
+               "files" : [
+                  {
+                     "created" : "2018-12-04T09:54:24Z",
+                     "size" : 4374,
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262231/tabby_cat-1.0-an+fx.xpi?src=",
+                     "is_mozilla_signed_extension" : false,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "id" : 262231,
+                     "hash" : "sha256:f12c8a8b71e7d4c48e38db6b6a374ca8dcde42d6cb13fb1f2a8299bb51116615",
+                     "platform" : "all",
+                     "permissions" : []
+                  }
+               ]
+            },
+            "average_daily_users" : 1,
+            "slug" : "tabby-catextension"
+         },
+         "is_recommendation" : false,
+         "heading" : "...because cats are awesome <span> with <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/tabby-catextension/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Tabby Cat by úþÿ Ψ Φ ֎</a> </span>"
+      },
+      {
+         "addon" : {
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/the-moon-cat/",
+            "ratings" : {
+               "average" : 4.8182,
+               "text_count" : 11,
+               "count" : 11,
+               "bayesian_average" : 4.78325
+            },
+            "type" : "statictheme",
+            "slug" : "the-moon-cat",
+            "average_daily_users" : 2,
+            "current_version" : {
+               "files" : [
+                  {
+                     "is_mozilla_signed_extension" : false,
+                     "status" : "public",
+                     "is_webextension" : true,
+                     "id" : 262333,
+                     "permissions" : [],
+                     "hash" : "sha256:d159190add69c739b0fe07b19ad3ff48045c5ded502a8df0f892b8feb645c5ae",
+                     "platform" : "all",
+                     "is_restart_required" : false,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262333/the_moon_cat-1.0-an+fx.xpi?src=",
+                     "size" : 102889,
+                     "created" : "2019-01-16T08:31:21Z"
+                  }
+               ],
+               "is_strict_compatibility_enabled" : false,
+               "id" : 1541672,
+               "compatibility" : {
+                  "firefox" : {
+                     "max" : "*",
+                     "min" : "53.0"
+                  },
+                  "android" : {
+                     "min" : "65.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "authors" : [
+               {
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/5822165/",
+                  "username" : "Rallara",
+                  "name" : "Rallara",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/5/5822/5822165.png?modified=1391855104",
+                  "id" : 5822165
+               }
+            ],
+            "guid" : "{db4f6548-da04-43fb-a03e-249bf70ef5a1}",
+            "previews" : [
+               {
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "caption" : null,
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14307.png?modified=1547627485",
+                  "image_size" : [
+                     680,
+                     92
+                  ],
+                  "id" : 14307,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14307.png?modified=1547627485"
+               },
+               {
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ],
+                  "caption" : null,
+                  "id" : 14308,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14308.png?modified=1547627486",
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14308.png?modified=1547627486"
+               },
+               {
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ],
+                  "caption" : null,
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14309.png?modified=1547627487",
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14309.png?modified=1547627487",
+                  "id" : 14309
+               }
+            ],
+            "name" : "the Moon Cat",
+            "id" : 502859
+         },
+         "description" : "",
+         "heading_text" : "cool moon cat",
+         "description_text" : "",
+         "heading" : "cool moon cat <span>by <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/the-moon-cat/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">Rallara</a></span>",
+         "is_recommendation" : false
+      },
+      {
+         "heading" : "Testptcustomheading",
+         "is_recommendation" : false,
+         "description" : "<blockquote>AAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGG</blockquote>",
+         "addon" : {
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{2e5ff8c8-32fe-46d0-9fc8-6b8986621f3c}",
+            "authors" : [
+               {
+                  "id" : 10641570,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/10641570/",
+                  "name" : "BobsDisplayName",
+                  "picture_url" : "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641570.png?modified=1536063975",
+                  "username" : "BobsUserName"
+               }
+            ],
+            "previews" : [],
+            "name" : "SI",
+            "id" : 495710,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/search_by_image/",
+            "ratings" : {
+               "average" : 3.8333,
+               "text_count" : 5,
+               "count" : 6,
+               "bayesian_average" : 3.77144
+            },
+            "type" : "extension",
+            "slug" : "search_by_image",
+            "current_version" : {
+               "files" : [
+                  {
+                     "id" : 262271,
+                     "permissions" : [
+                        "contextMenus",
+                        "storage",
+                        "tabs",
+                        "activeTab",
+                        "notifications",
+                        "webRequest",
+                        "webRequestBlocking",
+                        "<all_urls>",
+                        "http://*/*",
+                        "https://*/*",
+                        "ftp://*/*",
+                        "file:///*"
+                     ],
+                     "platform" : "all",
+                     "hash" : "sha256:f358b24d0b950f5acf035342dec64c99ee2e22a5cf369e7c787ebb00013127a8",
+                     "is_mozilla_signed_extension" : false,
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262271/search_by_image_reverse_image_search-1.12.6-fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "size" : 372225,
+                     "created" : "2018-12-14T13:48:23Z"
+                  }
+               ],
+               "id" : 1541610,
+               "is_strict_compatibility_enabled" : false,
+               "compatibility" : {
+                  "firefox" : {
+                     "min" : "57.0",
+                     "max" : "*"
+                  }
+               }
+            },
+            "average_daily_users" : 374
+         },
+         "description_text" : "AAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGG",
+         "heading_text" : "Testptcustomheading"
+      },
+      {
+         "description" : "",
+         "addon" : {
+            "icon_url" : "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+            "guid" : "{f5e7a6ee-ebe0-4add-8f75-b5e4015feca1}",
+            "authors" : [
+               {
+                  "id" : 8733220,
+                  "url" : "https://addons-dev.allizom.org/en-US/firefox/user/8733220/",
+                  "username" : "michellet-2",
+                  "name" : "michellet",
+                  "picture_url" : null
+               }
+            ],
+            "previews" : [
+               {
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     473,
+                     64
+                  ],
+                  "id" : 14304,
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14304.png?modified=1547627480",
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14304.png?modified=1547627480",
+                  "image_size" : [
+                     680,
+                     92
+                  ]
+               },
+               {
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14305.png?modified=1547627481",
+                  "image_size" : [
+                     760,
+                     92
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14305.png?modified=1547627481",
+                  "id" : 14305,
+                  "thumbnail_size" : [
+                     529,
+                     64
+                  ],
+                  "caption" : null
+               },
+               {
+                  "caption" : null,
+                  "thumbnail_size" : [
+                     501,
+                     64
+                  ],
+                  "thumbnail_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14306.png?modified=1547627482",
+                  "id" : 14306,
+                  "image_size" : [
+                     720,
+                     92
+                  ],
+                  "image_url" : "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14306.png?modified=1547627482"
+               }
+            ],
+            "name" : "Purple Sparkles",
+            "id" : 502858,
+            "url" : "https://addons-dev.allizom.org/en-US/firefox/addon/purple-sparkles/",
+            "type" : "statictheme",
+            "ratings" : {
+               "count" : 4,
+               "bayesian_average" : 4.1476,
+               "average" : 4.25,
+               "text_count" : 3
+            },
+            "slug" : "purple-sparkles",
+            "average_daily_users" : 445,
+            "current_version" : {
+               "compatibility" : {
+                  "firefox" : {
+                     "min" : "53.0",
+                     "max" : "*"
+                  },
+                  "android" : {
+                     "max" : "*",
+                     "min" : "65.0"
+                  }
+               },
+               "id" : 1541671,
+               "is_strict_compatibility_enabled" : false,
+               "files" : [
+                  {
+                     "created" : "2019-01-16T08:31:18Z",
+                     "size" : 237348,
+                     "url" : "https://addons-dev.allizom.org/firefox/downloads/file/262332/purple_sparkles-1.0-an+fx.xpi?src=",
+                     "is_restart_required" : false,
+                     "is_mozilla_signed_extension" : false,
+                     "is_webextension" : true,
+                     "status" : "public",
+                     "id" : 262332,
+                     "hash" : "sha256:5a3d311b7c1be2ee32446dbcf1422c5d7c786c5a237aa3d4e2939074ab50ad30",
+                     "platform" : "all",
+                     "permissions" : []
+                  }
+               ]
+            }
+         },
+         "description_text" : "",
+         "heading_text" : "Purple Sparkles",
+         "heading" : "Purple Sparkles <span>by <a href=\"https://addons-dev.allizom.org/en-US/firefox/addon/purple-sparkles/?utm_source=discovery.addons-dev.allizom.org&utm_medium=firefox-browser&utm_content=discopane-entry-link&src=api\">michellet</a></span>",
+         "is_recommendation" : false
+      }
+   ],
+   "count" : 9
+}
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..862d1dd10cc9f261e0b4df57902258a85b2e6f4b
GIT binary patch
literal 82
zc%17D@N?(olHy`uVBq!ia0vp^j3CU&3?x-=hn)ga%mF?jt_=SfOyUl#0CKrJT^vI=
bWRnwsY$gWA^CwK70$B{6u6{1-oD!M<RL&6Z