From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../test/browser/browser_html_discover_view.js | 660 +++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js (limited to 'toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js') 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 `` 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: [""], + }, + }); + 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 ``. + 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); +}); -- cgit v1.2.3