"use strict"; import { actionCreators as ac, actionTypes as at, } from "common/Actions.sys.mjs"; import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils"; import { insertPinned, TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW, } from "common/Reducers.sys.mjs"; import { getDefaultOptions } from "lib/ActivityStreamStorage.jsm"; import injector from "inject!lib/TopSitesFeed.jsm"; import { Screenshots } from "lib/Screenshots.jsm"; import { LinksCache } from "lib/LinksCache.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 REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting"; 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"; function FakeTippyTopProvider() {} FakeTippyTopProvider.prototype = { async init() { this.initialized = true; }, processSite(site) { return site; }, }; describe("Top Sites Feed", () => { let TopSitesFeed; let DEFAULT_TOP_SITES; let feed; let globals; let sandbox; let links; let fakeNewTabUtils; let fakeScreenshot; let filterAdultStub; let shortURLStub; let fakePageThumbs; let fetchStub; let fakeNimbusFeatures; let fakeSampling; beforeEach(() => { globals = new GlobalOverrider(); sandbox = globals.sandbox; fakeNewTabUtils = { blockedLinks: { links: [], isBlocked: () => false, unblock: sandbox.spy(), }, activityStreamLinks: { getTopSites: sandbox.spy(() => Promise.resolve(links)), }, activityStreamProvider: { _addFavicons: sandbox.spy(l => Promise.resolve( l.map(link => { link.favicon = FAKE_FAVICON; link.faviconSize = FAKE_FAVICON_SIZE; return link; }) ) ), _faviconBytesToDataURI: sandbox.spy(), }, pinnedLinks: { links: [], isPinned: () => false, pin: sandbox.spy(), unpin: sandbox.spy(), }, }; fakeScreenshot = { getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT)), maybeCacheScreenshot: sandbox.spy(Screenshots.maybeCacheScreenshot), _shouldGetScreenshots: sinon.stub().returns(true), }; filterAdultStub = { filter: sinon.stub().returnsArg(0), }; shortURLStub = sinon .stub() .callsFake(site => site.url.replace(/(.com|.ca)/, "").replace("https://", "") ); const fakeDedupe = function () {}; fakePageThumbs = { addExpirationFilter: sinon.stub(), removeExpirationFilter: sinon.stub(), }; fakeNimbusFeatures = { newtab: { getVariable: sinon.stub(), onUpdate: sinon.stub(), offUpdate: sinon.stub(), }, pocketNewtab: { getVariable: sinon.stub(), }, }; fakeSampling = { ratioSample: sinon.stub(), }; globals.set({ PageThumbs: fakePageThumbs, NewTabUtils: fakeNewTabUtils, gFilterAdultEnabled: false, NimbusFeatures: fakeNimbusFeatures, LinksCache, FilterAdult: filterAdultStub, Screenshots: fakeScreenshot, Sampling: fakeSampling, }); sandbox.spy(global.XPCOMUtils, "defineLazyGetter"); FAKE_GLOBAL_PREFS.set("default.sites", "https://foo.com/"); ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs }, "common/Dedupe.jsm": { Dedupe: fakeDedupe }, "common/Reducers.jsm": { insertPinned, TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW, }, "lib/FilterAdult.jsm": { FilterAdult: filterAdultStub }, "lib/Screenshots.jsm": { Screenshots: fakeScreenshot }, "lib/TippyTopProvider.sys.mjs": { TippyTopProvider: FakeTippyTopProvider, }, "lib/ShortURL.jsm": { shortURL: shortURLStub }, "lib/ActivityStreamStorage.jsm": { ActivityStreamStorage: function Fake() {}, getDefaultOptions, }, })); feed = new TopSitesFeed(); 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 feed._storage = storage; feed.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) }, }; feed.dedupe.group = (...sites) => sites; links = FAKE_LINKS; // Turn off the search shortcuts experiment by default for other tests feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "google,amazon"; }); afterEach(() => { globals.restore(); sandbox.restore(); }); function stubFaviconsToUseScreenshots() { fakeNewTabUtils.activityStreamProvider._addFavicons = sandbox.stub(); } describe("#constructor", () => { it("should defineLazyGetter for log, contextId, and _currentSearchHostname", () => { assert.calledThrice(global.XPCOMUtils.defineLazyGetter); let spyCall = global.XPCOMUtils.defineLazyGetter.getCall(0); assert.ok(spyCall.calledWith(sinon.match.any, "log", sinon.match.func)); spyCall = global.XPCOMUtils.defineLazyGetter.getCall(1); assert.ok( spyCall.calledWith(sinon.match.any, "contextId", sinon.match.func) ); spyCall = global.XPCOMUtils.defineLazyGetter.getCall(2); assert.ok( spyCall.calledWith(feed, "_currentSearchHostname", sinon.match.func) ); }); }); describe("#refreshDefaults", () => { it("should add defaults on PREFS_INITIAL_VALUES", () => { feed.onAction({ type: at.PREFS_INITIAL_VALUES, data: { "default.sites": "https://foo.com" }, }); assert.isAbove(DEFAULT_TOP_SITES.length, 0); }); it("should add defaults on default.sites PREF_CHANGED", () => { feed.onAction({ type: at.PREF_CHANGED, data: { name: "default.sites", value: "https://foo.com" }, }); assert.isAbove(DEFAULT_TOP_SITES.length, 0); }); it("should refresh on topSiteRows PREF_CHANGED", () => { feed.refresh = sinon.spy(); feed.onAction({ type: at.PREF_CHANGED, data: { name: "topSitesRows" } }); assert.calledOnce(feed.refresh); }); it("should have default sites with .isDefault = true", () => { feed.refreshDefaults("https://foo.com"); DEFAULT_TOP_SITES.forEach(link => assert.propertyVal(link, "isDefault", true) ); }); it("should have default sites with appropriate hostname", () => { feed.refreshDefaults("https://foo.com"); DEFAULT_TOP_SITES.forEach(link => assert.propertyVal(link, "hostname", shortURLStub(link)) ); }); it("should add no defaults on empty pref", () => { feed.refreshDefaults(""); assert.equal(DEFAULT_TOP_SITES.length, 0); }); it("should clear defaults", () => { feed.refreshDefaults("https://foo.com"); feed.refreshDefaults(""); assert.equal(DEFAULT_TOP_SITES.length, 0); }); }); describe("#filterForThumbnailExpiration", () => { it("should pass rows.urls to the callback provided", () => { const rows = [ { url: "foo.com" }, { url: "bar.com", customScreenshotURL: "custom" }, ]; feed.store.state.TopSites = { rows }; const stub = sinon.stub(); feed.filterForThumbnailExpiration(stub); assert.calledOnce(stub); assert.calledWithExactly(stub, ["foo.com", "bar.com", "custom"]); }); }); describe("#getLinksWithDefaults", () => { beforeEach(() => { feed.refreshDefaults("https://foo.com"); }); describe("general", () => { it("should get the links from NewTabUtils", async () => { const result = await feed.getLinksWithDefaults(); const reference = links.map(site => Object.assign({}, site, { hostname: shortURLStub(site), typedBonus: true, }) ); assert.deepEqual(result, reference); assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites); }); it("should indicate the links get typed bonus", async () => { const result = await feed.getLinksWithDefaults(); assert.propertyVal(result[0], "typedBonus", true); }); it("should filter out non-pinned adult sites", async () => { filterAdultStub.filter = sinon.stub().returns([]); fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; const result = await feed.getLinksWithDefaults(); // The stub filters out everything assert.calledOnce(filterAdultStub.filter); assert.equal(result.length, 1); assert.equal(result[0].url, fakeNewTabUtils.pinnedLinks.links[0].url); }); it("should filter out the defaults that have been blocked", async () => { // 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: shortURLStub({ url }), typedBonus: true, url, }; const blockedDefaultSite = { url: "https://foo.com" }; fakeNewTabUtils.activityStreamLinks.getTopSites = () => [topsite]; fakeNewTabUtils.blockedLinks.isBlocked = site => site.url === blockedDefaultSite.url; const result = await feed.getLinksWithDefaults(); // what we should be left with is just the top site we added, and not the default site we blocked assert.lengthOf(result, 1); assert.deepEqual(result[0], topsite); assert.notInclude(result, blockedDefaultSite); }); it("should call dedupe on the links", async () => { const stub = sinon.stub(feed.dedupe, "group").callsFake((...id) => id); await feed.getLinksWithDefaults(); assert.calledOnce(stub); }); it("should dedupe the links by hostname", async () => { const site = { url: "foo", hostname: "bar" }; const result = feed._dedupeKey(site); assert.equal(result, site.hostname); }); it("should add defaults if there are are not enough links", async () => { links = [{ frecency: FAKE_FRECENCY, url: "foo.com" }]; const result = await feed.getLinksWithDefaults(); const reference = [...links, ...DEFAULT_TOP_SITES].map(s => Object.assign({}, s, { hostname: shortURLStub(s), typedBonus: true, }) ); assert.deepEqual(result, reference); }); it("should only add defaults up to the number of visible slots", async () => { links = []; const numVisible = TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW; for (let i = 0; i < numVisible - 1; i++) { links.push({ frecency: FAKE_FRECENCY, url: `foo${i}.com` }); } const result = await feed.getLinksWithDefaults(); const reference = [...links, DEFAULT_TOP_SITES[0]].map(s => Object.assign({}, s, { hostname: shortURLStub(s), typedBonus: true, }) ); assert.lengthOf(result, numVisible); assert.deepEqual(result, reference); }); it("should not throw if NewTabUtils returns null", () => { links = null; assert.doesNotThrow(() => { feed.getLinksWithDefaults(); }); }); it("should get more if the user has asked for more", async () => { links = new Array(4 * TOP_SITES_MAX_SITES_PER_ROW) .fill(null) .map((v, i) => ({ frecency: FAKE_FRECENCY, url: `http://www.site${i}.com`, })); feed.store.state.Prefs.values.topSitesRows = 3; const result = await feed.getLinksWithDefaults(); assert.propertyVal( result, "length", feed.store.state.Prefs.values.topSitesRows * TOP_SITES_MAX_SITES_PER_ROW ); }); }); describe("caching", () => { it("should reuse the cache on subsequent calls", async () => { await feed.getLinksWithDefaults(); await feed.getLinksWithDefaults(); assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites); }); it("should ignore the cache when requesting more", async () => { await feed.getLinksWithDefaults(); feed.store.state.Prefs.values.topSitesRows *= 3; await feed.getLinksWithDefaults(); assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites); }); it("should migrate frecent screenshot data without getting screenshots again", async () => { feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; stubFaviconsToUseScreenshots(); await feed.getLinksWithDefaults(); const { callCount } = fakeScreenshot.getScreenshotForURL; feed.frecentCache.expire(); const result = await feed.getLinksWithDefaults(); assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites); assert.callCount(fakeScreenshot.getScreenshotForURL, callCount); assert.propertyVal(result[0], "screenshot", FAKE_SCREENSHOT); }); it("should migrate pinned favicon data without getting favicons again", async () => { fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; await feed.getLinksWithDefaults(); const { callCount } = fakeNewTabUtils.activityStreamProvider._addFavicons; feed.pinnedCache.expire(); const result = await feed.getLinksWithDefaults(); assert.callCount( fakeNewTabUtils.activityStreamProvider._addFavicons, callCount ); assert.propertyVal(result[0], "favicon", FAKE_FAVICON); assert.propertyVal(result[0], "faviconSize", FAKE_FAVICON_SIZE); }); it("should not expose internal link properties", async () => { const result = await feed.getLinksWithDefaults(); const internal = Object.keys(result[0]).filter(key => key.startsWith("__") ); assert.equal(internal.join(""), ""); }); it("should copy the screenshot of the frecent site if pinned site doesn't have customScreenshotURL", async () => { links = [{ url: "https://foo.com/", screenshot: "screenshot" }]; fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; const result = await feed.getLinksWithDefaults(); assert.equal(result[0].screenshot, links[0].screenshot); }); it("should not copy the frecent screenshot if customScreenshotURL is set", async () => { links = [{ url: "https://foo.com/", screenshot: "screenshot" }]; fakeNewTabUtils.pinnedLinks.links = [ { url: "https://foo.com/", customScreenshotURL: "custom" }, ]; const result = await feed.getLinksWithDefaults(); assert.isUndefined(result[0].screenshot); }); it("should keep the same screenshot if no frecent site is found", async () => { links = []; fakeNewTabUtils.pinnedLinks.links = [ { url: "https://foo.com/", screenshot: "custom" }, ]; const result = await feed.getLinksWithDefaults(); assert.equal(result[0].screenshot, "custom"); }); it("should not overwrite pinned site screenshot", async () => { links = [{ url: "https://foo.com/", screenshot: "foo" }]; fakeNewTabUtils.pinnedLinks.links = [ { url: "https://foo.com/", screenshot: "bar" }, ]; const result = await feed.getLinksWithDefaults(); assert.equal(result[0].screenshot, "bar"); }); it("should not set searchTopSite from frecent site", async () => { links = [ { url: "https://foo.com/", searchTopSite: true, screenshot: "screenshot", }, ]; fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; const result = await feed.getLinksWithDefaults(); assert.propertyVal(result[0], "searchTopSite", false); // But it should copy over other properties assert.propertyVal(result[0], "screenshot", "screenshot"); }); describe("concurrency", () => { beforeEach(() => { stubFaviconsToUseScreenshots(); fakeScreenshot.getScreenshotForURL = sandbox .stub() .resolves(FAKE_SCREENSHOT); }); afterEach(() => { sandbox.restore(); }); const getTwice = () => Promise.all([ feed.getLinksWithDefaults(), feed.getLinksWithDefaults(), ]); it("should call the backing data once", async () => { await getTwice(); assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites); }); it("should get screenshots once per link", async () => { feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; await getTwice(); assert.callCount( fakeScreenshot.getScreenshotForURL, FAKE_LINKS.length ); }); it("should dispatch once per link screenshot fetched", async () => { feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; feed._requestRichIcon = sinon.stub(); await getTwice(); assert.callCount(feed.store.dispatch, FAKE_LINKS.length); }); }); }); describe("deduping", () => { beforeEach(() => { ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs }, "common/Reducers.jsm": { insertPinned, TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW, }, "lib/Screenshots.jsm": { Screenshots: fakeScreenshot }, })); sandbox.stub(global.Services.eTLD, "getPublicSuffix").returns("com"); feed = Object.assign(new TopSitesFeed(), { store: feed.store }); }); it("should not dedupe pinned sites", async () => { fakeNewTabUtils.pinnedLinks.links = [ { url: "https://developer.mozilla.org/en-US/docs/Web" }, { url: "https://developer.mozilla.org/en-US/docs/Learn" }, ]; const sites = await feed.getLinksWithDefaults(); assert.lengthOf(sites, 2 * TOP_SITES_MAX_SITES_PER_ROW); assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url); assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url); assert.equal(sites[0].hostname, sites[1].hostname); }); it("should prefer pinned sites over links", async () => { fakeNewTabUtils.pinnedLinks.links = [ { url: "https://developer.mozilla.org/en-US/docs/Web" }, { url: "https://developer.mozilla.org/en-US/docs/Learn" }, ]; // These will be the frecent results. links = [ { frecency: FAKE_FRECENCY, url: "https://developer.mozilla.org/" }, { frecency: FAKE_FRECENCY, url: "https://www.mozilla.org/" }, ]; const sites = await feed.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.lengthOf(sites, 3); assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url); assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url); assert.equal(sites[2].url, links[1].url); }); it("should return sites that have a title", async () => { // Simulate a pinned link with no title. fakeNewTabUtils.pinnedLinks.links = [ { url: "https://github.com/mozilla/activity-stream" }, ]; const sites = await feed.getLinksWithDefaults(); for (const site of sites) { assert.isDefined(site.hostname); } }); it("should check against null entries", async () => { fakeNewTabUtils.pinnedLinks.links = [null]; await feed.getLinksWithDefaults(); }); }); it("should call _fetchIcon for each link", async () => { sinon.spy(feed, "_fetchIcon"); const results = await feed.getLinksWithDefaults(); assert.callCount(feed._fetchIcon, results.length); results.forEach(link => { assert.calledWith(feed._fetchIcon, link); }); }); it("should call _fetchScreenshot when customScreenshotURL is set", async () => { links = []; fakeNewTabUtils.pinnedLinks.links = [ { url: "https://foo.com", customScreenshotURL: "custom" }, ]; sinon.stub(feed, "_fetchScreenshot"); await feed.getLinksWithDefaults(); assert.calledWith(feed._fetchScreenshot, sinon.match.object, "custom"); }); describe("discoverystream", () => { 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" }], }, }, }, }); it("should add a sponsored topsite from discoverystream to all the valid indices", async () => { for (let i = 0; i < FAKE_LINKS.length; i++) { feed.store.state.DiscoveryStream = makeStreamData(i); const result = await feed.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"); } }); }); }); describe("#init", () => { it("should call refresh (broadcast:true)", async () => { sandbox.stub(feed, "refresh"); await feed.init(); assert.calledOnce(feed.refresh); assert.calledWithExactly(feed.refresh, { broadcast: true, isStartup: true, }); }); it("should initialise the storage", async () => { await feed.init(); assert.calledOnce(feed.store.dbStorage.getDbTable); assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs"); }); it("should call onUpdate to set up Nimbus update listener", async () => { await feed.init(); assert.calledOnce(fakeNimbusFeatures.newtab.onUpdate); }); }); describe("#refresh", () => { beforeEach(() => { sandbox.stub(feed, "_fetchIcon"); feed._startedUp = true; }); it("should wait for tippytop to initialize", async () => { feed._tippyTopProvider.initialized = false; sinon.stub(feed._tippyTopProvider, "init").resolves(); await feed.refresh(); assert.calledOnce(feed._tippyTopProvider.init); }); it("should not init the tippyTopProvider if already initialized", async () => { feed._tippyTopProvider.initialized = true; sinon.stub(feed._tippyTopProvider, "init").resolves(); await feed.refresh(); assert.notCalled(feed._tippyTopProvider.init); }); it("should broadcast TOP_SITES_UPDATED", async () => { sinon.stub(feed, "getLinksWithDefaults").returns(Promise.resolve([])); await feed.refresh({ broadcast: true }); assert.calledOnce(feed.store.dispatch); assert.calledWithExactly( feed.store.dispatch, ac.BroadcastToContent({ type: at.TOP_SITES_UPDATED, data: { links: [], pref: { collapsed: false } }, }) ); }); it("should dispatch an action with the links returned", async () => { await feed.refresh({ broadcast: true }); const reference = links.map(site => Object.assign({}, site, { hostname: shortURLStub(site), typedBonus: true, }) ); assert.calledOnce(feed.store.dispatch); assert.propertyVal( feed.store.dispatch.firstCall.args[0], "type", at.TOP_SITES_UPDATED ); assert.deepEqual( feed.store.dispatch.firstCall.args[0].data.links, reference ); }); it("should handle empty slots in the resulting top sites array", async () => { links = [FAKE_LINKS[0]]; fakeNewTabUtils.pinnedLinks.links = [ null, null, FAKE_LINKS[1], null, null, null, null, null, FAKE_LINKS[2], ]; await feed.refresh({ broadcast: true }); assert.calledOnce(feed.store.dispatch); }); it("should dispatch AlsoToPreloaded when broadcast is false", async () => { sandbox.stub(feed, "getLinksWithDefaults").returns([]); await feed.refresh({ broadcast: false }); assert.calledOnce(feed.store.dispatch); assert.calledWithExactly( feed.store.dispatch, ac.AlsoToPreloaded({ type: at.TOP_SITES_UPDATED, data: { links: [], pref: { collapsed: false } }, }) ); }); it("should not init storage if it is already initialized", async () => { feed._storage.initialized = true; await feed.refresh({ broadcast: false }); assert.notCalled(feed._storage.init); }); it("should catch indexedDB errors", async () => { feed._storage.get.throws(new Error()); globals.sandbox.spy(global.console, "error"); try { await feed.refresh({ broadcast: false }); } catch (e) { assert.fails(); } assert.calledOnce(console.error); }); }); describe("#updateSectionPrefs", () => { it("should call updateSectionPrefs on UPDATE_SECTION_PREFS", () => { sandbox.stub(feed, "updateSectionPrefs"); feed.onAction({ type: at.UPDATE_SECTION_PREFS, data: { id: "topsites" }, }); assert.calledOnce(feed.updateSectionPrefs); }); it("should dispatch TOP_SITES_PREFS_UPDATED", async () => { await feed.updateSectionPrefs({ collapsed: true }); assert.calledOnce(feed.store.dispatch); assert.calledWithExactly( feed.store.dispatch, ac.BroadcastToContent({ type: at.TOP_SITES_PREFS_UPDATED, data: { pref: { collapsed: true } }, }) ); }); }); describe("#getScreenshotPreview", () => { it("should dispatch preview if request is succesful", async () => { await feed.getScreenshotPreview("custom", 1234); assert.calledOnce(feed.store.dispatch); assert.calledWithExactly( feed.store.dispatch, ac.OnlyToOneContent( { data: { preview: FAKE_SCREENSHOT, url: "custom" }, type: at.PREVIEW_RESPONSE, }, 1234 ) ); }); it("should return empty string if request fails", async () => { fakeScreenshot.getScreenshotForURL = sandbox .stub() .returns(Promise.resolve(null)); await feed.getScreenshotPreview("custom", 1234); assert.calledOnce(feed.store.dispatch); assert.calledWithExactly( feed.store.dispatch, ac.OnlyToOneContent( { data: { preview: "", url: "custom" }, type: at.PREVIEW_RESPONSE, }, 1234 ) ); }); }); describe("#_fetchIcon", () => { it("should reuse screenshot on the link", () => { const link = { screenshot: "reuse.png" }; feed._fetchIcon(link); assert.notCalled(fakeScreenshot.getScreenshotForURL); assert.propertyVal(link, "screenshot", "reuse.png"); }); it("should reuse existing fetching screenshot on the link", async () => { const link = { __sharedCache: { fetchingScreenshot: Promise.resolve("fetching.png") }, }; await feed._fetchIcon(link); assert.notCalled(fakeScreenshot.getScreenshotForURL); }); it("should get a screenshot if the link is missing it", () => { feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0])); assert.calledOnce(fakeScreenshot.getScreenshotForURL); assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_LINKS[0].url); }); it("should not get a screenshot if the link is missing it but top sites aren't shown", () => { feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = false; feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0])); assert.notCalled(fakeScreenshot.getScreenshotForURL); }); it("should update the link's cache with a screenshot", async () => { feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; const updateLink = sandbox.stub(); const link = { __sharedCache: { updateLink } }; await feed._fetchIcon(link); assert.calledOnce(updateLink); assert.calledWith(updateLink, "screenshot", FAKE_SCREENSHOT); }); it("should skip getting a screenshot if there is a tippy top icon", () => { feed._tippyTopProvider.processSite = site => { site.tippyTopIcon = "icon.png"; site.backgroundColor = "#fff"; return site; }; const link = { url: "example.com" }; feed._fetchIcon(link); assert.propertyVal(link, "tippyTopIcon", "icon.png"); assert.notProperty(link, "screenshot"); assert.notCalled(fakeScreenshot.getScreenshotForURL); }); it("should skip getting a screenshot if there is an icon of size greater than 96x96 and no tippy top", () => { const link = { url: "foo.com", favicon: "data:foo", faviconSize: 196, }; feed._fetchIcon(link); assert.notProperty(link, "tippyTopIcon"); assert.notProperty(link, "screenshot"); assert.notCalled(fakeScreenshot.getScreenshotForURL); }); it("should use the link's rich icon even if there's a tippy top", () => { feed._tippyTopProvider.processSite = site => { site.tippyTopIcon = "icon.png"; site.backgroundColor = "#fff"; return site; }; const link = { url: "foo.com", favicon: "data:foo", faviconSize: 196, }; feed._fetchIcon(link); assert.notProperty(link, "tippyTopIcon"); }); }); describe("#_fetchScreenshot", () => { it("should call maybeCacheScreenshot", async () => { feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; const updateLink = sinon.stub(); const link = { customScreenshotURL: "custom", __sharedCache: { updateLink }, }; await feed._fetchScreenshot(link, "custom"); assert.calledOnce(fakeScreenshot.maybeCacheScreenshot); assert.calledWithExactly( fakeScreenshot.maybeCacheScreenshot, link, link.customScreenshotURL, "screenshot", sinon.match.func ); }); it("should not call maybeCacheScreenshot if screenshot is set", async () => { const updateLink = sinon.stub(); const link = { customScreenshotURL: "custom", __sharedCache: { updateLink }, screenshot: true, }; await feed._fetchScreenshot(link, "custom"); assert.notCalled(fakeScreenshot.maybeCacheScreenshot); }); }); describe("#onAction", () => { it("should call getScreenshotPreview on PREVIEW_REQUEST", () => { sandbox.stub(feed, "getScreenshotPreview"); feed.onAction({ type: at.PREVIEW_REQUEST, data: { url: "foo" }, meta: { fromTarget: 1234 }, }); assert.calledOnce(feed.getScreenshotPreview); assert.calledWithExactly(feed.getScreenshotPreview, "foo", 1234); }); it("should refresh on SYSTEM_TICK", async () => { sandbox.stub(feed, "refresh"); feed.onAction({ type: at.SYSTEM_TICK }); assert.calledOnce(feed.refresh); assert.calledWithExactly(feed.refresh, { broadcast: false }); }); it("should call with correct parameters on TOP_SITES_PIN", () => { const pinAction = { type: at.TOP_SITES_PIN, data: { site: { url: "foo.com" }, index: 7 }, }; feed.onAction(pinAction); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith( fakeNewTabUtils.pinnedLinks.pin, pinAction.data.site, pinAction.data.index ); }); it("should call pin on TOP_SITES_PIN", () => { sinon.stub(feed, "pin"); const pinExistingAction = { type: at.TOP_SITES_PIN, data: { site: FAKE_LINKS[4], index: 4 }, }; feed.onAction(pinExistingAction); assert.calledOnce(feed.pin); }); it("should trigger refresh on TOP_SITES_PIN", async () => { sinon.stub(feed, "refresh"); const pinExistingAction = { type: at.TOP_SITES_PIN, data: { site: FAKE_LINKS[4], index: 4 }, }; await feed.pin(pinExistingAction); assert.calledOnce(feed.refresh); }); it("should unblock a previously blocked top site if we are now adding it manually via 'Add a Top Site' option", async () => { const pinAction = { type: at.TOP_SITES_PIN, data: { site: { url: "foo.com" }, index: -1 }, }; feed.onAction(pinAction); assert.calledWith(fakeNewTabUtils.blockedLinks.unblock, { url: pinAction.data.site.url, }); }); it("should call insert on TOP_SITES_INSERT", async () => { sinon.stub(feed, "insert"); const addAction = { type: at.TOP_SITES_INSERT, data: { site: { url: "foo.com" } }, }; feed.onAction(addAction); assert.calledOnce(feed.insert); }); it("should trigger refresh on TOP_SITES_INSERT", async () => { sinon.stub(feed, "refresh"); const addAction = { type: at.TOP_SITES_INSERT, data: { site: { url: "foo.com" } }, }; await feed.insert(addAction); assert.calledOnce(feed.refresh); }); it("should call unpin with correct parameters on TOP_SITES_UNPIN", () => { fakeNewTabUtils.pinnedLinks.links = [ null, null, { url: "foo.com" }, null, null, null, null, null, FAKE_LINKS[0], ]; const unpinAction = { type: at.TOP_SITES_UNPIN, data: { site: { url: "foo.com" } }, }; feed.onAction(unpinAction); assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin); assert.calledWith( fakeNewTabUtils.pinnedLinks.unpin, unpinAction.data.site ); }); it("should call refresh without a target if we clear history with PLACES_HISTORY_CLEARED", () => { sandbox.stub(feed, "refresh"); feed.onAction({ type: at.PLACES_HISTORY_CLEARED }); assert.calledOnce(feed.refresh); assert.calledWithExactly(feed.refresh, { broadcast: true }); }); it("should call refresh without a target if we remove a Topsite from history", () => { sandbox.stub(feed, "refresh"); feed.onAction({ type: at.PLACES_LINKS_DELETED }); assert.calledOnce(feed.refresh); assert.calledWithExactly(feed.refresh, { broadcast: true }); }); it("should still dispatch an action even if there's no target provided", async () => { sandbox.stub(feed, "_fetchIcon"); feed._startedUp = true; await feed.refresh({ broadcast: true }); assert.calledOnce(feed.store.dispatch); assert.propertyVal( feed.store.dispatch.firstCall.args[0], "type", at.TOP_SITES_UPDATED ); }); it("should call init on INIT action", async () => { sinon.stub(feed, "init"); feed.onAction({ type: at.INIT }); assert.calledOnce(feed.init); }); it("should call refresh on PLACES_LINK_BLOCKED action", async () => { sinon.stub(feed, "refresh"); await feed.onAction({ type: at.PLACES_LINK_BLOCKED }); assert.calledOnce(feed.refresh); assert.calledWithExactly(feed.refresh, { broadcast: true }); }); it("should call refresh on PLACES_LINKS_CHANGED action", async () => { sinon.stub(feed, "refresh"); await feed.onAction({ type: at.PLACES_LINKS_CHANGED }); assert.calledOnce(feed.refresh); assert.calledWithExactly(feed.refresh, { broadcast: false }); }); it("should call pin with correct args on TOP_SITES_INSERT without an index specified", () => { const addAction = { type: at.TOP_SITES_INSERT, data: { site: { url: "foo.bar", label: "foo" } }, }; feed.onAction(addAction); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith( fakeNewTabUtils.pinnedLinks.pin, addAction.data.site, 0 ); }); it("should call pin with correct args on TOP_SITES_INSERT", () => { const dropAction = { type: at.TOP_SITES_INSERT, data: { site: { url: "foo.bar", label: "foo" }, index: 3 }, }; feed.onAction(dropAction); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith( fakeNewTabUtils.pinnedLinks.pin, dropAction.data.site, 3 ); }); it("should remove the expiration filter on UNINIT", () => { feed.onAction({ type: "UNINIT" }); assert.calledOnce(fakePageThumbs.removeExpirationFilter); }); it("should call updatePinnedSearchShortcuts on UPDATE_PINNED_SEARCH_SHORTCUTS action", async () => { sinon.stub(feed, "updatePinnedSearchShortcuts"); const addedShortcuts = [ { url: "https://google.com", searchVendor: "google", label: "google", searchTopSite: true, }, ]; await feed.onAction({ type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, data: { addedShortcuts }, }); assert.calledOnce(feed.updatePinnedSearchShortcuts); }); it("should refresh from Contile on SHOW_SPONSORED_PREF if Contile is enabled", () => { sandbox.spy(feed._contile, "refresh"); const prefChangeAction = { type: at.PREF_CHANGED, data: { name: SHOW_SPONSORED_PREF }, }; fakeNimbusFeatures.newtab.getVariable.returns(true); feed.onAction(prefChangeAction); assert.calledOnce(feed._contile.refresh); }); it("should not refresh from Contile on SHOW_SPONSORED_PREF if Contile is disabled", () => { sandbox.spy(feed._contile, "refresh"); const prefChangeAction = { type: at.PREF_CHANGED, data: { name: SHOW_SPONSORED_PREF }, }; fakeNimbusFeatures.newtab.getVariable.returns(false); feed.onAction(prefChangeAction); assert.notCalled(feed._contile.refresh); }); it("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, 15 * 60 * 1000); Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_PREF, Date.now()); sandbox.spy(feed._contile, "refresh"); const prefChangeAction = { type: at.PREF_CHANGED, data: { name: SHOW_SPONSORED_PREF, value: false }, }; fakeNimbusFeatures.newtab.getVariable.returns(true); feed.onAction(prefChangeAction); assert.calledOnce(feed._contile.refresh); // cached pref values should have reset assert.isUndefined(Services.prefs.getStringPref(CONTILE_CACHE_PREF)); assert.isUndefined( Services.prefs.getIntPref(CONTILE_CACHE_LAST_FETCH_PREF) ); assert.isUndefined( Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_PREF) ); }); }); describe("#add", () => { it("should pin site in first slot of empty pinned list", () => { const site = { url: "foo.bar", label: "foo" }; feed.insert({ data: { site } }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); }); it("should pin site in first slot of pinned list with empty first slot", () => { fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; const site = { url: "foo.bar", label: "foo" }; feed.insert({ data: { site } }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); }); it("should move a pinned site in first slot to the next slot: part 1", () => { const site1 = { url: "example.com" }; fakeNewTabUtils.pinnedLinks.links = [site1]; const site = { url: "foo.bar", label: "foo" }; feed.insert({ data: { site } }); assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1); }); it("should move a pinned site in first slot to the next slot: part 2", () => { const site1 = { url: "example.com" }; const site2 = { url: "example.org" }; fakeNewTabUtils.pinnedLinks.links = [site1, null, site2]; const site = { url: "foo.bar", label: "foo" }; feed.insert({ data: { site } }); assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1); }); it("should unpin the last site if all slots are already pinned", () => { const site1 = { url: "example.com" }; const site2 = { url: "example.org" }; const site3 = { url: "example.net" }; const site4 = { url: "example.biz" }; const site5 = { url: "example.info" }; const site6 = { url: "example.news" }; const site7 = { url: "example.lol" }; const site8 = { url: "example.golf" }; fakeNewTabUtils.pinnedLinks.links = [ site1, site2, site3, site4, site5, site6, site7, site8, ]; feed.store.state.Prefs.values.topSitesRows = 1; const site = { url: "foo.bar", label: "foo" }; feed.insert({ data: { site } }); assert.equal(fakeNewTabUtils.pinnedLinks.pin.callCount, 8); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 2); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site3, 3); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site4, 4); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site5, 5); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site6, 6); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site7, 7); }); }); describe("#pin", () => { it("should pin site in specified slot empty pinned list", async () => { const site = { url: "foo.bar", label: "foo", customScreenshotURL: "screenshot", }; await feed.pin({ data: { index: 2, site } }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); }); it("should lookup the link object to update the custom screenshot", async () => { const site = { url: "foo.bar", label: "foo", customScreenshotURL: "screenshot", }; sandbox.spy(feed.pinnedCache, "request"); await feed.pin({ data: { index: 2, site } }); assert.calledOnce(feed.pinnedCache.request); }); it("should lookup the link object to update the custom screenshot", async () => { const site = { url: "foo.bar", label: "foo", customScreenshotURL: null }; sandbox.spy(feed.pinnedCache, "request"); await feed.pin({ data: { index: 2, site } }); assert.calledOnce(feed.pinnedCache.request); }); it("should not do a link object lookup if custom screenshot field is not set", async () => { const site = { url: "foo.bar", label: "foo" }; sandbox.spy(feed.pinnedCache, "request"); await feed.pin({ data: { index: 2, site } }); assert.notCalled(feed.pinnedCache.request); }); it("should pin site in specified slot of pinned list that is free", () => { fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; const site = { url: "foo.bar", label: "foo" }; feed.pin({ data: { index: 2, site } }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); }); it("should save the searchTopSite attribute if set", () => { fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; const site = { url: "foo.bar", label: "foo", searchTopSite: true }; feed.pin({ data: { index: 2, site } }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.propertyVal( fakeNewTabUtils.pinnedLinks.pin.firstCall.args[0], "searchTopSite", true ); }); it("should NOT move a pinned site in specified slot to the next slot", () => { fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }]; const site = { url: "foo.bar", label: "foo" }; feed.pin({ data: { index: 2, site } }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); }); it("should properly update LinksCache object properties between migrations", async () => { fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; let pinnedLinks = await feed.pinnedCache.request(); assert.equal(pinnedLinks.length, 1); feed.pinnedCache.expire(); pinnedLinks[0].__sharedCache.updateLink("screenshot", "foo"); pinnedLinks = await feed.pinnedCache.request(); assert.propertyVal(pinnedLinks[0], "screenshot", "foo"); // Force cache expiration in order to trigger a migration of objects feed.pinnedCache.expire(); pinnedLinks[0].__sharedCache.updateLink("screenshot", "bar"); pinnedLinks = await feed.pinnedCache.request(); assert.propertyVal(pinnedLinks[0], "screenshot", "bar"); }); it("should call insert if index < 0", () => { const site = { url: "foo.bar", label: "foo" }; const action = { data: { index: -1, site } }; sandbox.spy(feed, "insert"); feed.pin(action); assert.calledOnce(feed.insert); assert.calledWithExactly(feed.insert, action); }); it("should not call insert if index == 0", () => { const site = { url: "foo.bar", label: "foo" }; const action = { data: { index: 0, site } }; sandbox.spy(feed, "insert"); feed.pin(action); assert.notCalled(feed.insert); }); }); describe("clearLinkCustomScreenshot", () => { it("should remove cached screenshot if custom url changes", async () => { const stub = sandbox.stub(); sandbox.stub(feed.pinnedCache, "request").returns( Promise.resolve([ { url: "foo", customScreenshotURL: "old_screenshot", __sharedCache: { updateLink: stub }, }, ]) ); await feed._clearLinkCustomScreenshot({ url: "foo", customScreenshotURL: "new_screenshot", }); assert.calledOnce(stub); assert.calledWithExactly(stub, "screenshot", undefined); }); it("should remove cached screenshot if custom url is removed", async () => { const stub = sandbox.stub(); sandbox.stub(feed.pinnedCache, "request").returns( Promise.resolve([ { url: "foo", customScreenshotURL: "old_screenshot", __sharedCache: { updateLink: stub }, }, ]) ); await feed._clearLinkCustomScreenshot({ url: "foo", customScreenshotURL: "new_screenshot", }); assert.calledOnce(stub); assert.calledWithExactly(stub, "screenshot", undefined); }); }); describe("#drop", () => { it("should correctly handle different index values", () => { let index = -1; const site = { url: "foo.bar", label: "foo" }; const action = { data: { index, site } }; feed.insert(action); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); index = undefined; feed.insert(action); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); }); it("should pin site in specified slot that is free", () => { fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; const site = { url: "foo.bar", label: "foo" }; feed.insert({ data: { index: 2, site, draggedFromIndex: 0 } }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); }); it("should move a pinned site in specified slot to the next slot", () => { fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }]; const site = { url: "foo.bar", label: "foo" }; feed.insert({ data: { index: 2, site, draggedFromIndex: 3 } }); assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); assert.calledWith( fakeNewTabUtils.pinnedLinks.pin, { url: "example.com" }, 3 ); }); it("should move pinned sites in the direction of the dragged site", () => { const site1 = { url: "foo.bar", label: "foo" }; const site2 = { url: "example.com", label: "example" }; fakeNewTabUtils.pinnedLinks.links = [null, null, site2]; feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 0 } }); assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 1); fakeNewTabUtils.pinnedLinks.pin.resetHistory(); feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 5 } }); assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2); assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 3); }); it("should not insert past the visible top sites", () => { const site1 = { url: "foo.bar", label: "foo" }; feed.insert({ data: { index: 42, site: site1, draggedFromIndex: 0 } }); assert.notCalled(fakeNewTabUtils.pinnedLinks.pin); }); }); describe("integration", () => { let resolvers = []; beforeEach(() => { feed.store.dispatch = sandbox.stub().callsFake(() => { resolvers.shift()(); }); feed._startedUp = true; sandbox.stub(feed, "_fetchScreenshot"); }); afterEach(() => { sandbox.restore(); }); const forDispatch = action => new Promise(resolve => { resolvers.push(resolve); feed.onAction(action); }); it("should add a pinned site and remove it", async () => { feed._requestRichIcon = sinon.stub(); const url = "https://pin.me"; fakeNewTabUtils.pinnedLinks.pin = sandbox.stub().callsFake(link => { fakeNewTabUtils.pinnedLinks.links.push(link); }); await forDispatch({ type: at.TOP_SITES_INSERT, data: { site: { url } } }); fakeNewTabUtils.pinnedLinks.links.pop(); await forDispatch({ type: at.PLACES_LINK_BLOCKED }); assert.calledTwice(feed.store.dispatch); assert.equal( feed.store.dispatch.firstCall.args[0].data.links[0].url, url ); assert.equal( feed.store.dispatch.secondCall.args[0].data.links[0].url, FAKE_LINKS[0].url ); }); }); describe("improvesearch.noDefaultSearchTile experiment", () => { const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile"; beforeEach(() => { global.Services.search.getDefault = async () => ({ identifier: "google", searchForm: "google.com", }); feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; }); it("should filter out alexa top 5 search from the default sites", async () => { const TOP_5_TEST = [ "google.com", "search.yahoo.com", "yahoo.com", "bing.com", "ask.com", "duckduckgo.com", ]; links = [{ url: "amazon.com" }, ...TOP_5_TEST.map(url => ({ url }))]; const urlsReturned = (await feed.getLinksWithDefaults()).map( link => link.url ); assert.include(urlsReturned, "amazon.com"); TOP_5_TEST.forEach(url => assert.notInclude(urlsReturned, url)); }); it("should not filter out alexa, default search from the query results if the experiment pref is off", async () => { links = [ { url: "google.com" }, { url: "foo.com" }, { url: "duckduckgo" }, ]; feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = false; const urlsReturned = (await feed.getLinksWithDefaults()).map( link => link.url ); assert.include(urlsReturned, "google.com"); }); it("should filter out the current default search from the default sites", async () => { feed._currentSearchHostname = "amazon"; feed.onAction({ type: at.PREFS_INITIAL_VALUES, data: { "default.sites": "google.com,amazon.com" }, }); links = [{ url: "foo.com" }]; const urlsReturned = (await feed.getLinksWithDefaults()).map( link => link.url ); assert.notInclude(urlsReturned, "amazon.com"); }); it("should not filter out current default search from pinned sites even if it matches the current default search", async () => { links = [{ url: "foo.com" }]; fakeNewTabUtils.pinnedLinks.links = [{ url: "google.com" }]; const urlsReturned = (await feed.getLinksWithDefaults()).map( link => link.url ); assert.include(urlsReturned, "google.com"); }); it("should call refresh and set ._currentSearchHostname to the new engine hostname when the the default search engine has been set", () => { sinon.stub(feed, "refresh"); sandbox .stub(global.Services.search, "defaultEngine") .value({ identifier: "ddg", searchForm: "duckduckgo.com" }); feed.observe(null, "browser-search-engine-modified", "engine-default"); assert.equal(feed._currentSearchHostname, "duckduckgo"); assert.calledOnce(feed.refresh); }); it("should call refresh when the experiment pref has changed", () => { sinon.stub(feed, "refresh"); feed.onAction({ type: at.PREF_CHANGED, data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: true }, }); assert.calledOnce(feed.refresh); feed.onAction({ type: at.PREF_CHANGED, data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: false }, }); assert.calledTwice(feed.refresh); }); }); describe("improvesearch.topSitesSearchShortcuts", () => { beforeEach(() => { feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = true; feed.store.state.Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF] = "google,amazon"; feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = ""; const searchEngines = [ { aliases: ["@google"] }, { aliases: ["@amazon"] }, ]; global.Services.search.getAppProvidedEngines = async () => searchEngines; fakeNewTabUtils.pinnedLinks.pin = sinon .stub() .callsFake((site, index) => { fakeNewTabUtils.pinnedLinks.links[index] = site; }); }); it("should properly disable search improvements if the pref is off", async () => { sandbox.stub(global.Services.prefs, "clearUserPref"); sandbox.spy(feed.pinnedCache, "expire"); sandbox.spy(feed, "refresh"); // an actual implementation of unpin (until we can get a mochitest for search improvements) fakeNewTabUtils.pinnedLinks.unpin = sinon.stub().callsFake(site => { let index = -1; for (let i = 0; i < fakeNewTabUtils.pinnedLinks.links.length; i++) { let link = fakeNewTabUtils.pinnedLinks.links[i]; if (link && link.url === site.url) { index = i; } } if (index > -1) { fakeNewTabUtils.pinnedLinks.links[index] = null; } }); // ensure we've inserted search shorcuts + pin an additional site in space 4 await feed._maybeInsertSearchShortcuts(fakeNewTabUtils.pinnedLinks.links); fakeNewTabUtils.pinnedLinks.pin({ url: "https://dontunpinme.com" }, 3); // turn the experiment off feed.onAction({ type: at.PREF_CHANGED, data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: false }, }); // check we cleared the pref, expired the pinned cache, and refreshed the feed assert.calledWith( global.Services.prefs.clearUserPref, `browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}` ); assert.calledOnce(feed.pinnedCache.expire); assert.calledWith(feed.refresh, { broadcast: true }); // check that the search shortcuts were removed from the list of pinned sites const urlsReturned = fakeNewTabUtils.pinnedLinks.links .filter(s => s) .map(link => link.url); assert.notInclude(urlsReturned, "https://amazon.com"); assert.notInclude(urlsReturned, "https://google.com"); assert.include(urlsReturned, "https://dontunpinme.com"); // check that the positions where the search shortcuts were null, and the additional pinned site is untouched in space 4 assert.equal(fakeNewTabUtils.pinnedLinks.links[0], null); assert.equal(fakeNewTabUtils.pinnedLinks.links[1], null); assert.equal(fakeNewTabUtils.pinnedLinks.links[2], undefined); assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], { url: "https://dontunpinme.com", }); }); it("should updateCustomSearchShortcuts when experiment pref is turned on", async () => { feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; feed.updateCustomSearchShortcuts = sinon.spy(); // turn the experiment on feed.onAction({ type: at.PREF_CHANGED, data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: true }, }); assert.calledOnce(feed.updateCustomSearchShortcuts); }); it("should filter out default top sites that match a hostname of a search shortcut if previously blocked", async () => { feed.refreshDefaults("https://amazon.ca"); fakeNewTabUtils.blockedLinks.links = [{ url: "https://amazon.com" }]; fakeNewTabUtils.blockedLinks.isBlocked = site => fakeNewTabUtils.blockedLinks.links[0].url === site.url; const urlsReturned = (await feed.getLinksWithDefaults()).map( link => link.url ); assert.notInclude(urlsReturned, "https://amazon.ca"); }); it("should update frecent search topsite icon", async () => { feed._tippyTopProvider.processSite = site => { site.tippyTopIcon = "icon.png"; site.backgroundColor = "#fff"; return site; }; links = [{ url: "google.com" }]; const urlsReturned = await feed.getLinksWithDefaults(); const defaultSearchTopsite = urlsReturned.find( s => s.url === "google.com" ); assert.propertyVal(defaultSearchTopsite, "searchTopSite", true); assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); }); it("should update default search topsite icon", async () => { feed._tippyTopProvider.processSite = site => { site.tippyTopIcon = "icon.png"; site.backgroundColor = "#fff"; return site; }; links = [{ url: "foo.com" }]; feed.onAction({ type: at.PREFS_INITIAL_VALUES, data: { "default.sites": "google.com,amazon.com" }, }); const urlsReturned = await feed.getLinksWithDefaults(); const defaultSearchTopsite = urlsReturned.find( s => s.url === "amazon.com" ); assert.propertyVal(defaultSearchTopsite, "searchTopSite", true); assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); }); it("should dispatch UPDATE_SEARCH_SHORTCUTS on updateCustomSearchShortcuts", async () => { feed.store.state.Prefs.values["improvesearch.noDefaultSearchTile"] = true; await feed.updateCustomSearchShortcuts(); assert.calledOnce(feed.store.dispatch); assert.calledWith(feed.store.dispatch, { data: { searchShortcuts: [ { keyword: "@google", shortURL: "google", url: "https://google.com", }, { keyword: "@amazon", shortURL: "amazon", url: "https://amazon.com", }, ], }, meta: { from: "ActivityStream:Main", to: "ActivityStream:Content", isStartup: false, }, type: "UPDATE_SEARCH_SHORTCUTS", }); }); describe("_maybeInsertSearchShortcuts", () => { beforeEach(() => { // Default is one row feed.store.state.Prefs.values.topSitesRows = TOP_SITES_DEFAULT_ROWS; // Eight slots per row fakeNewTabUtils.pinnedLinks.links = [ { url: "" }, { url: "" }, { url: "" }, null, { url: "" }, { url: "" }, null, { url: "" }, ]; }); it("should be called on getLinksWithDefaults", async () => { sandbox.spy(feed, "_maybeInsertSearchShortcuts"); await feed.getLinksWithDefaults(); assert.calledOnce(feed._maybeInsertSearchShortcuts); }); it("should do nothing and return false if the experiment is disabled", async () => { feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; assert.isFalse( await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ) ); assert.notCalled(fakeNewTabUtils.pinnedLinks.pin); }); it("should pin shortcuts in the correct order, into the available unpinned slots", async () => { await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); // The shouldPin pref is "google,amazon" so expect the shortcuts in that order assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], { url: "https://google.com", searchTopSite: true, label: "@google", }); assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[6], { url: "https://amazon.com", searchTopSite: true, label: "@amazon", }); }); it("should not pin shortcuts for the current default search engine", async () => { feed._currentSearchHostname = "google"; await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], { url: "https://amazon.com", searchTopSite: true, label: "@amazon", }); }); it("should only pin the first shortcut if there's only one available slot", async () => { fakeNewTabUtils.pinnedLinks.links[3] = { url: "" }; await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); // The first item in the shouldPin pref is "google" so expect only Google to be pinned assert.ok( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://google.com" ) ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://amazon.com" ) ); }); it("should pin none if there's no available slot", async () => { fakeNewTabUtils.pinnedLinks.links[3] = { url: "" }; fakeNewTabUtils.pinnedLinks.links[6] = { url: "" }; await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://google.com" ) ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://amazon.com" ) ); }); it("should not pin a shortcut if the corresponding search engine is not available", async () => { // Make Amazon search engine unavailable global.Services.search.getAppProvidedEngines = async () => [ { aliases: ["@google"] }, ]; fakeNewTabUtils.pinnedLinks.links.fill(null); await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://amazon.com" ) ); }); it("should not pin a search shortcut if it's been pinned before", async () => { fakeNewTabUtils.pinnedLinks.links.fill(null); feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "google,amazon"; await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://google.com" ) ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://amazon.com" ) ); fakeNewTabUtils.pinnedLinks.links.fill(null); feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "amazon"; await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); assert.ok( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://google.com" ) ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://amazon.com" ) ); fakeNewTabUtils.pinnedLinks.links.fill(null); feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "google"; await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); assert.notOk( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://google.com" ) ); assert.ok( fakeNewTabUtils.pinnedLinks.links.find( s => s && s.url === "https://amazon.com" ) ); }); it("should record the insertion of a search shortcut", async () => { feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = ""; // Fill up one slot, so there's only one left - to be filled by Google fakeNewTabUtils.pinnedLinks.links[3] = { url: "" }; await feed._maybeInsertSearchShortcuts( fakeNewTabUtils.pinnedLinks.links ); assert.calledWithExactly(feed.store.dispatch, { data: { name: SEARCH_SHORTCUTS_HAVE_PINNED_PREF, value: "google" }, meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, type: "SET_PREF", }); }); }); }); describe("updatePinnedSearchShortcuts", () => { it("should unpin a shortcut in deletedShortcuts", () => { const deletedShortcuts = [ { url: "https://google.com", searchVendor: "google", label: "google", searchTopSite: true, }, ]; const addedShortcuts = []; fakeNewTabUtils.pinnedLinks.links = [ null, null, { url: "https://amazon.com", searchVendor: "amazon", label: "amazon", searchTopSite: true, }, ]; feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); assert.notCalled(fakeNewTabUtils.pinnedLinks.pin); assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin); assert.calledWith(fakeNewTabUtils.pinnedLinks.unpin, { url: "https://google.com", }); }); it("should pin a shortcut in addedShortcuts", () => { const addedShortcuts = [ { url: "https://google.com", searchVendor: "google", label: "google", searchTopSite: true, }, ]; const deletedShortcuts = []; fakeNewTabUtils.pinnedLinks.links = [ null, null, { url: "https://amazon.com", searchVendor: "amazon", label: "amazon", searchTopSite: true, }, ]; feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin); assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); assert.calledWith( fakeNewTabUtils.pinnedLinks.pin, { label: "google", searchTopSite: true, searchVendor: "google", url: "https://google.com", }, 0 ); }); it("should pin and unpin in the same action", () => { const addedShortcuts = [ { url: "https://google.com", searchVendor: "google", label: "google", searchTopSite: true, }, { url: "https://ebay.com", searchVendor: "ebay", label: "ebay", searchTopSite: true, }, ]; const deletedShortcuts = [ { url: "https://amazon.com", searchVendor: "amazon", label: "amazon", searchTopSite: true, }, ]; fakeNewTabUtils.pinnedLinks.links = [ { url: "https://foo.com" }, { url: "https://amazon.com", searchVendor: "amazon", label: "amazon", searchTopSite: true, }, ]; feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin); assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); }); it("should pin a shortcut in addedShortcuts even if pinnedLinks is full", () => { const addedShortcuts = [ { url: "https://google.com", searchVendor: "google", label: "google", searchTopSite: true, }, ]; const deletedShortcuts = []; fakeNewTabUtils.pinnedLinks.links = FAKE_LINKS; feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin); assert.calledWith( fakeNewTabUtils.pinnedLinks.pin, { label: "google", searchTopSite: true, url: "https://google.com" }, 0 ); }); }); describe("#_attachTippyTopIconForSearchShortcut", () => { beforeEach(() => { feed._tippyTopProvider.processSite = site => { if (site.url === "https://www.yandex.ru/") { site.tippyTopIcon = "yandex-ru.png"; site.smallFavicon = "yandex-ru.ico"; } else if ( site.url === "https://www.yandex.com/" || site.url === "https://yandex.com" ) { site.tippyTopIcon = "yandex.png"; site.smallFavicon = "yandex.ico"; } else { site.tippyTopIcon = "google.png"; site.smallFavicon = "google.ico"; } return site; }; }); it("should choose the -ru icons for Yandex search shortcut", async () => { sandbox.stub(global.Services.search, "getEngineByAlias").resolves({ wrappedJSObject: { _searchForm: "https://www.yandex.ru/" }, }); const link = { url: "https://yandex.com" }; await feed._attachTippyTopIconForSearchShortcut(link, "@yandex"); assert.equal(link.tippyTopIcon, "yandex-ru.png"); assert.equal(link.smallFavicon, "yandex-ru.ico"); assert.equal(link.url, "https://yandex.com"); }); it("should choose -com icons for Yandex search shortcut", async () => { sandbox.stub(global.Services.search, "getEngineByAlias").resolves({ wrappedJSObject: { _searchForm: "https://www.yandex.com/" }, }); const link = { url: "https://yandex.com" }; await feed._attachTippyTopIconForSearchShortcut(link, "@yandex"); assert.equal(link.tippyTopIcon, "yandex.png"); assert.equal(link.smallFavicon, "yandex.ico"); assert.equal(link.url, "https://yandex.com"); }); it("should use the -com icons if can't fetch the search form URL", async () => { sandbox.stub(global.Services.search, "getEngineByAlias").resolves(null); const link = { url: "https://yandex.com" }; await feed._attachTippyTopIconForSearchShortcut(link, "@yandex"); assert.equal(link.tippyTopIcon, "yandex.png"); assert.equal(link.smallFavicon, "yandex.ico"); assert.equal(link.url, "https://yandex.com"); }); it("should choose the correct icon for other non-yandex search shortcut", async () => { sandbox.stub(global.Services.search, "getEngineByAlias").resolves({ wrappedJSObject: { _searchForm: "https://www.google.com/" }, }); const link = { url: "https://google.com" }; await feed._attachTippyTopIconForSearchShortcut(link, "@google"); assert.equal(link.tippyTopIcon, "google.png"); assert.equal(link.smallFavicon, "google.ico"); assert.equal(link.url, "https://google.com"); }); }); describe("#ContileIntegration", () => { let getStringPrefStub; let getIntPrefStub; beforeEach(() => { // Turn on sponsored TopSites for testing feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; fetchStub = sandbox.stub(); globals.set("fetch", fetchStub); getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); getStringPrefStub .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF) .returns(`["foo","bar"]`); getIntPrefStub = sandbox.stub(global.Services.prefs, "getIntPref"); fakeNimbusFeatures.newtab.getVariable.returns(true); sandbox.spy(global.Services.prefs, "setStringPref"); sandbox.spy(global.Services.prefs, "setIntPref"); }); afterEach(() => { sandbox.restore(); }); it("should fetch sites from Contile", async () => { 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", }, ], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.equal(feed._contile.sites.length, 2); }); it("should fetch SOV (Share-of-Voice) settings from Contile", async () => { const 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", }, ], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.deepEqual(feed._contile.sov, sov); assert.equal(feed._contile.sites.length, 2); }); it("should not fetch from Contile if it's not enabled", async () => { fakeNimbusFeatures.newtab.getVariable.reset(); fakeNimbusFeatures.newtab.getVariable.returns(false); const fetched = await feed._contile._fetchSites(); assert.notCalled(fetchStub); assert.ok(!fetched); assert.equal(feed._contile.sites.length, 0); }); it("should still return two tiles when Contile provides more than 2 tiles and filtering results in more than 2 tiles", async () => { fakeNimbusFeatures.newtab.getVariable.reset(); fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true); fakeNimbusFeatures.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", }, ], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); // Both "foo" and "bar" should be filtered assert.equal(feed._contile.sites.length, 2); assert.equal(feed._contile.sites[0].url, "https://www.test.com"); assert.equal(feed._contile.sites[1].url, "https://test1.com"); }); it("should still return two tiles with replacement if the Nimbus variable was unset", async () => { fakeNimbusFeatures.newtab.getVariable.reset(); fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true); fakeNimbusFeatures.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", }, ], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.equal(feed._contile.sites.length, 2); assert.equal(feed._contile.sites[0].url, "https://www.test.com"); assert.equal(feed._contile.sites[1].url, "https://test1.com"); }); it("should filter the blocked sponsors", async () => { 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", }, ], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); // Both "foo" and "bar" should be filtered assert.equal(feed._contile.sites.length, 1); assert.equal(feed._contile.sites[0].url, "https://www.test.com"); }); it("should return false when Contile returns with error status and no values are stored in cache prefs", async () => { fetchStub.resolves({ ok: false, status: 500, }); const fetched = await feed._contile._fetchSites(); assert.ok(!fetched); assert.ok(!feed._contile.sites.length); }); it("should return false when Contile returns with error status and cached tiles are expried", async () => { getIntPrefStub .withArgs(CONTILE_CACHE_VALID_FOR_PREF) .returns(1000 * 60 * 15); getIntPrefStub .withArgs(CONTILE_CACHE_LAST_FETCH_PREF) .returns(Date.now() - 1000 * 60 * 30); fetchStub.resolves({ ok: false, status: 500, }); const fetched = await feed._contile._fetchSites(); assert.ok(!fetched); assert.ok(!feed._contile.sites.length); }); it("should handle invalid payload properly from Contile", async () => { fetchStub.resolves({ ok: true, status: 200, json: () => Promise.resolve({ unknown: [], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(!fetched); assert.ok(!feed._contile.sites.length); }); it("should handle empty payload properly from Contile", async () => { fetchStub.resolves({ ok: true, status: 200, headers: new Map([ ["cache-control", "private, max-age=859, stale-if-error=10463"], ]), json: () => Promise.resolve({ tiles: [], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.ok(!feed._contile.sites.length); }); it("should handle no content properly from Contile", async () => { fetchStub.resolves({ ok: true, status: 204 }); const fetched = await feed._contile._fetchSites(); assert.ok(!fetched); assert.ok(!feed._contile.sites.length); }); it("should set Caching Prefs after a sucessful request", async () => { const 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, }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.calledOnce(Services.prefs.setStringPref); assert.calledTwice(Services.prefs.setIntPref); assert.calledWith( Services.prefs.setStringPref, CONTILE_CACHE_PREF, JSON.stringify(tiles) ); assert.calledWith( Services.prefs.setIntPref, CONTILE_CACHE_VALID_FOR_PREF, 11322 ); }); it("should return cached valid tiles when Contile returns error status", async () => { const 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", }, ]; getStringPrefStub .withArgs(CONTILE_CACHE_PREF) .returns(JSON.stringify(tiles)); // valid for 15 mins getIntPrefStub .withArgs(CONTILE_CACHE_VALID_FOR_PREF) .returns(1000 * 60 * 15); getIntPrefStub .withArgs(CONTILE_CACHE_LAST_FETCH_PREF) .returns(Date.now()); fetchStub.resolves({ status: 304, }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.equal(feed._contile.sites.length, 2); assert.equal(feed._contile.sites[0].url, "https://www.test-cached.com"); assert.equal(feed._contile.sites[1].url, "https://www.test1-cached.com"); }); it("should not be successful when contile returns an error and no valid tiles are cached", async () => { getStringPrefStub.withArgs(CONTILE_CACHE_PREF).returns("[]"); getIntPrefStub.withArgs(CONTILE_CACHE_VALID_FOR_PREF).returns(0); getIntPrefStub.withArgs(CONTILE_CACHE_LAST_FETCH_PREF).returns(0); fetchStub.resolves({ status: 500, }); const fetched = await feed._contile._fetchSites(); assert.ok(!fetched); }); it("should return cached valid tiles filtering blocked tiles when Contile returns error status", async () => { const 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", }, ]; getStringPrefStub .withArgs(CONTILE_CACHE_PREF) .returns(JSON.stringify(tiles)); // valid for 15 mins getIntPrefStub .withArgs(CONTILE_CACHE_VALID_FOR_PREF) .returns(1000 * 60 * 15); getIntPrefStub .withArgs(CONTILE_CACHE_LAST_FETCH_PREF) .returns(Date.now()); fetchStub.resolves({ status: 304, }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.equal(feed._contile.sites.length, 1); assert.equal(feed._contile.sites[0].url, "https://www.test1-cached.com"); }); it("should still return 3 tiles when nimbus variable overrides max num of sponsored contile tiles", async () => { fakeNimbusFeatures.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", }, ], }), }); const fetched = await feed._contile._fetchSites(); assert.ok(fetched); assert.equal(feed._contile.sites.length, 3); assert.equal(feed._contile.sites[0].url, "https://www.test.com"); assert.equal(feed._contile.sites[1].url, "https://test1.com"); assert.equal(feed._contile.sites[2].url, "https://test2.com"); }); }); describe("#_mergeSponsoredLinks", () => { let fakeSponsoredLinks; let sov; beforeEach(() => { fakeSponsoredLinks = { amp: [ { 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", partner: "amp", sponsored_position: 1, }, { 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", partner: "amp", sponsored_position: 2, }, { url: "https://www.test2.com", image_url: "images/test2-com.png", click_url: "https://www.test2-click.com", impression_url: "https://www.test2-impression.com", name: "test2", partner: "amp", sponsored_position: 2, }, ], "moz-sales": [ { 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", partner: "moz-sales", pos: 2, }, ], }; 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, }, ], }, ], }; }); afterEach(() => { sandbox.restore(); }); it("should join sponsored links if the sov object is absent", async () => { sandbox.stub(feed._contile, "sov").get(() => null); const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat()); }); it("should join sponosred links if the SOV Nimbus variable is disabled", async () => { fakeNimbusFeatures.pocketNewtab.getVariable.returns(false); const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat()); }); it("should pick sponsored links based on sov configurations", async () => { sandbox.stub(feed._contile, "sov").get(() => sov); fakeNimbusFeatures.pocketNewtab.getVariable.reset(); fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true); fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(undefined); global.Sampling.ratioSample.onCall(0).resolves(0); global.Sampling.ratioSample.onCall(1).resolves(1); const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); assert.equal(sponsored.length, 2); assert.equal(sponsored[0].partner, "amp"); assert.equal(sponsored[0].sponsored_position, 1); assert.equal(sponsored[1].partner, "moz-sales"); assert.equal(sponsored[1].sponsored_position, 2); assert.equal(sponsored[1].pos, 1); }); it("should add remaining contile tiles when nimbus var contile max num sponsored is present", async () => { sandbox.stub(feed._contile, "sov").get(() => sov); fakeNimbusFeatures.pocketNewtab.getVariable.reset(); fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true); fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(3); global.Sampling.ratioSample.resolves(0); const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); assert.equal(sponsored.length, 3); }); it("should fall back to other partners if the chosen partner does not have any links", async () => { sandbox.stub(feed._contile, "sov").get(() => sov); fakeNimbusFeatures.pocketNewtab.getVariable.returns(true); global.Sampling.ratioSample.onCall(0).resolves(0); global.Sampling.ratioSample.onCall(1).resolves(0); fakeSponsoredLinks.amp = []; const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); assert.equal(sponsored.length, 1); assert.equal(sponsored[0].partner, "moz-sales"); assert.equal(sponsored[0].sponsored_position, 1); assert.equal(sponsored[0].pos, 0); }); it("should return an empty array if none of the partners have links", async () => { sandbox.stub(feed._contile, "sov").get(() => sov); fakeNimbusFeatures.pocketNewtab.getVariable.returns(true); global.Sampling.ratioSample.onCall(0).resolves(0); global.Sampling.ratioSample.onCall(1).resolves(0); fakeSponsoredLinks.amp = []; fakeSponsoredLinks["moz-sales"] = []; const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); assert.equal(sponsored.length, 0); }); }); describe("#_readDefaults", () => { beforeEach(() => { // Turn on sponsored TopSites for testing feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; fetchStub = sandbox.stub(); globals.set("fetch", fetchStub); fetchStub.resolves({ ok: true, status: 204 }); sandbox .stub(global.Services.prefs, "getBoolPref") .withArgs(REMOTE_SETTING_DEFAULTS_PREF) .returns(true); sandbox .stub(global.Services.prefs, "getStringPref") .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF) .returns(`["foo","bar"]`); sandbox.stub(global.Services.prefs, "prefIsLocked").returns(false); }); afterEach(() => { sandbox.restore(); }); it("should filter all blocked sponsored tiles from RemoteSettings when Contile is disabled", async () => { sandbox.stub(feed, "_getRemoteConfig").resolves([ { url: "https://foo.com", title: "foo", sponsored_position: 1 }, { url: "https://bar.com", title: "bar", sponsored_position: 2 }, { url: "https://test.com", title: "test", sponsored_position: 3 }, ]); fakeNimbusFeatures.newtab.getVariable.returns(false); await feed._readDefaults(); assert.equal(DEFAULT_TOP_SITES.length, 1); assert.equal(DEFAULT_TOP_SITES[0].label, "test"); }); it("should also filter all blocked sponsored tiles from RemoteSettings when Contile is enabled", async () => { sandbox.stub(feed, "_getRemoteConfig").resolves([ { url: "https://foo.com", title: "foo", sponsored_position: 1 }, { url: "https://bar.com", title: "bar", sponsored_position: 2 }, { url: "https://test.com", title: "test", sponsored_position: 3 }, ]); fakeNimbusFeatures.newtab.getVariable.returns(true); await feed._readDefaults(); assert.equal(DEFAULT_TOP_SITES.length, 1); assert.equal(DEFAULT_TOP_SITES[0].label, "test"); }); it("should not filter non-sponsored tiles from RemoteSettings", async () => { sandbox.stub(feed, "_getRemoteConfig").resolves([ { url: "https://foo.com", title: "foo", sponsored_position: 1 }, { url: "https://bar.com", title: "bar", sponsored_position: 2 }, { url: "https://foo.com", title: "foo" }, ]); await feed._readDefaults(); assert.equal(DEFAULT_TOP_SITES.length, 1); assert.equal(DEFAULT_TOP_SITES[0].label, "foo"); }); it("should take the image from Contile if it's a hi-res one", async () => { fakeNimbusFeatures.newtab.getVariable.returns(true); sandbox.stub(feed, "_getRemoteConfig").resolves([]); sandbox.stub(feed._contile, "sites").get(() => [ { url: "https://test.com", image_url: "https://images.test.com/test-com.png", image_size: 192, click_url: "https://www.test-click.com", impression_url: "https://www.test-impression.com", name: "test", }, { url: "https://test1.com", image_url: "https://images.test1.com/test1-com.png", image_size: 32, click_url: "https://www.test1-click.com", impression_url: "https://www.test1-impression.com", name: "test1", }, ]); await feed._readDefaults(); const [site1, site2] = DEFAULT_TOP_SITES; assert.propertyVal( site1, "favicon", "https://images.test.com/test-com.png" ); assert.propertyVal(site1, "faviconSize", 192); // Should not be taken as it's not hi-res assert.isUndefined(site2.favicon); assert.isUndefined(site2.faviconSize); }); }); describe("#_nimbusChangeListener", () => { it("should refresh on Nimbus feature updates reasons", () => { sandbox.spy(feed._contile, "refresh"); feed._nimbusChangeListener(null, "experiment-updated"); assert.calledOnce(feed._contile.refresh); }); it("should not refresh on Nimbus feature loaded reasons", () => { sandbox.spy(feed._contile, "refresh"); feed._nimbusChangeListener(null, "feature-experiment-loaded"); feed._nimbusChangeListener(null, "feature-rollout-loaded"); assert.notCalled(feed._contile.refresh); }); }); describe("#_maybeCapSponsoredLinks", () => { let sponsoredLinks; beforeEach(() => { sponsoredLinks = [ { url: "https://www.test.com", name: "test", sponsored_position: 1, }, { url: "https://www.test1.com", name: "test1", sponsored_position: 2, }, { url: "https://www.test2.com", name: "test2", sponsored_position: 3, }, ]; }); afterEach(() => { sandbox.restore(); }); it("should fall back to the default if the Nimbus variable is unspecified", () => { feed._maybeCapSponsoredLinks(sponsoredLinks); assert.equal(sponsoredLinks.length, 2); }); it("should cap the links if specified by the Nimbus variable", () => { fakeNimbusFeatures.pocketNewtab.getVariable.returns(1); feed._maybeCapSponsoredLinks(sponsoredLinks); assert.equal(sponsoredLinks.length, 1); }); it("should leave all the links if the Nimbus variable is equal to what we have", () => { fakeNimbusFeatures.pocketNewtab.getVariable.returns(3); feed._maybeCapSponsoredLinks(sponsoredLinks); assert.equal(sponsoredLinks.length, 3); }); it("should ignore caps if they are more than what we have", () => { fakeNimbusFeatures.pocketNewtab.getVariable.returns(10); feed._maybeCapSponsoredLinks(sponsoredLinks); assert.equal(sponsoredLinks.length, 3); }); }); });