summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js')
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js660
1 files changed, 660 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
new file mode 100644
index 0000000000..9dcc3a0006
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
@@ -0,0 +1,660 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+loadTestSubscript("head_disco.js");
+
+// 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 = PathUtils.join(
+ Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
+ // Trim empty component from splitting with trailing slash.
+ ...RELATIVE_DIR.split("/").filter(c => c.length),
+ "discovery",
+ "api_response.json"
+);
+
+const AMO_TEST_HOST = "rewritten-for-testing.addons.allizom.org";
+
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream",
+ "setData"
+);
+
+const amoServer = AddonTestUtils.createHttpServer({ hosts: [AMO_TEST_HOST] });
+
+amoServer.registerFile(
+ "/png",
+ FileUtils.getFile(
+ "CurWorkD",
+ `${RELATIVE_DIR}discovery/small-1x1.png`.split("/")
+ )
+);
+amoServer.registerPathHandler("/dummy", (request, response) => {
+ response.write("Dummy");
+});
+
+// `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,
+ editorialBody: result.description_text,
+ dailyUsers: result.addon.average_daily_users,
+ rating: result.addon.ratings.average,
+ };
+}
+
+// A helper to declare a response to discovery API requests.
+class DiscoveryAPIHandler {
+ constructor(responseText) {
+ this.setResponseText(responseText);
+ this.requestCount = 0;
+
+ // Overwrite the previous discovery response handler.
+ amoServer.registerPathHandler("/discoapi", this);
+ }
+
+ setResponseText(responseText) {
+ this.responseBody = new TextEncoder().encode(responseText).buffer;
+ }
+
+ // Suspend discovery API requests until unblockResponses is called.
+ 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) {
+ ++this.requestCount;
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.processAsync();
+ await this._unblockPromise;
+
+ let body = this.responseBody;
+ let binStream = new ArrayBufferInputStream(body, 0, body.byteLength);
+ response.bodyOutputStream.writeFrom(binStream, body.byteLength);
+ response.finish();
+ }
+}
+
+// Retrieve the list of visible action elements inside a document or container.
+function getVisibleActions(documentOrElement) {
+ return Array.from(documentOrElement.querySelectorAll("[action]")).filter(
+ elem =>
+ elem.getAttribute("action") !== "page-options" &&
+ elem.offsetWidth &&
+ elem.offsetHeight
+ );
+}
+
+function getActionName(actionElement) {
+ return actionElement.getAttribute("action");
+}
+
+function getCardByAddonId(win, addonId) {
+ for (let card of win.document.querySelectorAll("recommended-addon-card")) {
+ if (card.addonId === addonId) {
+ return card;
+ }
+ }
+ return null;
+}
+
+// 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.
+ win.gViewController.loadView("addons://list/extension");
+ await wait_for_view_load(win);
+ 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"
+ );
+ win.gViewController.loadView("addons://discover/");
+ await wait_for_view_load(win);
+ 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("discovery-pane 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;
+}
+
+// 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_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "extensions.getAddons.discovery.api_url",
+ `http://${AMO_TEST_HOST}/discoapi`,
+ ],
+ // Disable non-discopane recommendations to avoid unexpected discovery
+ // API requests.
+ ["extensions.htmlaboutaddons.recommendations.enabled", false],
+ // Disable the telemetry client ID (and its associated UI warning).
+ // browser_html_discover_view_clientid.js covers this functionality.
+ ["browser.discovery.enabled", false],
+ ],
+ });
+});
+
+// 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 readAPIResponseFixture(
+ AMO_TEST_HOST,
+ API_RESPONSE_FILE
+ );
+ let apiHandler = new DiscoveryAPIHandler(apiText);
+
+ const apiResultArray = JSON.parse(apiText).results;
+ ok(apiResultArray.length, `Mock has ${apiResultArray.length} results`);
+
+ apiHandler.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"
+ );
+
+ apiHandler.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."
+ );
+
+ let imgCount = await waitForAllImagesLoaded(win);
+ is(imgCount, apiResultArray.length, "Expected an image for every result");
+
+ // 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);
+ await win.document.l10n.translateFragment(card);
+ checkContent(
+ ".disco-addon-author [data-l10n-name='author']",
+ expectations.authorName
+ );
+
+ let amoListingLink = card.querySelector(".disco-addon-author a");
+ ok(
+ amoListingLink.search.includes("utm_source=firefox-browser"),
+ `Listing link should have attribution parameter, url=${amoListingLink}`
+ );
+
+ 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-main", expectations.editorialBody);
+
+ let ratingElem = card.querySelector("five-star-rating");
+ if (expectations.rating) {
+ is(ratingElem.rating, expectations.rating, "Expected rating value");
+ ok(ratingElem.offsetWidth, "Rating element is visible");
+ } else {
+ is(ratingElem.offsetWidth, 0, "Rating element is not visible");
+ }
+
+ let userCountElem = card.querySelector(".disco-user-count");
+ if (expectations.dailyUsers) {
+ Assert.deepEqual(
+ win.document.l10n.getAttributes(userCountElem),
+ { id: "user-count", args: { dailyUsers: expectations.dailyUsers } },
+ "Card count should be rendered"
+ );
+ } else {
+ is(userCountElem.offsetWidth, 0, "User count element is not visible");
+ }
+ }
+ }
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// 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 readAPIResponseFixture(
+ AMO_TEST_HOST,
+ API_RESPONSE_FILE
+ );
+ 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 apiHandler = new DiscoveryAPIHandler(apiText);
+
+ let win = await loadInitialView("discover");
+ await promiseDiscopaneUpdate(win);
+ await waitForAllImagesLoaded(win);
+
+ // Test extension install.
+ let installExtensionPromise = promiseAddonInstall(amoServer, {
+ manifest: {
+ name: "My Awesome Add-on",
+ description: "Test extension install button",
+ browser_specific_settings: { 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",
+ browser_specific_settings: { 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"
+ );
+
+ // End of the testing installation from a card.
+
+ // Click on the Manage button to verify that it does something useful,
+ // and in order to be able to force the discovery pane to be rendered again.
+ let loaded = waitForViewLoad(win);
+ getCardByAddonId(win, FIRST_EXTENSION_ID)
+ .querySelector("[action='manage-addon']")
+ .click();
+ await loaded;
+ {
+ let addonCard = win.document.querySelector(
+ `addon-card[addon-id="${FIRST_EXTENSION_ID}"]`
+ );
+ ok(addonCard, "Add-on details should be shown");
+ ok(addonCard.expanded, "The card should have been expanded");
+ // TODO bug 1540253: Check that the "recommended" badge is visible.
+ }
+
+ // 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.
+ 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"
+ );
+
+ promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+ await testAddonUninstall(getCardByAddonId(win, FIRST_THEME_ID));
+ await promiseThemeChange;
+ await testAddonUninstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// 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 apiHandler = new DiscoveryAPIHandler(`{"results": []}`);
+
+ apiHandler.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>`.
+ apiHandler.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"
+ );
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// 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 apiHandler = new DiscoveryAPIHandler(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;
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ 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.
+ apiHandler.setResponseText(`{"results": []}`);
+
+ await switchToNonDiscoView(win);
+ await switchToDiscoView(win); // Request #2
+
+ is(
+ apiHandler.requestCount,
+ 2,
+ "Should fetch new data because an invalid response should not be cached"
+ );
+
+ await switchToNonDiscoView(win);
+ await switchToDiscoView(win);
+ await closeView(win);
+
+ is(
+ apiHandler.requestCount,
+ 2,
+ "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);
+
+ is(apiHandler.requestCount, 3, "discovery API should be requested again");
+});
+
+add_task(async function discopane_no_cookies() {
+ let requestPromise = new Promise(resolve => {
+ amoServer.registerPathHandler("/discoapi", resolve);
+ });
+ Services.cookies.add(
+ AMO_TEST_HOST,
+ "/",
+ "name",
+ "value",
+ false,
+ false,
+ false,
+ Date.now() / 1000 + 600,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+ let win = await loadInitialView("discover");
+ let request = await requestPromise;
+ ok(!request.hasHeader("Cookie"), "discovery API should not receive cookies");
+ await closeView(win);
+});
+
+// The CSP of about:addons whitelists http:, but not data:, hence we are
+// loading a little red data: image which gets blocked by the CSP.
+add_task(async function csp_img_src() {
+ const RED_DATA_IMAGE =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAA" +
+ "AHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+
+ // Minimal API response to get the image in recommended-addon-card to render.
+ const DUMMY_EXTENSION_ID = "dummy-csp@extensionid";
+ const apiResponse = {
+ results: [
+ {
+ addon: {
+ guid: DUMMY_EXTENSION_ID,
+ type: "extension",
+ authors: [
+ {
+ name: "Some CSP author",
+ },
+ ],
+ url: `http://${AMO_TEST_HOST}/dummy`,
+ icon_url: RED_DATA_IMAGE,
+ },
+ },
+ ],
+ };
+
+ let apiHandler = new DiscoveryAPIHandler(JSON.stringify(apiResponse));
+ apiHandler.blockNextResponses();
+ let win = await loadInitialView("discover");
+
+ let cspPromise = new Promise(resolve => {
+ win.addEventListener("securitypolicyviolation", e => {
+ // non http(s) loads only report the scheme
+ is(e.blockedURI, "data", "CSP: blocked URI");
+ is(e.violatedDirective, "img-src", "CSP: violated directive");
+ resolve();
+ });
+ });
+
+ apiHandler.unblockResponses();
+ await cspPromise;
+
+ await closeView(win);
+});
+
+add_task(async function checkDiscopaneNotice() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.discovery.enabled", true],
+ // Enabling the Data Upload pref may upload data.
+ // Point data reporting services to localhost so the data doesn't escape.
+ ["toolkit.telemetry.server", "https://localhost:1337"],
+ ["telemetry.fog.test.localhost_port", -1],
+ ["datareporting.healthreport.uploadEnabled", true],
+ ["extensions.htmlaboutaddons.recommendations.enabled", true],
+ ["extensions.recommendations.hideNotice", false],
+ ],
+ });
+
+ let win = await loadInitialView("extension");
+ let messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(messageBar, "Recommended notice should exist in extensions view");
+ await switchToDiscoView(win);
+ messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(messageBar, "Recommended notice should exist in disco view");
+
+ messageBar.closeButton.click();
+ messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(!messageBar, "Recommended notice should not exist in disco view");
+ await switchToNonDiscoView(win);
+ messageBar = win.document.querySelector("message-bar.discopane-notice");
+ ok(!messageBar, "Recommended notice should not exist in extensions view");
+
+ await closeView(win);
+});