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

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Rob Wu 2019-05-06 10:41:10 +00:00
Родитель 17836b9070
Коммит acb77d2f29
13 изменённых файлов: 2051 добавлений и 1 удалений

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

@ -44,6 +44,7 @@ pref("extensions.getAddons.search.browseURL", "https://addons.mozilla.org/%LOCAL
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);

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

@ -105,6 +105,8 @@ legacyWarning.description=Missing something? Some extensions are no longer suppo
#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

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

@ -343,6 +343,31 @@ 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. Heres 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

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

@ -58,6 +58,7 @@ addon-list .addon.card {
}
.card-contents {
word-break: break-word;
flex-grow: 1;
display: flex;
flex-direction: column;
@ -120,6 +121,74 @@ addon-card:not([expanded]) .addon-description {
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;
}

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

@ -55,6 +55,22 @@
</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">
@ -139,5 +155,33 @@
<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>

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

@ -10,7 +10,10 @@
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(
@ -33,6 +36,9 @@ const PERMISSION_MASKS = {
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: []};
@ -147,6 +153,84 @@ function nl2br(text) {
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"];
@ -960,6 +1044,159 @@ class AddonCard extends HTMLElement {
}
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
@ -1223,6 +1460,115 @@ class AddonList extends HTMLElement {
}
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;
@ -1311,6 +1657,14 @@ class UpdatesView {
}
}
class DiscoveryView {
render() {
let discopane = document.createElement("discovery-pane");
discopane.render();
return discopane;
}
}
// Generic view management.
let root = null;
@ -1340,8 +1694,16 @@ async function show(type, param) {
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}`);
}
}

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

@ -733,13 +733,13 @@ var gViewController = {
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.
@ -747,6 +747,7 @@ var gViewController = {
} else {
this.viewObjects.list = gListView;
this.viewObjects.detail = gDetailView;
this.viewObjects.discover = gDiscoverView;
this.viewObjects.updates = gUpdatesView;
}
@ -917,6 +918,10 @@ var gViewController = {
} 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);

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

@ -629,6 +629,7 @@ var AddonRepository = {
switch (aEntry.type) {
case "persona":
case "statictheme":
addon.type = "theme";
break;

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

@ -9,6 +9,8 @@ support-files =
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
@ -76,6 +78,8 @@ skip-if = true # Bug 1449071 - Frequent failures
[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

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

@ -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();
});

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

@ -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);
});

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

@ -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
}

Двоичные данные
toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 82 B