From a90a5cba08fdf6c0ceb95101c275108a152a3aed Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 12 Jun 2024 07:35:37 +0200 Subject: Merging upstream version 127.0. Signed-off-by: Daniel Baumann --- browser/components/topsites/TopSites.sys.mjs | 2039 +++++++++++ browser/components/topsites/moz.build | 12 + .../topsites/test/unit/test_top_sites.js | 3571 ++++++++++++++++++++ .../components/topsites/test/unit/xpcshell.toml | 4 + 4 files changed, 5626 insertions(+) create mode 100644 browser/components/topsites/TopSites.sys.mjs create mode 100644 browser/components/topsites/moz.build create mode 100644 browser/components/topsites/test/unit/test_top_sites.js create mode 100644 browser/components/topsites/test/unit/xpcshell.toml (limited to 'browser/components/topsites') diff --git a/browser/components/topsites/TopSites.sys.mjs b/browser/components/topsites/TopSites.sys.mjs new file mode 100644 index 0000000000..736a079f34 --- /dev/null +++ b/browser/components/topsites/TopSites.sys.mjs @@ -0,0 +1,2039 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "resource://activity-stream/common/Actions.mjs"; +import { TippyTopProvider } from "resource://activity-stream/lib/TippyTopProvider.sys.mjs"; +import { + insertPinned, + TOP_SITES_MAX_SITES_PER_ROW, +} from "resource://activity-stream/common/Reducers.sys.mjs"; +import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs"; +import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; +import { getDefaultOptions } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"; + +import { + CUSTOM_SEARCH_SHORTCUTS, + SEARCH_SHORTCUTS_EXPERIMENT, + SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF, + SEARCH_SHORTCUTS_HAVE_PINNED_PREF, + checkHasSearchEngine, + getSearchProvider, + getSearchFormURL, +} from "resource://activity-stream/lib/SearchShortcuts.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + LinksCache: "resource://activity-stream/lib/LinksCache.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", + Screenshots: "resource://activity-stream/lib/Screenshots.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("TopSites"); +}); + +// `contextId` is a unique identifier used by Contextual Services +const CONTEXT_ID_PREF = "browser.contextual-services.contextId"; +ChromeUtils.defineLazyGetter(lazy, "contextId", () => { + let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null); + if (!_contextId) { + _contextId = String(Services.uuid.generateUUID()); + Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId); + } + return _contextId; +}); + +const DEFAULT_SITES_PREF = "default.sites"; +const SHOWN_ON_NEWTAB_PREF = "feeds.topsites"; +export const DEFAULT_TOP_SITES = []; +const FRECENCY_THRESHOLD = 100 + 1; // 1 visit (skip first-run/one-time pages) +const MIN_FAVICON_SIZE = 96; +const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot", "customScreenshot"]; +const PINNED_FAVICON_PROPS_TO_MIGRATE = [ + "favicon", + "faviconRef", + "faviconSize", +]; +const SECTION_ID = "topsites"; +const ROWS_PREF = "topSitesRows"; +const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; +// The default total number of sponsored top sites to fetch from Contile +// and Pocket. +const MAX_NUM_SPONSORED = 2; +// Nimbus variable for the total number of sponsored top sites including +// both Contile and Pocket sources. +// The default will be `MAX_NUM_SPONSORED` if this variable is unspecified. +const NIMBUS_VARIABLE_MAX_SPONSORED = "topSitesMaxSponsored"; +// Nimbus variable to allow more than two sponsored tiles from Contile to be +//considered for Top Sites. +const NIMBUS_VARIABLE_ADDITIONAL_TILES = + "topSitesUseAdditionalTilesFromContile"; +// Nimbus variable to enable the SOV feature for sponsored tiles. +const NIMBUS_VARIABLE_CONTILE_SOV_ENABLED = "topSitesContileSovEnabled"; +// Nimbu variable for the total number of sponsor topsite that come from Contile +// The default will be `CONTILE_MAX_NUM_SPONSORED` if variable is unspecified. +const NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED = "topSitesContileMaxSponsored"; + +// Search experiment stuff +const FILTER_DEFAULT_SEARCH_PREF = "improvesearch.noDefaultSearchTile"; +const SEARCH_FILTERS = [ + "google", + "search.yahoo", + "yahoo", + "bing", + "ask", + "duckduckgo", +]; + +const REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting"; +const DEFAULT_SITES_OVERRIDE_PREF = + "browser.newtabpage.activity-stream.default.sites"; +const DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH = "browser.topsites.experiment."; + +// Mozilla Tiles Service (Contile) prefs +// Nimbus variable for the Contile integration. It falls back to the pref: +// `browser.topsites.contile.enabled`. +const NIMBUS_VARIABLE_CONTILE_ENABLED = "topSitesContileEnabled"; +const NIMBUS_VARIABLE_CONTILE_POSITIONS = "contileTopsitesPositions"; +const CONTILE_ENDPOINT_PREF = "browser.topsites.contile.endpoint"; +const CONTILE_UPDATE_INTERVAL = 15 * 60 * 1000; // 15 minutes +// The maximum number of sponsored top sites to fetch from Contile. +const CONTILE_MAX_NUM_SPONSORED = 2; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; +const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles"; +const CONTILE_CACHE_VALID_FOR_PREF = "browser.topsites.contile.cacheValidFor"; +const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch"; + +// Partners of sponsored tiles. +const SPONSORED_TILE_PARTNER_AMP = "amp"; +const SPONSORED_TILE_PARTNER_MOZ_SALES = "moz-sales"; +const SPONSORED_TILE_PARTNERS = new Set([ + SPONSORED_TILE_PARTNER_AMP, + SPONSORED_TILE_PARTNER_MOZ_SALES, +]); + +const DISPLAY_FAIL_REASON_OVERSOLD = "oversold"; +const DISPLAY_FAIL_REASON_DISMISSED = "dismissed"; +const DISPLAY_FAIL_REASON_UNRESOLVED = "unresolved"; + +function getShortURLForCurrentSearch() { + const url = shortURL({ url: Services.search.defaultEngine.searchForm }); + return url; +} + +class TopSitesTelemetry { + constructor() { + this.allSponsoredTiles = {}; + this.sponsoredTilesConfigured = 0; + } + + _tileProviderForTiles(tiles) { + // Assumption: the list of tiles is from a single provider + return tiles && tiles.length ? this._tileProvider(tiles[0]) : null; + } + + _tileProvider(tile) { + return tile.partner || SPONSORED_TILE_PARTNER_AMP; + } + + _buildPropertyKey(tile) { + let provider = this._tileProvider(tile); + return provider + shortURL(tile); + } + + // Returns an array of strings indicating the property name (based on the + // provider and brand) of tiles that have been filtered e.g. ["moz-salesbrand1"] + // currentTiles: The list of tiles remaining and may be displayed in new tab. + // this.allSponsoredTiles: The original list of tiles set via setTiles prior to any filtering + // The returned list indicated the difference between these two lists (excluding any previously filtered tiles). + _getFilteredTiles(currentTiles) { + let notPreviouslyFilteredTiles = Object.assign( + {}, + ...Object.entries(this.allSponsoredTiles) + .filter( + ([, v]) => + v.display_fail_reason === null || + v.display_fail_reason === undefined + ) + .map(([k, v]) => ({ [k]: v })) + ); + + // Get the property names of the newly filtered list. + let remainingTiles = currentTiles.map(el => { + return this._buildPropertyKey(el); + }); + + // Get the property names of the tiles that were filtered. + let tilesToUpdate = Object.keys(notPreviouslyFilteredTiles).filter( + element => !remainingTiles.includes(element) + ); + return tilesToUpdate; + } + + setSponsoredTilesConfigured() { + const maxSponsored = + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_MAX_SPONSORED + ) ?? MAX_NUM_SPONSORED; + + this.sponsoredTilesConfigured = maxSponsored; + Glean.topsites.sponsoredTilesConfigured.set(maxSponsored); + } + + clearTilesForProvider(provider) { + Object.entries(this.allSponsoredTiles) + .filter(([k]) => k.startsWith(provider)) + .map(([k]) => delete this.allSponsoredTiles[k]); + } + + _getAdvertiser(tile) { + let label = tile.label || null; + let title = tile.title || null; + + return label ?? title ?? shortURL(tile); + } + + setTiles(tiles) { + // Assumption: the list of tiles is from a single provider, + // should be called once per tile source. + if (tiles && tiles.length) { + let tile_provider = this._tileProviderForTiles(tiles); + this.clearTilesForProvider(tile_provider); + + for (let sponsoredTile of tiles) { + this.allSponsoredTiles[this._buildPropertyKey(sponsoredTile)] = { + advertiser: this._getAdvertiser(sponsoredTile).toLowerCase(), + provider: tile_provider, + display_position: null, + display_fail_reason: null, + }; + } + } + } + + _setDisplayFailReason(filteredTiles, reason) { + for (let tile of filteredTiles) { + if (tile in this.allSponsoredTiles) { + let tileToUpdate = this.allSponsoredTiles[tile]; + tileToUpdate.display_position = null; + tileToUpdate.display_fail_reason = reason; + } + } + } + + determineFilteredTilesAndSetToOversold(nonOversoldTiles) { + let filteredTiles = this._getFilteredTiles(nonOversoldTiles); + this._setDisplayFailReason(filteredTiles, DISPLAY_FAIL_REASON_OVERSOLD); + } + + determineFilteredTilesAndSetToDismissed(nonDismissedTiles) { + let filteredTiles = this._getFilteredTiles(nonDismissedTiles); + this._setDisplayFailReason(filteredTiles, DISPLAY_FAIL_REASON_DISMISSED); + } + + _setTilePositions(currentTiles) { + // This function performs many loops over a small dataset. The size of + // dataset is limited by the number of sponsored tiles displayed on + // the newtab instance. + if (this.allSponsoredTiles) { + let tilePositionsAssigned = []; + // processing the currentTiles parameter, assigns a position to the + // corresponding property in this.allSponsoredTiles + currentTiles.forEach(item => { + let tile = this.allSponsoredTiles[this._buildPropertyKey(item)]; + if ( + tile && + (tile.display_fail_reason === undefined || + tile.display_fail_reason === null) + ) { + tile.display_position = item.sponsored_position; + // Track assigned tile slots. + tilePositionsAssigned.push(item.sponsored_position); + } + }); + + // Need to check if any objects in this.allSponsoredTiles do not + // have either a display_fail_reason or a display_position set. + // This can happen if the tiles list was updated before the + // metric is written to Glean. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1877197 + let tilesMissingPosition = []; + Object.keys(this.allSponsoredTiles).forEach(property => { + let tile = this.allSponsoredTiles[property]; + if (!tile.display_fail_reason && !tile.display_position) { + tilesMissingPosition.push(property); + } + }); + + if (tilesMissingPosition.length) { + // Determine if any available slots exist based on max number of tiles + // and the list of tiles already used and assign to a tile with missing + // value. + for (let i = 1; i <= this.sponsoredTilesConfigured; i++) { + if (!tilePositionsAssigned.includes(i)) { + let tileProperty = tilesMissingPosition.shift(); + this.allSponsoredTiles[tileProperty].display_position = i; + } + } + } + + // At this point we might still have a few unresolved states. These + // rows will be tagged with a display_fail_reason `unresolved`. + this._detectErrorConditionAndSetUnresolved(); + } + } + + // Checks the data for inconsistent state and updates the display_fail_reason + _detectErrorConditionAndSetUnresolved() { + Object.keys(this.allSponsoredTiles).forEach(property => { + let tile = this.allSponsoredTiles[property]; + if ( + (!tile.display_fail_reason && !tile.display_position) || + (tile.display_fail_reason && tile.display_position) + ) { + tile.display_position = null; + tile.display_fail_reason = DISPLAY_FAIL_REASON_UNRESOLVED; + } + }); + } + + finalizeNewtabPingFields(currentTiles) { + this._setTilePositions(currentTiles); + Glean.topsites.sponsoredTilesReceived.set( + JSON.stringify({ + sponsoredTilesReceived: Object.values(this.allSponsoredTiles), + }) + ); + } +} + +export class ContileIntegration { + constructor(topSitesFeed) { + this._topSitesFeed = topSitesFeed; + this._lastPeriodicUpdate = 0; + this._sites = []; + // The Share-of-Voice object managed by Shepherd and sent via Contile. + this._sov = null; + } + + get sites() { + return this._sites; + } + + get sov() { + return this._sov; + } + + periodicUpdate() { + let now = Date.now(); + if (now - this._lastPeriodicUpdate >= CONTILE_UPDATE_INTERVAL) { + this._lastPeriodicUpdate = now; + this.refresh(); + } + } + + async refresh() { + let updateDefaultSites = await this._fetchSites(); + await this._topSitesFeed.allocatePositions(); + if (updateDefaultSites) { + this._topSitesFeed._readDefaults(); + } + } + + /** + * Clear Contile Cache Prefs. + */ + _resetContileCachePrefs() { + Services.prefs.clearUserPref(CONTILE_CACHE_PREF); + Services.prefs.clearUserPref(CONTILE_CACHE_LAST_FETCH_PREF); + Services.prefs.clearUserPref(CONTILE_CACHE_VALID_FOR_PREF); + } + + /** + * Filter the tiles whose sponsor is on the Top Sites sponsor blocklist. + * + * @param {Array} tiles + * An array of the tile objects + */ + _filterBlockedSponsors(tiles) { + const blocklist = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + return tiles.filter(tile => !blocklist.includes(shortURL(tile))); + } + + /** + * Calculate the time Contile response is valid for based on cache-control header + * + * @param {string} cacheHeader + * string value of the Contile resposne cache-control header + */ + _extractCacheValidFor(cacheHeader) { + if (!cacheHeader) { + lazy.log.warn("Contile response cache control header is empty"); + return 0; + } + const [, staleIfError] = cacheHeader.match(/stale-if-error=\s*([0-9]+)/i); + const [, maxAge] = cacheHeader.match(/max-age=\s*([0-9]+)/i); + const validFor = + Number.parseInt(staleIfError, 10) + Number.parseInt(maxAge, 10); + return isNaN(validFor) ? 0 : validFor; + } + + /** + * Load Tiles from Contile Cache Prefs + */ + _loadTilesFromCache() { + lazy.log.info("Contile client is trying to load tiles from local cache."); + const now = Math.round(Date.now() / 1000); + const lastFetch = Services.prefs.getIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + 0 + ); + const validFor = Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_PREF, 0); + this._topSitesFeed._telemetryUtility.setSponsoredTilesConfigured(); + if (now <= lastFetch + validFor) { + try { + let cachedTiles = JSON.parse( + Services.prefs.getStringPref(CONTILE_CACHE_PREF) + ); + this._topSitesFeed._telemetryUtility.setTiles(cachedTiles); + cachedTiles = this._filterBlockedSponsors(cachedTiles); + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToDismissed( + cachedTiles + ); + this._sites = cachedTiles; + lazy.log.info("Local cache loaded."); + return true; + } catch (error) { + lazy.log.warn(`Failed to load tiles from local cache: ${error}.`); + return false; + } + } + + return false; + } + + /** + * Determine number of Tiles to get from Contile + */ + _getMaxNumFromContile() { + return ( + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED + ) ?? CONTILE_MAX_NUM_SPONSORED + ); + } + + async _fetchSites() { + if ( + !lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_CONTILE_ENABLED + ) || + !this._topSitesFeed.store.getState().Prefs.values[SHOW_SPONSORED_PREF] + ) { + if (this._sites.length) { + this._sites = []; + return true; + } + return false; + } + try { + let url = Services.prefs.getStringPref(CONTILE_ENDPOINT_PREF); + const response = await this._topSitesFeed.fetch(url, { + credentials: "omit", + }); + if (!response.ok) { + lazy.log.warn( + `Contile endpoint returned unexpected status: ${response.status}` + ); + if (response.status === 304 || response.status >= 500) { + return this._loadTilesFromCache(); + } + } + + const lastFetch = Math.round(Date.now() / 1000); + Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, lastFetch); + this._topSitesFeed._telemetryUtility.setSponsoredTilesConfigured(); + + // Contile returns 204 indicating there is no content at the moment. + // If this happens, it will clear `this._sites` reset the cached tiles + // to an empty array. + if (response.status === 204) { + this._topSitesFeed._telemetryUtility.clearTilesForProvider( + SPONSORED_TILE_PARTNER_AMP + ); + if (this._sites.length) { + this._sites = []; + Services.prefs.setStringPref( + CONTILE_CACHE_PREF, + JSON.stringify(this._sites) + ); + return true; + } + return false; + } + const body = await response.json(); + + if (body?.sov) { + this._sov = JSON.parse(atob(body.sov)); + } + if (body?.tiles && Array.isArray(body.tiles)) { + const useAdditionalTiles = lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_ADDITIONAL_TILES + ); + + const maxNumFromContile = this._getMaxNumFromContile(); + + let { tiles } = body; + this._topSitesFeed._telemetryUtility.setTiles(tiles); + if ( + useAdditionalTiles !== undefined && + !useAdditionalTiles && + tiles.length > maxNumFromContile + ) { + tiles.length = maxNumFromContile; + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToOversold( + tiles + ); + } + tiles = this._filterBlockedSponsors(tiles); + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToDismissed( + tiles + ); + if (tiles.length > maxNumFromContile) { + lazy.log.info("Remove unused links from Contile"); + tiles.length = maxNumFromContile; + this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToOversold( + tiles + ); + } + this._sites = tiles; + Services.prefs.setStringPref( + CONTILE_CACHE_PREF, + JSON.stringify(this._sites) + ); + Services.prefs.setIntPref( + CONTILE_CACHE_VALID_FOR_PREF, + this._extractCacheValidFor( + response.headers.get("cache-control") || + response.headers.get("Cache-Control") + ) + ); + + return true; + } + } catch (error) { + lazy.log.warn( + `Failed to fetch data from Contile server: ${error.message}` + ); + return this._loadTilesFromCache(); + } + return false; + } +} + +class _TopSites { + constructor() { + this._telemetryUtility = new TopSitesTelemetry(); + this._contile = new ContileIntegration(this); + this._tippyTopProvider = new TippyTopProvider(); + ChromeUtils.defineLazyGetter( + this, + "_currentSearchHostname", + getShortURLForCurrentSearch + ); + this.dedupe = new Dedupe(this._dedupeKey); + this.frecentCache = new lazy.LinksCache( + lazy.NewTabUtils.activityStreamLinks, + "getTopSites", + CACHED_LINK_PROPS_TO_MIGRATE, + (oldOptions, newOptions) => + // Refresh if no old options or requesting more items + !(oldOptions.numItems >= newOptions.numItems) + ); + this.pinnedCache = new lazy.LinksCache( + lazy.NewTabUtils.pinnedLinks, + "links", + [...CACHED_LINK_PROPS_TO_MIGRATE, ...PINNED_FAVICON_PROPS_TO_MIGRATE] + ); + lazy.PageThumbs.addExpirationFilter(this); + this._nimbusChangeListener = this._nimbusChangeListener.bind(this); + } + + _nimbusChangeListener(event, reason) { + // The Nimbus API current doesn't specify the changed variable(s) in the + // listener callback, so we have to refresh unconditionally on every change + // of the `newtab` feature. It should be a manageable overhead given the + // current update cadence (6 hours) of Nimbus. + // + // Skip the experiment and rollout loading reasons since this feature has + // `isEarlyStartup` enabled, the feature variables are already available + // before the experiment or rollout loads. + if ( + !["feature-experiment-loaded", "feature-rollout-loaded"].includes(reason) + ) { + this._contile.refresh(); + } + } + + init() { + // If the feed was previously disabled PREFS_INITIAL_VALUES was never received + this._readDefaults({ isStartup: true }); + this._storage = this.store.dbStorage.getDbTable("sectionPrefs"); + this._contile.refresh(); + Services.obs.addObserver(this, "browser-search-engine-modified"); + Services.obs.addObserver(this, "browser-region-updated"); + Services.prefs.addObserver(REMOTE_SETTING_DEFAULTS_PREF, this); + Services.prefs.addObserver(DEFAULT_SITES_OVERRIDE_PREF, this); + Services.prefs.addObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this); + lazy.NimbusFeatures.newtab.onUpdate(this._nimbusChangeListener); + } + + uninit() { + lazy.PageThumbs.removeExpirationFilter(this); + Services.obs.removeObserver(this, "browser-search-engine-modified"); + Services.obs.removeObserver(this, "browser-region-updated"); + Services.prefs.removeObserver(REMOTE_SETTING_DEFAULTS_PREF, this); + Services.prefs.removeObserver(DEFAULT_SITES_OVERRIDE_PREF, this); + Services.prefs.removeObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this); + lazy.NimbusFeatures.newtab.offUpdate(this._nimbusChangeListener); + } + + observe(subj, topic, data) { + switch (topic) { + case "browser-search-engine-modified": + // We should update the current top sites if the search engine has been changed since + // the search engine that gets filtered out of top sites has changed. + // We also need to drop search shortcuts when their engine gets removed / hidden. + if ( + data === "engine-default" && + this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF] + ) { + delete this._currentSearchHostname; + this._currentSearchHostname = getShortURLForCurrentSearch(); + } + this.refresh({ broadcast: true }); + break; + case "browser-region-updated": + this._readDefaults(); + break; + case "nsPref:changed": + if ( + data === REMOTE_SETTING_DEFAULTS_PREF || + data === DEFAULT_SITES_OVERRIDE_PREF || + data.startsWith(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH) + ) { + this._readDefaults(); + } + break; + } + } + + _dedupeKey(site) { + return site && site.hostname; + } + + /** + * _readDefaults - sets DEFAULT_TOP_SITES + */ + async _readDefaults({ isStartup = false } = {}) { + this._useRemoteSetting = false; + + if (!Services.prefs.getBoolPref(REMOTE_SETTING_DEFAULTS_PREF)) { + this.refreshDefaults( + this.store.getState().Prefs.values[DEFAULT_SITES_PREF], + { isStartup } + ); + return; + } + + // Try using default top sites from enterprise policies or tests. The pref + // is locked when set via enterprise policy. Tests have no default sites + // unless they set them via this pref. + if ( + Services.prefs.prefIsLocked(DEFAULT_SITES_OVERRIDE_PREF) || + Cu.isInAutomation + ) { + let sites = Services.prefs.getStringPref(DEFAULT_SITES_OVERRIDE_PREF, ""); + this.refreshDefaults(sites, { isStartup }); + return; + } + + // Clear out the array of any previous defaults. + DEFAULT_TOP_SITES.length = 0; + + // Read defaults from contile. + const contileEnabled = lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_CONTILE_ENABLED + ); + + // Keep the number of positions in the array in sync with CONTILE_MAX_NUM_SPONSORED. + // sponsored_position is a 1-based index, and contilePositions is a 0-based index, + // so we need to add 1 to each of these. + // Also currently this does not work with SOV. + let contilePositions = lazy.NimbusFeatures.pocketNewtab + .getVariable(NIMBUS_VARIABLE_CONTILE_POSITIONS) + ?.split(",") + .map(item => parseInt(item, 10) + 1) + .filter(item => !Number.isNaN(item)); + if (!contilePositions || contilePositions.length === 0) { + contilePositions = [1, 2]; + } + + let hasContileTiles = false; + if (contileEnabled) { + let contilePositionIndex = 0; + // We need to loop through potential spocs and set their positions. + // If we run out of spocs or positions, we stop. + // First, we need to know which array is shortest. This is our exit condition. + const minLength = Math.min( + contilePositions.length, + this._contile.sites.length + ); + // Loop until we run out of spocs or positions. + for (let i = 0; i < minLength; i++) { + let site = this._contile.sites[i]; + let hostname = shortURL(site); + let link = { + isDefault: true, + url: site.url, + hostname, + sendAttributionRequest: false, + label: site.name, + show_sponsored_label: hostname !== "yandex", + sponsored_position: contilePositions[contilePositionIndex++], + sponsored_click_url: site.click_url, + sponsored_impression_url: site.impression_url, + sponsored_tile_id: site.id, + partner: SPONSORED_TILE_PARTNER_AMP, + }; + if (site.image_url && site.image_size >= MIN_FAVICON_SIZE) { + // Only use the image from Contile if it's hi-res, otherwise, fallback + // to the built-in favicons. + link.favicon = site.image_url; + link.faviconSize = site.image_size; + } + DEFAULT_TOP_SITES.push(link); + } + hasContileTiles = contilePositionIndex > 0; + //This is to catch where we receive 3 tiles but reduce to 2 early in the filtering, before blocked list applied. + this._telemetryUtility.determineFilteredTilesAndSetToOversold( + DEFAULT_TOP_SITES + ); + } + + // Read defaults from remote settings. + this._useRemoteSetting = true; + let remoteSettingData = await this._getRemoteConfig(); + + const sponsoredBlocklist = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + + for (let siteData of remoteSettingData) { + let hostname = shortURL(siteData); + // Drop default sites when Contile already provided a sponsored one with + // the same host name. + if ( + contileEnabled && + DEFAULT_TOP_SITES.findIndex(site => site.hostname === hostname) > -1 + ) { + continue; + } + // Also drop those sponsored sites that were blocked by the user before + // with the same hostname. + if ( + siteData.sponsored_position && + sponsoredBlocklist.includes(hostname) + ) { + continue; + } + let link = { + isDefault: true, + url: siteData.url, + hostname, + sendAttributionRequest: !!siteData.send_attribution_request, + }; + if (siteData.url_urlbar_override) { + link.url_urlbar = siteData.url_urlbar_override; + } + if (siteData.title) { + link.label = siteData.title; + } + if (siteData.search_shortcut) { + link = await this.topSiteToSearchTopSite(link); + } else if (siteData.sponsored_position) { + if (contileEnabled && hasContileTiles) { + continue; + } + const { + sponsored_position, + sponsored_tile_id, + sponsored_impression_url, + sponsored_click_url, + } = siteData; + link = { + sponsored_position, + sponsored_tile_id, + sponsored_impression_url, + sponsored_click_url, + show_sponsored_label: link.hostname !== "yandex", + ...link, + }; + } + DEFAULT_TOP_SITES.push(link); + } + + this.refresh({ broadcast: true, isStartup }); + } + + refreshDefaults(sites, { isStartup = false } = {}) { + // Clear out the array of any previous defaults + DEFAULT_TOP_SITES.length = 0; + + // Add default sites if any based on the pref + if (sites) { + for (const url of sites.split(",")) { + const site = { + isDefault: true, + url, + }; + site.hostname = shortURL(site); + DEFAULT_TOP_SITES.push(site); + } + } + + this.refresh({ broadcast: true, isStartup }); + } + + async _getRemoteConfig(firstTime = true) { + if (!this._remoteConfig) { + this._remoteConfig = await lazy.RemoteSettings("top-sites"); + this._remoteConfig.on("sync", () => { + this._readDefaults(); + }); + } + + let result = []; + let failed = false; + try { + result = await this._remoteConfig.get(); + } catch (ex) { + console.error(ex); + failed = true; + } + if (!result.length) { + console.error("Received empty top sites configuration!"); + failed = true; + } + // If we failed, or the result is empty, try loading from the local dump. + if (firstTime && failed) { + await this._remoteConfig.db.clear(); + // Now call this again. + return this._getRemoteConfig(false); + } + + // Sort sites based on the "order" attribute. + result.sort((a, b) => a.order - b.order); + + result = result.filter(topsite => { + // Filter by region. + if (topsite.exclude_regions?.includes(lazy.Region.home)) { + return false; + } + if ( + topsite.include_regions?.length && + !topsite.include_regions.includes(lazy.Region.home) + ) { + return false; + } + + // Filter by locale. + if (topsite.exclude_locales?.includes(Services.locale.appLocaleAsBCP47)) { + return false; + } + if ( + topsite.include_locales?.length && + !topsite.include_locales.includes(Services.locale.appLocaleAsBCP47) + ) { + return false; + } + + // Filter by experiment. + // Exclude this top site if any of the specified experiments are running. + if ( + topsite.exclude_experiments?.some(experimentID => + Services.prefs.getBoolPref( + DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID, + false + ) + ) + ) { + return false; + } + // Exclude this top site if none of the specified experiments are running. + if ( + topsite.include_experiments?.length && + topsite.include_experiments.every( + experimentID => + !Services.prefs.getBoolPref( + DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID, + false + ) + ) + ) { + return false; + } + + return true; + }); + + return result; + } + + filterForThumbnailExpiration(callback) { + const { rows } = this.store.getState().TopSites; + callback( + rows.reduce((acc, site) => { + acc.push(site.url); + if (site.customScreenshotURL) { + acc.push(site.customScreenshotURL); + } + return acc; + }, []) + ); + } + + /** + * shouldFilterSearchTile - is default filtering enabled and does a given hostname match the user's default search engine? + * + * @param {string} hostname a top site hostname, such as "amazon" or "foo" + * @returns {bool} + */ + shouldFilterSearchTile(hostname) { + if ( + this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF] && + (SEARCH_FILTERS.includes(hostname) || + hostname === this._currentSearchHostname) + ) { + return true; + } + return false; + } + + /** + * _maybeInsertSearchShortcuts - if the search shortcuts experiment is running, + * insert search shortcuts if needed + * + * @param {Array} plainPinnedSites (from the pinnedSitesCache) + * @returns {boolean} Did we insert any search shortcuts? + */ + async _maybeInsertSearchShortcuts(plainPinnedSites) { + // Only insert shortcuts if the experiment is running + if (this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) { + // We don't want to insert shortcuts we've previously inserted + const prevInsertedShortcuts = this.store + .getState() + .Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF].split(",") + .filter(s => s); // Filter out empty strings + const newInsertedShortcuts = []; + + let shouldPin = this._useRemoteSetting + ? DEFAULT_TOP_SITES.filter(s => s.searchTopSite).map(s => s.hostname) + : this.store + .getState() + .Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF].split(","); + shouldPin = shouldPin + .map(getSearchProvider) + .filter(s => s && s.shortURL !== this._currentSearchHostname); + + // If we've previously inserted all search shortcuts return early + if ( + shouldPin.every(shortcut => + prevInsertedShortcuts.includes(shortcut.shortURL) + ) + ) { + return false; + } + + const numberOfSlots = + this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW; + + // The plainPinnedSites array is populated with pinned sites at their + // respective indices, and null everywhere else, but is not always the + // right length + const emptySlots = Math.max(numberOfSlots - plainPinnedSites.length, 0); + const pinnedSites = [...plainPinnedSites].concat( + Array(emptySlots).fill(null) + ); + + const tryToInsertSearchShortcut = async shortcut => { + const nextAvailable = pinnedSites.indexOf(null); + // Only add a search shortcut if the site isn't already pinned, we + // haven't previously inserted it, there's space to pin it, and the + // search engine is available in Firefox + if ( + !pinnedSites.find(s => s && shortURL(s) === shortcut.shortURL) && + !prevInsertedShortcuts.includes(shortcut.shortURL) && + nextAvailable > -1 && + (await checkHasSearchEngine(shortcut.keyword)) + ) { + const site = await this.topSiteToSearchTopSite({ url: shortcut.url }); + this._pinSiteAt(site, nextAvailable); + pinnedSites[nextAvailable] = site; + newInsertedShortcuts.push(shortcut.shortURL); + } + }; + + for (let shortcut of shouldPin) { + await tryToInsertSearchShortcut(shortcut); + } + + if (newInsertedShortcuts.length) { + this.store.dispatch( + ac.SetPref( + SEARCH_SHORTCUTS_HAVE_PINNED_PREF, + prevInsertedShortcuts.concat(newInsertedShortcuts).join(",") + ) + ); + return true; + } + } + + return false; + } + + /** + * This thin wrapper around global.fetch makes it easier for us to write + * automated tests that simulate responses from this fetch. + */ + fetch(...args) { + return fetch(...args); + } + + /** + * Fetch topsites spocs from the DiscoveryStream feed. + * + * @returns {Array} An array of sponsored tile objects. + */ + fetchDiscoveryStreamSpocs() { + let sponsored = []; + const { DiscoveryStream } = this.store.getState(); + if (DiscoveryStream) { + const discoveryStreamSpocs = + DiscoveryStream.spocs.data["sponsored-topsites"]?.items || []; + // Find the first component of a type and remove it from layout + const findSponsoredTopsitesPositions = name => { + for (const row of DiscoveryStream.layout) { + for (const component of row.components) { + if (component.placement?.name === name) { + return component.spocs.positions; + } + } + } + return null; + }; + + // Get positions from layout for now. This could be improved if we store position data in state. + const discoveryStreamSpocPositions = + findSponsoredTopsitesPositions("sponsored-topsites"); + + if (discoveryStreamSpocPositions?.length) { + function reformatImageURL(url, width, height) { + // Change the image URL to request a size tailored for the parent container width + // Also: force JPEG, quality 60, no upscaling, no EXIF data + // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html + // For now we wrap this in single quotes because this is being used in a url() css rule, and otherwise would cause a parsing error. + return `'https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent( + url + )}'`; + } + + // We need to loop through potential spocs and set their positions. + // If we run out of spocs or positions, we stop. + // First, we need to know which array is shortest. This is our exit condition. + const minLength = Math.min( + discoveryStreamSpocPositions.length, + discoveryStreamSpocs.length + ); + // Loop until we run out of spocs or positions. + for (let i = 0; i < minLength; i++) { + const positionIndex = discoveryStreamSpocPositions[i].index; + const spoc = discoveryStreamSpocs[i]; + const link = { + favicon: reformatImageURL(spoc.raw_image_src, 96, 96), + faviconSize: 96, + type: "SPOC", + label: spoc.title || spoc.sponsor, + title: spoc.title || spoc.sponsor, + url: spoc.url, + flightId: spoc.flight_id, + id: spoc.id, + guid: spoc.id, + shim: spoc.shim, + // For now we are assuming position based on intended position. + // Actual position can shift based on other content. + // We send the intended position in the ping. + pos: positionIndex, + // Set this so that SPOC topsites won't be shown in the URL bar. + // See Bug 1822027. Note that `sponsored_position` is 1-based. + sponsored_position: positionIndex + 1, + // This is used for topsites deduping. + hostname: shortURL({ url: spoc.url }), + partner: SPONSORED_TILE_PARTNER_MOZ_SALES, + }; + sponsored.push(link); + } + } + } + return sponsored; + } + + // eslint-disable-next-line max-statements + async getLinksWithDefaults(isStartup = false) { + const prefValues = this.store.getState().Prefs.values; + const numItems = prefValues[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW; + const searchShortcutsExperiment = prefValues[SEARCH_SHORTCUTS_EXPERIMENT]; + // We must wait for search services to initialize in order to access default + // search engine properties without triggering a synchronous initialization + try { + await Services.search.init(); + } catch { + // We continue anyway because we want the user to see their sponsored, + // saved, or visited shortcut tiles even if search engines are not + // available. + } + + // Get all frecent sites from history. + let frecent = []; + let cache; + try { + // Request can throw if executing the linkGetter inside LinksCache returns + // a null object. + cache = await this.frecentCache.request({ + // We need to overquery due to the top 5 alexa search + default search possibly being removed + numItems: numItems + SEARCH_FILTERS.length + 1, + topsiteFrecency: FRECENCY_THRESHOLD, + }); + } catch (ex) { + cache = []; + } + + for (let link of cache) { + // The cache can contain null values. + if (!link) { + continue; + } + const hostname = shortURL(link); + if (!this.shouldFilterSearchTile(hostname)) { + frecent.push({ + ...(searchShortcutsExperiment + ? await this.topSiteToSearchTopSite(link) + : link), + hostname, + }); + } + } + + // Get defaults. + let contileSponsored = []; + let notBlockedDefaultSites = []; + for (let link of DEFAULT_TOP_SITES) { + // For sponsored Yandex links, default filtering is reversed: we only + // show them if Yandex is the default search engine. + if (link.sponsored_position && link.hostname === "yandex") { + if (link.hostname !== this._currentSearchHostname) { + continue; + } + } else if (this.shouldFilterSearchTile(link.hostname)) { + continue; + } + // Drop blocked default sites. + if ( + lazy.NewTabUtils.blockedLinks.isBlocked({ + url: link.url, + }) + ) { + continue; + } + // If we've previously blocked a search shortcut, remove the default top site + // that matches the hostname + const searchProvider = getSearchProvider(shortURL(link)); + if ( + searchProvider && + lazy.NewTabUtils.blockedLinks.isBlocked({ url: searchProvider.url }) + ) { + continue; + } + if (link.sponsored_position) { + if (!prefValues[SHOW_SPONSORED_PREF]) { + continue; + } + contileSponsored[link.sponsored_position - 1] = link; + + // Unpin search shortcut if present for the sponsored link to be shown + // instead. + this._unpinSearchShortcut(link.hostname); + } else { + notBlockedDefaultSites.push( + searchShortcutsExperiment + ? await this.topSiteToSearchTopSite(link) + : link + ); + } + } + this._telemetryUtility.determineFilteredTilesAndSetToDismissed( + contileSponsored + ); + + const discoverySponsored = this.fetchDiscoveryStreamSpocs(); + this._telemetryUtility.setTiles(discoverySponsored); + + const sponsored = this._mergeSponsoredLinks({ + [SPONSORED_TILE_PARTNER_AMP]: contileSponsored, + [SPONSORED_TILE_PARTNER_MOZ_SALES]: discoverySponsored, + }); + + this._maybeCapSponsoredLinks(sponsored); + + // This will set all extra tiles to oversold, including moz-sales. + this._telemetryUtility.determineFilteredTilesAndSetToOversold(sponsored); + + // Get pinned links augmented with desired properties + let plainPinned = await this.pinnedCache.request(); + + // Insert search shortcuts if we need to. + // _maybeInsertSearchShortcuts returns true if any search shortcuts are + // inserted, meaning we need to expire and refresh the pinnedCache + if (await this._maybeInsertSearchShortcuts(plainPinned)) { + this.pinnedCache.expire(); + plainPinned = await this.pinnedCache.request(); + } + + const pinned = await Promise.all( + plainPinned.map(async link => { + if (!link) { + return link; + } + + // Drop pinned search shortcuts when their engine has been removed / hidden. + if (link.searchTopSite) { + const searchProvider = getSearchProvider(shortURL(link)); + if ( + !searchProvider || + !(await checkHasSearchEngine(searchProvider.keyword)) + ) { + return null; + } + } + + // Copy all properties from a frecent link and add more + const finder = other => other.url === link.url; + + // Remove frecent link's screenshot if pinned link has a custom one + const frecentSite = frecent.find(finder); + if (frecentSite && link.customScreenshotURL) { + delete frecentSite.screenshot; + } + // If the link is a frecent site, do not copy over 'isDefault', else check + // if the site is a default site + const copy = Object.assign( + {}, + frecentSite || { isDefault: !!notBlockedDefaultSites.find(finder) }, + link, + { hostname: shortURL(link) }, + { searchTopSite: !!link.searchTopSite } + ); + + // Add in favicons if we don't already have it + if (!copy.favicon) { + try { + lazy.NewTabUtils.activityStreamProvider._faviconBytesToDataURI( + await lazy.NewTabUtils.activityStreamProvider._addFavicons([copy]) + ); + + for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) { + copy.__sharedCache.updateLink(prop, copy[prop]); + } + } catch (e) { + // Some issue with favicon, so just continue without one + } + } + + return copy; + }) + ); + + // Remove any duplicates from frecent and default sites + const [, dedupedSponsored, dedupedFrecent, dedupedDefaults] = + this.dedupe.group(pinned, sponsored, frecent, notBlockedDefaultSites); + const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults]; + + // Remove adult sites if we need to + const checkedAdult = lazy.FilterAdult.filter(dedupedUnpinned); + + // Insert the original pinned sites into the deduped frecent and defaults. + let withPinned = insertPinned(checkedAdult, pinned); + // Insert sponsored sites at their desired position. + dedupedSponsored.forEach(link => { + if (!link) { + return; + } + let index = link.sponsored_position - 1; + if (index >= withPinned.length) { + withPinned[index] = link; + } else if (withPinned[index]?.sponsored_position) { + // We currently want DiscoveryStream spocs to replace existing spocs. + withPinned[index] = link; + } else { + withPinned.splice(index, 0, link); + } + }); + // Remove excess items after we inserted sponsored ones. + withPinned = withPinned.slice(0, numItems); + + // Now, get a tippy top icon, a rich icon, or screenshot for every item + for (const link of withPinned) { + if (link) { + // If there is a custom screenshot this is the only image we display + if (link.customScreenshotURL) { + this._fetchScreenshot(link, link.customScreenshotURL, isStartup); + } else if (link.searchTopSite && !link.isDefault) { + await this._attachTippyTopIconForSearchShortcut(link, link.label); + } else { + this._fetchIcon(link, isStartup); + } + + // Remove internal properties that might be updated after dispatch + delete link.__sharedCache; + + // Indicate that these links should get a frecency bonus when clicked + link.typedBonus = true; + } + } + + this._linksWithDefaults = withPinned; + + this._telemetryUtility.finalizeNewtabPingFields(dedupedSponsored); + return withPinned; + } + + /** + * Cap sponsored links if they're more than the specified maximum. + * + * @param {Array} links An array of sponsored links. Capping will be performed in-place. + */ + _maybeCapSponsoredLinks(links) { + // Set maximum sponsored top sites + const maxSponsored = + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_MAX_SPONSORED + ) ?? MAX_NUM_SPONSORED; + if (links.length > maxSponsored) { + links.length = maxSponsored; + } + } + + /** + * Merge sponsored links from all the partners using SOV if present. + * For each tile position, the user is assigned to one partner via stable sampling. + * If the chosen partner doesn't have a tile to serve, another tile from a different + * partner is used as the replacement. + * + * @param {object} sponsoredLinks An object with sponsored links from all the partners. + * @returns {Array} An array of merged sponsored links. + */ + _mergeSponsoredLinks(sponsoredLinks) { + const { positions: allocatedPositions, ready: sovReady } = + this.store.getState().TopSites.sov || {}; + if ( + !this._contile.sov || + !sovReady || + !lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_CONTILE_SOV_ENABLED + ) + ) { + return Object.values(sponsoredLinks).flat(); + } + + // AMP links might have empty slots, remove them as SOV doesn't need those. + sponsoredLinks[SPONSORED_TILE_PARTNER_AMP] = + sponsoredLinks[SPONSORED_TILE_PARTNER_AMP].filter(Boolean); + + let sponsored = []; + let chosenPartners = []; + + for (const allocation of allocatedPositions) { + let link = null; + const { assignedPartner } = allocation; + if (assignedPartner) { + // Unknown partners are allowed so that new parters can be added to Shepherd + // sooner without waiting for client changes. + link = sponsoredLinks[assignedPartner]?.shift(); + } + + if (!link) { + // If the chosen partner doesn't have a tile for this postion, choose any + // one from another group. For simplicity, we do _not_ do resampling here + // against the remaining partners. + for (const partner of SPONSORED_TILE_PARTNERS) { + if ( + partner === assignedPartner || + sponsoredLinks[partner].length === 0 + ) { + continue; + } + link = sponsoredLinks[partner].shift(); + break; + } + + if (!link) { + // No more links to be added across all the partners, just return. + if (chosenPartners.length) { + Glean.newtab.sovAllocation.set( + chosenPartners.map(entry => JSON.stringify(entry)) + ); + } + return sponsored; + } + } + + // Update the position fields. Note that postion is also 1-based in SOV. + link.sponsored_position = allocation.position; + if (link.pos !== undefined) { + // Pocket `pos` is 0-based. + link.pos = allocation.position - 1; + } + sponsored.push(link); + + chosenPartners.push({ + pos: allocation.position, + assigned: assignedPartner, // The assigned partner based on SOV + chosen: link.partner, + }); + } + // Record chosen partners to glean + if (chosenPartners.length) { + Glean.newtab.sovAllocation.set( + chosenPartners.map(entry => JSON.stringify(entry)) + ); + } + + // add the remaining contile sponsoredLinks when nimbus variable present + if ( + lazy.NimbusFeatures.pocketNewtab.getVariable( + NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED + ) + ) { + return sponsored.concat(sponsoredLinks[SPONSORED_TILE_PARTNER_AMP]); + } + + return sponsored; + } + + /** + * Attach TippyTop icon to the given search shortcut + * + * Note that it queries the search form URL from search service For Yandex, + * and uses it to choose the best icon for its shortcut variants. + * + * @param {object} link A link object with a `url` property + * @param {string} keyword Search keyword + */ + async _attachTippyTopIconForSearchShortcut(link, keyword) { + if ( + ["@\u044F\u043D\u0434\u0435\u043A\u0441", "@yandex"].includes(keyword) + ) { + let site = { url: link.url }; + site.url = (await getSearchFormURL(keyword)) || site.url; + this._tippyTopProvider.processSite(site); + link.tippyTopIcon = site.tippyTopIcon; + link.smallFavicon = site.smallFavicon; + link.backgroundColor = site.backgroundColor; + } else { + this._tippyTopProvider.processSite(link); + } + } + + /** + * Refresh the top sites data for content. + * + * @param {object} options + * @param {bool} options.broadcast Should the update be broadcasted. + * @param {bool} options.isStartup Being called while TopSitesFeed is initting. + */ + async refresh(options = {}) { + // Avoiding refreshing if it's already happening. + if (this._refreshing) { + return; + } + if (!this._startedUp && !options.isStartup) { + // Initial refresh still pending. + return; + } + this._refreshing = true; + this._startedUp = true; + + if (!this._tippyTopProvider.initialized) { + await this._tippyTopProvider.init(); + } + + const links = await this.getLinksWithDefaults({ + isStartup: options.isStartup, + }); + const newAction = { type: at.TOP_SITES_UPDATED, data: { links } }; + let storedPrefs; + try { + storedPrefs = (await this._storage.get(SECTION_ID)) || {}; + } catch (e) { + storedPrefs = {}; + console.error("Problem getting stored prefs for TopSites"); + } + newAction.data.pref = getDefaultOptions(storedPrefs); + + if (options.isStartup) { + newAction.meta = { + isStartup: true, + }; + } + + if (options.broadcast) { + // Broadcast an update to all open content pages + this.store.dispatch(ac.BroadcastToContent(newAction)); + } else { + // Don't broadcast only update the state and update the preloaded tab. + this.store.dispatch(ac.AlsoToPreloaded(newAction)); + } + this._refreshing = false; + if (Cu.isInAutomation) { + Services.obs.notifyObservers(null, "topsites-refreshed"); + } + } + + // Allocate ad positions to partners based on SOV via stable randomization. + async allocatePositions() { + // If the fetch to get sov fails for whatever reason, we can just return here. + // Code that uses this falls back to flattening allocations instead if this has failed. + if (!this._contile.sov) { + return; + } + // This sample input should ensure we return the same result for this allocation, + // even if called from other parts of the code. + const sampleInput = `${lazy.contextId}-${this._contile.sov.name}`; + const allocatedPositions = []; + for (const allocation of this._contile.sov.allocations) { + const allocatedPosition = { + position: allocation.position, + }; + allocatedPositions.push(allocatedPosition); + const ratios = allocation.allocation.map(alloc => alloc.percentage); + if (ratios.length) { + const index = await lazy.Sampling.ratioSample(sampleInput, ratios); + allocatedPosition.assignedPartner = + allocation.allocation[index].partner; + } + } + + this.store.dispatch( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: !!allocatedPositions.length, + positions: allocatedPositions, + }, + }) + ); + } + + async updateCustomSearchShortcuts(isStartup = false) { + if (!this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) { + return; + } + + if (!this._tippyTopProvider.initialized) { + await this._tippyTopProvider.init(); + } + + // Populate the state with available search shortcuts + let searchShortcuts = []; + for (const engine of await Services.search.getAppProvidedEngines()) { + const shortcut = CUSTOM_SEARCH_SHORTCUTS.find(s => + engine.aliases.includes(s.keyword) + ); + if (shortcut) { + let clone = { ...shortcut }; + await this._attachTippyTopIconForSearchShortcut(clone, clone.keyword); + searchShortcuts.push(clone); + } + } + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.UPDATE_SEARCH_SHORTCUTS, + data: { searchShortcuts }, + meta: { + isStartup, + }, + }) + ); + } + + async topSiteToSearchTopSite(site) { + const searchProvider = getSearchProvider(shortURL(site)); + if ( + !searchProvider || + !(await checkHasSearchEngine(searchProvider.keyword)) + ) { + return site; + } + return { + ...site, + searchTopSite: true, + label: searchProvider.keyword, + }; + } + + /** + * Get an image for the link preferring tippy top, rich favicon, screenshots. + */ + async _fetchIcon(link, isStartup = false) { + // Nothing to do if we already have a rich icon from the page + if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) { + return; + } + + // Nothing more to do if we can use a default tippy top icon + this._tippyTopProvider.processSite(link); + if (link.tippyTopIcon) { + return; + } + + // Make a request for a better icon + this._requestRichIcon(link.url); + + // Also request a screenshot if we don't have one yet + await this._fetchScreenshot(link, link.url, isStartup); + } + + /** + * Fetch, cache and broadcast a screenshot for a specific topsite. + * + * @param {object} link cached topsite object + * @param {string} url where to fetch the image from + * @param {boolean} isStartup Whether the screenshot is fetched while + * initting TopSitesFeed. + */ + async _fetchScreenshot(link, url, isStartup = false) { + // We shouldn't bother caching screenshots if they won't be shown. + if ( + link.screenshot || + !this.store.getState().Prefs.values[SHOWN_ON_NEWTAB_PREF] + ) { + return; + } + await lazy.Screenshots.maybeCacheScreenshot( + link, + url, + "screenshot", + screenshot => + this.store.dispatch( + ac.BroadcastToContent({ + data: { screenshot, url: link.url }, + type: at.SCREENSHOT_UPDATED, + meta: { + isStartup, + }, + }) + ) + ); + } + + /** + * Dispatch screenshot preview to target or notify if request failed. + * + * @param {string} url The URL used to capture the screenshot + * @param {string} target Id of content process where to dispatch the result + */ + async getScreenshotPreview(url, target) { + const preview = (await lazy.Screenshots.getScreenshotForURL(url)) || ""; + this.store.dispatch( + ac.OnlyToOneContent( + { + data: { url, preview }, + type: at.PREVIEW_RESPONSE, + }, + target + ) + ); + } + + _requestRichIcon(url) { + this.store.dispatch({ + type: at.RICH_ICON_MISSING, + data: { url }, + }); + } + + updateSectionPrefs(collapsed) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.TOP_SITES_PREFS_UPDATED, + data: { pref: collapsed }, + }) + ); + } + + /** + * Inform others that top sites data has been updated due to pinned changes. + */ + _broadcastPinnedSitesUpdated() { + // Pinned data changed, so make sure we get latest + this.pinnedCache.expire(); + + // Refresh to update pinned sites with screenshots, trigger deduping, etc. + this.refresh({ broadcast: true }); + } + + /** + * Pin a site at a specific position saving only the desired keys. + * + * @param customScreenshotURL {string} User set URL of preview image for site + * @param label {string} User set string of custom site name + */ + // To refactor in Bug 1891997 + /* eslint-enable jsdoc/check-param-names */ + async _pinSiteAt({ customScreenshotURL, label, url, searchTopSite }, index) { + const toPin = { url }; + if (label) { + toPin.label = label; + } + if (customScreenshotURL) { + toPin.customScreenshotURL = customScreenshotURL; + } + if (searchTopSite) { + toPin.searchTopSite = searchTopSite; + } + lazy.NewTabUtils.pinnedLinks.pin(toPin, index); + + await this._clearLinkCustomScreenshot({ customScreenshotURL, url }); + } + + async _clearLinkCustomScreenshot(site) { + // If screenshot url changed or was removed we need to update the cached link obj + if (site.customScreenshotURL !== undefined) { + const pinned = await this.pinnedCache.request(); + const link = pinned.find(pin => pin && pin.url === site.url); + if (link && link.customScreenshotURL !== site.customScreenshotURL) { + link.__sharedCache.updateLink("screenshot", undefined); + } + } + } + + /** + * Handle a pin action of a site to a position. + */ + async pin(action) { + let { site, index } = action.data; + index = this._adjustPinIndexForSponsoredLinks(site, index); + // If valid index provided, pin at that position + if (index >= 0) { + await this._pinSiteAt(site, index); + this._broadcastPinnedSitesUpdated(); + } else { + // Bug 1458658. If the top site is being pinned from an 'Add a Top Site' option, + // then we want to make sure to unblock that link if it has previously been + // blocked. We know if the site has been added because the index will be -1. + if (index === -1) { + lazy.NewTabUtils.blockedLinks.unblock({ url: site.url }); + this.frecentCache.expire(); + } + this.insert(action); + } + } + + /** + * Handle an unpin action of a site. + */ + unpin(action) { + const { site } = action.data; + lazy.NewTabUtils.pinnedLinks.unpin(site); + this._broadcastPinnedSitesUpdated(); + } + + unpinAllSearchShortcuts() { + Services.prefs.clearUserPref( + `browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}` + ); + for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) { + if (pinnedLink && pinnedLink.searchTopSite) { + lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink); + } + } + this.pinnedCache.expire(); + } + + _unpinSearchShortcut(vendor) { + for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) { + if ( + pinnedLink && + pinnedLink.searchTopSite && + shortURL(pinnedLink) === vendor + ) { + lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink); + this.pinnedCache.expire(); + + const prevInsertedShortcuts = this.store + .getState() + .Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF].split(","); + this.store.dispatch( + ac.SetPref( + SEARCH_SHORTCUTS_HAVE_PINNED_PREF, + prevInsertedShortcuts.filter(s => s !== vendor).join(",") + ) + ); + break; + } + } + } + + /** + * Reduces the given pinning index by the number of preceding sponsored + * sites, to accomodate for sponsored sites pushing pinned ones to the side, + * effectively increasing their index again. + */ + _adjustPinIndexForSponsoredLinks(site, index) { + if (!this._linksWithDefaults) { + return index; + } + // Adjust insertion index for sponsored sites since their position is + // fixed. + let adjustedIndex = index; + for (let i = 0; i < index; i++) { + const link = this._linksWithDefaults[i]; + if ( + link && + link.sponsored_position && + this._linksWithDefaults[i]?.url !== site.url + ) { + adjustedIndex--; + } + } + return adjustedIndex; + } + + /** + * Insert a site to pin at a position shifting over any other pinned sites. + */ + _insertPin(site, originalIndex, draggedFromIndex) { + let index = this._adjustPinIndexForSponsoredLinks(site, originalIndex); + + // Don't insert any pins past the end of the visible top sites. Otherwise, + // we can end up with a bunch of pinned sites that can never be unpinned again + // from the UI. + const topSitesCount = + this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW; + if (index >= topSitesCount) { + return; + } + + let pinned = lazy.NewTabUtils.pinnedLinks.links; + if (!pinned[index]) { + this._pinSiteAt(site, index); + } else { + pinned[draggedFromIndex] = null; + // Find the hole to shift the pinned site(s) towards. We shift towards the + // hole left by the site being dragged. + let holeIndex = index; + const indexStep = index > draggedFromIndex ? -1 : 1; + while (pinned[holeIndex]) { + holeIndex += indexStep; + } + if (holeIndex >= topSitesCount || holeIndex < 0) { + // There are no holes, so we will effectively unpin the last slot and shifting + // towards it. This only happens when adding a new top site to an already + // fully pinned grid. + holeIndex = topSitesCount - 1; + } + + // Shift towards the hole. + const shiftingStep = holeIndex > index ? -1 : 1; + while (holeIndex !== index) { + const nextIndex = holeIndex + shiftingStep; + this._pinSiteAt(pinned[nextIndex], holeIndex); + holeIndex = nextIndex; + } + this._pinSiteAt(site, index); + } + } + + /** + * Handle an insert (drop/add) action of a site. + */ + async insert(action) { + let { index } = action.data; + // Treat invalid pin index values (e.g., -1, undefined) as insert in the first position + if (!(index > 0)) { + index = 0; + } + + // Inserting a top site pins it in the specified slot, pushing over any link already + // pinned in the slot (unless it's the last slot, then it replaces). + this._insertPin( + action.data.site, + index, + action.data.draggedFromIndex !== undefined + ? action.data.draggedFromIndex + : this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW + ); + + await this._clearLinkCustomScreenshot(action.data.site); + this._broadcastPinnedSitesUpdated(); + } + + updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }) { + // Unpin the deletedShortcuts. + deletedShortcuts.forEach(({ url }) => { + lazy.NewTabUtils.pinnedLinks.unpin({ url }); + }); + + // Pin the addedShortcuts. + const numberOfSlots = + this.store.getState().Prefs.values[ROWS_PREF] * + TOP_SITES_MAX_SITES_PER_ROW; + addedShortcuts.forEach(shortcut => { + // Find first hole in pinnedLinks. + let index = lazy.NewTabUtils.pinnedLinks.links.findIndex(link => !link); + if ( + index < 0 && + lazy.NewTabUtils.pinnedLinks.links.length + 1 < numberOfSlots + ) { + // pinnedLinks can have less slots than the total available. + index = lazy.NewTabUtils.pinnedLinks.links.length; + } + if (index >= 0) { + lazy.NewTabUtils.pinnedLinks.pin(shortcut, index); + } else { + // No slots available, we need to do an insert in first slot and push over other pinned links. + this._insertPin(shortcut, 0, numberOfSlots); + } + }); + + this._broadcastPinnedSitesUpdated(); + } + + onAction(action) { + switch (action.type) { + case at.INIT: + this.init(); + this.updateCustomSearchShortcuts(true /* isStartup */); + break; + case at.SYSTEM_TICK: + this.refresh({ broadcast: false }); + this._contile.periodicUpdate(); + break; + // All these actions mean we need new top sites + case at.PLACES_HISTORY_CLEARED: + case at.PLACES_LINKS_DELETED: + this.frecentCache.expire(); + this.refresh({ broadcast: true }); + break; + case at.PLACES_LINKS_CHANGED: + this.frecentCache.expire(); + this.refresh({ broadcast: false }); + break; + case at.PLACES_LINK_BLOCKED: + this.frecentCache.expire(); + this.pinnedCache.expire(); + this.refresh({ broadcast: true }); + break; + case at.PREF_CHANGED: + switch (action.data.name) { + case DEFAULT_SITES_PREF: + if (!this._useRemoteSetting) { + this.refreshDefaults(action.data.value); + } + break; + case ROWS_PREF: + case FILTER_DEFAULT_SEARCH_PREF: + case SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF: + this.refresh({ broadcast: true }); + break; + case SHOW_SPONSORED_PREF: + if ( + lazy.NimbusFeatures.newtab.getVariable( + NIMBUS_VARIABLE_CONTILE_ENABLED + ) + ) { + this._contile.refresh(); + } else { + this.refresh({ broadcast: true }); + } + if (!action.data.value) { + this._contile._resetContileCachePrefs(); + } + + break; + case SEARCH_SHORTCUTS_EXPERIMENT: + if (action.data.value) { + this.updateCustomSearchShortcuts(); + } else { + this.unpinAllSearchShortcuts(); + } + this.refresh({ broadcast: true }); + } + break; + case at.UPDATE_SECTION_PREFS: + if (action.data.id === SECTION_ID) { + this.updateSectionPrefs(action.data.value); + } + break; + case at.PREFS_INITIAL_VALUES: + if (!this._useRemoteSetting) { + this.refreshDefaults(action.data[DEFAULT_SITES_PREF]); + } + break; + case at.TOP_SITES_PIN: + this.pin(action); + break; + case at.TOP_SITES_UNPIN: + this.unpin(action); + break; + case at.TOP_SITES_INSERT: + this.insert(action); + break; + case at.PREVIEW_REQUEST: + this.getScreenshotPreview(action.data.url, action.meta.fromTarget); + break; + case at.UPDATE_PINNED_SEARCH_SHORTCUTS: + this.updatePinnedSearchShortcuts(action.data); + break; + case at.DISCOVERY_STREAM_SPOCS_UPDATE: + // Refresh to update sponsored topsites. + this.refresh({ broadcast: true, isStartup: action.meta.isStartup }); + break; + case at.UNINIT: + this.uninit(); + break; + } + } +} + +export const TopSites = new _TopSites(); diff --git a/browser/components/topsites/moz.build b/browser/components/topsites/moz.build new file mode 100644 index 0000000000..f8c7a96fa2 --- /dev/null +++ b/browser/components/topsites/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES += ["TopSites.sys.mjs"] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Top Sites") diff --git a/browser/components/topsites/test/unit/test_top_sites.js b/browser/components/topsites/test/unit/test_top_sites.js new file mode 100644 index 0000000000..3de5f43262 --- /dev/null +++ b/browser/components/topsites/test/unit/test_top_sites.js @@ -0,0 +1,3571 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TopSites, DEFAULT_TOP_SITES } = ChromeUtils.importESModule( + "resource:///modules/TopSites.sys.mjs" +); + +const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + shortURL: "resource://activity-stream/lib/ShortURL.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", + Screenshots: "resource://activity-stream/lib/Screenshots.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", + SearchService: "resource://gre/modules/SearchService.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + TOP_SITES_DEFAULT_ROWS: "resource://activity-stream/common/Reducers.sys.mjs", + TOP_SITES_MAX_SITES_PER_ROW: + "resource://activity-stream/common/Reducers.sys.mjs", +}); + +const FAKE_FAVICON = "data987"; +const FAKE_FAVICON_SIZE = 128; +const FAKE_FRECENCY = 200; +const FAKE_LINKS = new Array(2 * TOP_SITES_MAX_SITES_PER_ROW) + .fill(null) + .map((v, i) => ({ + frecency: FAKE_FRECENCY, + url: `http://www.site${i}.com`, + })); +const FAKE_SCREENSHOT = "data123"; +const SEARCH_SHORTCUTS_EXPERIMENT_PREF = "improvesearch.topSiteSearchShortcuts"; +const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF = + "improvesearch.topSiteSearchShortcuts.searchEngines"; +const SEARCH_SHORTCUTS_HAVE_PINNED_PREF = + "improvesearch.topSiteSearchShortcuts.havePinned"; +const SHOWN_ON_NEWTAB_PREF = "feeds.topsites"; +const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; +const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles"; + +// This pref controls how long the contile cache is valid for in seconds. +const CONTILE_CACHE_VALID_FOR_SECONDS_PREF = + "browser.topsites.contile.cacheValidFor"; +// This pref records when the last contile fetch occurred, as a UNIX timestamp +// in seconds. +const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch"; + +function FakeTippyTopProvider() {} +FakeTippyTopProvider.prototype = { + async init() { + this.initialized = true; + }, + processSite(site) { + return site; + }, +}; + +let gSearchServiceInitStub; +let gGetTopSitesStub; + +function stubTopSites(sandbox) { + let cachedStorage = TopSites._storage; + let cachedStore = TopSites.store; + + async function cleanup() { + if (TopSites._refreshing) { + info("Wait for refresh to finish."); + // Wait for refresh to finish or else removing the store while a process + // is running will result in errors. + await TestUtils.topicObserved("topsites-refreshed"); + } + TopSites._tippyTopProvider.initialized = false; + TopSites._storage = cachedStorage; + TopSites.store = cachedStore; + TopSites.pinnedCache.clear(); + TopSites.frecentCache.clear(); + info("Finished cleaning up TopSites."); + } + + const storage = { + init: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + }; + + // Setup for tests that don't call `init` but require feed.storage + TopSites._storage = storage; + TopSites.store = { + dispatch: sinon.spy(), + getState() { + return this.state; + }, + state: { + Prefs: { values: { topSitesRows: 2 } }, + TopSites: { rows: Array(12).fill("site") }, + }, + dbStorage: { getDbTable: sandbox.stub().returns(storage) }, + }; + info("Created mock store for TopSites."); + return cleanup; +} + +add_setup(async () => { + let sandbox = sinon.createSandbox(); + sandbox.stub(SearchService.prototype, "defaultEngine").get(() => { + return { identifier: "ddg", searchForm: "https://duckduckgo.com" }; + }); + + gGetTopSitesStub = sandbox + .stub(NewTabUtils.activityStreamLinks, "getTopSites") + .resolves(FAKE_LINKS); + + gSearchServiceInitStub = sandbox + .stub(SearchService.prototype, "init") + .resolves(); + + sandbox.stub(NewTabUtils.activityStreamProvider, "_faviconBytesToDataURI"); + + sandbox + .stub(NewTabUtils.activityStreamProvider, "_addFavicons") + .callsFake(l => { + return Promise.resolve( + l.map(link => { + link.favicon = FAKE_FAVICON; + link.faviconSize = FAKE_FAVICON_SIZE; + return link; + }) + ); + }); + + sandbox.stub(Screenshots, "getScreenshotForURL").resolves(FAKE_SCREENSHOT); + sandbox.spy(Screenshots, "maybeCacheScreenshot"); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test_construction() { + Assert.ok(TopSites._currentSearchHostname, "_currentSearchHostname defined"); +}); + +add_task(async function test_refreshDefaults() { + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + Assert.ok( + !DEFAULT_TOP_SITES.length, + "Should have 0 DEFAULT_TOP_SITES initially." + ); + + info("refreshDefaults should add defaults on PREFS_INITIAL_VALUES"); + TopSites.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "https://foo.com" }, + }); + + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have 1 DEFAULT_TOP_SITES now." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should add defaults on default.sites PREF_CHANGED"); + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: "default.sites", value: "https://foo.com" }, + }); + + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have 1 DEFAULT_TOP_SITES now." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should refresh on topSiteRows PREF_CHANGED"); + let refreshStub = sandbox.stub(TopSites, "refresh"); + TopSites.onAction({ type: at.PREF_CHANGED, data: { name: "topSitesRows" } }); + Assert.ok(TopSites.refresh.calledOnce, "refresh called"); + refreshStub.restore(); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should have default sites with .isDefault = true"); + TopSites.refreshDefaults("https://foo.com"); + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have a DEFAULT_TOP_SITES now." + ); + Assert.ok( + DEFAULT_TOP_SITES[0].isDefault, + "Lone top site should be the default." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should have default sites with appropriate hostname"); + TopSites.refreshDefaults("https://foo.com"); + Assert.equal( + DEFAULT_TOP_SITES.length, + 1, + "Should have a DEFAULT_TOP_SITES now." + ); + let [site] = DEFAULT_TOP_SITES; + Assert.equal( + site.hostname, + shortURL(site), + "Lone top site should have the right hostname." + ); + + // Reset the DEFAULT_TOP_SITES; + DEFAULT_TOP_SITES.length = 0; + + info("refreshDefaults should add no defaults on empty pref"); + TopSites.refreshDefaults(""); + Assert.equal( + DEFAULT_TOP_SITES.length, + 0, + "Should have 0 DEFAULT_TOP_SITES now." + ); + + info("refreshDefaults should be able to clear defaults"); + TopSites.refreshDefaults("https://foo.com"); + TopSites.refreshDefaults(""); + + Assert.equal( + DEFAULT_TOP_SITES.length, + 0, + "Should have 0 DEFAULT_TOP_SITES now." + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_filterForThumbnailExpiration() { + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + + info( + "filterForThumbnailExpiration should pass rows.urls to the callback provided" + ); + const rows = [ + { url: "foo.com" }, + { url: "bar.com", customScreenshotURL: "custom" }, + ]; + TopSites.store.state.TopSites = { rows }; + const stub = sandbox.stub(); + TopSites.filterForThumbnailExpiration(stub); + Assert.ok(stub.calledOnce); + Assert.ok(stub.calledWithExactly(["foo.com", "bar.com", "custom"])); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_on_SearchService_init_failure() { + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + + TopSites.refreshDefaults("https://foo.com"); + + gSearchServiceInitStub.rejects( + new Error("Simulating search init failures") + ); + + const result = await TopSites.getLinksWithDefaults(); + Assert.ok(result); + + gSearchServiceInitStub.resolves(); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults() { + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + + let sandbox = sinon.createSandbox(); + let cleanup = stubTopSites(sandbox); + + TopSites.refreshDefaults("https://foo.com"); + + info("getLinksWithDefaults should get the links from NewTabUtils"); + let result = await TopSites.getLinksWithDefaults(); + + const reference = FAKE_LINKS.map(site => + Object.assign({}, site, { + hostname: shortURL(site), + typedBonus: true, + }) + ); + + Assert.deepEqual(result, reference); + Assert.ok(NewTabUtils.activityStreamLinks.getTopSites.calledOnce); + + info("getLinksWithDefaults should indicate the links get typed bonus"); + Assert.ok(result[0].typedBonus, "Expected typed bonus property to be true."); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_filterAdult() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should filter out non-pinned adult sites"); + + sandbox.stub(FilterAdult, "filter").returns([]); + const TEST_URL = "https://foo.com/"; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [{ url: TEST_URL }]); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const result = await TopSites.getLinksWithDefaults(); + Assert.ok(FilterAdult.filter.calledOnce); + Assert.equal(result.length, 1); + Assert.equal(result[0].url, TEST_URL); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_caching() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should filter out the defaults that have been blocked" + ); + // make sure we only have one top site, and we block the only default site we have to show + const url = "www.myonlytopsite.com"; + const topsite = { + frecency: FAKE_FRECENCY, + hostname: shortURL({ url }), + typedBonus: true, + url, + }; + + const blockedDefaultSite = { url: "https://foo.com" }; + gGetTopSitesStub.resolves([topsite]); + sandbox.stub(NewTabUtils.blockedLinks, "isBlocked").callsFake(site => { + return site.url === blockedDefaultSite.url; + }); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + const result = await TopSites.getLinksWithDefaults(); + + // what we should be left with is just the top site we added, and not the default site we blocked + Assert.equal(result.length, 1); + Assert.deepEqual(result[0], topsite); + let foundBlocked = result.find(site => site.url === blockedDefaultSite.url); + Assert.ok(!foundBlocked, "Should not have found blocked site."); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_dedupe() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should call dedupe.group on the links"); + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let stub = sandbox.stub(TopSites.dedupe, "group").callsFake((...id) => id); + await TopSites.getLinksWithDefaults(); + + Assert.ok(stub.calledOnce, "dedupe.group was called once"); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test__dedupe_key() { + let sandbox = sinon.createSandbox(); + + info("_dedupeKey should dedupe on hostname instead of url"); + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let site = { url: "foo", hostname: "bar" }; + let result = TopSites._dedupeKey(site); + + Assert.equal(result, site.hostname, "deduped on hostname"); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_adds_defaults() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should add defaults if there are are not enough links" + ); + const TEST_LINKS = [{ frecency: FAKE_FRECENCY, url: "foo.com" }]; + gGetTopSitesStub.resolves(TEST_LINKS); + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let result = await TopSites.getLinksWithDefaults(); + + let reference = [...TEST_LINKS, ...DEFAULT_TOP_SITES].map(s => + Object.assign({}, s, { + hostname: shortURL(s), + typedBonus: true, + }) + ); + + Assert.deepEqual(result, reference); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_adds_defaults_for_visible_slots() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should only add defaults up to the number of visible slots" + ); + const numVisible = TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW; + let testLinks = []; + for (let i = 0; i < numVisible - 1; i++) { + testLinks.push({ frecency: FAKE_FRECENCY, url: `foo${i}.com` }); + } + gGetTopSitesStub.resolves(testLinks); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let result = await TopSites.getLinksWithDefaults(); + + let reference = [...testLinks, DEFAULT_TOP_SITES[0]].map(s => + Object.assign({}, s, { + hostname: shortURL(s), + typedBonus: true, + }) + ); + + Assert.equal(result.length, numVisible); + Assert.deepEqual(result, reference); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_no_throw_on_no_links() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should not throw if NewTabUtils returns null"); + gGetTopSitesStub.resolves(null); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + await TopSites.getLinksWithDefaults(); + Assert.ok(true, "getLinksWithDefaults did not throw"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_get_more_on_request() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should get more if the user has asked for more"); + let testLinks = new Array(4 * TOP_SITES_MAX_SITES_PER_ROW) + .fill(null) + .map((v, i) => ({ + frecency: FAKE_FRECENCY, + url: `http://www.site${i}.com`, + })); + gGetTopSitesStub.resolves(testLinks); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const TEST_ROWS = 3; + TopSites.store.state.Prefs.values.topSitesRows = TEST_ROWS; + + let result = await TopSites.getLinksWithDefaults(); + Assert.equal(result.length, TEST_ROWS * TOP_SITES_MAX_SITES_PER_ROW); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_reuse_cache() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should reuse the cache on subsequent calls"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + await TopSites.getLinksWithDefaults(); + await TopSites.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_ignore_cache_on_requesting_more() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should ignore the cache when requesting more"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + await TopSites.getLinksWithDefaults(); + TopSites.store.state.Prefs.values.topSitesRows *= 3; + await TopSites.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledTwice, + "getTopSites called twice" + ); + + sandbox.restore(); + await cleanup(); + } +); + +add_task( + async function test_getLinksWithDefaults_migrate_frecent_screenshot_data() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should migrate frecent screenshot data without getting screenshots again" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + TopSites.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + await TopSites.getLinksWithDefaults(); + + let originalCallCount = Screenshots.getScreenshotForURL.callCount; + TopSites.frecentCache.expire(); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledTwice, + "getTopSites called twice" + ); + Assert.equal( + Screenshots.getScreenshotForURL.callCount, + originalCallCount, + "getScreenshotForURL was not called again." + ); + Assert.equal(result[0].screenshot, FAKE_SCREENSHOT); + + sandbox.restore(); + await cleanup(); + } +); + +add_task( + async function test_getLinksWithDefaults_migrate_pinned_favicon_data() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should migrate pinned favicon data without getting favicons again" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + await TopSites.getLinksWithDefaults(); + + let originalCallCount = + NewTabUtils.activityStreamProvider._addFavicons.callCount; + TopSites.pinnedCache.expire(); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal( + NewTabUtils.activityStreamProvider._addFavicons.callCount, + originalCallCount, + "_addFavicons was not called again." + ); + Assert.equal(result[0].favicon, FAKE_FAVICON); + Assert.equal(result[0].faviconSize, FAKE_FAVICON_SIZE); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_no_internal_properties() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not expose internal link properties"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + let result = await TopSites.getLinksWithDefaults(); + + let internal = Object.keys(result[0]).filter(key => key.startsWith("__")); + Assert.equal(internal.join(""), ""); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_copy_frecent_screenshot() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should copy the screenshot of the frecent site if " + + "pinned site doesn't have customScreenshotURL" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const TEST_SCREENSHOT = "screenshot"; + + gGetTopSitesStub.resolves([ + { url: "https://foo.com/", screenshot: TEST_SCREENSHOT }, + ]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, TEST_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_no_copy_frecent_screenshot() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should not copy the frecent screenshot if " + + "customScreenshotURL is set" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resolves([ + { url: "https://foo.com/", screenshot: "screenshot" }, + ]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/", customScreenshotURL: "custom" }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, undefined); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_persist_screenshot() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should keep the same screenshot if no frecent site is found" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const CUSTOM_SCREENSHOT = "custom"; + + gGetTopSitesStub.resolves([]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/", screenshot: CUSTOM_SCREENSHOT }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, CUSTOM_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_no_overwrite_pinned_screenshot() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not overwrite pinned site screenshot"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const EXISTING_SCREENSHOT = "some-screenshot"; + + gGetTopSitesStub.resolves([{ url: "https://foo.com/", screenshot: "foo" }]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + { url: "https://foo.com/", screenshot: EXISTING_SCREENSHOT }, + ]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, EXISTING_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); + } +); + +add_task( + async function test_getLinksWithDefaults_no_searchTopSite_from_frecent() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not set searchTopSite from frecent site"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + const EXISTING_SCREENSHOT = "some-screenshot"; + + gGetTopSitesStub.resolves([ + { + url: "https://foo.com/", + searchTopSite: true, + screenshot: EXISTING_SCREENSHOT, + }, + ]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + let result = await TopSites.getLinksWithDefaults(); + + Assert.ok(!result[0].searchTopSite); + // But it should copy over other properties + Assert.equal(result[0].screenshot, EXISTING_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_concurrency_getTopSites() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults concurrent calls should call the backing data once" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + + await Promise.all([ + TopSites.getLinksWithDefaults(), + TopSites.getLinksWithDefaults(), + ]); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_getLinksWithDefaults_concurrency_getScreenshotForURL() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults concurrent calls should call the backing data once" + ); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + TopSites.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + Screenshots.getScreenshotForURL.resetHistory(); + + await Promise.all([ + TopSites.getLinksWithDefaults(), + TopSites.getLinksWithDefaults(), + ]); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + Assert.equal( + Screenshots.getScreenshotForURL.callCount, + FAKE_LINKS.length, + "getLinksWithDefaults concurrent calls should get screenshots once per link" + ); + await cleanup(); + + cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + + TopSites.refreshDefaults("https://foo.com"); + + sandbox.stub(TopSites, "_requestRichIcon"); + await Promise.all([ + TopSites.getLinksWithDefaults(), + TopSites.getLinksWithDefaults(), + ]); + + Assert.equal( + TopSites.store.dispatch.callCount, + FAKE_LINKS.length, + "getLinksWithDefaults concurrent calls should dispatch once per link screenshot fetched" + ); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_getLinksWithDefaults_deduping_no_dedupe_pinned() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not dedupe pinned sites"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults("https://foo.com"); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + { url: "https://developer.mozilla.org/en-US/docs/Web" }, + { url: "https://developer.mozilla.org/en-US/docs/Learn" }, + ]); + + let sites = await TopSites.getLinksWithDefaults(); + Assert.equal(sites.length, 2 * TOP_SITES_MAX_SITES_PER_ROW); + Assert.equal(sites[0].url, NewTabUtils.pinnedLinks.links[0].url); + Assert.equal(sites[1].url, NewTabUtils.pinnedLinks.links[1].url); + Assert.equal(sites[0].hostname, sites[1].hostname); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_prefer_pinned_sites() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should prefer pinned sites over links"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + { url: "https://developer.mozilla.org/en-US/docs/Web" }, + { url: "https://developer.mozilla.org/en-US/docs/Learn" }, + ]); + + const SECOND_TOP_SITE_URL = "https://www.mozilla.org/"; + + gGetTopSitesStub.resolves([ + { frecency: FAKE_FRECENCY, url: "https://developer.mozilla.org/" }, + { frecency: FAKE_FRECENCY, url: SECOND_TOP_SITE_URL }, + ]); + + let sites = await TopSites.getLinksWithDefaults(); + + // Expecting 3 links where there's 2 pinned and 1 www.mozilla.org, so + // the frecent with matching hostname as pinned is removed. + Assert.equal(sites.length, 3); + Assert.equal(sites[0].url, NewTabUtils.pinnedLinks.links[0].url); + Assert.equal(sites[1].url, NewTabUtils.pinnedLinks.links[1].url); + Assert.equal(sites[2].url, SECOND_TOP_SITE_URL); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_title_and_null() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should return sites that have a title"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://github.com/mozilla/activity-stream" }]); + + let sites = await TopSites.getLinksWithDefaults(); + for (let site of sites) { + Assert.ok(site.hostname); + } + + info("getLinksWithDefaults should not throw for null entries"); + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [null]); + await TopSites.getLinksWithDefaults(); + Assert.ok(true, "getLinksWithDefaults didn't throw"); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_calls__fetchIcon() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should return sites that have a title"); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox.spy(TopSites, "_fetchIcon"); + let results = await TopSites.getLinksWithDefaults(); + Assert.ok(results.length, "Got back some results"); + Assert.equal(TopSites._fetchIcon.callCount, results.length); + for (let result of results) { + Assert.ok(TopSites._fetchIcon.calledWith(result)); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_calls__fetchScreenshot() { + let sandbox = sinon.createSandbox(); + + info( + "getLinksWithDefaults should call _fetchScreenshot when customScreenshotURL is set" + ); + + gGetTopSitesStub.resolves([]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com", customScreenshotURL: "custom" }]); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + sandbox.stub(TopSites, "_fetchScreenshot"); + await TopSites.getLinksWithDefaults(); + + Assert.ok(TopSites._fetchScreenshot.calledWith(sinon.match.object, "custom")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getLinksWithDefaults_with_DiscoveryStream() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults should add a sponsored topsite from discoverystream to all the valid indices" + ); + + let makeStreamData = index => ({ + layout: [ + { + components: [ + { + placement: { + name: "sponsored-topsites", + }, + spocs: { + positions: [{ index }], + }, + }, + ], + }, + ], + spocs: { + data: { + "sponsored-topsites": { + items: [{ title: "test spoc", url: "https://test-spoc.com" }], + }, + }, + }, + }); + + let cleanup = stubTopSites(sandbox); + TopSites.refreshDefaults(); + + for (let i = 0; i < FAKE_LINKS.length; i++) { + TopSites.store.state.DiscoveryStream = makeStreamData(i); + const result = await TopSites.getLinksWithDefaults(); + const link = result[i]; + + Assert.equal(link.type, "SPOC"); + Assert.equal(link.title, "test spoc"); + Assert.equal(link.sponsored_position, i + 1); + Assert.equal(link.hostname, "test-spoc"); + Assert.equal(link.url, "https://test-spoc.com"); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_init() { + let sandbox = sinon.createSandbox(); + + sandbox.stub(NimbusFeatures.newtab, "onUpdate"); + + let cleanup = stubTopSites(sandbox); + + sandbox.stub(TopSites, "refresh"); + await TopSites.init(); + + info("TopSites.init should call refresh (broadcast: true)"); + Assert.ok(TopSites.refresh.calledOnce, "refresh called once"); + Assert.ok( + TopSites.refresh.calledWithExactly({ + broadcast: true, + isStartup: true, + }) + ); + + info("TopSites.init should initialise the storage"); + Assert.ok( + TopSites.store.dbStorage.getDbTable.calledOnce, + "getDbTable called once" + ); + Assert.ok( + TopSites.store.dbStorage.getDbTable.calledWithExactly("sectionPrefs") + ); + + info("TopSites.init should call onUpdate to set up Nimbus update listener"); + + Assert.ok( + NimbusFeatures.newtab.onUpdate.calledOnce, + "NimbusFeatures.newtab.onUpdate called once" + ); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh() { + let sandbox = sinon.createSandbox(); + + sandbox.stub(NimbusFeatures.newtab, "onUpdate"); + + let cleanup = stubTopSites(sandbox); + + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + info("TopSites.refresh should wait for tippytop to initialize"); + TopSites._tippyTopProvider.initialized = false; + sandbox.stub(TopSites._tippyTopProvider, "init").resolves(); + + await TopSites.refresh(); + + Assert.ok( + TopSites._tippyTopProvider.init.calledOnce, + "TopSites._tippyTopProvider.init called once" + ); + + info( + "TopSites.refresh should not init the tippyTopProvider if already initialized" + ); + TopSites._tippyTopProvider.initialized = true; + TopSites._tippyTopProvider.init.resetHistory(); + + await TopSites.refresh(); + + Assert.ok( + TopSites._tippyTopProvider.init.notCalled, + "tippyTopProvider not initted again" + ); + + info("TopSites.refresh should broadcast TOP_SITES_UPDATED"); + TopSites.store.dispatch.resetHistory(); + sandbox.stub(TopSites, "getLinksWithDefaults").resolves([]); + + await TopSites.refresh({ broadcast: true }); + + Assert.ok(TopSites.store.dispatch.calledOnce, "dispatch called once"); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.BroadcastToContent({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_dispatch() { + let sandbox = sinon.createSandbox(); + + info("TopSites.refresh should dispatch an action with the links returned"); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + await TopSites.refresh({ broadcast: true }); + let reference = FAKE_LINKS.map(site => + Object.assign({}, site, { + hostname: shortURL(site), + typedBonus: true, + }) + ); + + Assert.ok(TopSites.store.dispatch.calledOnce, "Store.dispatch called once"); + Assert.equal( + TopSites.store.dispatch.firstCall.args[0].type, + at.TOP_SITES_UPDATED + ); + Assert.deepEqual( + TopSites.store.dispatch.firstCall.args[0].data.links, + reference + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_empty_slots() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.refresh should handle empty slots in the resulting top sites array" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + gGetTopSitesStub.resolves([FAKE_LINKS[0]]); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + null, + null, + FAKE_LINKS[1], + null, + null, + null, + null, + null, + FAKE_LINKS[2], + ]); + + await TopSites.refresh({ broadcast: true }); + + Assert.ok(TopSites.store.dispatch.calledOnce, "Store.dispatch called once"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_to_preloaded() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.refresh should dispatch AlsoToPreloaded when broadcast is false" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + gGetTopSitesStub.resolves([]); + await TopSites.refresh({ broadcast: false }); + + Assert.ok(TopSites.store.dispatch.calledOnce, "Store.dispatch called once"); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.AlsoToPreloaded({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ) + ); + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_init_storage() { + let sandbox = sinon.createSandbox(); + + info("TopSites.refresh should not init storage of it's already initialized"); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + TopSites._storage.initialized = true; + + await TopSites.refresh({ broadcast: false }); + + Assert.ok( + TopSites._storage.init.notCalled, + "TopSites._storage.init was not called." + ); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_refresh_handles_indexedDB_errors() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.refresh should dispatch AlsoToPreloaded when broadcast is false" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "_fetchIcon"); + TopSites._startedUp = true; + + TopSites._storage.get.throws(new Error()); + + try { + await TopSites.refresh({ broadcast: false }); + Assert.ok(true, "refresh should have succeeded"); + } catch (e) { + Assert.ok(false, "Should not have thrown"); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_updateSectionPrefs_on_UPDATE_SECTION_PREFS() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.onAction should call updateSectionPrefs on UPDATE_SECTION_PREFS" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "updateSectionPrefs"); + TopSites.onAction({ + type: at.UPDATE_SECTION_PREFS, + data: { id: "topsites" }, + }); + + Assert.ok( + TopSites.updateSectionPrefs.calledOnce, + "TopSites.updateSectionPrefs called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task( + async function test_updateSectionPrefs_dispatch_TOP_SITES_PREFS_UPDATED() { + let sandbox = sinon.createSandbox(); + + info("TopSites.updateSectionPrefs should dispatch TOP_SITES_PREFS_UPDATED"); + + let cleanup = stubTopSites(sandbox); + await TopSites.updateSectionPrefs({ collapsed: true }); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.BroadcastToContent({ + type: at.TOP_SITES_PREFS_UPDATED, + data: { pref: { collapsed: true } }, + }) + ) + ); + + sandbox.restore(); + await cleanup(); + } +); + +add_task(async function test_allocatePositions() { + let sandbox = sinon.createSandbox(); + + info("TopSites.allocationPositions should allocate positions and dispatch"); + + let cleanup = stubTopSites(sandbox); + + let sov = { + name: "SOV-20230518215316", + allocations: [ + { + position: 1, + allocation: [ + { + partner: "amp", + percentage: 100, + }, + { + partner: "moz-sales", + percentage: 0, + }, + ], + }, + { + position: 2, + allocation: [ + { + partner: "amp", + percentage: 80, + }, + { + partner: "moz-sales", + percentage: 20, + }, + ], + }, + ], + }; + + sandbox.stub(TopSites._contile, "sov").get(() => sov); + + sandbox.stub(Sampling, "ratioSample"); + Sampling.ratioSample.onCall(0).resolves(0); + Sampling.ratioSample.onCall(1).resolves(1); + + await TopSites.allocatePositions(); + + Assert.ok( + TopSites.store.dispatch.calledOnce, + "TopSites.store.dispatch called once" + ); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: true, + positions: [ + { position: 1, assignedPartner: "amp" }, + { position: 2, assignedPartner: "moz-sales" }, + ], + }, + }) + ) + ); + + Sampling.ratioSample.onCall(2).resolves(0); + Sampling.ratioSample.onCall(3).resolves(0); + + await TopSites.allocatePositions(); + + Assert.ok( + TopSites.store.dispatch.calledTwice, + "TopSites.store.dispatch called twice" + ); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: true, + positions: [ + { position: 1, assignedPartner: "amp" }, + { position: 2, assignedPartner: "amp" }, + ], + }, + }) + ) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getScreenshotPreview() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.getScreenshotPreview should dispatch preview if request is succesful" + ); + + let cleanup = stubTopSites(sandbox); + await TopSites.getScreenshotPreview("custom", 1234); + + Assert.ok(TopSites.store.dispatch.calledOnce); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: FAKE_SCREENSHOT, url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_getScreenshotPreview() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.getScreenshotPreview should return empty string if request fails" + ); + + let cleanup = stubTopSites(sandbox); + Screenshots.getScreenshotForURL.resolves(Promise.resolve(null)); + await TopSites.getScreenshotPreview("custom", 1234); + + Assert.ok(TopSites.store.dispatch.calledOnce); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: "", url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + Screenshots.getScreenshotForURL.resolves(FAKE_SCREENSHOT); + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_onAction_part_1() { + let sandbox = sinon.createSandbox(); + + info("TopSites.onAction should call getScreenshotPreview on PREVIEW_REQUEST"); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "getScreenshotPreview"); + TopSites.onAction({ + type: at.PREVIEW_REQUEST, + data: { url: "foo" }, + meta: { fromTarget: 1234 }, + }); + + Assert.ok( + TopSites.getScreenshotPreview.calledOnce, + "TopSites.getScreenshotPreview called once" + ); + Assert.ok(TopSites.getScreenshotPreview.calledWithExactly("foo", 1234)); + + info("TopSites.onAction should refresh on SYSTEM_TICK"); + sandbox.stub(TopSites, "refresh"); + TopSites.onAction({ type: at.SYSTEM_TICK }); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: false })); + + info( + "TopSites.onAction should call with correct parameters on TOP_SITES_PIN" + ); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.spy(TopSites, "pin"); + + let pinAction = { + type: at.TOP_SITES_PIN, + data: { site: { url: "foo.com" }, index: 7 }, + }; + TopSites.onAction(pinAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWithExactly( + pinAction.data.site, + pinAction.data.index + ) + ); + Assert.ok( + TopSites.pin.calledOnce, + "TopSites.onAction should call pin on TOP_SITES_PIN" + ); + + info( + "TopSites.onAction should unblock a previously blocked top site if " + + "we are now adding it manually via 'Add a Top Site' option" + ); + sandbox.stub(NewTabUtils.blockedLinks, "unblock"); + pinAction = { + type: at.TOP_SITES_PIN, + data: { site: { url: "foo.com" }, index: -1 }, + }; + TopSites.onAction(pinAction); + Assert.ok( + NewTabUtils.blockedLinks.unblock.calledWith({ + url: pinAction.data.site.url, + }) + ); + + info("TopSites.onAction should call insert on TOP_SITES_INSERT"); + sandbox.stub(TopSites, "insert"); + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + TopSites.onAction(addAction); + Assert.ok(TopSites.insert.calledOnce, "TopSites.insert called once"); + + info( + "TopSites.onAction should call unpin with correct parameters " + + "on TOP_SITES_UNPIN" + ); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [ + null, + null, + { url: "foo.com" }, + null, + null, + null, + null, + null, + FAKE_LINKS[0], + ]); + sandbox.stub(NewTabUtils.pinnedLinks, "unpin"); + + let unpinAction = { + type: at.TOP_SITES_UNPIN, + data: { site: { url: "foo.com" } }, + }; + TopSites.onAction(unpinAction); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledOnce, + "NewTabUtils.pinnedLinks.unpin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.unpin.calledWith(unpinAction.data.site)); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_onAction_part_2() { + let sandbox = sinon.createSandbox(); + + info( + "TopSites.onAction should call refresh without a target if we clear " + + "history with PLACES_HISTORY_CLEARED" + ); + + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "refresh"); + TopSites.onAction({ type: at.PLACES_HISTORY_CLEARED }); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: true })); + + TopSites.refresh.resetHistory(); + + info( + "TopSites.onAction should call refresh without a target " + + "if we remove a Topsite from history" + ); + TopSites.onAction({ type: at.PLACES_LINKS_DELETED }); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: true })); + + info("TopSites.onAction should call init on INIT action"); + TopSites.onAction({ type: at.PLACES_LINKS_DELETED }); + sandbox.stub(TopSites, "init"); + TopSites.onAction({ type: at.INIT }); + Assert.ok(TopSites.init.calledOnce, "TopSites.init called once"); + + info("TopSites.onAction should call refresh on PLACES_LINK_BLOCKED action"); + TopSites.refresh.resetHistory(); + await TopSites.onAction({ type: at.PLACES_LINK_BLOCKED }); + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: true })); + + info("TopSites.onAction should call refresh on PLACES_LINKS_CHANGED action"); + TopSites.refresh.resetHistory(); + await TopSites.onAction({ type: at.PLACES_LINKS_CHANGED }); + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + Assert.ok(TopSites.refresh.calledWithExactly({ broadcast: false })); + + info( + "TopSites.onAction should call pin with correct args on " + + "TOP_SITES_INSERT without an index specified" + ); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.bar", label: "foo" } }, + }; + TopSites.onAction(addAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(addAction.data.site, 0)); + + info( + "TopSites.onAction should call pin with correct args on " + + "TOP_SITES_INSERT" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + let dropAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.bar", label: "foo" }, index: 3 }, + }; + TopSites.onAction(dropAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(dropAction.data.site, 3)); + + // TopSites.init needs to actually run in order to register the observers that'll + // be removed in the following UNINIT test, otherwise uninit will throw. + TopSites.init.restore(); + TopSites.init(); + + info("TopSites.onAction should remove the expiration filter on UNINIT"); + sandbox.stub(PageThumbs, "removeExpirationFilter"); + TopSites.onAction({ type: "UNINIT" }); + Assert.ok( + PageThumbs.removeExpirationFilter.calledOnce, + "PageThumbs.removeExpirationFilter called once" + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_onAction_part_3() { + let sandbox = sinon.createSandbox(); + + let cleanup = stubTopSites(sandbox); + + info( + "TopSites.onAction should call updatePinnedSearchShortcuts " + + "on UPDATE_PINNED_SEARCH_SHORTCUTS action" + ); + sandbox.stub(TopSites, "updatePinnedSearchShortcuts"); + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + await TopSites.onAction({ + type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, + data: { addedShortcuts }, + }); + Assert.ok( + TopSites.updatePinnedSearchShortcuts.calledOnce, + "TopSites.updatePinnedSearchShortcuts called once" + ); + + info( + "TopSites.onAction should refresh from Contile on " + + "SHOW_SPONSORED_PREF if Contile is enabled" + ); + sandbox.spy(TopSites._contile, "refresh"); + let prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF }, + }; + sandbox.stub(NimbusFeatures.newtab, "getVariable").returns(true); + TopSites.onAction(prefChangeAction); + + Assert.ok( + TopSites._contile.refresh.calledOnce, + "TopSites._contile.refresh called once" + ); + + info( + "TopSites.onAction should not refresh from Contile on " + + "SHOW_SPONSORED_PREF if Contile is disabled" + ); + NimbusFeatures.newtab.getVariable.returns(false); + TopSites._contile.refresh.resetHistory(); + TopSites.onAction(prefChangeAction); + + Assert.ok( + !TopSites._contile.refresh.calledOnce, + "TopSites._contile.refresh never called" + ); + + info( + "TopSites.onAction should reset Contile cache prefs " + + "when SHOW_SPONSORED_PREF is false" + ); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 15 * 60); + prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF, value: false }, + }; + NimbusFeatures.newtab.getVariable.returns(true); + TopSites._contile.refresh.resetHistory(); + + TopSites.onAction(prefChangeAction); + Assert.ok(!Services.prefs.prefHasUserValue(CONTILE_CACHE_PREF)); + Assert.ok(!Services.prefs.prefHasUserValue(CONTILE_CACHE_LAST_FETCH_PREF)); + Assert.ok( + !Services.prefs.prefHasUserValue(CONTILE_CACHE_VALID_FOR_SECONDS_PREF) + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_insert_part_1() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + let cleanup = stubTopSites(sandbox); + + info("TopSites.insert should pin site in first slot of empty pinned list"); + Screenshots.getScreenshotForURL.resolves(Promise.resolve(null)); + await TopSites.getScreenshotPreview("custom", 1234); + + Assert.ok(TopSites.store.dispatch.calledOnce); + Assert.ok( + TopSites.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: "", url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + Screenshots.getScreenshotForURL.resolves(FAKE_SCREENSHOT); + + { + info( + "TopSites.insert should pin site in first slot of pinned list with " + + "empty first slot" + ); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + let site = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ data: { site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move a pinned site in first slot to the " + + "next slot: part 1" + ); + let site1 = { url: "example.com" }; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [site1]); + let site = { url: "foo.bar", label: "foo" }; + + await TopSites.insert({ data: { site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move a pinned site in first slot to the " + + "next slot: part 2" + ); + let site1 = { url: "example.com" }; + let site2 = { url: "example.org" }; + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [site1, null, site2]); + let site = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ data: { site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_insert_part_2() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + let cleanup = stubTopSites(sandbox); + + { + info( + "TopSites.insert should unpin the last site if all slots are " + + "already pinned" + ); + let site1 = { url: "example.com" }; + let site2 = { url: "example.org" }; + let site3 = { url: "example.net" }; + let site4 = { url: "example.biz" }; + let site5 = { url: "example.info" }; + let site6 = { url: "example.news" }; + let site7 = { url: "example.lol" }; + let site8 = { url: "example.golf" }; + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [site1, site2, site3, site4, site5, site6, site7, site8]); + TopSites.store.state.Prefs.values.topSitesRows = 1; + let site = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ data: { site } }); + Assert.equal( + NewTabUtils.pinnedLinks.pin.callCount, + 8, + "NewTabUtils.pinnedLinks.pin called 8 times" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 2)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site3, 3)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site4, 4)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site5, 5)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site6, 6)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site7, 7)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info("TopSites.insert should trigger refresh on TOP_SITES_INSERT"); + sandbox.stub(TopSites, "refresh"); + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + await TopSites.insert(addAction); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + } + + { + info("TopSites.insert should correctly handle different index values"); + let index = -1; + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index, site } }; + + await TopSites.insert(action); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + + index = undefined; + await TopSites.insert(action); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_insert_part_3() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + let cleanup = stubTopSites(sandbox); + + { + info("TopSites.insert should pin site in specified slot that is free"); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + + await TopSites.insert({ data: { index: 2, site, draggedFromIndex: 0 } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move a pinned site in specified slot " + + "to the next slot" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + + await TopSites.insert({ data: { index: 2, site, draggedFromIndex: 3 } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWith({ url: "example.com" }, 3) + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSites.insert should move pinned sites in the direction " + + "of the dragged site" + ); + + let site1 = { url: "foo.bar", label: "foo" }; + let site2 = { url: "example.com", label: "example" }; + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, null, site2]); + + await TopSites.insert({ + data: { index: 2, site: site1, draggedFromIndex: 0 }, + }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 2)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 1)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + + await TopSites.insert({ + data: { index: 2, site: site1, draggedFromIndex: 5 }, + }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 2)); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 3)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info("TopSites.insert should not insert past the visible top sites"); + let site1 = { url: "foo.bar", label: "foo" }; + await TopSites.insert({ + data: { index: 42, site: site1, draggedFromIndex: 0 }, + }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.notCalled, + "NewTabUtils.pinnedLinks.pin wasn't called" + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_pin_part_1() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.spy(TopSites.pinnedCache, "request"); + let cleanup = stubTopSites(sandbox); + + { + info("TopSites.pin should pin site in specified slot empty pinned list"); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + Assert.ok( + TopSites.pinnedCache.request.notCalled, + "TopSites.pinnedCache.request not called" + ); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.called, + "NewTabUtils.pinnedLinks.pin called" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should lookup the link object to update the custom " + + "screenshot" + ); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + Assert.ok( + TopSites.pinnedCache.request.notCalled, + "TopSites.pinnedCache.request not called" + ); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + TopSites.pinnedCache.request.called, + "TopSites.pinnedCache.request called" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should lookup the link object to update the custom " + + "screenshot when the custom screenshot is initially null" + ); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: null, + }; + Assert.ok( + TopSites.pinnedCache.request.notCalled, + "TopSites.pinnedCache.request not called" + ); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + TopSites.pinnedCache.request.called, + "TopSites.pinnedCache.request called" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should not do a link object lookup if custom " + + "screenshot field is not set" + ); + let site = { url: "foo.bar", label: "foo" }; + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + !TopSites.pinnedCache.request.called, + "TopSites.pinnedCache.request never called" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.pinnedCache.request.resetHistory(); + } + + { + info( + "TopSites.pin should pin site in specified slot of pinned " + + "list that is free" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_pin_part_2() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info("TopSites.pin should save the searchTopSite attribute if set"); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo", searchTopSite: true }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.firstCall.args[0].searchTopSite); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.pin should NOT move a pinned site in specified " + + "slot to the next slot" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, null, { url: "example.com" }]); + + let site = { url: "foo.bar", label: "foo" }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin({ data: { index: 2, site } }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.pin should properly update LinksCache object " + + "properties between migrations" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + let cleanup = stubTopSites(sandbox); + let pinnedLinks = await TopSites.pinnedCache.request(); + Assert.equal(pinnedLinks.length, 1); + TopSites.pinnedCache.expire(); + + pinnedLinks[0].__sharedCache.updateLink("screenshot", "foo"); + + pinnedLinks = await TopSites.pinnedCache.request(); + Assert.equal(pinnedLinks[0].screenshot, "foo"); + + // Force cache expiration in order to trigger a migration of objects + TopSites.pinnedCache.expire(); + pinnedLinks[0].__sharedCache.updateLink("screenshot", "bar"); + + pinnedLinks = await TopSites.pinnedCache.request(); + Assert.equal(pinnedLinks[0].screenshot, "bar"); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task(async function test_pin_part_3() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.spy(TopSites, "insert"); + + { + info("TopSites.pin should call insert if index < 0"); + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index: -1, site } }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin(action); + + Assert.ok(TopSites.insert.calledOnce, "TopSites.insert called once"); + Assert.ok(TopSites.insert.calledWithExactly(action)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + TopSites.insert.resetHistory(); + await cleanup(); + } + + { + info("TopSites.pin should not call insert if index == 0"); + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index: 0, site } }; + let cleanup = stubTopSites(sandbox); + await TopSites.pin(action); + + Assert.ok(!TopSites.insert.called, "TopSites.insert not called"); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + { + info("TopSites.pin should trigger refresh on TOP_SITES_PIN"); + let cleanup = stubTopSites(sandbox); + sandbox.stub(TopSites, "refresh"); + let pinExistingAction = { + type: at.TOP_SITES_PIN, + data: { site: FAKE_LINKS[4], index: 4 }, + }; + + await TopSites.pin(pinExistingAction); + + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + NewTabUtils.pinnedLinks.pin.resetHistory(); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task(async function test_integration() { + let sandbox = sinon.createSandbox(); + + info("Test adding a pinned site and removing it with actions"); + let cleanup = stubTopSites(sandbox); + + let resolvers = []; + TopSites.store.dispatch = sandbox.stub().callsFake(() => { + resolvers.shift()(); + }); + TopSites._startedUp = true; + sandbox.stub(TopSites, "_fetchScreenshot"); + + let forDispatch = action => + new Promise(resolve => { + resolvers.push(resolve); + TopSites.onAction(action); + }); + + TopSites._requestRichIcon = sandbox.stub(); + let url = "https://pin.me"; + sandbox.stub(NewTabUtils.pinnedLinks, "pin").callsFake(link => { + NewTabUtils.pinnedLinks.links.push(link); + }); + + await forDispatch({ type: at.TOP_SITES_INSERT, data: { site: { url } } }); + NewTabUtils.pinnedLinks.links.pop(); + await forDispatch({ type: at.PLACES_LINK_BLOCKED }); + + Assert.ok( + TopSites.store.dispatch.calledTwice, + "TopSites.store.dispatch called twice" + ); + Assert.equal( + TopSites.store.dispatch.firstCall.args[0].data.links[0].url, + url + ); + Assert.equal( + TopSites.store.dispatch.secondCall.args[0].data.links[0].url, + FAKE_LINKS[0].url + ); + + sandbox.restore(); + await cleanup(); +}); + +add_task(async function test_improvesearch_noDefaultSearchTile_experiment() { + let sandbox = sinon.createSandbox(); + const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile"; + + sandbox.stub(SearchService.prototype, "getDefault").resolves({ + identifier: "google", + searchForm: "google.com", + }); + + { + info( + "TopSites.getLinksWithDefaults should filter out alexa top 5 " + + "search from the default sites" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + let top5Test = [ + "https://google.com", + "https://search.yahoo.com", + "https://yahoo.com", + "https://bing.com", + "https://ask.com", + "https://duckduckgo.com", + ]; + + gGetTopSitesStub.resolves([ + { url: "https://amazon.com" }, + ...top5Test.map(url => ({ url })), + ]); + + const urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok( + urlsReturned.includes("https://amazon.com"), + "amazon included in default links" + ); + top5Test.forEach(url => + Assert.ok(!urlsReturned.includes(url), `Should not include ${url}`) + ); + + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should not filter out alexa, default " + + "search from the query results if the experiment pref is off" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = false; + + gGetTopSitesStub.resolves([ + { url: "https://google.com" }, + { url: "https://foo.com" }, + { url: "https://duckduckgo" }, + ]); + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + + Assert.ok(urlsReturned.includes("https://google.com")); + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should filter out the current " + + "default search from the default sites" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + sandbox.stub(TopSites, "_currentSearchHostname").get(() => "amazon"); + TopSites.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(!urlsReturned.includes("https://amazon.com")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should not filter out current " + + "default search from pinned sites even if it matches the current " + + "default search" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "google.com" }]); + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(urlsReturned.includes("google.com")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task( + async function test_improvesearch_noDefaultSearchTile_experiment_part_2() { + let sandbox = sinon.createSandbox(); + const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile"; + + sandbox.stub(SearchService.prototype, "getDefault").resolves({ + identifier: "google", + searchForm: "google.com", + }); + + sandbox.stub(TopSites, "refresh"); + + { + info( + "TopSites.getLinksWithDefaults should call refresh and set " + + "._currentSearchHostname to the new engine hostname when the " + + "default search engine has been set" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + TopSites.observe( + null, + "browser-search-engine-modified", + "engine-default" + ); + Assert.equal(TopSites._currentSearchHostname, "duckduckgo"); + Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites.refresh.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.getLinksWithDefaults should call refresh when the " + + "experiment pref has changed" + ); + let cleanup = stubTopSites(sandbox); + TopSites.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: true }, + }); + Assert.ok( + TopSites.refresh.calledOnce, + "TopSites.refresh was called once" + ); + + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: false }, + }); + Assert.ok( + TopSites.refresh.calledTwice, + "TopSites.refresh was called twice" + ); + + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites.refresh.resetHistory(); + await cleanup(); + } + + sandbox.restore(); + } +); + +// eslint-disable-next-line max-statements +add_task(async function test_improvesearch_topSitesSearchShortcuts() { + let sandbox = sinon.createSandbox(); + let searchEngines = [{ aliases: ["@google"] }, { aliases: ["@amazon"] }]; + sandbox + .stub(SearchService.prototype, "getAppProvidedEngines") + .resolves(searchEngines); + sandbox.stub(NewTabUtils.pinnedLinks, "pin").callsFake((site, index) => { + NewTabUtils.pinnedLinks.links[index] = site; + }); + + let prepTopSites = () => { + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = true; + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF] = + "google,amazon"; + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = ""; + }; + + { + info( + "TopSites should updateCustomSearchShortcuts when experiment " + + "pref is turned on" + ); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + TopSites.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; + sandbox.spy(TopSites, "updateCustomSearchShortcuts"); + + // turn the experiment on + TopSites.onAction({ + type: at.PREF_CHANGED, + data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: true }, + }); + + Assert.ok( + TopSites.updateCustomSearchShortcuts.calledOnce, + "TopSites.updateCustomSearchShortcuts called once" + ); + TopSites.updateCustomSearchShortcuts.restore(); + await cleanup(); + } + + { + info( + "TopSites should filter out default top sites that match a " + + "hostname of a search shortcut if previously blocked" + ); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + TopSites.refreshDefaults("https://amazon.ca"); + sandbox + .stub(NewTabUtils.blockedLinks, "links") + .value([{ url: "https://amazon.com" }]); + sandbox.stub(NewTabUtils.blockedLinks, "isBlocked").callsFake(site => { + return NewTabUtils.blockedLinks.links[0].url === site.url; + }); + + let urlsReturned = (await TopSites.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(!urlsReturned.includes("https://amazon.ca")); + await cleanup(); + } + + { + info("TopSites should update frecent search topsite icon"); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + sandbox.stub(TopSites._tippyTopProvider, "processSite").callsFake(site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }); + gGetTopSitesStub.resolves([{ url: "https://google.com" }]); + + let urlsReturned = await TopSites.getLinksWithDefaults(); + + let defaultSearchTopsite = urlsReturned.find( + s => s.url === "https://google.com" + ); + Assert.ok(defaultSearchTopsite.searchTopSite); + Assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); + Assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites._tippyTopProvider.processSite.restore(); + await cleanup(); + } + + { + info("TopSites should update default search topsite icon"); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + sandbox.stub(TopSites._tippyTopProvider, "processSite").callsFake(site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }); + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + TopSites.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + + let urlsReturned = await TopSites.getLinksWithDefaults(); + + let defaultSearchTopsite = urlsReturned.find( + s => s.url === "https://amazon.com" + ); + Assert.ok(defaultSearchTopsite.searchTopSite); + Assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); + Assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); + gGetTopSitesStub.resolves(FAKE_LINKS); + TopSites._tippyTopProvider.processSite.restore(); + TopSites.store.dispatch.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites should dispatch UPDATE_SEARCH_SHORTCUTS on " + + "updateCustomSearchShortcuts" + ); + let cleanup = stubTopSites(sandbox); + prepTopSites(); + TopSites.store.state.Prefs.values[ + "improvesearch.noDefaultSearchTile" + ] = true; + await TopSites.updateCustomSearchShortcuts(); + Assert.ok( + TopSites.store.dispatch.calledOnce, + "TopSites.store.dispatch called once" + ); + Assert.ok( + TopSites.store.dispatch.calledWith({ + data: { + searchShortcuts: [ + { + keyword: "@google", + shortURL: "google", + url: "https://google.com", + backgroundColor: undefined, + smallFavicon: + "chrome://activity-stream/content/data/content/tippytop/favicons/google-com.ico", + tippyTopIcon: + "chrome://activity-stream/content/data/content/tippytop/images/google-com@2x.png", + }, + { + keyword: "@amazon", + shortURL: "amazon", + url: "https://amazon.com", + backgroundColor: undefined, + smallFavicon: + "chrome://activity-stream/content/data/content/tippytop/favicons/amazon.ico", + tippyTopIcon: + "chrome://activity-stream/content/data/content/tippytop/images/amazon@2x.png", + }, + ], + }, + meta: { + from: "ActivityStream:Main", + to: "ActivityStream:Content", + isStartup: false, + }, + type: "UPDATE_SEARCH_SHORTCUTS", + }) + ); + await cleanup(); + } + + sandbox.restore(); +}); + +add_task(async function test_updatePinnedSearchShortcuts() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.stub(NewTabUtils.pinnedLinks, "unpin"); + + { + info( + "TopSites.updatePinnedSearchShortcuts should unpin a " + + "shortcut in deletedShortcuts" + ); + let cleanup = stubTopSites(sandbox); + + let deletedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + let addedShortcuts = []; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [ + null, + null, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + Assert.ok( + NewTabUtils.pinnedLinks.pin.notCalled, + "NewTabUtils.pinnedLinks.pin not called" + ); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledOnce, + "NewTabUtils.pinnedLinks.unpin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledWith({ + url: "https://google.com", + }) + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.updatePinnedSearchShortcuts should pin a shortcut " + + "in addedShortcuts" + ); + let cleanup = stubTopSites(sandbox); + + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + let deletedShortcuts = []; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [ + null, + null, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.notCalled, + "NewTabUtils.pinnedLinks.unpin not called" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWith( + { + label: "google", + searchTopSite: true, + searchVendor: "google", + url: "https://google.com", + }, + 0 + ) + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.updatePinnedSearchShortcuts should pin and unpin " + + "in the same action" + ); + let cleanup = stubTopSites(sandbox); + + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + { + url: "https://ebay.com", + searchVendor: "ebay", + label: "ebay", + searchTopSite: true, + }, + ]; + let deletedShortcuts = [ + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]; + + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [ + { url: "https://foo.com" }, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledOnce, + "NewTabUtils.pinnedLinks.unpin called once" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledTwice, + "NewTabUtils.pinnedLinks.pin called twice" + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + { + info( + "TopSites.updatePinnedSearchShortcuts should pin a shortcut in " + + "addedShortcuts even if pinnedLinks is full" + ); + let cleanup = stubTopSites(sandbox); + + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + let deletedShortcuts = []; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => FAKE_LINKS); + TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + + Assert.ok( + NewTabUtils.pinnedLinks.unpin.notCalled, + "NewTabUtils.pinnedLinks.unpin not called" + ); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledWith( + { label: "google", searchTopSite: true, url: "https://google.com" }, + 0 + ), + "NewTabUtils.pinnedLinks.unpin not called" + ); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + NewTabUtils.pinnedLinks.unpin.resetHistory(); + await cleanup(); + } + + sandbox.restore(); +}); + +// eslint-disable-next-line max-statements +add_task(async function test_ContileIntegration() { + let sandbox = sinon.createSandbox(); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + sandbox.stub(NimbusFeatures.newtab, "getVariable").returns(true); + + let fetchStub; + + let prepTopSites = () => { + TopSites.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; + fetchStub = sandbox.stub(TopSites, "fetch"); + function cleanupPrep() { + TopSites._contile._sites = []; + fetchStub.restore(); + } + return cleanupPrep; + }; + + { + info("TopSites._fetchSites should fetch sites from Contile"); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 2); + await cleanup(); + cleanupPrep(); + } + + { + info("TopSites._fetchSites should call allocatePositions"); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + sandbox.stub(TopSites, "allocatePositions").resolves(); + await TopSites._contile.refresh(); + + Assert.ok( + TopSites.allocatePositions.calledOnce, + "TopSites.allocatePositions called once" + ); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should fetch SOV (Share-of-Voice) " + + "settings from Contile" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + let sov = { + name: "SOV-20230518215316", + allocations: [ + { + position: 1, + allocation: [ + { + partner: "foo", + percentage: 100, + }, + { + partner: "bar", + percentage: 0, + }, + ], + }, + { + position: 2, + allocation: [ + { + partner: "foo", + percentage: 80, + }, + { + partner: "bar", + percentage: 20, + }, + ], + }, + ], + }; + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + sov: btoa(JSON.stringify(sov)), + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.deepEqual(TopSites._contile.sov, sov); + Assert.equal(TopSites._contile.sites.length, 2); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should not fetch from Contile if " + + "it's not enabled" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + NimbusFeatures.newtab.getVariable.reset(); + NimbusFeatures.newtab.getVariable.returns(false); + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetchStub.notCalled, "TopSites.fetch was not called"); + Assert.ok(!fetched); + Assert.equal(TopSites._contile.sites.length, 0); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should still return two tiles when Contile " + + "provides more than 2 tiles and filtering results in more than 2 tiles" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + NimbusFeatures.newtab.getVariable.reset(); + NimbusFeatures.newtab.getVariable.onCall(0).returns(true); + NimbusFeatures.newtab.getVariable.onCall(1).returns(true); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://bar.com", + image_url: "images/bar-com.png", + click_url: "https://www.bar-click.com", + impression_url: "https://www.bar-impression.com", + name: "bar", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + { + url: "https://test2.com", + image_url: "images/test2-com.png", + click_url: "https://www.test2-click.com", + impression_url: "https://www.test2-impression.com", + name: "test2", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + // Both "foo" and "bar" should be filtered + Assert.equal(TopSites._contile.sites.length, 2); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + Assert.equal(TopSites._contile.sites[1].url, "https://test1.com"); + + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should still return two tiles with " + + "replacement if the Nimbus variable was unset" + ); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + NimbusFeatures.newtab.getVariable.reset(); + NimbusFeatures.newtab.getVariable.onCall(0).returns(true); + NimbusFeatures.newtab.getVariable.onCall(1).returns(undefined); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 2); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + Assert.equal(TopSites._contile.sites[1].url, "https://test1.com"); + + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info("TopSites._fetchSites should filter the blocked sponsors"); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://bar.com", + image_url: "images/bar-com.png", + click_url: "https://www.bar-click.com", + impression_url: "https://www.bar-impression.com", + name: "bar", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + // Both "foo" and "bar" should be filtered + Assert.equal(TopSites._contile.sites.length, 1); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should return false when Contile returns " + + "with error status and no values are stored in cache prefs" + ); + NimbusFeatures.newtab.getVariable.returns(true); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, 0); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: false, + status: 500, + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should return false when Contile " + + "returns with error status and cached tiles are expried" + ); + NimbusFeatures.newtab.getVariable.returns(true); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + const THIRTY_MINUTES_AGO_IN_SECONDS = + Math.round(Date.now() / 1000) - 60 * 30; + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + THIRTY_MINUTES_AGO_IN_SECONDS + ); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 60 * 15); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + fetchStub.resolves({ + ok: false, + status: 500, + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should handle invalid payload " + + "properly from Contile" + ); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + unknown: [], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should handle empty payload properly " + + "from Contile" + ); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info("TopSites._fetchSites should handle no content properly from Contile"); + NimbusFeatures.newtab.getVariable.returns(true); + + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + fetchStub.resolves({ ok: true, status: 204 }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!TopSites._contile.sites.length); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should set Caching Prefs after " + + "a successful request" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + let tiles = [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles, + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal( + Services.prefs.getStringPref(CONTILE_CACHE_PREF), + JSON.stringify(tiles) + ); + Assert.equal( + Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF), + 11322 + ); + await cleanup(); + cleanupPrep(); + } + + { + info( + "TopSites._fetchSites should return cached valid tiles " + + "when Contile returns error status" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + let tiles = [ + { + url: "https://www.test-cached.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1-cached.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + + Services.prefs.setStringPref(CONTILE_CACHE_PREF, JSON.stringify(tiles)); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 60 * 15); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + + fetchStub.resolves({ + status: 304, + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 2); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test-cached.com"); + Assert.equal( + TopSites._contile.sites[1].url, + "https://www.test1-cached.com" + ); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should not be successful when contile " + + "returns an error and no valid tiles are cached" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 0); + Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, 0); + + fetchStub.resolves({ + status: 500, + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(!fetched); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should return cached valid tiles " + + "filtering blocked tiles when Contile returns error status" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + let tiles = [ + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.test1-cached.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + Services.prefs.setStringPref(CONTILE_CACHE_PREF, JSON.stringify(tiles)); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_SECONDS_PREF, 60 * 15); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + + fetchStub.resolves({ + status: 304, + }); + + let fetched = await TopSites._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 1); + Assert.equal( + TopSites._contile.sites[0].url, + "https://www.test1-cached.com" + ); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + { + info( + "TopSites._fetchSites should still return 3 tiles when nimbus " + + "variable overrides max num of sponsored contile tiles" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let cleanup = stubTopSites(sandbox); + let cleanupPrep = prepTopSites(); + + sandbox.stub(NimbusFeatures.pocketNewtab, "getVariable").returns(3); + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + { + url: "https://test2.com", + image_url: "images/test2-com.png", + click_url: "https://www.test2-click.com", + impression_url: "https://www.test2-impression.com", + name: "test2", + }, + ], + }), + }); + + let fetched = await TopSites._contile._fetchSites(); + + Assert.ok(fetched); + Assert.equal(TopSites._contile.sites.length, 3); + Assert.equal(TopSites._contile.sites[0].url, "https://www.test.com"); + Assert.equal(TopSites._contile.sites[1].url, "https://test1.com"); + Assert.equal(TopSites._contile.sites[2].url, "https://test2.com"); + await cleanup(); + cleanupPrep(); + fetchStub.restore(); + } + + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); diff --git a/browser/components/topsites/test/unit/xpcshell.toml b/browser/components/topsites/test/unit/xpcshell.toml new file mode 100644 index 0000000000..98b0fc5360 --- /dev/null +++ b/browser/components/topsites/test/unit/xpcshell.toml @@ -0,0 +1,4 @@ +[DEFAULT] +firefox-appdir = "browser" + +["test_top_sites.js"] -- cgit v1.2.3