Bug 1550911 - Show recommendations on extension and theme lists r=robwu,flod,jaws

Differential Revision: https://phabricator.services.mozilla.com/D30745

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Mark Striemer 2019-06-04 01:50:33 +00:00
Родитель 10d8e97c57
Коммит 89f6a1aa42
12 изменённых файлов: 726 добавлений и 89 удалений

Просмотреть файл

@ -49,6 +49,11 @@ pref("extensions.getAddons.discovery.api_url", "https://services.addons.mozilla.
// Enable the HTML-based discovery panel at about:addons.
pref("extensions.htmlaboutaddons.discover.enabled", false);
// The URL for the privacy policy related to recommended extensions.
pref("extensions.recommendations.privacyPolicyUrl", "https://www.mozilla.org/privacy/firefox/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_content=privacy-policy-link#addons");
// The URL for Firefox Color, recommended on the theme page in about:addons.
pref("extensions.recommendations.themeRecommendationUrl", "https://color.firefox.com/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_content=theme-footer-link");
pref("extensions.update.autoUpdateDefault", true);
// Check AUS for system add-on updates.

Просмотреть файл

@ -5174,6 +5174,13 @@ pref("extensions.webextensions.performanceCountersMaxAge", 5000);
pref("extensions.htmlaboutaddons.enabled", false);
// Whether to allow the inline options browser in HTML about:addons page.
pref("extensions.htmlaboutaddons.inline-options.enabled", true);
// Show recommendations on the extension and theme list views.
pref("extensions.htmlaboutaddons.recommendations.enabled", true);
// The URL for the privacy policy related to recommended add-ons.
pref("extensions.recommendations.privacyPolicyUrl", "");
// The URL for a recommended theme, shown on the theme page in about:addons.
pref("extensions.recommendations.themeRecommendationUrl", "");
// Report Site Issue button
// Note that on enabling the button in other release channels, make sure to

Просмотреть файл

@ -37,6 +37,8 @@ user_pref("extensions.enabledScopes", 5);
user_pref("extensions.legacy.enabled", true);
// Turn off extension updates so they don't bother tests
user_pref("extensions.update.enabled", false);
// Prevent network access for recommendations by default. The payload is {"results":[]}.
user_pref("extensions.getAddons.discovery.api_url", "data:;base64,eyJyZXN1bHRzIjpbXX0%3D");
// Disable useragent updates.
user_pref("general.useragent.updates.enabled", false);
// Ensure WR doesn't get enabled in tests unless we do it explicitly with the MOZ_WEBRENDER envvar.

Просмотреть файл

@ -461,3 +461,10 @@ release-notes-loading = Loading…
release-notes-error = Sorry, but there was an error loading the release notes.
addon-permissions-empty = This extension doesnt require any permissions
recommended-extensions-heading = Recommended Extensions
recommended-themes-heading = Recommended Themes
# A recommendation for the Firefox Color theme shown at the bottom of the theme
# list view. The "Firefox Color" name itself should not be translated.
recommended-theme-1 = Feeling creative? <a data-l10n-name="link">Build your own theme with Firefox Color.</a>

Просмотреть файл

@ -3,6 +3,14 @@
--addon-icon-size: 32px;
}
*|*[hidden] {
display: none !important;
}
.header-name {
-moz-user-select: initial;
}
#main {
margin-inline-start: 28px;
margin-bottom: 28px;
@ -56,7 +64,7 @@ addon-card:not([expanded]) > .addon.card:hover {
display: flex;
}
addon-list .addon.card {
addon-list addon-card > .addon.card {
-moz-user-select: none;
}
@ -143,6 +151,11 @@ addon-card:not([expanded]) .addon-description {
margin-inline-end: -8px;
}
/* Recommended add-ons on list views */
.recommended-heading {
margin-bottom: 16px;
}
/* Discopane extensions to the add-on card */
recommended-addon-card .addon-name {
@ -203,15 +216,15 @@ recommended-addon-card .addon-description:not(:empty) {
flex-shrink: 0;
}
.discopane-footer {
.view-footer {
text-align: center;
}
.discopane-footer > * {
.view-footer-item {
margin-top: 30px;
}
.discopane-privacy-policy-link {
.privacy-policy-link {
font-size: small;
}
@ -338,7 +351,7 @@ panel-item-separator {
margin: 6px 0;
}
panel-item-separator[hidden] {
.hide-amo-link .amo-link-container {
display: none;
}

Просмотреть файл

@ -196,6 +196,24 @@
<button><slot></slot></button>
</template>
<template name="taar-notice">
<message-bar class="discopane-notice">
<div class="discopane-notice-content">
<span data-l10n-id="discopane-notice-recommendations"></span>
<button data-l10n-id="discopane-notice-learn-more" action="notice-learn-more"></button>
</div>
</message-bar>
</template>
<template name="recommended-footer">
<div class="amo-link-container view-footer-item">
<button class="primary" action="open-amo" data-l10n-id="find-more-addons"></button>
</div>
<div class="view-footer-item">
<a class="privacy-policy-link" data-l10n-id="privacy-policy" target="_blank"></a>
</div>
</template>
<template name="discopane">
<header>
<p>
@ -208,24 +226,26 @@
</span>
</p>
</header>
<message-bar class="discopane-notice">
<div class="discopane-notice-content">
<span data-l10n-id="discopane-notice-recommendations"></span>
<button data-l10n-id="discopane-notice-learn-more" action="notice-learn-more"></button>
</div>
</message-bar>
<taar-notice></taar-notice>
<recommended-addon-list></recommended-addon-list>
<footer class="discopane-footer">
<footer is="recommended-footer" class="view-footer"></footer>
</template>
<template name="recommended-extensions-section">
<h2 data-l10n-id="recommended-extensions-heading" class="header-name recommended-heading"></h2>
<taar-notice></taar-notice>
<recommended-addon-list type="extension" hide-installed></recommended-addon-list>
<footer is="recommended-footer" class="view-footer hide-amo-link"></footer>
</template>
<template name="recommended-themes-section">
<h2 data-l10n-id="recommended-themes-heading" class="header-name recommended-heading"></h2>
<recommended-addon-list type="theme" hide-installed></recommended-addon-list>
<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>
<p data-l10n-id="recommended-theme-1" class="theme-recommendation">
<a data-l10n-name="link" target="_blank"></a>
</p>
</div>
</footer>
</template>

Просмотреть файл

@ -46,6 +46,9 @@ const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds)
XPCOMUtils.defineLazyPreferenceGetter(this, "ABUSE_REPORT_ENABLED",
"extensions.abuseReport.enabled", false);
XPCOMUtils.defineLazyPreferenceGetter(
this, "LIST_RECOMMENDATIONS_ENABLED",
"extensions.htmlaboutaddons.recommendations.enabled", false);
const PLUGIN_ICON_URL = "chrome://global/skin/plugins/pluginGeneric.svg";
const PERMISSION_MASKS = {
@ -59,6 +62,9 @@ const PERMISSION_MASKS = {
};
const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
const PREF_THEME_RECOMMENDATION_URL =
"extensions.recommendations.themeRecommendationUrl";
const PREF_PRIVACY_POLICY_URL = "extensions.recommendations.privacyPolicyUrl";
const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled";
const PREF_TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled";
const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
@ -281,6 +287,11 @@ class DiscoAddonWrapper {
* A helper to retrieve the list of recommended add-ons via AMO's discovery API.
*/
var DiscoveryAPI = {
// Map<boolean, Promise> Promises from fetching the API results with or
// without a client ID. The `false` (no client ID) case could actually
// have been fetched with a client ID. See getResults() for more info.
_resultPromises: new Map(),
/**
* Fetch the list of recommended add-ons. The results are cached.
*
@ -289,20 +300,41 @@ var DiscoveryAPI = {
* call will result in a new request. A succesful response is cached for the
* lifetime of the document.
*
* @param {boolean} preferClientId
* A boolean indicating a preference for using a client ID.
* This will not overwrite the user preference but will
* avoid sending a client ID if no request has been made yet.
* @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;
});
async getResults(preferClientId = true) {
// Allow a caller to set preferClientId to false, but not true if discovery
// is disabled.
preferClientId = preferClientId && this.clientIdDiscoveryEnabled;
// Reuse a request for this preference first.
let resultPromise = this._resultPromises.get(preferClientId) ||
// If the client ID isn't preferred, we can still reuse a request with the
// client ID.
!preferClientId && this._resultPromises.get(true);
if (resultPromise) {
return resultPromise;
}
return this._resultPromise;
// Nothing is prepared for this preference, make a new request.
resultPromise = this._fetchRecommendedAddons(preferClientId)
.catch(e => {
// Delete the pending promise, so _fetchRecommendedAddons can be
// called again at the next property access.
this._resultPromises.delete(preferClientId);
Cu.reportError(e);
throw e;
});
// Store the new result for the preference.
this._resultPromises.set(preferClientId, resultPromise);
return resultPromise;
},
get clientIdDiscoveryEnabled() {
@ -312,11 +344,11 @@ var DiscoveryAPI = {
!PrivateBrowsingUtils.isContentWindowPrivate(window);
},
async _fetchRecommendedAddons() {
async _fetchRecommendedAddons(useClientId) {
let discoveryApiUrl =
new URL(Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL));
if (DiscoveryAPI.clientIdDiscoveryEnabled) {
if (useClientId) {
let clientId = await ClientID.getClientIdHash();
discoveryApiUrl.searchParams.set("telemetry-client-id", clientId);
}
@ -582,12 +614,13 @@ class AddonOptions extends HTMLElement {
case "report":
el.hidden = !ABUSE_REPORT_ENABLED;
break;
case "toggle-disabled":
case "toggle-disabled": {
let toggleDisabledAction = addon.userDisabled ? "enable" : "disable";
document.l10n.setAttributes(
el, `${toggleDisabledAction}-addon-button`);
el.hidden = !hasPermission(addon, toggleDisabledAction);
break;
}
case "install-update":
el.hidden = !updateInstall;
break;
@ -1741,7 +1774,7 @@ class RecommendedAddonCard extends HTMLElement {
case "install-addon":
AMTelemetry.recordActionEvent({
object: "aboutAddons",
view: this.getTelemetryViewName(),
view: getTelemetryViewName(this),
action: "installFromRecommendation",
addon: this.discoAddon,
});
@ -1750,7 +1783,7 @@ class RecommendedAddonCard extends HTMLElement {
case "manage-addon":
AMTelemetry.recordActionEvent({
object: "aboutAddons",
view: this.getTelemetryViewName(),
view: getTelemetryViewName(this),
action: "manage",
addon: this.discoAddon,
});
@ -1764,20 +1797,13 @@ class RecommendedAddonCard extends HTMLElement {
// is the author name, but the link URL the add-on's listing URL.
value: "discohome",
extra: {
view: this.getTelemetryViewName(),
view: getTelemetryViewName(this),
},
});
}
}
}
/**
* The name of the view for use in addonsManager telemetry events.
*/
getTelemetryViewName() {
return "discover";
}
async installDiscoAddon() {
let addon = this.discoAddon;
let url = addon.sourceURI.spec;
@ -2164,17 +2190,50 @@ class RecommendedAddonList extends HTMLElement {
AddonManager.removeAddonListener(this);
}
get type() {
return this.getAttribute("type");
}
/**
* Set the add-on type for this list. This will be used to filter the add-ons
* that are displayed.
*
* Must be set prior to the first render.
*
* @param {string} val The type to filter on.
*/
set type(val) {
this.setAttribute("type", val);
}
get hideInstalled() {
return this.hasAttribute("hide-installed");
}
/**
* Set whether installed add-ons should be hidden from the list. If false,
* installed add-ons will be shown with a "Manage" button, otherwise they
* will be hidden.
*
* Must be set prior to the first render.
*
* @param {boolean} val Whether to show installed add-ons.
*/
set hideInstalled(val) {
this.toggleAttribute("hide-installed", val);
}
onInstalled(addon) {
let card = this.getCardById(addon.id);
if (card) {
card.setAddon(addon);
this.setAddonForCard(card, addon);
}
}
onUninstalled(addon) {
let card = this.getCardById(addon.id);
if (card) {
card.setAddon(null);
this.setAddonForCard(card, null);
}
}
@ -2187,13 +2246,33 @@ class RecommendedAddonList extends HTMLElement {
return null;
}
setAddonForCard(card, addon) {
card.setAddon(addon);
let wasHidden = card.hidden;
card.hidden = this.hideInstalled && addon;
if (wasHidden != card.hidden) {
let eventName = card.hidden ? "card-hidden" : "card-shown";
this.dispatchEvent(new CustomEvent(eventName, {detail: {card}}));
}
}
/**
* Whether the client ID should be preferred. This is disabled for themes
* since they don't use the telemetry data and don't show the TAAR notice.
*/
get preferClientId() {
return !this.type || this.type == "extension";
}
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);
this.setAddonForCard(card, addon);
if (addon) {
// Already installed, move card to end.
this.append(card);
@ -2212,13 +2291,16 @@ class RecommendedAddonList extends HTMLElement {
async _loadCards() {
let recommendedAddons;
try {
recommendedAddons = await DiscoveryAPI.getResults();
recommendedAddons = await DiscoveryAPI.getResults(this.preferClientId);
} catch (e) {
return;
}
let frag = document.createDocumentFragment();
for (let addon of recommendedAddons) {
if (this.type && addon.type != this.type) {
continue;
}
let card = document.createElement("recommended-addon-card");
card.setDiscoAddon(addon);
frag.append(card);
@ -2229,41 +2311,46 @@ class RecommendedAddonList extends HTMLElement {
}
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";
class TaarMessageBar extends HTMLElement {
connectedCallback() {
this.hidden = !this.hidden && !DiscoveryAPI.clientIdDiscoveryEnabled;
if (this.childElementCount == 0 && !this.hidden) {
this.appendChild(importTemplate("taar-notice"));
this.addEventListener("click", this);
}
}
this.querySelector(".discopane-notice").hidden =
!DiscoveryAPI.clientIdDiscoveryEnabled;
this.addEventListener("click", this);
handleEvent(e) {
if (e.type == "click" &&
e.target.getAttribute("action") == "notice-learn-more") {
// The element is a button but opens a URL, so record as link.
AMTelemetry.recordLinkEvent({
object: "aboutAddons",
value: "disconotice",
extra: {
view: getTelemetryViewName(this),
},
});
windowRoot.ownerGlobal.openTrustedLinkIn(
SUPPORT_URL + "personalized-addons", "tab");
}
}
}
customElements.define("taar-notice", TaarMessageBar);
// 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; });
class RecommendedFooter extends HTMLElement {
connectedCallback() {
if (this.childElementCount == 0) {
this.appendChild(importTemplate("recommended-footer"));
this.querySelector(".privacy-policy-link")
.href = Services.prefs.getStringPref(PREF_PRIVACY_POLICY_URL);
this.addEventListener("click", this);
}
}
handleEvent(event) {
let action = event.target.getAttribute("action");
switch (action) {
case "notice-learn-more":
// The element is a button but opens a URL, so record as link.
AMTelemetry.recordLinkEvent({
object: "aboutAddons",
value: "disconotice",
extra: {
view: "discover",
},
});
windowRoot.ownerGlobal.openTrustedLinkIn(
Services.urlFormatter.formatURLPref("app.support.baseURL") +
"personalized-extension-recommendations", "tab");
break;
case "open-amo":
// The element is a button but opens a URL, so record as link.
AMTelemetry.recordLinkEvent({
@ -2281,7 +2368,110 @@ class DiscoveryPane extends HTMLElement {
}
}
}
customElements.define(
"recommended-footer", RecommendedFooter, {extends: "footer"});
/**
* This element will handle showing recommendations with a
* <recommended-addon-list> and a <footer>. The footer will be hidden until
* the <recommended-addon-list> is done making its request so the footer
* doesn't move around.
*
* Subclass this element to use it and define a `template` property to pull
* the template from. Expected template:
*
* <h1>My extra content can go here.</h1>
* <p>It can be anything but a footer or recommended-addon-list.</p>
* <recommended-addon-list></recommended-addon-list>
* <footer>My custom footer</footer>
*/
class RecommendedSection extends HTMLElement {
connectedCallback() {
if (this.childElementCount == 0) {
this.render();
}
}
get list() {
return this.querySelector("recommended-addon-list");
}
get footer() {
return this.querySelector("footer");
}
render() {
this.appendChild(importTemplate(this.template));
// Hide footer until the cards are loaded, to prevent the content from
// suddenly shifting when the user attempts to interact with it.
let {footer} = this;
footer.hidden = true;
this.list.loadCardsIfNeeded().finally(() => { footer.hidden = false; });
}
}
class RecommendedExtensionsSection extends RecommendedSection {
get template() {
return "recommended-extensions-section";
}
setAmoButtonVisibility() {
// Show the AMO button if there are no cards, this is mostly for the case
// where the user has no extensions and is offline.
let cards = Array.from(this.list.children);
let cardVisible = cards.some(card => !card.hidden);
this.footer.classList.toggle("hide-amo-link", cardVisible);
}
render() {
super.render();
let {list} = this;
list.cardsReady.then(() => this.setAmoButtonVisibility());
list.addEventListener("card-hidden", this);
list.addEventListener("card-shown", this);
}
handleEvent(e) {
if (e.type == "card-hidden") {
this.setAmoButtonVisibility();
} else if (e.type == "card-shown") {
this.footer.classList.add("hide-amo-link");
}
}
}
customElements.define(
"recommended-extensions-section", RecommendedExtensionsSection);
class RecommendedThemesSection extends RecommendedSection {
get template() {
return "recommended-themes-section";
}
render() {
super.render();
let themeRecommendationRow = this.querySelector(".theme-recommendation");
let themeRecommendationUrl =
Services.prefs.getStringPref(PREF_THEME_RECOMMENDATION_URL);
if (themeRecommendationUrl) {
themeRecommendationRow.querySelector("a").href = themeRecommendationUrl;
}
themeRecommendationRow.hidden = !themeRecommendationUrl;
}
}
customElements.define("recommended-themes-section", RecommendedThemesSection);
class DiscoveryPane extends RecommendedSection {
get template() {
return "discopane";
}
render() {
super.render();
this.querySelector(".discopane-intro-learn-more-link").href =
SUPPORT_URL + "recommended-extensions-program";
}
}
customElements.define("discovery-pane", DiscoveryPane);
class ListView {
@ -2291,6 +2481,8 @@ class ListView {
}
async render() {
let frag = document.createDocumentFragment();
let list = document.createElement("addon-list");
list.type = this.type;
list.setSections([{
@ -2302,10 +2494,24 @@ class ListView {
filterFn: addon => !addon.hidden && !addon.isActive &&
!isPending(addon, "uninstall"),
}]);
frag.appendChild(list);
// Show recommendations for themes and extensions.
if (LIST_RECOMMENDATIONS_ENABLED &&
(this.type == "extension" || this.type == "theme")) {
let elementName = this.type == "extension" ?
"recommended-extensions-section" : "recommended-themes-section";
let recommendations = document.createElement(elementName);
// Start loading the recommendations. This can finish after the view load
// event is sent.
recommendations.render();
frag.appendChild(recommendations);
}
await list.render();
this.root.textContent = "";
this.root.appendChild(list);
this.root.appendChild(frag);
}
}
@ -2396,6 +2602,17 @@ class DiscoveryView {
// Generic view management.
let root = null;
/**
* The name of the view for an element, used for telemetry.
*
* @param {Element} el The element to find the view from. A parent of the
* element must define a current-view property.
* @returns {string} The current view name.
*/
function getTelemetryViewName(el) {
return el.closest("[current-view]").getAttribute("current-view");
}
/**
* Called from extensions.js once, when about:addons is loading.
*/
@ -2419,21 +2636,24 @@ function initialize(opts) {
* views.
*/
async function show(type, param) {
let container = document.createElement("div");
container.setAttribute("current-view", type);
if (type == "list") {
await new ListView({param, root}).render();
await new ListView({param, root: container}).render();
} else if (type == "detail") {
await new DetailView({param, root}).render();
await new DetailView({param, root: container}).render();
} else if (type == "discover") {
let discoverView = new DiscoveryView();
let elem = discoverView.render();
await document.l10n.translateFragment(elem);
root.textContent = "";
root.append(elem);
container.append(elem);
} else if (type == "updates") {
await new UpdatesView({param, root}).render();
await new UpdatesView({param, root: container}).render();
} else {
throw new Error(`Unknown view type: ${type}`);
}
root.textContent = "";
root.appendChild(container);
}
function hide() {

Просмотреть файл

@ -84,6 +84,7 @@ skip-if = os == 'linux' && !debug # Bug 1398766
[browser_html_discover_view_clientid.js]
[browser_html_discover_view_prefs.js]
[browser_html_list_view.js]
[browser_html_list_view_recommendations.js]
[browser_html_message_bar.js]
[browser_html_named_deck.js]
[browser_html_options_ui.js]

Просмотреть файл

@ -14,7 +14,7 @@ const {
AddonTestUtils.initMochitest(this);
const server = AddonTestUtils.createHttpServer();
const serverBaseUrl = `http://localhost:${server.identity.primaryPort}/`;
server.registerPathHandler("/sumo/personalized-extension-recommendations",
server.registerPathHandler("/sumo/personalized-addons",
(request, response) => {
response.write("This is a SUMO page that explains personalized add-ons.");
});
@ -40,7 +40,8 @@ function getNoticeButton(win) {
}
function isNoticeVisible(win) {
return getNoticeButton(win).closest("message-bar").offsetHeight > 0;
let message = win.document.querySelector("taar-notice");
return message && message.offsetHeight > 0;
}
add_task(async function setup() {
@ -52,6 +53,7 @@ add_task(async function setup() {
["extensions.getAddons.discovery.api_url", `${serverBaseUrl}discoapi`],
["app.support.baseURL", `${serverBaseUrl}sumo/`],
["extensions.htmlaboutaddons.discover.enabled", true],
["extensions.htmlaboutaddons.enabled", true],
],
});
});
@ -83,8 +85,7 @@ add_task(async function clientid_enabled() {
Services.telemetry.clearEvents();
let tabbrowser = win.windowRoot.ownerGlobal.gBrowser;
let expectedUrl =
`${serverBaseUrl}sumo/personalized-extension-recommendations`;
let expectedUrl = `${serverBaseUrl}sumo/personalized-addons`;
let tabPromise = BrowserTestUtils.waitForNewTab(tabbrowser, expectedUrl);
getNoticeButton(win).click();
@ -140,3 +141,51 @@ add_task(async function clientid_from_private_window() {
await close_manager(managerWindow);
await BrowserTestUtils.closeWindow(privateWindow);
});
add_task(async function clientid_enabled_from_extension_list() {
// Force the extension list to be the first load. This pref will be
// overwritten once the view loads.
Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/extension");
let requestPromise = promiseOneDiscoveryApiRequest();
let win = await loadInitialView("extension");
ok(isNoticeVisible(win), "Notice about personalization should be visible");
ok(await requestPromise,
"Moz-Client-Id should be set when telemetry & discovery are enabled");
// Make sure switching to the theme view doesn't trigger another request.
await switchView(win, "theme");
// Wait until the request would have happened so promiseOneDiscoveryApiRequest
// can fail if it does.
let recommendations = win.document.querySelector("recommended-addon-list");
await recommendations.loadCardsIfNeeded();
await closeView(win);
});
add_task(async function clientid_enabled_from_theme_list() {
// Force the theme list to be the first load. This pref will be overwritten
// once the view loads.
Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/theme");
let requestPromise = promiseOneDiscoveryApiRequest();
let win = await loadInitialView("theme");
ok(!isNoticeVisible(win), "Notice about personalization should be hidden");
is(await requestPromise, null,
"Moz-Client-Id should not be sent when loading themes initially");
info("Load the extension list and verify the client ID is now sent");
requestPromise = promiseOneDiscoveryApiRequest();
await switchView(win, "extension");
ok(await requestPromise,
"Moz-Client-Id is now sent for extensions");
await closeView(win);
});

Просмотреть файл

@ -279,6 +279,7 @@ add_task(async function testKeyboardSupport() {
// Test opening and closing the menu.
let moreOptionsMenu = card.querySelector("panel-list");
let expandButton = moreOptionsMenu.querySelector('[action="expand"]');
is(moreOptionsMenu.open, false, "The menu is closed");
space();
is(moreOptionsMenu.open, true, "The menu is open");
@ -292,12 +293,14 @@ add_task(async function testKeyboardSupport() {
is(moreOptionsMenu.open, false, "Tabbing away from the menu closes it");
tab();
isFocused(moreOptionsButton, "The button is focused again");
let shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
space();
await shown;
is(moreOptionsMenu.open, true, "The menu is open");
tab();
tab();
tab();
isFocused(moreOptionsButton, "The last item is focused");
isFocused(expandButton, "The last item is focused");
tab();
is(moreOptionsMenu.open, false, "Tabbing out of the menu closes it");
@ -306,7 +309,7 @@ add_task(async function testKeyboardSupport() {
isFocused(moreOptionsButton, "The button is focused again");
// Open the menu to test contents.
let shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
space();
is(moreOptionsMenu.open, true, "The menu is open");
// Wait for the panel to be shown.

Просмотреть файл

@ -0,0 +1,306 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/* eslint max-len: ["error", 80] */
"use strict";
const {
AddonTestUtils,
} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
const {
TelemetryTestUtils,
} = ChromeUtils.import("resource://testing-common/TelemetryTestUtils.jsm");
AddonTestUtils.initMochitest(this);
function makeResult({guid, type}) {
return {
addon: {
authors: [{name: "Some author"}],
current_version: {
files: [{platform: "all", url: "data:,"}],
},
url: "data:,",
guid,
type,
},
};
}
function mockResults() {
let types = ["extension", "theme", "extension", "extension", "theme"];
return {
results: types.map((type, i) => makeResult({
guid: `${type}${i}@mochi.test`,
type,
})),
};
}
add_task(async function setup() {
let results = btoa(JSON.stringify(mockResults()));
await SpecialPowers.pushPrefEnv({
set: [
// Disable personalized recommendations, they will break the data URI.
["browser.discovery.enabled", false],
["extensions.htmlaboutaddons.enabled", true],
["extensions.getAddons.discovery.api_url", `data:;base64,${results}`],
["extensions.recommendations.themeRecommendationUrl",
"https://example.com/theme"],
],
});
});
function checkExtraContents(doc, type, opts = {}) {
let {
showAmoButton = false,
showThemeRecommendationFooter = type === "theme",
} = opts;
let footer = doc.querySelector("footer");
let amoButton = footer.querySelector('[action="open-amo"]');
let privacyPolicyLink = footer.querySelector(".privacy-policy-link");
let themeRecommendationFooter = footer.querySelector(".theme-recommendation");
let themeRecommendationLink =
themeRecommendationFooter && themeRecommendationFooter.querySelector("a");
let taarNotice = doc.querySelector("taar-notice");
is_element_visible(footer, "The footer is visible");
if (type == "extension") {
ok(taarNotice, "There is a TAAR notice");
if (showAmoButton) {
is_element_visible(amoButton, "The AMO button is shown");
} else {
is_element_hidden(amoButton, "The AMO button is hidden");
}
is_element_visible(privacyPolicyLink, "The privacy policy is visible");
} else if (type == "theme") {
ok(!taarNotice, "There is no TAAR notice");
ok(!amoButton, "There is no AMO button");
ok(!privacyPolicyLink, "There is no privacy policy");
} else {
throw new Error(`Unknown type ${type}`);
}
if (showThemeRecommendationFooter) {
is_element_visible(
themeRecommendationFooter, "There's a theme recommendation footer");
is_element_visible(themeRecommendationLink, "There's a link to the theme");
is(themeRecommendationLink.target, "_blank", "The link opens in a new tab");
is(themeRecommendationLink.href, "https://example.com/theme",
"The link goes to the pref's URL");
is(doc.l10n.getAttributes(themeRecommendationFooter).id,
"recommended-theme-1", "The recommendation has the right l10n-id");
} else {
ok(!themeRecommendationFooter || themeRecommendationFooter.hidden,
"There's no theme recommendation");
}
}
async function installAddon({card, recommendedList, manifestExtra = {}}) {
// Install an add-on to hide the card.
let hidden = BrowserTestUtils.waitForEvent(
recommendedList, "card-hidden", false, e => e.detail.card == card);
let extension = ExtensionTestUtils.loadExtension({
manifest: {
applications: {gecko: {id: card.addonId}},
...manifestExtra,
},
useAddonManager: "temporary",
});
await extension.startup();
await hidden;
return extension;
}
async function testListRecommendations({type, manifestExtra = {}}) {
Services.telemetry.clearEvents();
let win = await loadInitialView(type);
let doc = win.document;
// Wait for the list to render, rendering is tested with the discovery pane.
let recommendedList = doc.querySelector("recommended-addon-list");
await recommendedList.cardsReady;
checkExtraContents(doc, type);
// Check that the cards are all for the right type.
let cards = doc.querySelectorAll("recommended-addon-card");
ok(cards.length > 0, "There were some cards found");
for (let card of cards) {
is(card.discoAddon.type, type, `The card is for a ${type}`);
is_element_visible(card, "The card is visible");
}
// Install an add-on for the first card, verify it is hidden.
let {addonId} = cards[0];
ok(addonId, "The card has an addonId");
// Installing the add-on will fail since the URL doesn't point to a valid
// XPI. This will trigger the telemetry though.
let installButton = cards[0].querySelector('[action="install-addon"]');
let {panel} = PopupNotifications;
let popupId = "addon-install-failed-notification";
let failPromise = TestUtils.topicObserved("addon-install-failed");
installButton.click();
await failPromise;
// Wait for the installing popup to be hidden and leave just the error popup.
await BrowserTestUtils.waitForCondition(() => {
return panel.children.length == 1 && panel.firstElementChild.id == popupId;
});
// Dismiss the popup.
panel.firstElementChild.button.click();
await BrowserTestUtils.waitForPopupEvent(panel, "hidden");
let extension = await installAddon({card: cards[0], recommendedList});
is_element_hidden(cards[0], "The card is now hidden");
// Switch away and back, there should still be a hidden card.
await closeView(win);
win = await loadInitialView(type);
doc = win.document;
recommendedList = doc.querySelector("recommended-addon-list");
await recommendedList.cardsReady;
cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
let hiddenCard = cards.pop();
is(hiddenCard.addonId, addonId, "The expected card was found");
is_element_hidden(hiddenCard, "The card is still hidden");
ok(cards.length > 0, "There are still some visible cards");
for (let card of cards) {
is(card.discoAddon.type, type, `The card is for a ${type}`);
is_element_visible(card, "The card is visible");
}
// Uninstall the add-on, verify the card is shown again.
let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
await extension.unload();
await shown;
is_element_visible(hiddenCard, "The card is now shown");
await closeView(win);
TelemetryTestUtils.assertEvents([{
category: "addonsManager",
method: "action",
object: "aboutAddons",
extra: {
action: "installFromRecommendation",
view: "list",
addonId,
type,
},
}], {
category: "addonsManager",
method: "action",
object: "aboutAddons",
});
}
add_task(async function testExtensionList() {
await testListRecommendations({type: "extension"});
});
add_task(async function testThemeList() {
await testListRecommendations({
type: "theme",
manifestExtra: {theme: {}},
});
});
add_task(async function testInstallAllExtensions() {
let type = "extension";
let win = await loadInitialView(type);
let doc = win.document;
// Wait for the list to render, rendering is tested with the discovery pane.
let recommendedList = doc.querySelector("recommended-addon-list");
await recommendedList.cardsReady;
// Find more button is hidden.
checkExtraContents(doc, type);
let cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
is(cards.length, 3, "We found some cards");
let extensions = await Promise.all(
cards.map(card => installAddon({card, recommendedList})));
// The find more on AMO button is now shown.
checkExtraContents(doc, type, {showAmoButton: true});
// Uninstall one of the extensions, the button should be hidden again.
let extension = extensions.pop();
let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
await extension.unload();
await shown;
// The find more on AMO button is now hidden.
checkExtraContents(doc, type);
await Promise.all(extensions.map(extension => extension.unload()));
await closeView(win);
});
add_task(async function testError() {
await SpecialPowers.pushPrefEnv({
set: [
["extensions.getAddons.discovery.api_url", "data:,"],
],
});
let win = await loadInitialView("extension");
let doc = win.document;
// Wait for the list to render, rendering is tested with the discovery pane.
let recommendedList = doc.querySelector("recommended-addon-list");
await recommendedList.cardsReady;
checkExtraContents(doc, "extension", {showAmoButton: true});
await closeView(win);
await SpecialPowers.popPrefEnv();
});
add_task(async function testThemesNoRecommendationUrl() {
await SpecialPowers.pushPrefEnv({
set: [["extensions.recommendations.themeRecommendationUrl", ""]],
});
let win = await loadInitialView("theme");
let doc = win.document;
// Wait for the list to render, rendering is tested with the discovery pane.
let recommendedList = doc.querySelector("recommended-addon-list");
await recommendedList.cardsReady;
checkExtraContents(doc, "theme", {showThemeRecommendationFooter: false});
await closeView(win);
await SpecialPowers.popPrefEnv();
});
add_task(async function testRecommendationsDisabled() {
await SpecialPowers.pushPrefEnv({
set: [["extensions.htmlaboutaddons.recommendations.enabled", false]],
});
let types = ["extension", "theme"];
for (let type of types) {
let win = await loadInitialView(type);
let doc = win.document;
let recommendedList = doc.querySelector("recommended-addon-list");
ok(!recommendedList, `There are no recommendations on the ${type} page`);
await closeView(win);
}
await SpecialPowers.popPrefEnv();
});

Просмотреть файл

@ -1495,6 +1495,10 @@ function closeView(win) {
return close_manager(win.managerWindow);
}
function switchView(win, type) {
return new CategoryUtilities(win.managerWindow).openType(type);
}
function mockPromptService() {
let {prompt} = Services;
let promptService = {